tasktree-manager 2.13.3__tar.gz → 2.14.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.13.3 → tasktree_manager-2.14.0}/CHANGELOG.md +6 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/PKG-INFO +1 -1
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/README.md +17 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/pyproject.toml +1 -1
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/_version.py +2 -2
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/app.py +10 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/cli.py +204 -11
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/services/models.py +65 -5
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/services/task_manager.py +170 -30
- tasktree_manager-2.14.0/tests/test_epics.py +430 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.claude/skills/textual-tui/SKILL.md +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.claude/skills/textual-tui/assets/README.md +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.claude/skills/textual-tui/references/styling.md +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.github/templates/CHANGELOG.md.j2 +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.github/workflows/ci.yml +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.github/workflows/release.yml +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/.gitignore +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/CONTRIBUTING.md +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/LICENSE +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/ROADMAP.md +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/docs/claude-code-integration-spec.md +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/docs/configuration.md +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/docs/installation.md +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/docs/troubleshooting.md +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/docs/user-guide.md +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/mise.toml +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/scripts/bump_version.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/__init__.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/commands.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/services/__init__.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/services/agent_sessions.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/services/claude_hooks.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/services/claude_sessions.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/services/config.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/services/forge.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/services/git_ops.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/themes.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/widgets/__init__.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/widgets/app_header.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/widgets/create_modal.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/widgets/messages_panel.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/widgets/setup_modal.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/widgets/status_panel.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/widgets/task_list.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tasktree_manager/widgets/worktree_list.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/__init__.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/conftest.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_agent_sessions.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_agent_sessions_decode.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_app.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_app_review_fixes.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_bump_version.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_claude_hooks.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_claude_sessions.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_cli.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_commands.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_config.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_forge.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_git_ops.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_integration_git.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_list_selection.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_medium_fixes.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_memory.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_messages_panel.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_modals.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_safety_fixes.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_setup_modal.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_status_panel.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_task_manager.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.14.0}/tests/test_worktree_badges.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.14.0] - 2026-10-19
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- **epic**: Group tasks under epics from the CLI
|
|
22
|
+
|
|
23
|
+
|
|
18
24
|
## [2.13.3] - 2026-10-16
|
|
19
25
|
|
|
20
26
|
### Changed
|
|
@@ -86,6 +86,23 @@ tasktree-manager finish DIC-1901-argocd-tls # safety sweep -> archive diff + t
|
|
|
86
86
|
tasktree-manager delete DIC-1901-argocd-tls # archive diff + task files -> delete; refuses on unfinished work; --force overrides
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
+
Epics group related tasks, for example a Jira epic and its child tasks. An epic is a task
|
|
90
|
+
folder with a `.tasktree_kind` marker. It can hold its own notes, Claude session and
|
|
91
|
+
worktrees. A child task names its epic in `.tasktree_epic`. Folders never move:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
tasktree-manager epic create DIC-2466-pcaps-epic # --repos is optional for an epic
|
|
95
|
+
tasktree-manager create DIC-2861-pcaps-logs --repos infra --epic DIC-2466-pcaps-epic
|
|
96
|
+
tasktree-manager epic add DIC-2466-pcaps-epic DIC-2862-pcaps-s3 # attach or move existing tasks
|
|
97
|
+
tasktree-manager epic remove DIC-2862-pcaps-s3 # detach
|
|
98
|
+
tasktree-manager epic mark DIC-2466-pcaps-epic # turn an existing task into an epic; unmark reverts
|
|
99
|
+
tasktree-manager epic list # epics and their tasks; also --json
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Epics don't nest. `list --json` and `status --json` carry `kind` (`task` or `epic`) and
|
|
103
|
+
`epic` (the parent epic or `null`). `delete` and `finish` refuse an epic that still has
|
|
104
|
+
tasks, even with `--force`.
|
|
105
|
+
|
|
89
106
|
Exit code is 0 on success, 1 on failure with the reason on stderr. `delete` and `finish` run
|
|
90
107
|
the same safety check as the TUI (uncommitted, unpushed, or unmerged work blocks deletion);
|
|
91
108
|
with `glab`/`gh` installed, squash/rebase-merged branches are recognized as merged. If the
|
|
@@ -71,7 +71,7 @@ version-file = "tasktree_manager/_version.py"
|
|
|
71
71
|
# Semantic Release Configuration
|
|
72
72
|
[tool.semantic_release]
|
|
73
73
|
version_toml = ["pyproject.toml:tool.semantic_release.version"]
|
|
74
|
-
version = "2.
|
|
74
|
+
version = "2.14.0"
|
|
75
75
|
tag_format = "v{version}"
|
|
76
76
|
branch = "main"
|
|
77
77
|
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.14.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 14, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -1300,6 +1300,16 @@ class TaskTreeApp(App):
|
|
|
1300
1300
|
if not self.current_task:
|
|
1301
1301
|
self.notify("No task selected", severity="warning")
|
|
1302
1302
|
return
|
|
1303
|
+
if self.current_task.is_epic:
|
|
1304
|
+
children = self.task_manager.epic_child_names(self.current_task.name)
|
|
1305
|
+
if children:
|
|
1306
|
+
self.notify(
|
|
1307
|
+
escape(
|
|
1308
|
+
f"Epic '{self.current_task.name}' still has tasks: {', '.join(children)}"
|
|
1309
|
+
),
|
|
1310
|
+
severity="warning",
|
|
1311
|
+
)
|
|
1312
|
+
return
|
|
1303
1313
|
|
|
1304
1314
|
# Safety checks hit the network (git fetch per worktree), so run them
|
|
1305
1315
|
# in a background thread and show the dialog when they complete
|
|
@@ -16,7 +16,7 @@ from . import __version__
|
|
|
16
16
|
from .services import forge
|
|
17
17
|
from .services.config import Config, ConfigError
|
|
18
18
|
from .services.git_ops import GitOps
|
|
19
|
-
from .services.models import ArchiveResult, Task
|
|
19
|
+
from .services.models import EPIC_KIND, TASK_KIND, ArchiveResult, Task
|
|
20
20
|
from .services.task_manager import ARCHIVE_NOTES_SHOWN, ArchiveIncompleteError, TaskManager
|
|
21
21
|
|
|
22
22
|
|
|
@@ -41,8 +41,43 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
41
41
|
default=None,
|
|
42
42
|
help="Base branch for the worktrees (default: git.default_base_branch from config)",
|
|
43
43
|
)
|
|
44
|
+
create.add_argument("--epic", default=None, help="Attach the new task to this epic")
|
|
44
45
|
create.set_defaults(func=cmd_create)
|
|
45
46
|
|
|
47
|
+
epic = sub.add_parser("epic", help="Group tasks under epics")
|
|
48
|
+
epic_sub = epic.add_subparsers(dest="epic_command", required=True)
|
|
49
|
+
|
|
50
|
+
epic_create = epic_sub.add_parser("create", help="Create an epic (repos are optional)")
|
|
51
|
+
epic_create.add_argument("name", help="Epic name (also the branch name of its worktrees)")
|
|
52
|
+
epic_create.add_argument(
|
|
53
|
+
"--repos", default="", help="Comma-separated repo names from REPOS_DIR"
|
|
54
|
+
)
|
|
55
|
+
epic_create.add_argument("--base", default=None, help="Base branch for the worktrees")
|
|
56
|
+
epic_create.set_defaults(func=cmd_epic_create)
|
|
57
|
+
|
|
58
|
+
epic_add = epic_sub.add_parser(
|
|
59
|
+
"add", help="Attach tasks to an epic (moves them from another epic)"
|
|
60
|
+
)
|
|
61
|
+
epic_add.add_argument("epic", help="Epic name")
|
|
62
|
+
epic_add.add_argument("tasks", nargs="+", help="Task names")
|
|
63
|
+
epic_add.set_defaults(func=cmd_epic_add)
|
|
64
|
+
|
|
65
|
+
epic_remove = epic_sub.add_parser("remove", help="Detach tasks from their epic")
|
|
66
|
+
epic_remove.add_argument("tasks", nargs="+", help="Task names")
|
|
67
|
+
epic_remove.set_defaults(func=cmd_epic_remove)
|
|
68
|
+
|
|
69
|
+
epic_mark = epic_sub.add_parser("mark", help="Turn an existing task into an epic")
|
|
70
|
+
epic_mark.add_argument("name", help="Task name")
|
|
71
|
+
epic_mark.set_defaults(func=cmd_epic_mark)
|
|
72
|
+
|
|
73
|
+
epic_unmark = epic_sub.add_parser("unmark", help="Turn an epic without tasks back into a task")
|
|
74
|
+
epic_unmark.add_argument("name", help="Epic name")
|
|
75
|
+
epic_unmark.set_defaults(func=cmd_epic_unmark)
|
|
76
|
+
|
|
77
|
+
epic_list = epic_sub.add_parser("list", help="List epics and their tasks")
|
|
78
|
+
epic_list.add_argument("--json", action="store_true", dest="as_json", help="Output JSON")
|
|
79
|
+
epic_list.set_defaults(func=cmd_epic_list)
|
|
80
|
+
|
|
46
81
|
list_parser = sub.add_parser("list", help="List tasks with their repos and dirty state")
|
|
47
82
|
list_parser.add_argument("--json", action="store_true", dest="as_json", help="Output JSON")
|
|
48
83
|
list_parser.set_defaults(func=cmd_list)
|
|
@@ -56,7 +91,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
56
91
|
"--force",
|
|
57
92
|
action="store_true",
|
|
58
93
|
help="Delete even with uncommitted, unpushed or unmerged work, or task files "
|
|
59
|
-
"that could not be archived",
|
|
94
|
+
"that could not be archived (never an epic that still has tasks)",
|
|
60
95
|
)
|
|
61
96
|
delete.set_defaults(func=cmd_delete)
|
|
62
97
|
|
|
@@ -81,7 +116,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
81
116
|
"--force",
|
|
82
117
|
action="store_true",
|
|
83
118
|
help="Finish even with unfinished work or task files that could not be archived "
|
|
84
|
-
"(the archive is still written)",
|
|
119
|
+
"(the archive is still written; never an epic that still has tasks)",
|
|
85
120
|
)
|
|
86
121
|
finish.set_defaults(func=cmd_finish)
|
|
87
122
|
|
|
@@ -123,19 +158,42 @@ def _unknown_repos_error(config: Config, repos: list[str]) -> str | None:
|
|
|
123
158
|
return None
|
|
124
159
|
|
|
125
160
|
|
|
161
|
+
def _split_repos(value: str) -> list[str]:
|
|
162
|
+
return [r.strip() for r in value.split(",") if r.strip()]
|
|
163
|
+
|
|
164
|
+
|
|
126
165
|
def cmd_create(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
|
|
127
166
|
"""Create a task with worktrees for the given repos."""
|
|
128
|
-
repos =
|
|
167
|
+
repos = _split_repos(args.repos)
|
|
129
168
|
if not repos:
|
|
130
169
|
return _error("--repos needs at least one repo name")
|
|
131
|
-
|
|
170
|
+
return _create(manager, config, args.name, repos, args.base, epic=args.epic)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def cmd_epic_create(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
|
|
174
|
+
"""Create an epic, with or without worktrees."""
|
|
175
|
+
return _create(manager, config, args.name, _split_repos(args.repos), args.base, kind=EPIC_KIND)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _create(
|
|
179
|
+
manager: TaskManager,
|
|
180
|
+
config: Config,
|
|
181
|
+
name: str,
|
|
182
|
+
repos: list[str],
|
|
183
|
+
base: str | None,
|
|
184
|
+
*,
|
|
185
|
+
epic: str | None = None,
|
|
186
|
+
kind: str = TASK_KIND,
|
|
187
|
+
) -> int:
|
|
188
|
+
"""Shared body of `create` and `epic create`."""
|
|
189
|
+
error = _unknown_repos_error(config, repos) if repos else None
|
|
132
190
|
if error:
|
|
133
191
|
return _error(error)
|
|
134
|
-
if manager.get_task(
|
|
135
|
-
return _error(f"task already exists: {
|
|
192
|
+
if manager.get_task(name):
|
|
193
|
+
return _error(f"task already exists: {name}")
|
|
136
194
|
|
|
137
|
-
base =
|
|
138
|
-
task = manager.create_task(
|
|
195
|
+
base = base or config.default_base_branch
|
|
196
|
+
task = manager.create_task(name, repos, base, epic=epic, kind=kind)
|
|
139
197
|
# CLI-created tasks may never pass through the TUI's lazy Claude prep,
|
|
140
198
|
# so materialize CLAUDE.md files and worktree settings right away. These
|
|
141
199
|
# are best-effort polish on a task that already exists: a failure here
|
|
@@ -149,11 +207,138 @@ def cmd_create(manager: TaskManager, config: Config, args: argparse.Namespace) -
|
|
|
149
207
|
except Exception as e:
|
|
150
208
|
print(f"warning: {step_name} setup failed: {e}", file=sys.stderr)
|
|
151
209
|
|
|
152
|
-
|
|
153
|
-
|
|
210
|
+
noun = "epic" if task.is_epic else "task"
|
|
211
|
+
repo_list = ", ".join(wt.name for wt in task.worktrees) or "none"
|
|
212
|
+
line = f"Created {noun} {task.name} at {task.path} ({len(task.worktrees)} repo(s): {repo_list})"
|
|
213
|
+
if task.epic_name is not None:
|
|
214
|
+
line += f" in epic {task.epic_name}"
|
|
215
|
+
print(line)
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _get_tasks(manager: TaskManager, names: list[str]) -> list[Task] | None:
|
|
220
|
+
"""Resolve task names, printing an error for each missing one (None if any)."""
|
|
221
|
+
tasks = []
|
|
222
|
+
missing = False
|
|
223
|
+
for name in names:
|
|
224
|
+
task = manager.get_task(name)
|
|
225
|
+
if task is None:
|
|
226
|
+
_error(f"no such task: {name}")
|
|
227
|
+
missing = True
|
|
228
|
+
else:
|
|
229
|
+
tasks.append(task)
|
|
230
|
+
return None if missing else tasks
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def cmd_epic_add(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
|
|
234
|
+
"""Attach tasks to an epic; a task in another epic moves."""
|
|
235
|
+
tasks = _get_tasks(manager, args.tasks)
|
|
236
|
+
if tasks is None:
|
|
237
|
+
return 1
|
|
238
|
+
code = 0
|
|
239
|
+
for task in tasks:
|
|
240
|
+
previous = task.epic_name
|
|
241
|
+
try:
|
|
242
|
+
# Always through the setter: it re-checks that the epic still exists
|
|
243
|
+
epic = manager.set_task_epic(task, args.epic)
|
|
244
|
+
except ValueError as e:
|
|
245
|
+
code = _error(str(e))
|
|
246
|
+
continue
|
|
247
|
+
if previous == epic:
|
|
248
|
+
print(f"Task {task.name} already in epic {epic}")
|
|
249
|
+
elif previous is None:
|
|
250
|
+
print(f"Added {task.name} to epic {epic}")
|
|
251
|
+
else:
|
|
252
|
+
print(f"Moved {task.name} from epic {previous} to epic {epic}")
|
|
253
|
+
return code
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def cmd_epic_remove(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
|
|
257
|
+
"""Detach tasks from their epic."""
|
|
258
|
+
tasks = _get_tasks(manager, args.tasks)
|
|
259
|
+
if tasks is None:
|
|
260
|
+
return 1
|
|
261
|
+
for task in tasks:
|
|
262
|
+
previous = task.epic_name
|
|
263
|
+
manager.set_task_epic(task, None)
|
|
264
|
+
if previous is None:
|
|
265
|
+
print(f"Task {task.name} is not in an epic")
|
|
266
|
+
else:
|
|
267
|
+
print(f"Removed {task.name} from epic {previous}")
|
|
268
|
+
return 0
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def cmd_epic_mark(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
|
|
272
|
+
"""Turn an existing task into an epic."""
|
|
273
|
+
task = manager.get_task(args.name)
|
|
274
|
+
if not task:
|
|
275
|
+
return _error(f"no such task: {args.name}")
|
|
276
|
+
if task.is_epic:
|
|
277
|
+
print(f"Task {args.name} is already an epic")
|
|
278
|
+
return 0
|
|
279
|
+
manager.set_task_kind(task, EPIC_KIND)
|
|
280
|
+
print(f"Marked {args.name} as an epic")
|
|
281
|
+
return 0
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def cmd_epic_unmark(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
|
|
285
|
+
"""Turn an epic without tasks back into a plain task."""
|
|
286
|
+
task = manager.get_task(args.name)
|
|
287
|
+
if not task:
|
|
288
|
+
return _error(f"no such task: {args.name}")
|
|
289
|
+
if not task.is_epic:
|
|
290
|
+
print(f"Task {args.name} is not an epic")
|
|
291
|
+
return 0
|
|
292
|
+
manager.set_task_kind(task, TASK_KIND)
|
|
293
|
+
print(f"Unmarked {args.name}: it is a plain task now")
|
|
294
|
+
return 0
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def cmd_epic_list(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
|
|
298
|
+
"""List epics and the tasks attached to each (no git calls)."""
|
|
299
|
+
epics = [task for task in manager.list_tasks() if task.is_epic]
|
|
300
|
+
children = {epic.name: manager.epic_child_names(epic.name) for epic in epics}
|
|
301
|
+
|
|
302
|
+
if args.as_json:
|
|
303
|
+
payload = [
|
|
304
|
+
{
|
|
305
|
+
"name": epic.name,
|
|
306
|
+
"display_name": epic.display_name,
|
|
307
|
+
"path": str(epic.path),
|
|
308
|
+
"repos": [wt.name for wt in epic.worktrees],
|
|
309
|
+
"tasks": children[epic.name],
|
|
310
|
+
}
|
|
311
|
+
for epic in epics
|
|
312
|
+
]
|
|
313
|
+
print(json.dumps(payload, indent=2))
|
|
314
|
+
return 0
|
|
315
|
+
|
|
316
|
+
if not epics:
|
|
317
|
+
print("No epics")
|
|
318
|
+
return 0
|
|
319
|
+
for epic in epics:
|
|
320
|
+
names = children[epic.name]
|
|
321
|
+
print(f"{epic.name}\t{len(names)} task(s)\t{', '.join(names) or '(no tasks)'}")
|
|
154
322
|
return 0
|
|
155
323
|
|
|
156
324
|
|
|
325
|
+
def _refuse_epic_with_children(manager: TaskManager, task: Task, verb: str) -> bool:
|
|
326
|
+
"""Print an error and return True if `task` is an epic that still has tasks.
|
|
327
|
+
|
|
328
|
+
Checked before the safety sweep: --force covers git state, not hierarchy.
|
|
329
|
+
"""
|
|
330
|
+
if not task.is_epic:
|
|
331
|
+
return False
|
|
332
|
+
children = manager.epic_child_names(task.name)
|
|
333
|
+
if not children:
|
|
334
|
+
return False
|
|
335
|
+
_error(
|
|
336
|
+
f"cannot {verb} epic {task.name}: it still has {len(children)} task(s): "
|
|
337
|
+
f"{', '.join(children)}; finish them or run: tasktree-manager epic remove TASK"
|
|
338
|
+
)
|
|
339
|
+
return True
|
|
340
|
+
|
|
341
|
+
|
|
157
342
|
def cmd_list(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
|
|
158
343
|
"""List tasks with their repos and dirty state."""
|
|
159
344
|
tasks = manager.list_tasks()
|
|
@@ -171,6 +356,8 @@ def cmd_list(manager: TaskManager, config: Config, args: argparse.Namespace) ->
|
|
|
171
356
|
{
|
|
172
357
|
"name": task.name,
|
|
173
358
|
"display_name": task.display_name,
|
|
359
|
+
"kind": task.kind,
|
|
360
|
+
"epic": task.epic_name,
|
|
174
361
|
"path": str(task.path),
|
|
175
362
|
"repos": [wt.name for wt in task.worktrees],
|
|
176
363
|
"dirty": dirty[task.name],
|
|
@@ -252,6 +439,8 @@ def cmd_delete(manager: TaskManager, config: Config, args: argparse.Namespace) -
|
|
|
252
439
|
task = manager.get_task(args.name)
|
|
253
440
|
if not task:
|
|
254
441
|
return _error(f"no such task: {args.name}")
|
|
442
|
+
if _refuse_epic_with_children(manager, task, "delete"):
|
|
443
|
+
return 1
|
|
255
444
|
|
|
256
445
|
report = manager.check_task_safety(task)
|
|
257
446
|
if not report.is_safe() and not args.force:
|
|
@@ -276,6 +465,8 @@ def cmd_finish(manager: TaskManager, config: Config, args: argparse.Namespace) -
|
|
|
276
465
|
task = manager.get_task(args.name)
|
|
277
466
|
if not task:
|
|
278
467
|
return _error(f"no such task: {args.name}")
|
|
468
|
+
if _refuse_epic_with_children(manager, task, "finish"):
|
|
469
|
+
return 1
|
|
279
470
|
|
|
280
471
|
# Push unconditionally when asked (TUI parity: `git push -u origin HEAD`).
|
|
281
472
|
# Gating on has_unpushed() would skip never-pushed --no-track branches,
|
|
@@ -431,6 +622,8 @@ def cmd_status(manager: TaskManager, config: Config, args: argparse.Namespace) -
|
|
|
431
622
|
if args.as_json:
|
|
432
623
|
payload = {
|
|
433
624
|
"task": task.name,
|
|
625
|
+
"kind": task.kind,
|
|
626
|
+
"epic": task.epic_name,
|
|
434
627
|
"path": str(task.path),
|
|
435
628
|
"claude": claude,
|
|
436
629
|
"worktrees": [
|
|
@@ -1,8 +1,46 @@
|
|
|
1
1
|
"""Data models for tasktree-manager."""
|
|
2
2
|
|
|
3
|
+
import re
|
|
3
4
|
from dataclasses import dataclass, field
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
7
|
+
# Task name validation pattern. No '/': a task is exactly one directory
|
|
8
|
+
# level under TASKS_DIR (list_tasks() only reads that level), and a slashed
|
|
9
|
+
# name would be surfaced as its parent directory with the wrong branch name.
|
|
10
|
+
TASK_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9._\-]+$")
|
|
11
|
+
|
|
12
|
+
EPIC_KIND = "epic"
|
|
13
|
+
TASK_KIND = "task"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_task_name(name: str) -> str | None:
|
|
17
|
+
"""Validate a task name, returning an error message or None if valid.
|
|
18
|
+
|
|
19
|
+
Task names become a directory under TASKS_DIR and a git branch name, so
|
|
20
|
+
they must be a single path segment that cannot traverse ('.', '..') or
|
|
21
|
+
look like a git option (leading '-').
|
|
22
|
+
"""
|
|
23
|
+
if not name:
|
|
24
|
+
return "Task name cannot be empty"
|
|
25
|
+
if name.startswith("-"):
|
|
26
|
+
return "Task name cannot start with '-'"
|
|
27
|
+
if name.startswith("."):
|
|
28
|
+
# list_tasks() hides dot-dirs (so the task would vanish from the
|
|
29
|
+
# list), and the archive dir itself lives at TASKS_DIR/.archive
|
|
30
|
+
return "Task name cannot start with '.'"
|
|
31
|
+
if not TASK_NAME_PATTERN.fullmatch(name):
|
|
32
|
+
return "Task name can only contain letters, numbers, '.', '_', '-'"
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _read_marker(path: Path) -> str | None:
|
|
37
|
+
"""Stripped content of a marker file, or None when missing or empty."""
|
|
38
|
+
try:
|
|
39
|
+
value = path.read_text(encoding="utf-8").strip()
|
|
40
|
+
except (OSError, UnicodeDecodeError):
|
|
41
|
+
return None
|
|
42
|
+
return value or None
|
|
43
|
+
|
|
6
44
|
|
|
7
45
|
@dataclass
|
|
8
46
|
class Worktree:
|
|
@@ -55,17 +93,39 @@ class Task:
|
|
|
55
93
|
Read lazily from disk so every Task construction site picks it up
|
|
56
94
|
and a rename is visible on the next render without reloading.
|
|
57
95
|
"""
|
|
58
|
-
|
|
59
|
-
value = (self.path / ".tasktree_name").read_text(encoding="utf-8").strip()
|
|
60
|
-
except OSError:
|
|
61
|
-
return None
|
|
62
|
-
return value or None
|
|
96
|
+
return _read_marker(self.path / ".tasktree_name")
|
|
63
97
|
|
|
64
98
|
@property
|
|
65
99
|
def display_label(self) -> str:
|
|
66
100
|
"""What the task list shows: the alias when set, else the real name."""
|
|
67
101
|
return self.display_name or self.name
|
|
68
102
|
|
|
103
|
+
@property
|
|
104
|
+
def kind(self) -> str:
|
|
105
|
+
"""'epic' when <task>/.tasktree_kind says so, else 'task'."""
|
|
106
|
+
if _read_marker(self.path / ".tasktree_kind") == EPIC_KIND:
|
|
107
|
+
return EPIC_KIND
|
|
108
|
+
return TASK_KIND
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def is_epic(self) -> bool:
|
|
112
|
+
"""True for an epic: a task folder that groups other tasks."""
|
|
113
|
+
return self.kind == EPIC_KIND
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def epic_name(self) -> str | None:
|
|
117
|
+
"""Parent epic from <task>/.tasktree_epic, or None.
|
|
118
|
+
|
|
119
|
+
The marker becomes a path, so a value that is not a valid task name
|
|
120
|
+
or names the task itself reads as None. Epics never have a parent.
|
|
121
|
+
"""
|
|
122
|
+
value = _read_marker(self.path / ".tasktree_epic")
|
|
123
|
+
if value is None or value == self.name or validate_task_name(value):
|
|
124
|
+
return None
|
|
125
|
+
if self.is_epic:
|
|
126
|
+
return None
|
|
127
|
+
return value
|
|
128
|
+
|
|
69
129
|
|
|
70
130
|
@dataclass
|
|
71
131
|
class GitStatus:
|