From 56b4ce5c3888d3c2e687a69d18f42bc238f4dce2 Mon Sep 17 00:00:00 2001 From: Ricardo Henriques Date: Wed, 7 Jan 2026 16:28:06 +0000 Subject: [PATCH] feat: TUI state persistence, new commands, and non-interactive improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## TUI State Persistence - Add configuration properties for remembering TUI state across sessions - remember_tui_state: master toggle (default: true) - tui_tree_view: persist tree/flat view preference - tui_last_view_item: persist selected repo/project/assignee - Restore view state on TUI startup - Save state on navigation (arrow keys, tree toggle) - Reset saved item when switching view modes (Tab key) - Fix bug where --repo flag always overwrote restored state - Update config display to show TUI persistence settings - Change tree toggle key from 'r' to 't' in help text ## New Commands - add-link: Add URLs to task links - Usage: tsk add-link - Validates HTTP/HTTPS URLs - append: Append text to task descriptions - Usage: tsk append --text "content" - update: Batch update task fields - Usage: tsk update [--priority/--status/--project/etc] - Supports: priority, status, project, tags, assignees, due date, title ## Non-Interactive Terminal Detection - Add sys.stdin.isatty() checks across commands - delete: require --force flag in non-interactive mode - done: auto-confirm subtask marking in non-interactive mode - unarchive: auto-confirm subtask operations in non-interactive mode - sync: add --non-interactive flag to skip unexpected file prompts ## Archive Command Enhancement - Add --all-completed flag to archive all completed tasks at once - Usage: tsk archive --all-completed [--repo ] ## Sync Command Improvements - Add run_git_verbose() for interactive git operations - Implement SimpleSyncProgress for safer terminal output - Better handling of prompts and credential helpers - Improved display of unexpected files during sync šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/taskrepo/cli/commands/add_link.py | 59 +++++ src/taskrepo/cli/commands/append.py | 49 ++++ src/taskrepo/cli/commands/archive.py | 40 ++- src/taskrepo/cli/commands/config.py | 6 + src/taskrepo/cli/commands/delete.py | 11 + src/taskrepo/cli/commands/done.py | 45 ++-- src/taskrepo/cli/commands/sync.py | 336 +++++++++++++++---------- src/taskrepo/cli/commands/tui.py | 11 +- src/taskrepo/cli/commands/unarchive.py | 46 ++-- src/taskrepo/cli/commands/update.py | 145 +++++++++++ src/taskrepo/cli/main.py | 6 + src/taskrepo/core/config.py | 60 +++++ src/taskrepo/tui/task_tui.py | 39 ++- src/taskrepo/utils/file_validation.py | 39 +-- 14 files changed, 695 insertions(+), 197 deletions(-) create mode 100644 src/taskrepo/cli/commands/add_link.py create mode 100644 src/taskrepo/cli/commands/append.py create mode 100644 src/taskrepo/cli/commands/update.py diff --git a/src/taskrepo/cli/commands/add_link.py b/src/taskrepo/cli/commands/add_link.py new file mode 100644 index 0000000..feb2bc9 --- /dev/null +++ b/src/taskrepo/cli/commands/add_link.py @@ -0,0 +1,59 @@ +"""Add-link command for adding URLs to task links.""" + +import sys +from typing import Optional + +import click + +from taskrepo.core.repository import RepositoryManager +from taskrepo.utils.helpers import find_task_by_title_or_id, select_task_from_result + + +@click.command(name="add-link") +@click.argument("task_id", required=True) +@click.argument("url", required=True) +@click.option("--repo", "-r", help="Repository name (will search all repos if not specified)") +@click.pass_context +def add_link(ctx, task_id: str, url: str, repo: Optional[str]): + """Add a link/URL to a task. + + Examples: + tsk add-link 5 "https://github.com/org/repo/issues/123" + tsk add-link 10 "https://mail.google.com/..." --repo work + + TASK_ID: Task ID, UUID, or title + URL: URL to add to task links + """ + config = ctx.obj["config"] + manager = RepositoryManager(config.parent_dir) + + # Validate URL format + if not url.startswith(("http://", "https://")): + click.secho("Error: URL must start with http:// or https://", fg="red", err=True) + ctx.exit(1) + + # Find task + result = find_task_by_title_or_id(manager, task_id, repo) + + if result[0] is None: + click.secho(f"Error: No task found matching '{task_id}'", fg="red", err=True) + ctx.exit(1) + + task, repository = select_task_from_result(ctx, result, task_id) + + # Add link if not already present + if task.links is None: + task.links = [] + + if url in task.links: + click.secho(f"Link already exists in task: {task.title}", fg="yellow") + ctx.exit(0) + + task.links.append(url) + + # Save task + repository.save_task(task) + + click.secho(f"āœ“ Added link to task: {task.title}", fg="green") + click.echo(f"\nLink added: {url}") + click.echo(f"Total links: {len(task.links)}") diff --git a/src/taskrepo/cli/commands/append.py b/src/taskrepo/cli/commands/append.py new file mode 100644 index 0000000..c5aa6ee --- /dev/null +++ b/src/taskrepo/cli/commands/append.py @@ -0,0 +1,49 @@ +"""Append command for adding content to task descriptions.""" + +import sys +from typing import Optional + +import click + +from taskrepo.core.repository import RepositoryManager +from taskrepo.utils.helpers import find_task_by_title_or_id, select_task_from_result + + +@click.command() +@click.argument("task_id", required=True) +@click.option("--text", "-t", required=True, help="Text to append to task description") +@click.option("--repo", "-r", help="Repository name (will search all repos if not specified)") +@click.pass_context +def append(ctx, task_id: str, text: str, repo: Optional[str]): + """Append text to a task's description. + + Examples: + tsk append 5 --text "Additional note from meeting" + tsk append 10 -t "Updated requirements" --repo work + + TASK_ID: Task ID, UUID, or title to append to + """ + config = ctx.obj["config"] + manager = RepositoryManager(config.parent_dir) + + # Find task + result = find_task_by_title_or_id(manager, task_id, repo) + + if result[0] is None: + click.secho(f"Error: No task found matching '{task_id}'", fg="red", err=True) + ctx.exit(1) + + task, repository = select_task_from_result(ctx, result, task_id) + + # Append text to description + if task.description: + task.description = task.description.rstrip() + "\n\n" + text + else: + task.description = text + + # Save task + repository.save_task(task) + + click.secho(f"āœ“ Appended text to task: {task.title}", fg="green") + click.echo(f"\nNew content added:") + click.echo(f" {text}") diff --git a/src/taskrepo/cli/commands/archive.py b/src/taskrepo/cli/commands/archive.py index b13df19..febc938 100644 --- a/src/taskrepo/cli/commands/archive.py +++ b/src/taskrepo/cli/commands/archive.py @@ -18,15 +18,53 @@ @click.argument("task_ids", nargs=-1) @click.option("--repo", "-r", help="Repository name (will search all repos if not specified)") @click.option("--yes", "-y", is_flag=True, help="Automatically archive subtasks (skip prompt)") +@click.option("--all-completed", is_flag=True, help="Archive all completed tasks") @click.pass_context -def archive(ctx, task_ids: Tuple[str, ...], repo, yes): +def archive(ctx, task_ids: Tuple[str, ...], repo, yes, all_completed): """Archive one or more tasks, or list archived tasks if no task IDs are provided. TASK_IDS: One or more task IDs to archive (optional - if omitted, lists archived tasks) + + Use --all-completed to archive all tasks with status 'completed' in one command. """ config = ctx.obj["config"] manager = RepositoryManager(config.parent_dir) + # Handle --all-completed flag + if all_completed and not task_ids: + # Get all completed tasks + if repo: + repository = manager.get_repository(repo) + if not repository: + click.secho(f"Error: Repository '{repo}' not found", fg="red", err=True) + ctx.exit(1) + all_tasks = repository.list_tasks(include_archived=False) + else: + all_tasks = manager.list_all_tasks(include_archived=False) + + # Filter for completed status + completed_tasks = [task for task in all_tasks if task.status == "completed"] + + if not completed_tasks: + repo_msg = f" in repository '{repo}'" if repo else "" + click.echo(f"No completed tasks found{repo_msg}.") + return + + # Get display IDs from cache for completed tasks + from taskrepo.utils.id_mapping import get_display_id_from_uuid + completed_ids = [] + for task in completed_tasks: + display_id = get_display_id_from_uuid(task.id) + if display_id: + completed_ids.append(str(display_id)) + + if not completed_ids: + click.echo("No completed tasks found with display IDs.") + return + + click.echo(f"Found {len(completed_ids)} completed task(s) to archive.") + task_ids = tuple(completed_ids) + # If no task_ids provided, list archived tasks if not task_ids: # Get archived tasks from specified repo or all repos diff --git a/src/taskrepo/cli/commands/config.py b/src/taskrepo/cli/commands/config.py index 2359829..6c393e7 100644 --- a/src/taskrepo/cli/commands/config.py +++ b/src/taskrepo/cli/commands/config.py @@ -37,6 +37,12 @@ def _display_config(config): cluster_status = "enabled" if config.cluster_due_dates else "disabled" click.echo(f" Due date clustering: {cluster_status}") click.echo(f" TUI view mode: {config.tui_view_mode}") + remember_status = "enabled" if config.remember_tui_state else "disabled" + click.echo(f" Remember TUI state: {remember_status}") + tree_view_status = "enabled" if config.tui_tree_view else "disabled" + click.echo(f" TUI tree view default: {tree_view_status}") + last_item = config.tui_last_view_item or "(none)" + click.echo(f" TUI last view item: {last_item}") click.secho("-" * 50, fg="green") diff --git a/src/taskrepo/cli/commands/delete.py b/src/taskrepo/cli/commands/delete.py index a2f1612..a0b31fb 100644 --- a/src/taskrepo/cli/commands/delete.py +++ b/src/taskrepo/cli/commands/delete.py @@ -1,5 +1,6 @@ """Delete command for removing tasks.""" +import sys from typing import Tuple import click @@ -37,6 +38,11 @@ def delete(ctx, task_ids: Tuple[str, ...], repo, force): # Batch confirmation for multiple tasks (unless --force flag is used) if is_batch and not force: + # Check if we're in a terminal - if not, skip confirmation (auto-cancel for safety) + if not sys.stdin.isatty(): + click.echo("Warning: Non-interactive mode detected. Use --force to delete in non-interactive mode.") + ctx.exit(1) + click.echo(f"\nAbout to delete {task_id_count} tasks. This cannot be undone.") # Create a validator for y/n input @@ -60,6 +66,11 @@ def delete_task_handler(task, repository): """Handler to delete a task with optional confirmation.""" # Single task confirmation (only if not batch and not force) if not is_batch and not force: + # Check if we're in a terminal - if not, require --force flag + if not sys.stdin.isatty(): + click.echo("Warning: Non-interactive mode detected. Use --force to delete in non-interactive mode.") + ctx.exit(1) + # Format task display with colored UUID and title assignees_str = f" {', '.join(task.assignees)}" if task.assignees else "" project_str = f" [{task.project}]" if task.project else "" diff --git a/src/taskrepo/cli/commands/done.py b/src/taskrepo/cli/commands/done.py index 2f0a30c..6de5114 100644 --- a/src/taskrepo/cli/commands/done.py +++ b/src/taskrepo/cli/commands/done.py @@ -1,5 +1,6 @@ """Done command for marking tasks as completed.""" +import sys from typing import Tuple import click @@ -87,26 +88,30 @@ def mark_as_completed(task, repository): mark_subtasks = yes # Default to --yes flag value if not yes: - # Show subtasks and prompt - click.echo(f"\nThis task has {count} {subtask_word}:") - for subtask, subtask_repo in subtasks_with_repos: - status_emoji = STATUS_EMOJIS.get(subtask.status, "") - click.echo(f" • {status_emoji} {subtask.title} (repo: {subtask_repo.name})") - - # Prompt for confirmation with Y as default - yn_validator = Validator.from_callable( - lambda text: text.lower() in ["y", "n", "yes", "no"], - error_message="Please enter 'y' or 'n'", - move_cursor_to_end=True, - ) - - response = prompt( - f"Mark all {count} {subtask_word} as completed too? (Y/n) ", - default="y", - validator=yn_validator, - ).lower() - - mark_subtasks = response in ["y", "yes"] + # Check if we're in a terminal - if not, default to yes + if not sys.stdin.isatty(): + mark_subtasks = True + else: + # Show subtasks and prompt + click.echo(f"\nThis task has {count} {subtask_word}:") + for subtask, subtask_repo in subtasks_with_repos: + status_emoji = STATUS_EMOJIS.get(subtask.status, "") + click.echo(f" • {status_emoji} {subtask.title} (repo: {subtask_repo.name})") + + # Prompt for confirmation with Y as default + yn_validator = Validator.from_callable( + lambda text: text.lower() in ["y", "n", "yes", "no"], + error_message="Please enter 'y' or 'n'", + move_cursor_to_end=True, + ) + + response = prompt( + f"Mark all {count} {subtask_word} as completed too? (Y/n) ", + default="y", + validator=yn_validator, + ).lower() + + mark_subtasks = response in ["y", "yes"] if mark_subtasks: # Mark all subtasks as completed diff --git a/src/taskrepo/cli/commands/sync.py b/src/taskrepo/cli/commands/sync.py index 0f8c836..4cf86e3 100644 --- a/src/taskrepo/cli/commands/sync.py +++ b/src/taskrepo/cli/commands/sync.py @@ -1,6 +1,8 @@ """Sync command for git operations.""" import re +import subprocess +import sys import time import traceback from datetime import datetime @@ -22,6 +24,35 @@ console = Console() +def run_git_verbose(repo_path: str, args: list[str], error_msg: str) -> bool: + """Run a git command letting output flow to the terminal for visibility/interactivity. + + Args: + repo_path: Path to the repository + args: Git arguments (e.g. ["push", "origin", "main"]) + error_msg: Message to display on failure + + Returns: + True if successful, False otherwise + """ + try: + # flush console to ensure previous messages appear + sys.stdout.flush() + sys.stderr.flush() + + # Run git command, inheriting stdin/stdout/stderr + # We use a subprocess call to bypass GitPython's output capturing + result = subprocess.run( + ["git"] + args, + cwd=repo_path, + check=False + ) + return result.returncode == 0 + except Exception as e: + console.print(f" [red]āœ—[/red] {error_msg}: {e}") + return False + + def _log_sync_error(repo_name: str, error: Exception): """Log sync error to ~/.TaskRepo/sync_error.log. @@ -187,8 +218,60 @@ def get_commit_message(self) -> str: return f"Auto-sync: {', '.join(self.changes_to_commit)}" + +class SimpleSyncProgress: + """A simple, linear progress reporter that replaces Rich's live display. + + This class is safer for interactive prompts as it doesn't take over the terminal + screen or cursor in complex ways. It prints log-style updates instead of + updating a progress bar in place. + """ + + def __init__(self, *args, console=None, **kwargs): + self.console = console or Console() + self.tasks = {} + self._task_counter = 0 + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + pass + + def add_task(self, description, total=None, **kwargs): + task_id = self._task_counter + self._task_counter += 1 + self.tasks[task_id] = {"description": description, "total": total, "completed": 0} + + # Don't print empty spinner tasks + if description: + # Strip markup for simpler display if needed, but Rich console handles it + self.console.print(description) + + return task_id + + def update(self, task_id, advance=None, description=None, **kwargs): + if task_id not in self.tasks: + return + + task = self.tasks[task_id] + + if description: + task["description"] = description + # self.console.print(f" {description}") # Don't print every update, too noisy + + if advance: + task["completed"] += advance + + def start(self): + pass + + def stop(self): + pass + + def run_with_spinner( - progress: Progress, + progress: Progress | SimpleSyncProgress, spinner_task: TaskID, operation_name: str, operation_func, @@ -198,7 +281,7 @@ def run_with_spinner( """Run an operation with a spinner and timing. Args: - progress: Rich Progress instance + progress: Rich Progress instance or SimpleSyncProgress spinner_task: Spinner task ID operation_name: Name of operation to display operation_func: Function to execute @@ -206,7 +289,12 @@ def run_with_spinner( operations_task: Optional operations progress task to advance """ start_time = time.perf_counter() - progress.update(spinner_task, description=f"[cyan]{operation_name}...") + + # Update description (or print it for simple progress) + if isinstance(progress, SimpleSyncProgress): + progress.console.print(f"[cyan]{operation_name}...[/cyan]") + else: + progress.update(spinner_task, description=f"[cyan]{operation_name}...") try: result = operation_func() @@ -256,8 +344,13 @@ def run_with_spinner( is_flag=True, help="Show detailed progress and timing information", ) +@click.option( + "--non-interactive", + is_flag=True, + help="Do not prompt for user input (skip repositories with unexpected files)", +) @click.pass_context -def sync(ctx, repo, push, auto_merge, strategy, verbose): +def sync(ctx, repo, push, auto_merge, strategy, verbose, non_interactive): """Sync task repositories with git (pull and optionally push).""" config = ctx.obj["config"] manager = RepositoryManager(config.parent_dir) @@ -296,29 +389,30 @@ def sync(ctx, repo, push, auto_merge, strategy, verbose): repo_timings = {} total_start_time = time.perf_counter() - # Create progress context with progress bar (for repos or operations) - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - BarColumn(), - TextColumn("[progress.percentage]{task.completed}/{task.total}"), - TimeElapsedColumn(), - console=console, - ) as progress: + # Track timing for each repository + repo_timings = {} + total_start_time = time.perf_counter() + + # Use SimpleSyncProgress to avoid terminal freezing issues during prompts + # The user explicitly requested a non-interactive progress bar (linear logs) + # to solve the hanging issues with the spinner. + progress_manager = SimpleSyncProgress(console=console) + + with progress_manager as progress: # Add overall progress task # - For multiple repos: track repository progress # - For single repo: track operation progress if len(repositories) > 1: - overall_task = progress.add_task("[bold]Syncing repositories", total=len(repositories), completed=0) + overall_task = progress.add_task("[bold]Syncing repositories", total=len(repositories)) operations_task = None # Operations tracking not needed for multi-repo else: # Estimate operations for single repo (will be adjusted dynamically) estimated_ops = 6 # Base: check conflicts, pull, update readme, archive readme, maybe commit/push - overall_task = progress.add_task("[bold]Syncing operations", total=estimated_ops, completed=0) + overall_task = progress.add_task("[bold]Syncing operations", total=estimated_ops) operations_task = overall_task # Use same task for operations # Add spinner task for per-operation status - spinner_task = progress.add_task("", total=None) + spinner_task = progress.add_task("Initializing...", total=None) for repo_index, repository in enumerate(repositories, 1): repo_start_time = time.perf_counter() @@ -340,6 +434,39 @@ def sync(ctx, repo, push, auto_merge, strategy, verbose): f"[bold cyan][{repo_index}/{len(repositories)}][/bold cyan] {repository.name} [dim](local: {repository.path})[/dim]" ) + # Local flag for this repository's push status + should_push = push + + # Check for detached HEAD and try to recover + if git_repo.head.is_detached: + progress.console.print(" [yellow]⚠[/yellow] Repository is in detached HEAD state") + + # Use a separate exception block to ensure we don't crash the whole sync + try: + # Determine target branch (default to main, fallback to master) + target_branch = "main" + if "main" not in git_repo.heads and "master" in git_repo.heads: + target_branch = "master" + + if target_branch in git_repo.heads: + current_sha = git_repo.head.commit.hexsha + branch_sha = git_repo.heads[target_branch].commit.hexsha + + if current_sha == branch_sha: + # We are at the tip of the branch, just detached. Safe to switch. + git_repo.heads[target_branch].checkout() + progress.console.print(f" [green]āœ“[/green] Automatically re-attached to branch '{target_branch}'") + else: + progress.console.print(f" [yellow]⚠[/yellow] HEAD ({current_sha[:7]}) does not match {target_branch} ({branch_sha[:7]})") + progress.console.print(" [yellow]⚠[/yellow] Skipping push to avoid errors. Please checkout a branch manually.") + should_push = False + else: + progress.console.print(f" [yellow]⚠[/yellow] Default branch '{target_branch}' not found locally") + should_push = False + except Exception as e: + progress.console.print(f" [red]āœ—[/red] Failed to recover from detached HEAD: {e}") + should_push = False + try: # Check if there are uncommitted changes (including untracked files) if git_repo.is_dirty(untracked_files=True): @@ -354,23 +481,36 @@ def sync(ctx, repo, push, auto_merge, strategy, verbose): unexpected = detect_unexpected_files(git_repo, repository.path) if unexpected: - progress.console.print(" [yellow]⚠[/yellow] Found unexpected files") - action = prompt_unexpected_files(unexpected, repository.name) - - if action == "ignore": - # Add patterns to .gitignore - patterns = list(unexpected.keys()) - add_to_gitignore(patterns, repository.path) - # Stage .gitignore change - git_repo.git.add(".gitignore") - elif action == "delete": - # Delete the files - delete_unexpected_files(unexpected, repository.path) - elif action == "skip": + if non_interactive: + progress.console.print(" [yellow]⚠[/yellow] Found unexpected files - skipping in non-interactive mode") # Skip this repository progress.console.print(" [yellow]āŠ—[/yellow] Skipped repository") continue - # If "commit", proceed as normal + else: + # Interactive mode: Pause progress to allow cleaner input + progress.stop() + try: + # Provide clear visual separation + progress.console.print() + # Use separate console inside function to avoid progress bar conflict + action = prompt_unexpected_files(unexpected, repository.name) + finally: + progress.start() + + if action == "ignore": + # Add patterns to .gitignore + patterns = list(unexpected.keys()) + add_to_gitignore(patterns, repository.path) + # Stage .gitignore change + git_repo.git.add(".gitignore") + elif action == "delete": + # Delete the files + delete_unexpected_files(unexpected, repository.path) + elif action == "skip": + # Skip this repository + progress.console.print(" [yellow]āŠ—[/yellow] Skipped repository") + continue + # If "commit", proceed as normal # Stage all changes but don't commit yet (will consolidate commits later) def stage_changes(): @@ -450,9 +590,17 @@ def resolve_markers(): progress.update(overall_task, advance=1) continue + # Fetch first to check for changes + # Use verbose fetch to avoid hanging silently on network/auth + if git_repo.remotes: + current_branch = git_repo.active_branch.name + if not run_git_verbose(str(repository.path), ["fetch", "origin"], "Fetch failed"): + # If fetch fails, we might still proceed safely locally, or abort + progress.console.print(" [yellow]⚠[/yellow] Fetch failed - proceeding with local state") + # Detect conflicts before pulling (pass cache to avoid redundant parsing) def check_conflicts(): - return detect_conflicts(git_repo, repository.path, task_cache=task_cache) + return detect_conflicts(git_repo, repository.path, task_cache=task_cache, skip_fetch=True) conflicts, _ = run_with_spinner( progress, spinner_task, "Checking for conflicts", check_conflicts, verbose, operations_task @@ -700,109 +848,25 @@ def create_consolidated_commit(): # Push changes if push: - - def push_changes(): - origin = git_repo.remotes.origin - push_info = origin.push() - - # Check if push failed by examining PushInfo flags - # GitPython doesn't always raise exceptions on push failures - for info in push_info: - # Check for error flags - IMPORTANT: Check REJECTED before ERROR - # because rejected pushes set both flags, but we want auto-recovery for REJECTED - if info.flags & info.REJECTED: - # Non-fast-forward - will attempt auto-recovery - raise GitCommandError("git push", "REJECTED") - if info.flags & info.REMOTE_REJECTED: - raise GitCommandError("git push", f"Remote rejected push: {info.summary}") - if info.flags & info.REMOTE_FAILURE: - raise GitCommandError("git push", f"Remote failure during push: {info.summary}") - if info.flags & info.ERROR: - raise GitCommandError("git push", f"Push failed with ERROR flag: {info.summary}") - - return push_info - try: - run_with_spinner( - progress, spinner_task, "Pushing to remote", push_changes, verbose, operations_task - ) - except GitCommandError as e: - # Check if this is a non-fast-forward rejection that we can auto-recover - if "REJECTED" in str(e): - progress.console.print( - " [yellow]⚠[/yellow] Push rejected (branches diverged) - attempting auto-recovery..." - ) - - # Try to pull with rebase - try: + if should_push and git_repo.remotes: + progress.console.print(" [dim]Pushing to remote...[/dim]") + if not run_git_verbose(str(repository.path), ["push", "origin", current_branch], "Push failed"): + raise GitCommandError("git push", "Process failed") + elif not should_push and push and git_repo.remotes: + progress.console.print(" [dim]⊘ Pushing skipped (detached HEAD or error)[/dim]") + except GitCommandError: + # Fallback to recovery if we detect rejection + progress.console.print(" [yellow]⚠[/yellow] Push failed. Attempting auto-recovery (pull --rebase)...") + + if run_git_verbose(str(repository.path), ["pull", "--rebase", "origin", current_branch], "Rebase failed"): + # Try pushing again + progress.console.print(" [dim]Retrying push...[/dim]") + if not run_git_verbose(str(repository.path), ["push", "origin", current_branch], "Retry push failed"): + progress.console.print(" [red]āœ—[/red] Retry push failed after rebase") + else: + progress.console.print(" [red]āœ—[/red] Auto-recovery (rebase) failed") - def rebase_pull(): - # Get current branch - current_branch = git_repo.active_branch.name - # Pull with rebase to integrate remote changes - git_repo.git.pull("--rebase", "origin", current_branch) - - run_with_spinner( - progress, - spinner_task, - "Pulling with rebase", - rebase_pull, - verbose, - operations_task, - ) - - # Check if rebase created conflicts - if git_repo.is_dirty(working_tree=True, untracked_files=False): - # Rebase created conflicts - abort and report - progress.console.print( - " [red]āœ—[/red] Rebase created conflicts - aborting auto-recovery" - ) - try: - git_repo.git.rebase("--abort") - progress.console.print(" [yellow]→[/yellow] Aborted rebase") - except Exception: - pass - progress.console.print( - " [yellow]→[/yellow] Manual resolution required: git pull --rebase && git push" - ) - raise - else: - # Rebase succeeded - try push again - progress.console.print(" [green]āœ“[/green] Rebase successful - retrying push") - - def retry_push(): - repo_origin = git_repo.remotes.origin - push_info = repo_origin.push() - # Check for errors again - for info in push_info: - if info.flags & ( - info.ERROR - | info.REJECTED - | info.REMOTE_REJECTED - | info.REMOTE_FAILURE - ): - raise GitCommandError( - "git push", f"Push still failed: {info.summary}" - ) - return push_info - - run_with_spinner( - progress, spinner_task, "Pushing to remote (retry)", retry_push, verbose - ) - - except GitCommandError as rebase_error: - # Rebase or retry push failed - if "conflict" in str(rebase_error).lower(): - progress.console.print( - " [red]āœ—[/red] Auto-recovery failed: conflicts during rebase" - ) - progress.console.print( - " [yellow]→[/yellow] Resolve manually: git pull --rebase && git push" - ) - raise - else: - # Other push error - re-raise - raise else: progress.console.print(" • No remote configured (local repository only)") @@ -914,7 +978,13 @@ def create_consolidated_commit(): console.print("[cyan]IDs rebalanced to sequential order[/cyan]") console.print() - display_tasks_table(all_tasks, config, save_cache=False) + # If specific repo was synced, only show tasks for that repo + if repo: + tasks_to_display = [t for t in all_tasks if t.repo == repo] + else: + tasks_to_display = all_tasks + + display_tasks_table(tasks_to_display, config, save_cache=False) def _show_merge_details( diff --git a/src/taskrepo/cli/commands/tui.py b/src/taskrepo/cli/commands/tui.py index 2d91c35..977df8d 100644 --- a/src/taskrepo/cli/commands/tui.py +++ b/src/taskrepo/cli/commands/tui.py @@ -67,7 +67,7 @@ def _background_sync(config): # Run sync as subprocess with output suppressed to avoid interfering with TUI if tsk_cmd: result = subprocess.run( - [tsk_cmd, "sync"], + [tsk_cmd, "sync", "--non-interactive"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=300, # 5 minute timeout @@ -77,7 +77,7 @@ def _background_sync(config): import sys result = subprocess.run( - [sys.executable, "-m", "taskrepo.cli.main", "sync"], + [sys.executable, "-m", "taskrepo.cli.main", "sync", "--non-interactive"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=300, # 5 minute timeout @@ -198,7 +198,7 @@ def tui(ctx, repo, no_sync): sync_thread.start() # If repo specified, find its index and start there - start_repo_idx = -1 # Default to "All" tab + start_repo_idx = None # Will be set only if --repo flag provided if repo: try: start_repo_idx = next(i for i, r in enumerate(repositories) if r.name == repo) @@ -214,8 +214,9 @@ def tui(ctx, repo, no_sync): # Create and run TUI in a loop task_tui = TaskTUI(config, repositories) - # Set the starting view index - task_tui.current_view_idx = start_repo_idx + # Set the starting view index only if --repo was explicitly provided + if start_repo_idx is not None: + task_tui.current_view_idx = start_repo_idx while True: result = task_tui.run() diff --git a/src/taskrepo/cli/commands/unarchive.py b/src/taskrepo/cli/commands/unarchive.py index 9670c8c..773aa04 100644 --- a/src/taskrepo/cli/commands/unarchive.py +++ b/src/taskrepo/cli/commands/unarchive.py @@ -1,5 +1,7 @@ """Unarchive command for restoring archived tasks.""" +import sys + import click from prompt_toolkit.shortcuts import prompt from prompt_toolkit.validation import Validator @@ -76,26 +78,30 @@ def unarchive(ctx, task_ids, repo, yes): unarchive_subtasks = yes # Default to --yes flag value if not yes: - # Show subtasks and prompt - click.echo(f"\nThis task has {count} archived {subtask_word}:") - for subtask, subtask_repo in archived_subtasks: - status_emoji = STATUS_EMOJIS.get(subtask.status, "") - click.echo(f" • {status_emoji} {subtask.title} (repo: {subtask_repo.name})") - - # Prompt for confirmation with Y as default - yn_validator = Validator.from_callable( - lambda text: text.lower() in ["y", "n", "yes", "no"], - error_message="Please enter 'y' or 'n'", - move_cursor_to_end=True, - ) - - response = prompt( - f"Unarchive all {count} {subtask_word} too? (Y/n) ", - default="y", - validator=yn_validator, - ).lower() - - unarchive_subtasks = response in ["y", "yes"] + # Check if we're in a terminal - if not, default to yes + if not sys.stdin.isatty(): + unarchive_subtasks = True + else: + # Show subtasks and prompt + click.echo(f"\nThis task has {count} archived {subtask_word}:") + for subtask, subtask_repo in archived_subtasks: + status_emoji = STATUS_EMOJIS.get(subtask.status, "") + click.echo(f" • {status_emoji} {subtask.title} (repo: {subtask_repo.name})") + + # Prompt for confirmation with Y as default + yn_validator = Validator.from_callable( + lambda text: text.lower() in ["y", "n", "yes", "no"], + error_message="Please enter 'y' or 'n'", + move_cursor_to_end=True, + ) + + response = prompt( + f"Unarchive all {count} {subtask_word} too? (Y/n) ", + default="y", + validator=yn_validator, + ).lower() + + unarchive_subtasks = response in ["y", "yes"] if unarchive_subtasks: # Unarchive all subtasks diff --git a/src/taskrepo/cli/commands/update.py b/src/taskrepo/cli/commands/update.py new file mode 100644 index 0000000..9875f21 --- /dev/null +++ b/src/taskrepo/cli/commands/update.py @@ -0,0 +1,145 @@ +"""Update command for modifying task fields.""" + +import sys +from typing import Optional, Tuple + +import click +from dateparser import parse as parse_date + +from taskrepo.core.repository import RepositoryManager +from taskrepo.utils.helpers import find_task_by_title_or_id, select_task_from_result + + +@click.command() +@click.argument("task_ids", nargs=-1, required=True) +@click.option("--repo", "-r", help="Repository name (will search all repos if not specified)") +@click.option("--priority", "-p", type=click.Choice(["H", "M", "L"]), help="Set priority") +@click.option("--status", "-s", type=click.Choice(["pending", "in-progress", "completed", "cancelled"]), help="Set status") +@click.option("--project", help="Set project name") +@click.option("--add-tag", multiple=True, help="Add tag(s) to task") +@click.option("--remove-tag", multiple=True, help="Remove tag(s) from task") +@click.option("--add-assignee", multiple=True, help="Add assignee(s) to task (use @username format)") +@click.option("--remove-assignee", multiple=True, help="Remove assignee(s) from task") +@click.option("--due", help="Set due date (natural language or ISO format)") +@click.option("--title", help="Set new title") +@click.pass_context +def update(ctx, task_ids: Tuple[str, ...], repo: Optional[str], priority: Optional[str], + status: Optional[str], project: Optional[str], add_tag: Tuple[str, ...], + remove_tag: Tuple[str, ...], add_assignee: Tuple[str, ...], + remove_assignee: Tuple[str, ...], due: Optional[str], title: Optional[str]): + """Update fields for one or more tasks. + + Examples: + # Update single task + tsk update 5 --priority H --add-tag urgent + + # Update multiple tasks + tsk update 5,6,7 --status in-progress --add-assignee @alice + + # Update with various fields + tsk update 10 --priority M --project backend --due tomorrow + + TASK_IDS: One or more task IDs (comma-separated or space-separated) + """ + config = ctx.obj["config"] + manager = RepositoryManager(config.parent_dir) + + # Check that at least one update option is provided + if not any([priority, status, project, add_tag, remove_tag, add_assignee, + remove_assignee, due, title]): + click.secho("Error: At least one update option must be specified", fg="red", err=True) + ctx.exit(1) + + # Flatten comma-separated task IDs + task_id_list = [] + for task_id in task_ids: + task_id_list.extend([tid.strip() for tid in task_id.split(",")]) + + updated_count = 0 + + for task_id in task_id_list: + # Find task + result = find_task_by_title_or_id(manager, task_id, repo) + + if result[0] is None: + click.secho(f"āœ— No task found matching '{task_id}'", fg="red") + continue + + task, repository = select_task_from_result(ctx, result, task_id) + + # Apply updates + changes = [] + + if priority: + task.priority = priority + changes.append(f"priority → {priority}") + + if status: + task.status = status + changes.append(f"status → {status}") + + if project: + task.project = project + changes.append(f"project → {project}") + + if title: + task.title = title + changes.append(f"title → {title}") + + if add_tag: + if task.tags is None: + task.tags = [] + for tag in add_tag: + if tag not in task.tags: + task.tags.append(tag) + changes.append(f"+tag: {tag}") + + if remove_tag: + if task.tags: + for tag in remove_tag: + if tag in task.tags: + task.tags.remove(tag) + changes.append(f"-tag: {tag}") + + if add_assignee: + if task.assignees is None: + task.assignees = [] + for assignee in add_assignee: + # Ensure @ prefix + if not assignee.startswith("@"): + assignee = "@" + assignee + if assignee not in task.assignees: + task.assignees.append(assignee) + changes.append(f"+assignee: {assignee}") + + if remove_assignee: + if task.assignees: + for assignee in remove_assignee: + # Handle with or without @ prefix + if not assignee.startswith("@"): + assignee = "@" + assignee + if assignee in task.assignees: + task.assignees.remove(assignee) + changes.append(f"-assignee: {assignee}") + + if due: + parsed_date = parse_date(due) + if parsed_date: + task.due = parsed_date.isoformat() + changes.append(f"due → {due}") + else: + click.secho(f"Warning: Could not parse due date '{due}' for task {task_id}", fg="yellow") + + # Save task + if changes: + repository.save_task(task) + updated_count += 1 + click.secho(f"āœ“ Updated task: {task.title}", fg="green") + for change in changes: + click.echo(f" • {change}") + + click.echo() + if updated_count > 0: + click.secho(f"Updated {updated_count} task(s)", fg="green", bold=True) + else: + click.secho("No tasks were updated", fg="yellow") diff --git a/src/taskrepo/cli/main.py b/src/taskrepo/cli/main.py index ff7ba03..a7f5f17 100644 --- a/src/taskrepo/cli/main.py +++ b/src/taskrepo/cli/main.py @@ -10,6 +10,8 @@ from taskrepo.__version__ import __version__ from taskrepo.cli.commands.add import add +from taskrepo.cli.commands.add_link import add_link +from taskrepo.cli.commands.append import append from taskrepo.cli.commands.archive import archive from taskrepo.cli.commands.cancelled import cancelled from taskrepo.cli.commands.changelog import changelog @@ -28,6 +30,7 @@ from taskrepo.cli.commands.sync import sync from taskrepo.cli.commands.tui import tui from taskrepo.cli.commands.unarchive import unarchive +from taskrepo.cli.commands.update import update from taskrepo.cli.commands.upgrade import upgrade from taskrepo.core.config import Config from taskrepo.utils.banner import display_banner @@ -159,6 +162,8 @@ def process_result(ctx, result, **kwargs): # Register commands cli.add_command(add) +cli.add_command(add_link) +cli.add_command(append) cli.add_command(archive) cli.add_command(cancelled) cli.add_command(changelog) @@ -177,6 +182,7 @@ def process_result(ctx, result, **kwargs): cli.add_command(sync) cli.add_command(tui) cli.add_command(unarchive) +cli.add_command(update) cli.add_command(upgrade) diff --git a/src/taskrepo/core/config.py b/src/taskrepo/core/config.py index 7c26a46..3d1cc92 100644 --- a/src/taskrepo/core/config.py +++ b/src/taskrepo/core/config.py @@ -26,6 +26,9 @@ class Config: "sort_by": ["due", "priority"], "cluster_due_dates": False, "tui_view_mode": "repo", # Options: "repo", "project", "assignee" + "remember_tui_state": True, # Remember TUI view state (view mode, tree view, etc.) + "tui_tree_view": True, # Tree view enabled/disabled + "tui_last_view_item": None, # Last selected repo/project/assignee name "auto_sync_enabled": True, # Enable background sync in TUI "auto_sync_interval": 300, # Sync every 5 minutes (in seconds) "auto_sync_strategy": "auto", # Auto-merge strategy for background sync @@ -333,6 +336,63 @@ def tui_view_mode(self, value: str): self._data["tui_view_mode"] = value self.save() + @property + def remember_tui_state(self) -> bool: + """Get remember TUI state setting. + + Returns: + True if TUI state (view mode, tree view, selected item) should be remembered + """ + return self._data.get("remember_tui_state", True) + + @remember_tui_state.setter + def remember_tui_state(self, value: bool): + """Set remember TUI state setting. + + Args: + value: True to remember TUI state across sessions + """ + self._data["remember_tui_state"] = bool(value) + self.save() + + @property + def tui_tree_view(self) -> bool: + """Get TUI tree view setting. + + Returns: + True if tree view is enabled + """ + return self._data.get("tui_tree_view", True) + + @tui_tree_view.setter + def tui_tree_view(self, value: bool): + """Set TUI tree view setting. + + Args: + value: True to enable tree view + """ + self._data["tui_tree_view"] = bool(value) + self.save() + + @property + def tui_last_view_item(self) -> Optional[str]: + """Get last selected view item in TUI. + + Returns: + Name of last selected repo/project/assignee, or None + """ + return self._data.get("tui_last_view_item", None) + + @tui_last_view_item.setter + def tui_last_view_item(self, value: Optional[str]): + """Set last selected view item in TUI. + + Args: + value: Name of repo/project/assignee, or None for "All" + """ + self._data["tui_last_view_item"] = value + self.save() + @property def auto_sync_enabled(self) -> bool: """Get automatic sync enabled status. diff --git a/src/taskrepo/tui/task_tui.py b/src/taskrepo/tui/task_tui.py index d849f25..0e1cf04 100644 --- a/src/taskrepo/tui/task_tui.py +++ b/src/taskrepo/tui/task_tui.py @@ -53,12 +53,25 @@ def __init__(self, config: Config, repositories: list[Repository]): # Build view items based on mode self.view_items = self._build_view_items() - # Start at -1 to show "All" items first - self.current_view_idx = -1 + # Restore view state from config if remember_tui_state is enabled + if config.remember_tui_state: + # Restore tree view state + self.tree_view = config.tui_tree_view + + # Restore last selected view item + last_item = config.tui_last_view_item + if last_item and last_item in self.view_items: + self.current_view_idx = self.view_items.index(last_item) + else: + self.current_view_idx = -1 # Default to "All" + else: + # Default state + self.current_view_idx = -1 # Show "All" items first + self.tree_view = True + self.selected_row = 0 self.multi_selected: set[str] = set() # Store task UUIDs self.filter_text = "" - self.tree_view = True self.filter_active = False self.show_detail_panel = True # Always show detail panel @@ -647,6 +660,12 @@ def _(event): self.selected_row = 0 self.viewport_top = 0 # Reset viewport self.multi_selected.clear() + # Save current view item to config if remember_tui_state is enabled + if self.config.remember_tui_state: + if self.current_view_idx == -1: + self.config.tui_last_view_item = None + else: + self.config.tui_last_view_item = self.view_items[self.current_view_idx] @kb.add("left", filter=Condition(lambda: not self.filter_active)) def _(event): @@ -656,6 +675,12 @@ def _(event): self.selected_row = 0 self.viewport_top = 0 # Reset viewport self.multi_selected.clear() + # Save current view item to config if remember_tui_state is enabled + if self.config.remember_tui_state: + if self.current_view_idx == -1: + self.config.tui_last_view_item = None + else: + self.config.tui_last_view_item = self.view_items[self.current_view_idx] # Tab to switch view type (only when not filtering) @kb.add("tab", filter=Condition(lambda: not self.filter_active)) @@ -675,6 +700,9 @@ def _(event): # Reset to "All" view self.current_view_idx = -1 + # Reset last view item when switching modes + if self.config.remember_tui_state: + self.config.tui_last_view_item = None self.selected_row = 0 self.viewport_top = 0 self.multi_selected.clear() @@ -759,10 +787,13 @@ def _(event): event.app.exit(result="priority-low") # View operations (only when not filtering) - @kb.add("r", filter=Condition(lambda: not self.filter_active)) + @kb.add("t", filter=Condition(lambda: not self.filter_active)) def _(event): """Toggle tree view.""" self.tree_view = not self.tree_view + # Save to config if remember_tui_state is enabled + if self.config.remember_tui_state: + self.config.tui_tree_view = self.tree_view @kb.add("s", filter=Condition(lambda: not self.filter_active)) def _(event): diff --git a/src/taskrepo/utils/file_validation.py b/src/taskrepo/utils/file_validation.py index 515b12e..8c3ca70 100644 --- a/src/taskrepo/utils/file_validation.py +++ b/src/taskrepo/utils/file_validation.py @@ -4,7 +4,7 @@ import click from git import Repo as GitRepo -from prompt_toolkit.shortcuts import confirm +from prompt_toolkit.shortcuts import confirm, prompt def detect_unexpected_files(git_repo: GitRepo, repo_path: Path) -> dict[str, list[Path]]: @@ -82,6 +82,8 @@ def is_valid_file(file_path: Path) -> bool: return grouped +from rich.console import Console + def prompt_unexpected_files(unexpected_files: dict[str, list[Path]], repo_name: str) -> str: """Prompt user about unexpected files and return action choice. @@ -92,26 +94,35 @@ def prompt_unexpected_files(unexpected_files: dict[str, list[Path]], repo_name: Returns: User choice: "ignore", "delete", "commit", or "skip" """ - click.echo(f"\nāš ļø Found unexpected files in repository '{repo_name}':\n") + # Use a fresh Console to avoid conflicts with progress bar + console = Console() + + console.print(f"\n[yellow]āš ļø[/yellow] Found unexpected files in repository '{repo_name}':\n") # Display grouped files for pattern, files in unexpected_files.items(): file_count = len(files) - click.echo(f" {pattern} ({file_count} file{'s' if file_count != 1 else ''}):") + console.print(f" {pattern} ({file_count} file{'s' if file_count != 1 else ''}):") for file_path in sorted(files)[:5]: # Show max 5 files per pattern - click.echo(f" - {file_path}") + console.print(f" - {file_path}") if file_count > 5: - click.echo(f" ... and {file_count - 5} more") - click.echo() + console.print(f" ... and {file_count - 5} more") + console.print() - click.echo("Options:") - click.echo(" [i] Add patterns to .gitignore and exclude from commit") - click.echo(" [d] Delete these files") - click.echo(" [c] Commit these files anyway") - click.echo(" [s] Skip this repository (don't commit anything)") + console.print("Options:") + console.print(" \\[i] Add patterns to .gitignore and exclude from commit") + console.print(" \\[d] Delete these files") + console.print(" \\[c] Commit these files anyway") + console.print(" \\[s] Skip this repository (don't commit anything)") while True: - choice = click.prompt("\nYour choice", type=str, default="i").lower().strip() + # Use prompt_toolkit's prompt for robust input handling + try: + choice = prompt("\nYour choice (default: i): ", default="i").lower().strip() + except (KeyboardInterrupt, EOFError): + # Safe default on interrupt + return "skip" + if choice in ["i", "ignore"]: return "ignore" elif choice in ["d", "delete", "del"]: @@ -119,14 +130,14 @@ def prompt_unexpected_files(unexpected_files: dict[str, list[Path]], repo_name: if confirm("āš ļø Are you sure you want to delete these files? This cannot be undone."): return "delete" else: - click.echo("Cancelled deletion. Choose another option.") + console.print("Cancelled deletion. Choose another option.") continue elif choice in ["c", "commit"]: return "commit" elif choice in ["s", "skip"]: return "skip" else: - click.secho(f"Invalid choice: {choice}. Please enter i, d, c, or s.", fg="yellow") + console.print(f"[yellow]Invalid choice: {choice}. Please enter i, d, c, or s.[/yellow]") def add_to_gitignore(patterns: list[str], repo_path: Path) -> None: