From 56b4ce5c3888d3c2e687a69d18f42bc238f4dce2 Mon Sep 17 00:00:00 2001 From: Ricardo Henriques Date: Wed, 7 Jan 2026 16:28:06 +0000 Subject: [PATCH 1/5] 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: From 29d6de66e30da245daf57e9352818eaff33bbf38 Mon Sep 17 00:00:00 2001 From: Ricardo Henriques Date: Tue, 13 Jan 2026 13:44:02 +0000 Subject: [PATCH 2/5] feat: improve error handling for corrupted task files and git conflicts - Add silent_errors parameter to list_tasks() and list_archived_tasks() - Detect and report git conflict markers specifically - Collect all failed files and show helpful summary with resolution steps - Improve error messages to show filename only (not full path) - Suggest running 'git status' when conflicts are detected This makes the TUI more resilient to YAML parsing errors caused by unresolved git merge conflicts, preventing crashes and providing better guidance to users. --- src/taskrepo/core/repository.py | 52 +++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/src/taskrepo/core/repository.py b/src/taskrepo/core/repository.py index c860bd6..41f5c11 100644 --- a/src/taskrepo/core/repository.py +++ b/src/taskrepo/core/repository.py @@ -124,18 +124,20 @@ def _migrate_done_to_tasks(self) -> None: except Exception as e: print(f"Warning: Could not fully remove done/ folder: {e}") - def list_tasks(self, include_archived: bool = False) -> list[Task]: + def list_tasks(self, include_archived: bool = False, silent_errors: bool = False) -> list[Task]: """List all tasks in this repository. Uses LRU caching to avoid re-parsing unchanged task files. Args: include_archived: If True, also load tasks from archive/ folder + silent_errors: If True, suppress individual error messages (still collects errors) Returns: List of Task objects (from tasks/ folder, excluding archive/ subdirectory) """ tasks = [] + failed_files = [] # Load from tasks/ directory (excluding archive/ subdirectory) if self.tasks_dir.exists(): @@ -146,7 +148,13 @@ def list_tasks(self, include_archived: bool = False) -> list[Task]: task = _load_task_cached(str(task_file), mtime, self.name) tasks.append(task) except Exception as e: - print(f"Warning: Failed to load task {task_file}: {e}") + failed_files.append((task_file, str(e))) + if not silent_errors: + # Check if error is due to git conflict markers + if "<<<<<<< HEAD" in str(e) or "could not find expected ':'" in str(e): + print(f"Warning: Failed to load task {task_file.name}: Invalid YAML frontmatter: {e}") + else: + print(f"Warning: Failed to load task {task_file.name}: {e}") # Optionally load from archive/ directory if include_archived and self.archive_dir.exists(): @@ -157,7 +165,22 @@ def list_tasks(self, include_archived: bool = False) -> list[Task]: task = _load_task_cached(str(task_file), mtime, self.name) tasks.append(task) except Exception as e: - print(f"Warning: Failed to load task {task_file}: {e}") + failed_files.append((task_file, str(e))) + if not silent_errors: + # Check if error is due to git conflict markers + if "<<<<<<< HEAD" in str(e) or "could not find expected ':'" in str(e): + print(f"Warning: Failed to load task {task_file.name}: Invalid YAML frontmatter: {e}") + else: + print(f"Warning: Failed to load task {task_file.name}: {e}") + + # Show summary if there were errors and we're being silent + if silent_errors and failed_files: + print(f"Warning: Failed to load {len(failed_files)} task(s) in {self.name} repository") + # Check for git conflict markers + conflict_files = [f for f, e in failed_files if "<<<<<<< HEAD" in e or "could not find expected ':'" in e] + if conflict_files: + print(f" → {len(conflict_files)} file(s) appear to have unresolved git merge conflicts") + print(" → Run 'git status' to check for conflicts and resolve them") return tasks @@ -229,13 +252,17 @@ def delete_task(self, task_id: str) -> bool: return False - def list_archived_tasks(self) -> list[Task]: + def list_archived_tasks(self, silent_errors: bool = False) -> list[Task]: """List all archived tasks in this repository. + Args: + silent_errors: If True, suppress individual error messages (still collects errors) + Returns: List of Task objects from archive/ folder """ tasks = [] + failed_files = [] if self.archive_dir.exists(): for task_file in sorted(self.archive_dir.glob("task-*.md")): @@ -243,7 +270,22 @@ def list_archived_tasks(self) -> list[Task]: task = Task.load(task_file, repo=self.name) tasks.append(task) except Exception as e: - print(f"Warning: Failed to load archived task {task_file}: {e}") + failed_files.append((task_file, str(e))) + if not silent_errors: + # Check if error is due to git conflict markers + if "<<<<<<< HEAD" in str(e) or "could not find expected ':'" in str(e): + print(f"Warning: Failed to load archived task {task_file.name}: Invalid YAML frontmatter: {e}") + else: + print(f"Warning: Failed to load archived task {task_file.name}: {e}") + + # Show summary if there were errors and we're being silent + if silent_errors and failed_files: + print(f"Warning: Failed to load {len(failed_files)} archived task(s) in {self.name} repository") + # Check for git conflict markers + conflict_files = [f for f, e in failed_files if "<<<<<<< HEAD" in e or "could not find expected ':'" in e] + if conflict_files: + print(f" → {len(conflict_files)} file(s) appear to have unresolved git merge conflicts") + print(" → Run 'git status' to check for conflicts and resolve them") return tasks From ad09caad70bf0ce2682ef22dd526f4a13b63f262 Mon Sep 17 00:00:00 2001 From: Ricardo Henriques Date: Tue, 13 Jan 2026 13:47:45 +0000 Subject: [PATCH 3/5] fix: resolve linting errors and type issues for CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix all ruff linting errors (unused imports, whitespace, f-strings) - Move rich.console import to top of file_validation.py - Fix type error in update.py: use datetime instead of string for task.due - Format code with ruff formatter All must-fix issues from robot review addressed: - Type error in update.py:128 (datetime vs string) āœ“ - detect_conflicts signature verified (skip_fetch parameter exists) āœ“ - All linting errors fixed āœ“ --- src/taskrepo/cli/commands/add_link.py | 1 - src/taskrepo/cli/commands/append.py | 3 +- src/taskrepo/cli/commands/archive.py | 1 + src/taskrepo/cli/commands/sync.py | 93 +++++++++++++++------------ src/taskrepo/cli/commands/update.py | 28 +++++--- src/taskrepo/core/repository.py | 4 +- src/taskrepo/utils/file_validation.py | 7 +- 7 files changed, 80 insertions(+), 57 deletions(-) diff --git a/src/taskrepo/cli/commands/add_link.py b/src/taskrepo/cli/commands/add_link.py index feb2bc9..9caf199 100644 --- a/src/taskrepo/cli/commands/add_link.py +++ b/src/taskrepo/cli/commands/add_link.py @@ -1,6 +1,5 @@ """Add-link command for adding URLs to task links.""" -import sys from typing import Optional import click diff --git a/src/taskrepo/cli/commands/append.py b/src/taskrepo/cli/commands/append.py index c5aa6ee..4f80b56 100644 --- a/src/taskrepo/cli/commands/append.py +++ b/src/taskrepo/cli/commands/append.py @@ -1,6 +1,5 @@ """Append command for adding content to task descriptions.""" -import sys from typing import Optional import click @@ -45,5 +44,5 @@ def append(ctx, task_id: str, text: str, repo: Optional[str]): repository.save_task(task) click.secho(f"āœ“ Appended text to task: {task.title}", fg="green") - click.echo(f"\nNew content added:") + click.echo("\nNew content added:") click.echo(f" {text}") diff --git a/src/taskrepo/cli/commands/archive.py b/src/taskrepo/cli/commands/archive.py index febc938..236edd9 100644 --- a/src/taskrepo/cli/commands/archive.py +++ b/src/taskrepo/cli/commands/archive.py @@ -52,6 +52,7 @@ def archive(ctx, task_ids: Tuple[str, ...], repo, yes, all_completed): # 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) diff --git a/src/taskrepo/cli/commands/sync.py b/src/taskrepo/cli/commands/sync.py index 4cf86e3..e89472a 100644 --- a/src/taskrepo/cli/commands/sync.py +++ b/src/taskrepo/cli/commands/sync.py @@ -12,7 +12,7 @@ from git import GitCommandError from rich.console import Console from rich.markup import escape -from rich.progress import BarColumn, Progress, SpinnerColumn, TaskID, TextColumn, TimeElapsedColumn +from rich.progress import Progress, TaskID from taskrepo.core.repository import Repository, RepositoryManager from taskrepo.core.task import Task @@ -26,12 +26,12 @@ 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 """ @@ -39,14 +39,10 @@ def run_git_verbose(repo_path: str, args: list[str], error_msg: str) -> bool: # 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 - ) + 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}") @@ -218,10 +214,9 @@ 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. @@ -242,27 +237,27 @@ 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 @@ -289,7 +284,7 @@ def run_with_spinner( operations_task: Optional operations progress task to advance """ start_time = time.perf_counter() - + # Update description (or print it for simple progress) if isinstance(progress, SimpleSyncProgress): progress.console.print(f"[cyan]{operation_name}...[/cyan]") @@ -440,28 +435,36 @@ def sync(ctx, repo, push, auto_merge, strategy, verbose, non_interactive): # 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}'") + 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.") + 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") + 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}") @@ -482,7 +485,9 @@ def sync(ctx, repo, push, auto_merge, strategy, verbose, non_interactive): if unexpected: if non_interactive: - progress.console.print(" [yellow]⚠[/yellow] Found unexpected files - skipping in non-interactive mode") + progress.console.print( + " [yellow]⚠[/yellow] Found unexpected files - skipping in non-interactive mode" + ) # Skip this repository progress.console.print(" [yellow]āŠ—[/yellow] Skipped repository") continue @@ -593,10 +598,10 @@ def resolve_markers(): # 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") + 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(): @@ -851,21 +856,29 @@ def create_consolidated_commit(): 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"): + 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") + # 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") else: progress.console.print(" • No remote configured (local repository only)") diff --git a/src/taskrepo/cli/commands/update.py b/src/taskrepo/cli/commands/update.py index 9875f21..fd3d44a 100644 --- a/src/taskrepo/cli/commands/update.py +++ b/src/taskrepo/cli/commands/update.py @@ -1,6 +1,5 @@ """Update command for modifying task fields.""" -import sys from typing import Optional, Tuple import click @@ -14,7 +13,9 @@ @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( + "--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") @@ -23,10 +24,20 @@ @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]): +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: @@ -45,8 +56,7 @@ def update(ctx, task_ids: Tuple[str, ...], repo: Optional[str], priority: Option 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]): + 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) @@ -125,7 +135,7 @@ def update(ctx, task_ids: Tuple[str, ...], repo: Optional[str], priority: Option if due: parsed_date = parse_date(due) if parsed_date: - task.due = parsed_date.isoformat() + task.due = parsed_date changes.append(f"due → {due}") else: click.secho(f"Warning: Could not parse due date '{due}' for task {task_id}", fg="yellow") diff --git a/src/taskrepo/core/repository.py b/src/taskrepo/core/repository.py index 41f5c11..f5acd80 100644 --- a/src/taskrepo/core/repository.py +++ b/src/taskrepo/core/repository.py @@ -274,7 +274,9 @@ def list_archived_tasks(self, silent_errors: bool = False) -> list[Task]: if not silent_errors: # Check if error is due to git conflict markers if "<<<<<<< HEAD" in str(e) or "could not find expected ':'" in str(e): - print(f"Warning: Failed to load archived task {task_file.name}: Invalid YAML frontmatter: {e}") + print( + f"Warning: Failed to load archived task {task_file.name}: Invalid YAML frontmatter: {e}" + ) else: print(f"Warning: Failed to load archived task {task_file.name}: {e}") diff --git a/src/taskrepo/utils/file_validation.py b/src/taskrepo/utils/file_validation.py index 8c3ca70..b7f0b32 100644 --- a/src/taskrepo/utils/file_validation.py +++ b/src/taskrepo/utils/file_validation.py @@ -5,6 +5,7 @@ import click from git import Repo as GitRepo from prompt_toolkit.shortcuts import confirm, prompt +from rich.console import Console def detect_unexpected_files(git_repo: GitRepo, repo_path: Path) -> dict[str, list[Path]]: @@ -82,8 +83,6 @@ 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. @@ -96,7 +95,7 @@ def prompt_unexpected_files(unexpected_files: dict[str, list[Path]], repo_name: """ # 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 @@ -122,7 +121,7 @@ def prompt_unexpected_files(unexpected_files: dict[str, list[Path]], repo_name: except (KeyboardInterrupt, EOFError): # Safe default on interrupt return "skip" - + if choice in ["i", "ignore"]: return "ignore" elif choice in ["d", "delete", "del"]: From 0e48fcf2cc8c7dc077407dd5fccd668c3bf638ad Mon Sep 17 00:00:00 2001 From: Ricardo Henriques Date: Tue, 13 Jan 2026 13:48:27 +0000 Subject: [PATCH 4/5] ci(deps): bump GitHub Actions and pytest dependencies Integrate all dependabot dependency updates: - Bump actions/checkout from v5 to v6 - Bump actions/cache from v4 to v5 - Bump actions/upload-artifact from v5 to v6 - Update pytest requirement from <9.0 to <10.0 This consolidates PRs #7, #8, #9, and #10. --- .github/workflows/ci.yml | 18 +++++++++--------- pyproject.toml | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28f4845..267b57f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 @@ -66,7 +66,7 @@ jobs: enable-cache: true - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cache/uv @@ -111,7 +111,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 @@ -125,7 +125,7 @@ jobs: enable-cache: true - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cache/uv @@ -159,7 +159,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 @@ -173,7 +173,7 @@ jobs: enable-cache: true - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cache/uv @@ -193,7 +193,7 @@ jobs: echo "āœ… Coverage report generated" - name: Upload coverage artifact - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: coverage-report path: coverage.xml @@ -208,7 +208,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 @@ -239,7 +239,7 @@ jobs: echo "āœ… Package verification complete" - name: Upload build artifacts - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: dist-packages path: dist/ diff --git a/pyproject.toml b/pyproject.toml index 5d28dd0..dfba51b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ Issues = "https://github.com/henriqueslab/TaskRepo/issues" [project.optional-dependencies] dev = [ - "pytest>=7.4,<9.0", + "pytest>=7.4,<10.0", "pytest-cov>=4.0", "ruff>=0.12.2", "mypy>=1.0", From 9066d76a5be6df0791c55c06095b202c80dba9b5 Mon Sep 17 00:00:00 2001 From: Ricardo Henriques Date: Tue, 13 Jan 2026 13:50:22 +0000 Subject: [PATCH 5/5] chore: bump version to 0.10.17 --- CHANGELOG.md | 45 +++++++++++++++++++++++++++++++++++++ src/taskrepo/__version__.py | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddc82a0..a8dda66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.10.17] - 2026-01-13 + +### Added + +- **TUI State Persistence**: Remember TUI view mode and selection across sessions + - New config option `remember_tui_state` (default: true) + - Persists tree view state (`tui_tree_view`) + - Remembers last selected view item (`tui_last_view_item`) + - Automatically restores state when reopening TUI + +- **New Task Management Commands**: + - `tsk add-link`: Add URLs to task links field + - `tsk append`: Append text to task descriptions + - `tsk update`: Update multiple task fields atomically + +- **Archive Command Improvements**: + - New `--all-completed` flag to archive all completed tasks at once + - Shows count of tasks to be archived before confirmation + +- **Sync Command Non-Interactive Mode**: + - New `--non-interactive` flag for automation and scripting + - Auto-confirms all prompts with safe defaults + - Enables seamless integration with CI/CD pipelines + +### Changed + +- **Improved Error Handling**: Better resilience for corrupted task files + - Added `silent_errors` parameter to `list_tasks()` and `list_archived_tasks()` + - Detects and reports git conflict markers specifically + - Groups error messages with helpful resolution steps + - Suggests running `git status` when conflicts detected + +- **Code Quality**: Fixed all linting and formatting issues + - Removed unused imports across multiple files + - Fixed whitespace and f-string issues + - Moved imports to top of files per PEP 8 + - Fixed type error in update command (datetime vs string) + +### Dependencies + +- Bump actions/checkout from v5 to v6 +- Bump actions/cache from v4 to v5 +- Bump actions/upload-artifact from v5 to v6 +- Update pytest requirement from <9.0 to <10.0 + ## [0.10.16] - 2025-12-19 ### Changed diff --git a/src/taskrepo/__version__.py b/src/taskrepo/__version__.py index c69a94b..50ab2b0 100644 --- a/src/taskrepo/__version__.py +++ b/src/taskrepo/__version__.py @@ -1 +1 @@ -__version__ = "0.10.16" +__version__ = "0.10.17"