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.
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/CHANGELOG.md +6 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/PKG-INFO +1 -1
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/README.md +17 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/pyproject.toml +1 -1
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/_version.py +2 -2
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/app.py +6 -1
- tasktree_manager-2.10.0/tasktree_manager/cli.py +198 -0
- tasktree_manager-2.10.0/tests/test_cli.py +237 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/SKILL.md +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/assets/README.md +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/references/styling.md +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.github/templates/CHANGELOG.md.j2 +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.github/workflows/ci.yml +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.github/workflows/release.yml +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.gitignore +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/CONTRIBUTING.md +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/LICENSE +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/OPENSPEC.md +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/ROADMAP.md +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/docs/claude-code-integration-spec.md +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/docs/configuration.md +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/docs/installation.md +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/docs/troubleshooting.md +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/docs/user-guide.md +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/mise.toml +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/scripts/bump_version.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/__init__.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/commands.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/services/__init__.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/services/claude_hooks.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/services/config.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/services/git_ops.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/services/models.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/services/task_manager.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/themes.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/widgets/__init__.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/widgets/app_header.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/widgets/create_modal.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/widgets/messages_panel.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/widgets/setup_modal.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/widgets/status_panel.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/widgets/task_list.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/widgets/worktree_list.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/__init__.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/conftest.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/test_app.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/test_claude_hooks.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/test_commands.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/test_config.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/test_git_ops.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/test_integration_git.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/test_memory.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/test_messages_panel.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/test_modals.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/test_setup_modal.py +0 -0
- {tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tests/test_status_panel.py +0 -0
- {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
|
|
@@ -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.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (2,
|
|
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
|
|
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
|
|
File without changes
|
{tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/assets/README.md
RENAMED
|
File without changes
|
|
File without changes
|
{tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/assets/data_viewer.py
RENAMED
|
File without changes
|
{tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/assets/todo_app.py
RENAMED
|
File without changes
|
{tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/assets/worker_demo.py
RENAMED
|
File without changes
|
{tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/references/layouts.md
RENAMED
|
File without changes
|
|
File without changes
|
{tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/references/styling.md
RENAMED
|
File without changes
|
{tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/.claude/skills/textual-tui/references/widgets.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/services/claude_hooks.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/services/task_manager.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/widgets/messages_panel.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tasktree_manager-2.9.0 → tasktree_manager-2.10.0}/tasktree_manager/widgets/worktree_list.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|