tasktree-manager 2.9.0__tar.gz → 2.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/CHANGELOG.md +6 -0
  2. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/PKG-INFO +1 -1
  3. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/README.md +17 -0
  4. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/pyproject.toml +1 -1
  5. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/_version.py +2 -2
  6. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/app.py +6 -1
  7. tasktree_manager-2.10.0/tasktree_manager/cli.py +198 -0
  8. tasktree_manager-2.10.0/tests/test_cli.py +237 -0
  9. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/SKILL.md +0 -0
  10. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/assets/README.md +0 -0
  11. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
  12. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
  13. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
  14. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
  15. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
  16. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
  17. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/references/styling.md +0 -0
  18. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
  19. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.github/templates/CHANGELOG.md.j2 +0 -0
  20. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.github/workflows/ci.yml +0 -0
  21. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.github/workflows/release.yml +0 -0
  22. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.gitignore +0 -0
  23. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/CONTRIBUTING.md +0 -0
  24. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/LICENSE +0 -0
  25. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/OPENSPEC.md +0 -0
  26. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/ROADMAP.md +0 -0
  27. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/docs/claude-code-integration-spec.md +0 -0
  28. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/docs/configuration.md +0 -0
  29. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/docs/installation.md +0 -0
  30. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/docs/troubleshooting.md +0 -0
  31. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/docs/user-guide.md +0 -0
  32. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/mise.toml +0 -0
  33. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/scripts/bump_version.py +0 -0
  34. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/__init__.py +0 -0
  35. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/commands.py +0 -0
  36. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/services/__init__.py +0 -0
  37. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/services/claude_hooks.py +0 -0
  38. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/services/config.py +0 -0
  39. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/services/git_ops.py +0 -0
  40. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/services/models.py +0 -0
  41. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/services/task_manager.py +0 -0
  42. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/themes.py +0 -0
  43. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/widgets/__init__.py +0 -0
  44. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/widgets/app_header.py +0 -0
  45. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/widgets/create_modal.py +0 -0
  46. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/widgets/messages_panel.py +0 -0
  47. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/widgets/setup_modal.py +0 -0
  48. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/widgets/status_panel.py +0 -0
  49. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/widgets/task_list.py +0 -0
  50. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/widgets/worktree_list.py +0 -0
  51. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/__init__.py +0 -0
  52. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/conftest.py +0 -0
  53. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/test_app.py +0 -0
  54. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/test_claude_hooks.py +0 -0
  55. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/test_commands.py +0 -0
  56. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/test_config.py +0 -0
  57. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/test_git_ops.py +0 -0
  58. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/test_integration_git.py +0 -0
  59. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/test_memory.py +0 -0
  60. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/test_messages_panel.py +0 -0
  61. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/test_modals.py +0 -0
  62. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/test_setup_modal.py +0 -0
  63. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/test_status_panel.py +0 -0
  64. {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/test_task_manager.py +0 -0
@@ -15,6 +15,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
15
15
  - Git hooks support
16
16
  - Custom task metadata
17
17
 
18
+ ## [2.10.0] - 2026-07-23
19
+
20
+ ### Added
21
+ - **cli**: Add headless CLI for task management
22
+
23
+
18
24
  ## [2.9.0] - 2026-07-14
19
25
 
20
26
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tasktree-manager
3
- Version: 2.9.0
3
+ Version: 2.10.0
4
4
  Summary: TUI application for managing worktree-based tasks
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.10
@@ -18,6 +18,7 @@
18
18
  - ⌨️ **Keyboard-First** - Navigate and control everything without touching the mouse
19
19
  - 🚀 **Fast & Responsive** - Parallel git operations with loading indicators
20
20
  - 🔧 **Flexible Configuration** - TOML config file, custom keybindings, environment variables
21
+ - 🤖 **Headless CLI** - Create, list, and delete tasks from scripts and AI agents without opening the TUI
21
22
 
22
23
  ## 📊 Status Indicators
23
24
 
@@ -63,6 +64,22 @@ On first run, you'll configure:
63
64
 
64
65
  For detailed workflows and examples, see the [User Guide](docs/user-guide.md).
65
66
 
67
+ ### Headless CLI
68
+
69
+ Running `tasktree-manager` with arguments skips the TUI — useful for scripts and AI-agent
70
+ workflows (e.g. a skill that creates a Jira ticket and then the matching task):
71
+
72
+ ```bash
73
+ tasktree-manager create DIC-1901-argocd-tls --repos backend,frontend # --base overrides the default base branch
74
+ tasktree-manager list --json # tasks with repos and dirty state (plain text without --json)
75
+ tasktree-manager repos # available repos in REPOS_DIR
76
+ tasktree-manager add-repo DIC-1901-argocd-tls infra
77
+ tasktree-manager delete DIC-1901-argocd-tls # refuses on unfinished work; --force overrides
78
+ ```
79
+
80
+ Exit code is 0 on success, 1 on failure with the reason on stderr. `delete` runs the same
81
+ safety check as the TUI (uncommitted, unpushed, or unmerged work blocks deletion).
82
+
66
83
  ## 📖 Documentation
67
84
 
68
85
  **Complete guides:**
@@ -70,7 +70,7 @@ version-file = "tasktree_manager/_version.py"
70
70
  # Semantic Release Configuration
71
71
  [tool.semantic_release]
72
72
  version_toml = ["pyproject.toml:tool.semantic_release.version"]
73
- version = "2.9.0"
73
+ version = "2.10.0"
74
74
  tag_format = "v{version}"
75
75
  branch = "main"
76
76
  commit_message = "chore(release): {version} [skip ci]"
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '2.9.0'
22
- __version_tuple__ = version_tuple = (2, 9, 0)
21
+ __version__ = version = '2.10.0'
22
+ __version_tuple__ = version_tuple = (2, 10, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -2,6 +2,7 @@
2
2
 
3
3
  import json
4
4
  import subprocess
5
+ import sys
5
6
  import threading
6
7
  import time
7
8
  from pathlib import Path
@@ -1681,7 +1682,11 @@ class TaskTreeApp(App):
1681
1682
 
1682
1683
 
1683
1684
  def main():
1684
- """Entry point for the tasktree-manager application."""
1685
+ """Entry point: TUI with no arguments, headless CLI otherwise."""
1686
+ if len(sys.argv) > 1:
1687
+ from .cli import run_cli
1688
+
1689
+ raise SystemExit(run_cli(sys.argv[1:]))
1685
1690
  app = TaskTreeApp()
1686
1691
  app.run()
1687
1692
 
@@ -0,0 +1,198 @@
1
+ """Headless CLI for tasktree-manager.
2
+
3
+ Running ``tasktree-manager`` with no arguments starts the TUI; any argument
4
+ routes here instead. Built for scripts and automation (e.g. Claude skills
5
+ that create a task and then work in its worktrees): plain text on stdout,
6
+ errors on stderr, exit code 0 on success and 1 on failure.
7
+ """
8
+
9
+ import argparse
10
+ import json
11
+ import sys
12
+
13
+ from . import __version__
14
+ from .services.config import Config
15
+ from .services.git_ops import GitOps
16
+ from .services.task_manager import TaskManager
17
+
18
+
19
+ def build_parser() -> argparse.ArgumentParser:
20
+ """Build the argument parser for the headless CLI."""
21
+ parser = argparse.ArgumentParser(
22
+ prog="tasktree-manager",
23
+ description="Manage git worktree-based tasks (run with no arguments for the TUI).",
24
+ )
25
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
26
+ sub = parser.add_subparsers(dest="command", required=True)
27
+
28
+ create = sub.add_parser("create", help="Create a task with worktrees for the given repos")
29
+ create.add_argument("name", help="Task name (also used as the branch name)")
30
+ create.add_argument(
31
+ "--repos",
32
+ required=True,
33
+ help="Comma-separated repo names from REPOS_DIR (e.g. repo-a,repo-b)",
34
+ )
35
+ create.add_argument(
36
+ "--base",
37
+ default=None,
38
+ help="Base branch for the worktrees (default: git.default_base_branch from config)",
39
+ )
40
+ create.set_defaults(func=cmd_create)
41
+
42
+ list_parser = sub.add_parser("list", help="List tasks with their repos and dirty state")
43
+ list_parser.add_argument("--json", action="store_true", dest="as_json", help="Output JSON")
44
+ list_parser.set_defaults(func=cmd_list)
45
+
46
+ repos = sub.add_parser("repos", help="List available repos in REPOS_DIR (one per line)")
47
+ repos.set_defaults(func=cmd_repos)
48
+
49
+ delete = sub.add_parser("delete", help="Delete a task and its worktrees")
50
+ delete.add_argument("name", help="Task name")
51
+ delete.add_argument(
52
+ "--force",
53
+ action="store_true",
54
+ help="Delete even with uncommitted, unpushed or unmerged work",
55
+ )
56
+ delete.set_defaults(func=cmd_delete)
57
+
58
+ add_repo = sub.add_parser("add-repo", help="Add a repo worktree to an existing task")
59
+ add_repo.add_argument("name", help="Task name")
60
+ add_repo.add_argument("repo", help="Repo name from REPOS_DIR")
61
+ add_repo.add_argument("--base", default=None, help="Base branch for the worktree")
62
+ add_repo.set_defaults(func=cmd_add_repo)
63
+
64
+ return parser
65
+
66
+
67
+ def _error(message: str) -> int:
68
+ print(f"error: {message}", file=sys.stderr)
69
+ return 1
70
+
71
+
72
+ def _unknown_repos_error(config: Config, repos: list[str]) -> str | None:
73
+ """Return an error message if any repo is not in REPOS_DIR, else None."""
74
+ available = config.get_available_repos()
75
+ unknown = [r for r in repos if r not in available]
76
+ if unknown:
77
+ return (
78
+ f"unknown repo(s): {', '.join(unknown)}; "
79
+ f"available: {', '.join(available) or '(none)'}"
80
+ )
81
+ return None
82
+
83
+
84
+ def cmd_create(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
85
+ """Create a task with worktrees for the given repos."""
86
+ repos = [r.strip() for r in args.repos.split(",") if r.strip()]
87
+ if not repos:
88
+ return _error("--repos needs at least one repo name")
89
+ error = _unknown_repos_error(config, repos)
90
+ if error:
91
+ return _error(error)
92
+ if manager.get_task(args.name):
93
+ return _error(f"task already exists: {args.name}")
94
+
95
+ base = args.base or config.default_base_branch
96
+ task = manager.create_task(args.name, repos, base)
97
+ # CLI-created tasks may never pass through the TUI's lazy Claude prep,
98
+ # so materialize CLAUDE.md files and worktree settings right away
99
+ manager.ensure_claude_md_files(task)
100
+ manager.ensure_worktree_settings(task)
101
+
102
+ repo_list = ", ".join(wt.name for wt in task.worktrees)
103
+ print(f"Created task {task.name} at {task.path} ({len(task.worktrees)} repo(s): {repo_list})")
104
+ return 0
105
+
106
+
107
+ def cmd_list(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
108
+ """List tasks with their repos and dirty state."""
109
+ tasks = manager.list_tasks()
110
+ dirty: dict[str, bool] = {}
111
+ for task in tasks:
112
+ statuses = GitOps.get_statuses_parallel(task.worktrees)
113
+ dirty[task.name] = any(s.is_dirty for s in statuses.values())
114
+
115
+ if args.as_json:
116
+ payload = [
117
+ {
118
+ "name": task.name,
119
+ "path": str(task.path),
120
+ "repos": [wt.name for wt in task.worktrees],
121
+ "dirty": dirty[task.name],
122
+ }
123
+ for task in tasks
124
+ ]
125
+ print(json.dumps(payload, indent=2))
126
+ return 0
127
+
128
+ if not tasks:
129
+ print("No tasks")
130
+ return 0
131
+ for task in tasks:
132
+ state = "dirty" if dirty[task.name] else "clean"
133
+ repos = ", ".join(wt.name for wt in task.worktrees) or "(no repos)"
134
+ print(f"{task.name}\t{state}\t{repos}")
135
+ return 0
136
+
137
+
138
+ def cmd_repos(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
139
+ """List available repos (handles nested repos, unlike a plain ls)."""
140
+ for repo in config.get_available_repos():
141
+ print(repo)
142
+ return 0
143
+
144
+
145
+ def cmd_delete(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
146
+ """Delete a task after a safety check (bypass with --force)."""
147
+ task = manager.get_task(args.name)
148
+ if not task:
149
+ return _error(f"no such task: {args.name}")
150
+
151
+ report = manager.check_task_safety(task)
152
+ if not report.is_safe() and not args.force:
153
+ print(f"error: task {args.name} has unfinished work:", file=sys.stderr)
154
+ for issue in report.dirty + report.unpushed + report.unmerged:
155
+ print(f" {issue.repo_name}: {issue.details}", file=sys.stderr)
156
+ print("use --force to delete anyway", file=sys.stderr)
157
+ return 1
158
+
159
+ manager.finish_task(task)
160
+ print(f"Deleted task {args.name}")
161
+ return 0
162
+
163
+
164
+ def cmd_add_repo(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
165
+ """Add a repo worktree to an existing task."""
166
+ task = manager.get_task(args.name)
167
+ if not task:
168
+ return _error(f"no such task: {args.name}")
169
+ error = _unknown_repos_error(config, [args.repo])
170
+ if error:
171
+ return _error(error)
172
+ if any(wt.name == args.repo for wt in task.worktrees):
173
+ print(f"Repo {args.repo} already in task {args.name}")
174
+ return 0
175
+
176
+ base = args.base or config.default_base_branch
177
+ manager.add_repo_to_task(task, args.repo, base)
178
+ print(f"Added {args.repo} to task {args.name}")
179
+ return 0
180
+
181
+
182
+ def run_cli(argv: list[str], config: Config | None = None) -> int:
183
+ """Parse argv and run the matching subcommand, returning an exit code."""
184
+ args = build_parser().parse_args(argv)
185
+ if config is None:
186
+ config = Config.load()
187
+ config.ensure_dirs()
188
+ manager = TaskManager(config)
189
+ try:
190
+ return args.func(manager, config, args)
191
+ except FileNotFoundError as e:
192
+ return _error(f"repository not found: {e}")
193
+ except PermissionError:
194
+ return _error("permission denied: check directory permissions")
195
+ except ValueError as e:
196
+ return _error(str(e))
197
+ except Exception as e: # same taxonomy as the TUI worker: keep output clean
198
+ return _error(f"{type(e).__name__}: {e}")
@@ -0,0 +1,237 @@
1
+ """Tests for the headless CLI (tasktree_manager/cli.py)."""
2
+
3
+ import json
4
+ import subprocess
5
+
6
+ import pytest
7
+
8
+ from tasktree_manager.cli import run_cli
9
+
10
+
11
+ def cli(config, *argv):
12
+ """Run the CLI against the test config."""
13
+ return run_cli(list(argv), config=config)
14
+
15
+
16
+ @pytest.fixture
17
+ def cli_config(config, sample_repos):
18
+ """Config whose default base branch matches the sample repos' actual branch.
19
+
20
+ CI runners have no init.defaultBranch configured, so fixture repos may be
21
+ on master while the config default is main; tests that rely on the default
22
+ base branch must line the two up.
23
+ """
24
+ _, branch = sample_repos
25
+ config.default_base_branch = branch
26
+ return config
27
+
28
+
29
+ @pytest.fixture
30
+ def repo_with_origin(config, tmp_path):
31
+ """A repo in REPOS_DIR whose branch is pushed to a bare origin remote.
32
+
33
+ check_merged() compares HEAD against origin/<base>, so only a repo
34
+ with a remote can ever count as "merged" (and thus safe to delete).
35
+ Returns the default branch name.
36
+ """
37
+ remote = tmp_path / "origin" / "repo-remote.git"
38
+ remote.mkdir(parents=True)
39
+ subprocess.run(["git", "init", "--bare"], cwd=remote, capture_output=True, check=True)
40
+
41
+ repo = config.repos_dir / "repo-remote"
42
+ repo.mkdir()
43
+ subprocess.run(["git", "init"], cwd=repo, capture_output=True, check=True)
44
+ subprocess.run(
45
+ ["git", "config", "user.email", "test@test.com"], cwd=repo, capture_output=True, check=True
46
+ )
47
+ subprocess.run(
48
+ ["git", "config", "user.name", "Test User"], cwd=repo, capture_output=True, check=True
49
+ )
50
+ subprocess.run(
51
+ ["git", "remote", "add", "origin", str(remote)], cwd=repo, capture_output=True, check=True
52
+ )
53
+ (repo / "README.md").write_text("# repo-remote\n")
54
+ subprocess.run(["git", "add", "."], cwd=repo, capture_output=True, check=True)
55
+ subprocess.run(["git", "commit", "-m", "Initial"], cwd=repo, capture_output=True, check=True)
56
+ subprocess.run(
57
+ ["git", "push", "-u", "origin", "HEAD"], cwd=repo, capture_output=True, check=True
58
+ )
59
+ branch = subprocess.run(
60
+ ["git", "branch", "--show-current"], cwd=repo, capture_output=True, text=True
61
+ ).stdout.strip()
62
+ return branch
63
+
64
+
65
+ class TestCreate:
66
+ def test_create_task(self, cli_config, capsys):
67
+ code = cli(cli_config, "create", "DIC-1901-argocd-tls", "--repos", "repo-alpha,repo-beta")
68
+ assert code == 0
69
+ out = capsys.readouterr().out
70
+ assert "Created task DIC-1901-argocd-tls" in out
71
+ assert "2 repo(s)" in out
72
+
73
+ task_path = cli_config.tasks_dir / "DIC-1901-argocd-tls"
74
+ assert (task_path / "repo-alpha" / ".git").exists()
75
+ assert (task_path / "repo-beta" / ".git").exists()
76
+ # Branch name matches task name
77
+ result = subprocess.run(
78
+ ["git", "branch", "--show-current"],
79
+ cwd=task_path / "repo-alpha",
80
+ capture_output=True,
81
+ text=True,
82
+ )
83
+ assert result.stdout.strip() == "DIC-1901-argocd-tls"
84
+ # Task CLAUDE.md is materialized at create time (no TUI lazy prep)
85
+ assert (task_path / "CLAUDE.md").exists()
86
+
87
+ def test_create_with_base(self, config, sample_repos, capsys):
88
+ _, branch = sample_repos
89
+ code = cli(config, "create", "task-x", "--repos", "repo-alpha", "--base", branch)
90
+ assert code == 0
91
+
92
+ def test_create_unknown_repo(self, config, sample_repos, capsys):
93
+ code = cli(config, "create", "task-x", "--repos", "no-such-repo")
94
+ assert code == 1
95
+ err = capsys.readouterr().err
96
+ assert "unknown repo(s): no-such-repo" in err
97
+ assert "repo-alpha" in err # lists available repos
98
+ assert not (config.tasks_dir / "task-x").exists()
99
+
100
+ def test_create_duplicate(self, cli_config, capsys):
101
+ assert cli(cli_config, "create", "task-x", "--repos", "repo-alpha") == 0
102
+ capsys.readouterr()
103
+ code = cli(cli_config, "create", "task-x", "--repos", "repo-beta")
104
+ assert code == 1
105
+ assert "task already exists" in capsys.readouterr().err
106
+
107
+ def test_create_invalid_name(self, config, sample_repos, capsys):
108
+ code = cli(config, "create", "../escape", "--repos", "repo-alpha")
109
+ assert code == 1
110
+ assert "error:" in capsys.readouterr().err
111
+
112
+ def test_create_empty_repos(self, config, sample_repos, capsys):
113
+ code = cli(config, "create", "task-x", "--repos", " , ")
114
+ assert code == 1
115
+ assert "at least one repo" in capsys.readouterr().err
116
+
117
+
118
+ class TestList:
119
+ def test_list_empty(self, config, capsys):
120
+ assert cli(config, "list") == 0
121
+ assert "No tasks" in capsys.readouterr().out
122
+
123
+ def test_list_plain(self, cli_config, capsys):
124
+ cli(cli_config, "create", "task-x", "--repos", "repo-alpha,repo-beta")
125
+ capsys.readouterr()
126
+ assert cli(cli_config, "list") == 0
127
+ out = capsys.readouterr().out
128
+ assert "task-x" in out
129
+ assert "clean" in out
130
+ assert "repo-alpha, repo-beta" in out
131
+
132
+ def test_list_json(self, cli_config, capsys):
133
+ cli(cli_config, "create", "task-x", "--repos", "repo-alpha")
134
+ capsys.readouterr()
135
+ assert cli(cli_config, "list", "--json") == 0
136
+ payload = json.loads(capsys.readouterr().out)
137
+ assert len(payload) == 1
138
+ assert payload[0]["name"] == "task-x"
139
+ assert payload[0]["repos"] == ["repo-alpha"]
140
+ assert payload[0]["dirty"] is False
141
+ assert payload[0]["path"] == str(cli_config.tasks_dir / "task-x")
142
+
143
+ def test_list_json_empty(self, config, capsys):
144
+ assert cli(config, "list", "--json") == 0
145
+ assert json.loads(capsys.readouterr().out) == []
146
+
147
+ def test_list_dirty(self, cli_config, capsys):
148
+ cli(cli_config, "create", "task-x", "--repos", "repo-alpha")
149
+ (cli_config.tasks_dir / "task-x" / "repo-alpha" / "dirty.txt").write_text("x\n")
150
+ capsys.readouterr()
151
+ assert cli(cli_config, "list", "--json") == 0
152
+ payload = json.loads(capsys.readouterr().out)
153
+ assert payload[0]["dirty"] is True
154
+
155
+
156
+ class TestDelete:
157
+ def test_delete_clean(self, config, repo_with_origin, capsys):
158
+ cli(config, "create", "task-x", "--repos", "repo-remote", "--base", repo_with_origin)
159
+ capsys.readouterr()
160
+ assert cli(config, "delete", "task-x") == 0
161
+ assert "Deleted task task-x" in capsys.readouterr().out
162
+ assert not (config.tasks_dir / "task-x").exists()
163
+
164
+ def test_delete_unmerged_refuses(self, cli_config, capsys):
165
+ # Repos without a remote can never verify a merge -> treated as unmerged
166
+ cli(cli_config, "create", "task-x", "--repos", "repo-alpha")
167
+ capsys.readouterr()
168
+ assert cli(cli_config, "delete", "task-x") == 1
169
+ assert "unfinished work" in capsys.readouterr().err
170
+ assert (cli_config.tasks_dir / "task-x").exists()
171
+
172
+ def test_delete_missing(self, config, capsys):
173
+ assert cli(config, "delete", "nope") == 1
174
+ assert "no such task" in capsys.readouterr().err
175
+
176
+ def test_delete_dirty_refuses(self, cli_config, capsys):
177
+ cli(cli_config, "create", "task-x", "--repos", "repo-alpha")
178
+ (cli_config.tasks_dir / "task-x" / "repo-alpha" / "dirty.txt").write_text("x\n")
179
+ capsys.readouterr()
180
+ assert cli(cli_config, "delete", "task-x") == 1
181
+ err = capsys.readouterr().err
182
+ assert "unfinished work" in err
183
+ assert "--force" in err
184
+ assert (cli_config.tasks_dir / "task-x").exists()
185
+
186
+ def test_delete_dirty_force(self, cli_config, capsys):
187
+ cli(cli_config, "create", "task-x", "--repos", "repo-alpha")
188
+ (cli_config.tasks_dir / "task-x" / "repo-alpha" / "dirty.txt").write_text("x\n")
189
+ capsys.readouterr()
190
+ assert cli(cli_config, "delete", "task-x", "--force") == 0
191
+ assert not (cli_config.tasks_dir / "task-x").exists()
192
+
193
+
194
+ class TestAddRepo:
195
+ def test_add_repo(self, cli_config, capsys):
196
+ cli(cli_config, "create", "task-x", "--repos", "repo-alpha")
197
+ capsys.readouterr()
198
+ assert cli(cli_config, "add-repo", "task-x", "repo-beta") == 0
199
+ assert "Added repo-beta to task task-x" in capsys.readouterr().out
200
+ assert (cli_config.tasks_dir / "task-x" / "repo-beta" / ".git").exists()
201
+
202
+ def test_add_repo_already_present(self, cli_config, capsys):
203
+ cli(cli_config, "create", "task-x", "--repos", "repo-alpha")
204
+ capsys.readouterr()
205
+ assert cli(cli_config, "add-repo", "task-x", "repo-alpha") == 0
206
+ assert "already in task" in capsys.readouterr().out
207
+
208
+ def test_add_repo_missing_task(self, config, sample_repos, capsys):
209
+ assert cli(config, "add-repo", "nope", "repo-alpha") == 1
210
+ assert "no such task" in capsys.readouterr().err
211
+
212
+ def test_add_repo_unknown_repo(self, cli_config, capsys):
213
+ cli(cli_config, "create", "task-x", "--repos", "repo-alpha")
214
+ capsys.readouterr()
215
+ assert cli(cli_config, "add-repo", "task-x", "no-such-repo") == 1
216
+ assert "unknown repo(s)" in capsys.readouterr().err
217
+
218
+
219
+ class TestRepos:
220
+ def test_repos(self, config, sample_repos, capsys):
221
+ assert cli(config, "repos") == 0
222
+ assert capsys.readouterr().out.splitlines() == ["repo-alpha", "repo-beta", "repo-gamma"]
223
+
224
+ def test_repos_empty(self, config, capsys):
225
+ assert cli(config, "repos") == 0
226
+ assert capsys.readouterr().out == ""
227
+
228
+
229
+ class TestParser:
230
+ def test_no_command_exits(self, config):
231
+ with pytest.raises(SystemExit):
232
+ run_cli([], config=config)
233
+
234
+ def test_version_exits(self, config, capsys):
235
+ with pytest.raises(SystemExit) as exc_info:
236
+ run_cli(["--version"], config=config)
237
+ assert exc_info.value.code == 0