tasktree-manager 2.16.0__tar.gz → 2.16.1__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.16.0 → tasktree_manager-2.16.1}/.github/workflows/ci.yml +8 -2
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/CHANGELOG.md +6 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/PKG-INFO +1 -1
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/docs/configuration.md +3 -1
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/pyproject.toml +1 -1
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/_version.py +2 -2
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/app.py +13 -6
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/cli.py +6 -3
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/services/models.py +64 -17
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/services/task_manager.py +59 -50
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/task_list.py +5 -2
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/conftest.py +7 -1
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_epic_claude_md.py +2 -2
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_epic_tui.py +25 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_epics.py +78 -1
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_safety_fixes.py +4 -1
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/SKILL.md +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/assets/README.md +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/references/layouts.md +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/references/styling.md +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/references/widgets.md +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.github/templates/CHANGELOG.md.j2 +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.github/workflows/release.yml +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.gitignore +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/CONTRIBUTING.md +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/LICENSE +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/README.md +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/ROADMAP.md +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/docs/claude-code-integration-spec.md +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/docs/installation.md +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/docs/troubleshooting.md +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/docs/user-guide.md +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/mise.toml +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/scripts/bump_version.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/__init__.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/commands.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/services/__init__.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/services/agent_sessions.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/services/claude_hooks.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/services/claude_sessions.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/services/config.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/services/forge.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/services/git_ops.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/themes.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/__init__.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/app_header.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/create_modal.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/messages_panel.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/setup_modal.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/status_panel.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/worktree_list.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/__init__.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_agent_sessions.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_agent_sessions_decode.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_app.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_app_review_fixes.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_bump_version.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_claude_hooks.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_claude_sessions.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_cli.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_commands.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_config.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_forge.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_git_ops.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_integration_git.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_list_selection.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_medium_fixes.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_memory.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_messages_panel.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_modals.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_setup_modal.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_status_panel.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_task_manager.py +0 -0
- {tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tests/test_worktree_badges.py +0 -0
|
@@ -31,12 +31,18 @@ jobs:
|
|
|
31
31
|
run: ruff format --check tasktree_manager tests
|
|
32
32
|
|
|
33
33
|
test:
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
# Linux jobs keep the names branch protection requires
|
|
35
|
+
name: ${{ matrix.os == 'macos-latest' && format('Test (macOS, Python {0})', matrix.python-version) || format('Test (Python {0})', matrix.python-version) }}
|
|
36
|
+
runs-on: ${{ matrix.os }}
|
|
36
37
|
strategy:
|
|
37
38
|
fail-fast: false
|
|
38
39
|
matrix:
|
|
40
|
+
os: [ubuntu-latest]
|
|
39
41
|
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
42
|
+
include:
|
|
43
|
+
# Case-insensitive APFS: runs the case-variant epic tests Linux skips
|
|
44
|
+
- os: macos-latest
|
|
45
|
+
python-version: "3.13"
|
|
40
46
|
|
|
41
47
|
steps:
|
|
42
48
|
- name: Checkout code
|
|
@@ -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.16.1] - 2026-10-19
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- **epic**: One membership rule and safer marker writes (#12)
|
|
22
|
+
|
|
23
|
+
|
|
18
24
|
## [2.16.0] - 2026-10-19
|
|
19
25
|
|
|
20
26
|
### Added
|
|
@@ -611,7 +611,8 @@ All 27 customizable keybindings with descriptions:
|
|
|
611
611
|
| `add_repo` | `a` | Add repository to current task | `add_repo = "ctrl+a"` |
|
|
612
612
|
| `delete_task` | `d` | Delete/finish current task | `delete_task = "delete"` |
|
|
613
613
|
| `rename_task` | `R` | Rename task display label (alias only) | `rename_task = "ctrl+r"` |
|
|
614
|
-
| `set_epic` | `E` | Set the task's epic, or make it an epic | `set_epic = "ctrl+e"`
|
|
614
|
+
| `set_epic` | `E` | Set the task's epic, or make it an epic | `set_epic = "ctrl+shift+e"` |
|
|
615
|
+
| `delete_worktree` | `D` | Remove the selected worktree (the task stays) | `delete_worktree = "ctrl+d"` |
|
|
615
616
|
|
|
616
617
|
### Git Operations
|
|
617
618
|
|
|
@@ -632,6 +633,7 @@ All 27 customizable keybindings with descriptions:
|
|
|
632
633
|
| `open_claude_resume` | `c` | Open Claude CLI (resume if session exists, else new) | `open_claude_resume = "ctrl+c"` |
|
|
633
634
|
| `open_claude_gui_code`| `C` | Open Claude desktop on Code page in task folder | `open_claude_gui_code = "ctrl+shift+c"` |
|
|
634
635
|
| `toggle_messages` | `m` | Show/hide activity messages panel | `toggle_messages = "ctrl+m"` |
|
|
636
|
+
| `dispatch_agent` | `b` | Dispatch a background Claude agent into the worktree | `dispatch_agent = "ctrl+b"` |
|
|
635
637
|
|
|
636
638
|
### Navigation
|
|
637
639
|
|
|
@@ -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.16.
|
|
74
|
+
version = "2.16.1"
|
|
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.16.
|
|
22
|
-
__version_tuple__ = version_tuple = (2, 16,
|
|
21
|
+
__version__ = version = '2.16.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 16, 1)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -366,6 +366,9 @@ class TaskTreeApp(App):
|
|
|
366
366
|
(kb.get("delete_worktree", "D"), "delete_worktree", "Del WT"),
|
|
367
367
|
]
|
|
368
368
|
|
|
369
|
+
# Every key a configured action uses ("s,space" binds two keys)
|
|
370
|
+
claimed_keys = {key.strip() for keys in kb.values() for key in keys.split(",")}
|
|
371
|
+
|
|
369
372
|
yield AppHeader()
|
|
370
373
|
with Horizontal(id="main-container"):
|
|
371
374
|
# Left column: Tasks (top) and Worktrees (bottom)
|
|
@@ -382,7 +385,7 @@ class TaskTreeApp(App):
|
|
|
382
385
|
id="task-list",
|
|
383
386
|
context_bindings=task_panel_bindings,
|
|
384
387
|
# A fold key bound to any action in config stays that action
|
|
385
|
-
fold_keys=tuple(k for k in ("enter", "space") if k not in
|
|
388
|
+
fold_keys=tuple(k for k in ("enter", "space") if k not in claimed_keys),
|
|
386
389
|
)
|
|
387
390
|
with Vertical(id="worktree-panel"):
|
|
388
391
|
with Horizontal(classes="panel-title-bar"):
|
|
@@ -1302,7 +1305,8 @@ class TaskTreeApp(App):
|
|
|
1302
1305
|
initial_base_branch=self.config.default_base_branch,
|
|
1303
1306
|
title=f"Clone Task: {self.current_task.name}",
|
|
1304
1307
|
epics=self._epic_choices(),
|
|
1305
|
-
|
|
1308
|
+
# A clone joins the source task's epic; a clone of an epic joins none
|
|
1309
|
+
initial_epic=self.query_one("#task-list", TaskList).epic_of(self.current_task.name),
|
|
1306
1310
|
),
|
|
1307
1311
|
handle_result,
|
|
1308
1312
|
)
|
|
@@ -1432,7 +1436,7 @@ class TaskTreeApp(App):
|
|
|
1432
1436
|
return
|
|
1433
1437
|
try:
|
|
1434
1438
|
self.task_manager.set_task_display_name(task, new_name)
|
|
1435
|
-
except OSError as e:
|
|
1439
|
+
except (OSError, ValueError) as e:
|
|
1436
1440
|
self.notify(escape(f"Rename failed: {e}"), severity="error")
|
|
1437
1441
|
return
|
|
1438
1442
|
label = task.display_label
|
|
@@ -1452,6 +1456,7 @@ class TaskTreeApp(App):
|
|
|
1452
1456
|
self.notify("Epics don't nest: an epic can't join another epic", severity="warning")
|
|
1453
1457
|
return
|
|
1454
1458
|
epics = [(label, name) for label, name in self._epic_choices() if name != task.name]
|
|
1459
|
+
parent = self.query_one("#task-list", TaskList).epic_of(task.name)
|
|
1455
1460
|
|
|
1456
1461
|
def handle_choice(choice: tuple[str, str | None] | None) -> None:
|
|
1457
1462
|
if choice is None:
|
|
@@ -1469,8 +1474,8 @@ class TaskTreeApp(App):
|
|
|
1469
1474
|
EpicPickerModal(
|
|
1470
1475
|
task.display_label,
|
|
1471
1476
|
epics,
|
|
1472
|
-
|
|
1473
|
-
|
|
1477
|
+
parent,
|
|
1478
|
+
parent is None,
|
|
1474
1479
|
),
|
|
1475
1480
|
handle_choice,
|
|
1476
1481
|
)
|
|
@@ -2084,7 +2089,9 @@ class TaskTreeApp(App):
|
|
|
2084
2089
|
"""
|
|
2085
2090
|
fresh_task = self.task_manager.get_task(task.name)
|
|
2086
2091
|
if fresh_task:
|
|
2087
|
-
self.task_manager.ensure_claude_md_files(fresh_task)
|
|
2092
|
+
problem = self.task_manager.ensure_claude_md_files(fresh_task)
|
|
2093
|
+
if problem:
|
|
2094
|
+
self.call_from_thread(self.notify, escape(problem), severity="warning")
|
|
2088
2095
|
self.task_manager.ensure_worktree_settings(fresh_task)
|
|
2089
2096
|
ensure_claude_hooks(task.path, self.config.claude_memory_dir)
|
|
2090
2097
|
|
|
@@ -8,6 +8,7 @@ errors on stderr, exit code 0 on success and 1 on failure.
|
|
|
8
8
|
|
|
9
9
|
import argparse
|
|
10
10
|
import json
|
|
11
|
+
import os
|
|
11
12
|
import shutil
|
|
12
13
|
import sys
|
|
13
14
|
from pathlib import Path
|
|
@@ -16,7 +17,7 @@ from . import __version__
|
|
|
16
17
|
from .services import forge
|
|
17
18
|
from .services.config import Config, ConfigError
|
|
18
19
|
from .services.git_ops import GitOps
|
|
19
|
-
from .services.models import EPIC_KIND, TASK_KIND, ArchiveResult, Task
|
|
20
|
+
from .services.models import EPIC_KIND, TASK_KIND, ArchiveResult, Task, parent_epic_name
|
|
20
21
|
from .services.task_manager import ARCHIVE_NOTES_SHOWN, ArchiveIncompleteError, TaskManager
|
|
21
22
|
|
|
22
23
|
|
|
@@ -354,6 +355,8 @@ def _refuse_epic_with_children(manager: TaskManager, task: Task, verb: str) -> b
|
|
|
354
355
|
def cmd_list(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
|
|
355
356
|
"""List tasks with their repos and dirty state."""
|
|
356
357
|
tasks = manager.list_tasks()
|
|
358
|
+
# One TASKS_DIR listing for every parent_epic_name lookup below
|
|
359
|
+
listing = set(os.listdir(config.tasks_dir)) if config.tasks_dir.is_dir() else set()
|
|
357
360
|
dirty: dict[str, bool] = {}
|
|
358
361
|
errored: dict[str, bool] = {}
|
|
359
362
|
for task in tasks:
|
|
@@ -369,7 +372,7 @@ def cmd_list(manager: TaskManager, config: Config, args: argparse.Namespace) ->
|
|
|
369
372
|
"name": task.name,
|
|
370
373
|
"display_name": task.display_name,
|
|
371
374
|
"kind": task.kind,
|
|
372
|
-
"epic": task
|
|
375
|
+
"epic": parent_epic_name(task, listing),
|
|
373
376
|
"path": str(task.path),
|
|
374
377
|
"repos": [wt.name for wt in task.worktrees],
|
|
375
378
|
"dirty": dirty[task.name],
|
|
@@ -635,7 +638,7 @@ def cmd_status(manager: TaskManager, config: Config, args: argparse.Namespace) -
|
|
|
635
638
|
payload = {
|
|
636
639
|
"task": task.name,
|
|
637
640
|
"kind": task.kind,
|
|
638
|
-
"epic": task
|
|
641
|
+
"epic": parent_epic_name(task),
|
|
639
642
|
"path": str(task.path),
|
|
640
643
|
"claude": claude,
|
|
641
644
|
"worktrees": [
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
import re
|
|
5
|
+
from collections.abc import Collection
|
|
5
6
|
from dataclasses import dataclass, field
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
@@ -128,23 +129,60 @@ class Task:
|
|
|
128
129
|
return value
|
|
129
130
|
|
|
130
131
|
|
|
131
|
-
def
|
|
132
|
-
"""The
|
|
132
|
+
def on_disk_name(path: Path, names: Collection[str] | None = None) -> str:
|
|
133
|
+
"""The last path segment as spelled on disk, for an existing path.
|
|
133
134
|
|
|
134
|
-
|
|
135
|
-
|
|
135
|
+
On a case-insensitive disk `epic-1` opens `EPIC-1`; markers, branches and
|
|
136
|
+
the epic checks must all use the real spelling. `names` is the listing of
|
|
137
|
+
path.parent when the caller already has it (one listing per scan).
|
|
136
138
|
"""
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return
|
|
139
|
+
if names is None:
|
|
140
|
+
try:
|
|
141
|
+
names = set(os.listdir(path.parent))
|
|
142
|
+
except OSError:
|
|
143
|
+
return path.name
|
|
144
|
+
if path.name in names:
|
|
145
|
+
return path.name
|
|
146
|
+
for name in names:
|
|
147
|
+
if name.casefold() == path.name.casefold() and same_folder(path.parent / name, path):
|
|
148
|
+
return name
|
|
149
|
+
return path.name
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def same_folder(a: Path, b: Path) -> bool:
|
|
153
|
+
"""True when two paths open the same file or folder."""
|
|
154
|
+
try:
|
|
155
|
+
return os.path.samefile(a, b)
|
|
156
|
+
except OSError:
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def parent_epic_name(task: Task, names: Collection[str] | None = None) -> str | None:
|
|
161
|
+
"""The epic a task belongs to, spelled as on disk, or None.
|
|
162
|
+
|
|
163
|
+
The one membership rule for the CLI, the TUI, the delete guard and the
|
|
164
|
+
CLAUDE.md block: the marker must name a real epic folder next to the
|
|
165
|
+
task. A symlink alias, a missing folder or a plain task makes it an orphan.
|
|
166
|
+
`names` is the TASKS_DIR listing, passed by callers that resolve many tasks.
|
|
167
|
+
"""
|
|
168
|
+
marker = task.epic_name
|
|
169
|
+
if marker is None:
|
|
170
|
+
return None
|
|
171
|
+
path = task.path.parent / marker
|
|
172
|
+
if path.is_symlink() or not path.is_dir():
|
|
173
|
+
return None
|
|
174
|
+
name = on_disk_name(path, names)
|
|
175
|
+
if not Task(name=name, path=path.parent / name).is_epic:
|
|
176
|
+
return None
|
|
177
|
+
return name
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def resolve_epic(
|
|
181
|
+
task: Task, epics: dict[str, Task], names: Collection[str] | None = None
|
|
182
|
+
) -> str | None:
|
|
183
|
+
"""The epic in `epics` the task belongs to (see parent_epic_name), or None."""
|
|
184
|
+
parent = parent_epic_name(task, names)
|
|
185
|
+
return parent if parent in epics else None
|
|
148
186
|
|
|
149
187
|
|
|
150
188
|
def group_by_epic(tasks: list[Task]) -> tuple[list[Task], dict[str, list[Task]]]:
|
|
@@ -154,11 +192,20 @@ def group_by_epic(tasks: list[Task]) -> tuple[list[Task], dict[str, list[Task]]]
|
|
|
154
192
|
rule as TaskManager.epic_child_names (the delete guard). Anything else, an
|
|
155
193
|
orphan included, is top-level. Order within both is kept.
|
|
156
194
|
"""
|
|
157
|
-
|
|
195
|
+
# A symlinked folder never counts as an epic: it would alias a real one
|
|
196
|
+
epics = {task.name: task for task in tasks if task.is_epic and not task.path.is_symlink()}
|
|
158
197
|
children: dict[str, list[Task]] = {name: [] for name in epics}
|
|
159
198
|
top: list[Task] = []
|
|
199
|
+
listings: dict[Path, set[str]] = {}
|
|
160
200
|
for task in tasks:
|
|
161
|
-
|
|
201
|
+
folder = task.path.parent
|
|
202
|
+
if folder not in listings:
|
|
203
|
+
try:
|
|
204
|
+
listings[folder] = set(os.listdir(folder))
|
|
205
|
+
except OSError:
|
|
206
|
+
listings[folder] = set()
|
|
207
|
+
names = listings[folder]
|
|
208
|
+
parent = None if task.name in epics else resolve_epic(task, epics, names)
|
|
162
209
|
if parent is None:
|
|
163
210
|
top.append(task)
|
|
164
211
|
else:
|
{tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/services/task_manager.py
RENAMED
|
@@ -22,6 +22,9 @@ from .models import (
|
|
|
22
22
|
Task,
|
|
23
23
|
TaskSafetyReport,
|
|
24
24
|
Worktree,
|
|
25
|
+
on_disk_name,
|
|
26
|
+
parent_epic_name,
|
|
27
|
+
same_folder,
|
|
25
28
|
validate_task_name,
|
|
26
29
|
)
|
|
27
30
|
|
|
@@ -272,13 +275,7 @@ class TaskManager:
|
|
|
272
275
|
On a case-insensitive filesystem `epic-1` opens `EPIC-1`; markers and
|
|
273
276
|
branches must use the real spelling or the epic checks miss children.
|
|
274
277
|
"""
|
|
275
|
-
|
|
276
|
-
if any(item.name == name for item in self.config.tasks_dir.iterdir()):
|
|
277
|
-
return name
|
|
278
|
-
for item in self.config.tasks_dir.iterdir():
|
|
279
|
-
if item.name.casefold() == name.casefold() and os.path.samefile(item, path):
|
|
280
|
-
return item.name
|
|
281
|
-
return name
|
|
278
|
+
return on_disk_name(self.config.tasks_dir / name)
|
|
282
279
|
|
|
283
280
|
def create_task(
|
|
284
281
|
self,
|
|
@@ -669,16 +666,29 @@ class TaskManager:
|
|
|
669
666
|
lives in <task>/.tasktree_name. Empty input — or an alias equal to the
|
|
670
667
|
real name — clears the file.
|
|
671
668
|
"""
|
|
672
|
-
marker = task.path / ".tasktree_name"
|
|
673
|
-
if marker.is_symlink():
|
|
674
|
-
marker.unlink()
|
|
675
669
|
name = (display_name or "").strip()
|
|
676
670
|
if name == task.name:
|
|
677
671
|
name = ""
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
672
|
+
self._write_file_marker(task, ".tasktree_name", name or None)
|
|
673
|
+
|
|
674
|
+
def _write_file_marker(self, task: Task, marker: str, value: str | None) -> None:
|
|
675
|
+
"""Replace or remove <task>/<marker> without following links.
|
|
676
|
+
|
|
677
|
+
A planted symlink or hard link is swapped for a new file, never
|
|
678
|
+
written through; a task folder outside TASKS_DIR is refused.
|
|
679
|
+
"""
|
|
680
|
+
self._require_inside(task)
|
|
681
|
+
path = task.path / marker
|
|
682
|
+
if value is not None:
|
|
683
|
+
self._replace_file(path, (value + "\n").encode("utf-8"))
|
|
684
|
+
elif path.is_symlink() or path.exists():
|
|
685
|
+
path.unlink()
|
|
686
|
+
|
|
687
|
+
def _require_inside(self, task: Task) -> None:
|
|
688
|
+
"""Raise ValueError unless the task folder is a real folder in TASKS_DIR."""
|
|
689
|
+
tasks_dir = self.config.tasks_dir.resolve()
|
|
690
|
+
if task.path.is_symlink() or task.path.resolve().parent != tasks_dir:
|
|
691
|
+
raise ValueError(f"'{task.name}' is not a task folder inside {tasks_dir}")
|
|
682
692
|
|
|
683
693
|
def _write_marker(self, task: Task, marker: str, value: str | None) -> None:
|
|
684
694
|
"""Write <task>/<marker>, or remove it when value is None.
|
|
@@ -687,14 +697,7 @@ class TaskManager:
|
|
|
687
697
|
follows it. That sync never raises: callers that report problems run
|
|
688
698
|
sync_epic_block again and show what it returns.
|
|
689
699
|
"""
|
|
690
|
-
|
|
691
|
-
if path.is_symlink():
|
|
692
|
-
# Never write through a planted link to a file outside the task
|
|
693
|
-
path.unlink()
|
|
694
|
-
if value is not None:
|
|
695
|
-
path.write_text(value + "\n", encoding="utf-8")
|
|
696
|
-
else:
|
|
697
|
-
path.unlink(missing_ok=True)
|
|
700
|
+
self._write_file_marker(task, marker, value)
|
|
698
701
|
self.sync_epic_block(task)
|
|
699
702
|
|
|
700
703
|
def _render_epic_block(self, task: Task) -> str | None:
|
|
@@ -706,8 +709,7 @@ class TaskManager:
|
|
|
706
709
|
f"`{tasks_dir}` whose `.tasktree_epic` file names `{task.name}`; list them",
|
|
707
710
|
"with `tasktree-manager epic list`.",
|
|
708
711
|
]
|
|
709
|
-
elif task
|
|
710
|
-
epic = task.epic_name
|
|
712
|
+
elif (epic := parent_epic_name(task)) is not None:
|
|
711
713
|
body = [
|
|
712
714
|
f"This task belongs to the epic `{epic}` at `{tasks_dir / epic}`.",
|
|
713
715
|
"The epic folder holds the shared notes and plans. Its worktrees belong",
|
|
@@ -784,7 +786,10 @@ class TaskManager:
|
|
|
784
786
|
f.write(data)
|
|
785
787
|
f.flush()
|
|
786
788
|
os.fsync(f.fileno())
|
|
787
|
-
|
|
789
|
+
if path.is_file() and not path.is_symlink():
|
|
790
|
+
shutil.copymode(path, tmp)
|
|
791
|
+
else:
|
|
792
|
+
os.chmod(tmp, 0o644)
|
|
788
793
|
os.replace(tmp, path)
|
|
789
794
|
except BaseException:
|
|
790
795
|
Path(tmp).unlink(missing_ok=True)
|
|
@@ -801,6 +806,9 @@ class TaskManager:
|
|
|
801
806
|
epic_path = self.config.tasks_dir / epic_name
|
|
802
807
|
if not epic_path.is_dir():
|
|
803
808
|
raise ValueError(f"No such epic: {epic_name}")
|
|
809
|
+
if epic_path.is_symlink():
|
|
810
|
+
# An alias would let delete/finish miss the children of the real epic
|
|
811
|
+
raise ValueError(f"'{epic_name}' is a symlink, not an epic folder")
|
|
804
812
|
epic_name = self._on_disk_name(epic_name)
|
|
805
813
|
if not Task(name=epic_name, path=epic_path).is_epic:
|
|
806
814
|
raise ValueError(
|
|
@@ -811,37 +819,28 @@ class TaskManager:
|
|
|
811
819
|
def epic_child_names(self, epic_name: str) -> list[str]:
|
|
812
820
|
"""Names of the tasks whose .tasktree_epic marker names this epic.
|
|
813
821
|
|
|
814
|
-
Reads markers only: list_tasks() walks every task's worktrees.
|
|
822
|
+
Reads markers only: list_tasks() walks every task's worktrees. A child
|
|
823
|
+
counts when its parent (parent_epic_name) is the same folder as
|
|
824
|
+
`epic_name`, so a case variant or symlink alias of the epic still
|
|
825
|
+
finds every child.
|
|
815
826
|
"""
|
|
816
827
|
if not self.config.tasks_dir.exists():
|
|
817
828
|
return []
|
|
818
829
|
names = []
|
|
830
|
+
listing = set(os.listdir(self.config.tasks_dir))
|
|
831
|
+
# Symlinked task folders count too: the CLI and TUI show them as children
|
|
819
832
|
for item in sorted(self.config.tasks_dir.iterdir()):
|
|
820
833
|
if item.is_dir() and not item.name.startswith("."):
|
|
821
|
-
parent = Task(name=item.name, path=item)
|
|
822
|
-
if parent
|
|
823
|
-
parent
|
|
824
|
-
and parent.casefold() == epic_name.casefold()
|
|
825
|
-
and self._same_task(parent, epic_name)
|
|
834
|
+
parent = parent_epic_name(Task(name=item.name, path=item), listing)
|
|
835
|
+
if parent is not None and (
|
|
836
|
+
parent == epic_name or self._same_task(parent, epic_name)
|
|
826
837
|
):
|
|
827
838
|
names.append(item.name)
|
|
828
839
|
return names
|
|
829
840
|
|
|
830
841
|
def _same_task(self, a: str, b: str) -> bool:
|
|
831
|
-
"""True when two task names open the same folder
|
|
832
|
-
|
|
833
|
-
return os.path.samefile(self.config.tasks_dir / a, self.config.tasks_dir / b)
|
|
834
|
-
except OSError:
|
|
835
|
-
return False
|
|
836
|
-
|
|
837
|
-
def epic_children(self, epic: Task) -> list[Task]:
|
|
838
|
-
"""The tasks attached to an epic, with their worktrees, sorted by name."""
|
|
839
|
-
children = []
|
|
840
|
-
for name in self.epic_child_names(epic.name):
|
|
841
|
-
child = self.get_task(name)
|
|
842
|
-
if child is not None:
|
|
843
|
-
children.append(child)
|
|
844
|
-
return children
|
|
842
|
+
"""True when two task names open the same folder."""
|
|
843
|
+
return same_folder(self.config.tasks_dir / a, self.config.tasks_dir / b)
|
|
845
844
|
|
|
846
845
|
@staticmethod
|
|
847
846
|
def _epic_has_children_message(epic: Task, children: list[str]) -> str:
|
|
@@ -856,6 +855,7 @@ class TaskManager:
|
|
|
856
855
|
Returns the epic name as written. Epics don't nest: an epic cannot
|
|
857
856
|
join an epic.
|
|
858
857
|
"""
|
|
858
|
+
self._require_inside(task)
|
|
859
859
|
if epic_name is None:
|
|
860
860
|
self._write_marker(task, ".tasktree_epic", None)
|
|
861
861
|
return None
|
|
@@ -871,17 +871,22 @@ class TaskManager:
|
|
|
871
871
|
A task that belongs to an epic cannot become one, and an epic with
|
|
872
872
|
children cannot become a task.
|
|
873
873
|
"""
|
|
874
|
+
self._require_inside(task)
|
|
874
875
|
if kind == EPIC_KIND:
|
|
875
|
-
|
|
876
|
+
parent = parent_epic_name(task)
|
|
877
|
+
if parent is not None:
|
|
876
878
|
raise ValueError(
|
|
877
|
-
f"'{task.name}' belongs to epic '{
|
|
879
|
+
f"'{task.name}' belongs to epic '{parent}'; "
|
|
878
880
|
"detach it first (tasktree-manager epic remove)"
|
|
879
881
|
)
|
|
880
882
|
self._write_marker(task, ".tasktree_kind", EPIC_KIND)
|
|
881
883
|
# An unreadable or self-naming parent marker reads as None; drop it
|
|
882
884
|
self._write_marker(task, ".tasktree_epic", None)
|
|
883
885
|
elif kind == TASK_KIND:
|
|
884
|
-
|
|
886
|
+
if not task.is_epic:
|
|
887
|
+
# A plain task stays as it is; its parent marker is not ours to drop
|
|
888
|
+
return
|
|
889
|
+
children = self.epic_child_names(task.name)
|
|
885
890
|
if children:
|
|
886
891
|
raise ValueError(self._epic_has_children_message(task, children))
|
|
887
892
|
# A parent marker the epic carried was hidden; drop it before the
|
|
@@ -1480,7 +1485,7 @@ class TaskManager:
|
|
|
1480
1485
|
if repo_path.exists() and worktree.path.exists():
|
|
1481
1486
|
ensure_worktree_claude_settings(worktree.path, repo_path, status_file)
|
|
1482
1487
|
|
|
1483
|
-
def ensure_claude_md_files(self, task: Task) -> None:
|
|
1488
|
+
def ensure_claude_md_files(self, task: Task) -> str | None:
|
|
1484
1489
|
"""Create the task CLAUDE.md and backfill worktree ones from the repo.
|
|
1485
1490
|
|
|
1486
1491
|
The task-level file is generated (the task dir is not a git repo).
|
|
@@ -1490,16 +1495,20 @@ class TaskManager:
|
|
|
1490
1495
|
|
|
1491
1496
|
Args:
|
|
1492
1497
|
task: The task to create CLAUDE.md files for
|
|
1498
|
+
|
|
1499
|
+
Returns:
|
|
1500
|
+
Why the epic block could not be synced, or None
|
|
1493
1501
|
"""
|
|
1494
1502
|
task_claude_md = task.path / "CLAUDE.md"
|
|
1495
1503
|
if not task_claude_md.exists():
|
|
1496
1504
|
self._create_task_claude_md(task)
|
|
1497
|
-
self.sync_epic_block(task)
|
|
1505
|
+
problem = self.sync_epic_block(task)
|
|
1498
1506
|
|
|
1499
1507
|
for worktree in task.worktrees:
|
|
1500
1508
|
wt_claude_md = worktree.path / "CLAUDE.md"
|
|
1501
1509
|
if not wt_claude_md.exists():
|
|
1502
1510
|
self._copy_repo_claude_md(worktree)
|
|
1511
|
+
return problem
|
|
1503
1512
|
|
|
1504
1513
|
def _create_task_claude_md(self, task: Task) -> None:
|
|
1505
1514
|
"""Generate task CLAUDE.md with task name + worktree paths.
|
|
@@ -84,7 +84,7 @@ class TaskList(OptionList):
|
|
|
84
84
|
self._bindings.bind(key, f"app.{action}", description, show=True)
|
|
85
85
|
# Enter folds an epic instead of OptionList's select (a click still
|
|
86
86
|
# selects); a fold key the user bound to an action stays theirs
|
|
87
|
-
claimed = {key for
|
|
87
|
+
claimed = {key.strip() for keys, _, _ in context_bindings or [] for key in keys.split(",")}
|
|
88
88
|
for i, key in enumerate(k for k in fold_keys if k not in claimed):
|
|
89
89
|
self._bindings.key_to_bindings.pop(key, None)
|
|
90
90
|
self._bindings.bind(key, "toggle_epic", "Fold", show=i == 0)
|
|
@@ -308,7 +308,10 @@ class TaskList(OptionList):
|
|
|
308
308
|
|
|
309
309
|
@staticmethod
|
|
310
310
|
def _mtime(task: Task) -> float:
|
|
311
|
-
|
|
311
|
+
try:
|
|
312
|
+
return task.path.stat().st_mtime
|
|
313
|
+
except OSError: # deleted or unreadable since it was listed
|
|
314
|
+
return 0
|
|
312
315
|
|
|
313
316
|
def _sort_tasks(self, tasks: list[Task]) -> list[Task]:
|
|
314
317
|
"""Sort tasks according to current sort mode.
|
|
@@ -120,8 +120,14 @@ def sample_repos(temp_dirs):
|
|
|
120
120
|
|
|
121
121
|
|
|
122
122
|
@pytest.fixture
|
|
123
|
-
def app(config):
|
|
123
|
+
def app(config, tmp_path, monkeypatch):
|
|
124
124
|
"""Create app with test config."""
|
|
125
|
+
# TaskTreeApp() loads the real config first: keep it (and the default
|
|
126
|
+
# ~/tasks it would create) inside the test's temp dir
|
|
127
|
+
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
|
128
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "home" / ".config"))
|
|
129
|
+
for var in ("TASKS_DIR", "REPOS_DIR"):
|
|
130
|
+
monkeypatch.delenv(var, raising=False)
|
|
125
131
|
config.ensure_dirs()
|
|
126
132
|
# Keep TUI tests hermetic: never poll the real `claude` CLI from on_mount.
|
|
127
133
|
# Badge/dispatch tests drive the poll/apply methods directly instead.
|
|
@@ -149,10 +149,10 @@ class TestEpicBlockRobustness:
|
|
|
149
149
|
def full_disk(src, dst):
|
|
150
150
|
raise OSError(28, "No space left on device")
|
|
151
151
|
|
|
152
|
+
(task.path / ".tasktree_epic").write_text("E-1\n")
|
|
152
153
|
monkeypatch.setattr(os, "replace", full_disk)
|
|
153
|
-
tm.set_task_epic(task, "E-1")
|
|
154
|
-
assert path.read_text() == "notes\n" * 50
|
|
155
154
|
assert "No space left" in tm.sync_epic_block(task)
|
|
155
|
+
assert path.read_text() == "notes\n" * 50
|
|
156
156
|
assert [p.name for p in task.path.iterdir() if p.name.endswith(".tmp")] == []
|
|
157
157
|
|
|
158
158
|
def test_unmark_with_planted_parent_marker(self, tm):
|
|
@@ -468,3 +468,28 @@ class TestReviewFixesTui:
|
|
|
468
468
|
await pilot.press("h")
|
|
469
469
|
await pilot.pause()
|
|
470
470
|
assert started == []
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
class TestCumulativeReviewTui:
|
|
474
|
+
async def test_comma_separated_binding_keeps_space(self, app, epic_tasks):
|
|
475
|
+
app.config.keybindings["cycle_sort"] = "s,space"
|
|
476
|
+
async with app.run_test() as pilot:
|
|
477
|
+
await pilot.pause()
|
|
478
|
+
task_list = app.query_one("#task-list", TaskList)
|
|
479
|
+
task_list.focus()
|
|
480
|
+
await select(pilot, task_list, "E-1")
|
|
481
|
+
await pilot.press("space")
|
|
482
|
+
await pilot.pause()
|
|
483
|
+
assert task_list._sort_mode is SortMode.NAME_DESC
|
|
484
|
+
|
|
485
|
+
@pytest.mark.parametrize(("row", "expected"), [("E-1", None), ("T-1", "E-1")])
|
|
486
|
+
async def test_clone_preselects_source_parent(
|
|
487
|
+
self, app, epic_tasks, sample_repos, row, expected
|
|
488
|
+
):
|
|
489
|
+
async with app.run_test() as pilot:
|
|
490
|
+
await pilot.pause()
|
|
491
|
+
await select(pilot, app.query_one("#task-list", TaskList), row)
|
|
492
|
+
await pilot.press("y")
|
|
493
|
+
await pilot.pause()
|
|
494
|
+
assert isinstance(app.screen, CreateTaskModal)
|
|
495
|
+
assert app.screen.initial_epic == expected
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Tests for epics: the markers, the service rules and the `epic` CLI."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import os
|
|
4
5
|
|
|
5
6
|
import pytest
|
|
6
7
|
|
|
@@ -159,7 +160,6 @@ class TestHierarchy:
|
|
|
159
160
|
task_manager.create_task("T-1", [], epic="EPIC-1")
|
|
160
161
|
task_manager.create_task("T-3", [])
|
|
161
162
|
assert task_manager.epic_child_names("EPIC-1") == ["T-1", "T-2"]
|
|
162
|
-
assert [t.name for t in task_manager.epic_children(epic)] == ["T-1", "T-2"]
|
|
163
163
|
|
|
164
164
|
def test_finish_refuses_epic_with_children(self, task_manager, epic):
|
|
165
165
|
child = task_manager.create_task("T-1", [], epic="EPIC-1")
|
|
@@ -440,3 +440,80 @@ class TestMarkerSymlink:
|
|
|
440
440
|
assert outside.read_text() == "keep\n"
|
|
441
441
|
assert not (task.path / ".tasktree_epic").is_symlink()
|
|
442
442
|
assert task.epic_name == "EPIC-1"
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
class TestCumulativeReviewFixes:
|
|
446
|
+
def test_symlink_alias_is_not_an_epic(self, cli_config, capsys):
|
|
447
|
+
cli(cli_config, "epic", "create", "EPIC-1")
|
|
448
|
+
cli(cli_config, "create", "T-1", "--repos", "repo-alpha", "--epic", "EPIC-1")
|
|
449
|
+
(cli_config.tasks_dir / "alias").symlink_to(cli_config.tasks_dir / "EPIC-1")
|
|
450
|
+
capsys.readouterr()
|
|
451
|
+
assert cli(cli_config, "epic", "add", "alias", "T-1") == 1
|
|
452
|
+
assert "is a symlink" in capsys.readouterr().err
|
|
453
|
+
# Deleting through the alias still sees the real epic's children
|
|
454
|
+
assert cli(cli_config, "delete", "alias", "--force") == 1
|
|
455
|
+
assert "T-1" in capsys.readouterr().err
|
|
456
|
+
assert (cli_config.tasks_dir / "EPIC-1").exists()
|
|
457
|
+
|
|
458
|
+
def test_alias_marker_is_an_orphan(self, cli_config, capsys):
|
|
459
|
+
cli(cli_config, "epic", "create", "EPIC-1")
|
|
460
|
+
cli(cli_config, "create", "T-2", "--repos", "repo-alpha")
|
|
461
|
+
(cli_config.tasks_dir / "alias").symlink_to(cli_config.tasks_dir / "EPIC-1")
|
|
462
|
+
(cli_config.tasks_dir / "T-2" / ".tasktree_epic").write_text("alias\n")
|
|
463
|
+
capsys.readouterr()
|
|
464
|
+
assert cli(cli_config, "list", "--json") == 0
|
|
465
|
+
payload = {t["name"]: t for t in json.loads(capsys.readouterr().out)}
|
|
466
|
+
assert payload["T-2"]["epic"] is None
|
|
467
|
+
|
|
468
|
+
def test_orphan_marker_is_null_everywhere(self, cli_config, task_manager, capsys):
|
|
469
|
+
cli(cli_config, "create", "T-1", "--repos", "repo-alpha")
|
|
470
|
+
task = task_manager.get_task("T-1")
|
|
471
|
+
(task.path / ".tasktree_epic").write_text("GHOST\n")
|
|
472
|
+
task_manager.sync_epic_block(task)
|
|
473
|
+
assert "GHOST" not in (task.path / "CLAUDE.md").read_text()
|
|
474
|
+
capsys.readouterr()
|
|
475
|
+
assert cli(cli_config, "list", "--json") == 0
|
|
476
|
+
assert json.loads(capsys.readouterr().out)[0]["epic"] is None
|
|
477
|
+
assert cli(cli_config, "epic", "mark", "T-1") == 0 # an orphan may become an epic
|
|
478
|
+
assert not (task.path / ".tasktree_epic").exists()
|
|
479
|
+
|
|
480
|
+
def test_hard_linked_marker_not_written_through(self, task_manager, epic, tmp_path):
|
|
481
|
+
outside = tmp_path / "outside.txt"
|
|
482
|
+
outside.write_text("keep\n")
|
|
483
|
+
task = task_manager.create_task("T-1", [])
|
|
484
|
+
os.link(outside, task.path / ".tasktree_epic")
|
|
485
|
+
task_manager.set_task_epic(task, "EPIC-1")
|
|
486
|
+
assert outside.read_text() == "keep\n"
|
|
487
|
+
assert task.epic_name == "EPIC-1"
|
|
488
|
+
|
|
489
|
+
def test_task_folder_outside_tasks_dir_refused(self, task_manager, epic, tmp_path):
|
|
490
|
+
real = tmp_path / "elsewhere"
|
|
491
|
+
real.mkdir()
|
|
492
|
+
link = task_manager.config.tasks_dir / "T-LINK"
|
|
493
|
+
link.symlink_to(real)
|
|
494
|
+
task = Task(name="T-LINK", path=link)
|
|
495
|
+
with pytest.raises(ValueError, match="not a task folder"):
|
|
496
|
+
task_manager.set_task_epic(task, "EPIC-1")
|
|
497
|
+
assert not (real / ".tasktree_epic").exists()
|
|
498
|
+
|
|
499
|
+
def test_unmark_on_plain_child_keeps_its_epic(self, task_manager, epic):
|
|
500
|
+
child = task_manager.create_task("T-1", [], epic="EPIC-1")
|
|
501
|
+
task_manager.set_task_kind(child, "task")
|
|
502
|
+
assert child.epic_name == "EPIC-1"
|
|
503
|
+
|
|
504
|
+
def test_ensure_reports_block_problem(self, task_manager, epic):
|
|
505
|
+
from tasktree_manager.services.task_manager import EPIC_BLOCK_START
|
|
506
|
+
|
|
507
|
+
child = task_manager.create_task("T-1", [], epic="EPIC-1")
|
|
508
|
+
(child.path / "CLAUDE.md").write_text(EPIC_BLOCK_START + "\n")
|
|
509
|
+
assert "mismatched" in task_manager.ensure_claude_md_files(child)
|
|
510
|
+
|
|
511
|
+
def test_symlinked_child_still_blocks_epic_delete(self, cli_config, task_manager, capsys):
|
|
512
|
+
cli(cli_config, "epic", "create", "EPIC-1")
|
|
513
|
+
real = cli_config.tasks_dir.parent / "real-child"
|
|
514
|
+
real.mkdir()
|
|
515
|
+
(real / ".tasktree_epic").write_text("EPIC-1\n")
|
|
516
|
+
(cli_config.tasks_dir / "T-LINK").symlink_to(real)
|
|
517
|
+
assert task_manager.epic_child_names("EPIC-1") == ["T-LINK"]
|
|
518
|
+
capsys.readouterr()
|
|
519
|
+
assert cli(cli_config, "delete", "EPIC-1", "--force") == 1
|
|
@@ -272,7 +272,10 @@ class TestDeleteWorktreeSafety:
|
|
|
272
272
|
await pilot.pause()
|
|
273
273
|
modal = app.screen_stack[-1]
|
|
274
274
|
assert isinstance(modal, ConfirmModal)
|
|
275
|
-
modal.dismiss(True)
|
|
275
|
+
await modal.dismiss(True)
|
|
276
|
+
# The confirm callback starts the worker on a later message, so
|
|
277
|
+
# let it run before waiting, or the wait returns with no worker.
|
|
278
|
+
await pilot.pause()
|
|
276
279
|
await app.workers.wait_for_complete()
|
|
277
280
|
await pilot.pause()
|
|
278
281
|
assert not wt.path.exists()
|
|
File without changes
|
{tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/assets/README.md
RENAMED
|
File without changes
|
|
File without changes
|
{tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/assets/data_viewer.py
RENAMED
|
File without changes
|
{tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/assets/todo_app.py
RENAMED
|
File without changes
|
{tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/assets/worker_demo.py
RENAMED
|
File without changes
|
{tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/references/layouts.md
RENAMED
|
File without changes
|
|
File without changes
|
{tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.claude/skills/textual-tui/references/styling.md
RENAMED
|
File without changes
|
{tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/.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
|
{tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/services/agent_sessions.py
RENAMED
|
File without changes
|
{tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/services/claude_hooks.py
RENAMED
|
File without changes
|
{tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/services/claude_sessions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/create_modal.py
RENAMED
|
File without changes
|
{tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/messages_panel.py
RENAMED
|
File without changes
|
|
File without changes
|
{tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/tasktree_manager/widgets/status_panel.py
RENAMED
|
File without changes
|
{tasktree_manager-2.16.0 → tasktree_manager-2.16.1}/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
|
|
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
|