tasktree-manager 2.13.2__tar.gz → 2.13.3__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.2 → tasktree_manager-2.13.3}/.gitignore +0 -1
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/CHANGELOG.md +9 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/CONTRIBUTING.md +7 -10
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/PKG-INFO +1 -1
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/README.md +23 -16
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/docs/configuration.md +2 -2
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/docs/troubleshooting.md +1 -1
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/docs/user-guide.md +6 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/pyproject.toml +1 -1
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/_version.py +2 -2
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/app.py +59 -28
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/cli.py +63 -21
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/services/config.py +19 -11
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/services/models.py +16 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/services/task_manager.py +295 -53
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_app_review_fixes.py +97 -2
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_cli.py +88 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_medium_fixes.py +1 -1
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_task_manager.py +386 -24
- tasktree_manager-2.13.2/OPENSPEC.md +0 -457
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.claude/skills/textual-tui/SKILL.md +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.claude/skills/textual-tui/assets/README.md +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.claude/skills/textual-tui/references/layouts.md +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.claude/skills/textual-tui/references/styling.md +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.claude/skills/textual-tui/references/widgets.md +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.github/templates/CHANGELOG.md.j2 +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.github/workflows/ci.yml +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/.github/workflows/release.yml +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/LICENSE +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/ROADMAP.md +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/docs/claude-code-integration-spec.md +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/docs/installation.md +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/mise.toml +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/scripts/bump_version.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/__init__.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/commands.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/services/__init__.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/services/agent_sessions.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/services/claude_hooks.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/services/claude_sessions.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/services/forge.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/services/git_ops.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/themes.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/widgets/__init__.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/widgets/app_header.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/widgets/create_modal.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/widgets/messages_panel.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/widgets/setup_modal.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/widgets/status_panel.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/widgets/task_list.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tasktree_manager/widgets/worktree_list.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/__init__.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/conftest.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_agent_sessions.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_agent_sessions_decode.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_app.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_bump_version.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_claude_hooks.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_claude_sessions.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_commands.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_config.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_forge.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_git_ops.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_integration_git.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_list_selection.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_memory.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_messages_panel.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_modals.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_safety_fixes.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_setup_modal.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_status_panel.py +0 -0
- {tasktree_manager-2.13.2 → tasktree_manager-2.13.3}/tests/test_worktree_badges.py +0 -0
|
@@ -15,6 +15,15 @@ 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.13.3] - 2026-10-16
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- Remove unused specs and refresh the README roadmap
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- **archive**: Keep task-folder files when a task is finished
|
|
25
|
+
|
|
26
|
+
|
|
18
27
|
## [2.13.2] - 2026-09-28
|
|
19
28
|
|
|
20
29
|
### Changed
|
|
@@ -18,7 +18,7 @@ Be respectful, inclusive, and constructive. We're all here to build something us
|
|
|
18
18
|
|
|
19
19
|
1. **Fork and clone**
|
|
20
20
|
```bash
|
|
21
|
-
git clone https://github.com/
|
|
21
|
+
git clone https://github.com/ruslanfialkovskii/tasktree-manager.git
|
|
22
22
|
cd tasktree-manager
|
|
23
23
|
```
|
|
24
24
|
|
|
@@ -149,7 +149,7 @@ def create_task(name: str, repos: list[str]) -> Task:
|
|
|
149
149
|
- Add docstrings to all public functions/classes
|
|
150
150
|
- Use Google-style docstrings
|
|
151
151
|
- Update README.md for user-facing changes
|
|
152
|
-
- Update
|
|
152
|
+
- Update the Architecture section of CLAUDE.md for architecture changes
|
|
153
153
|
|
|
154
154
|
Example docstring:
|
|
155
155
|
```python
|
|
@@ -206,18 +206,15 @@ tasktree-manager/
|
|
|
206
206
|
│ ├── themes/ # Color themes
|
|
207
207
|
│ └── app.py # Main app
|
|
208
208
|
├── tests/ # Test suite
|
|
209
|
-
├── specs/ # Technical specs
|
|
210
209
|
└── docs/ # Documentation
|
|
211
210
|
```
|
|
212
211
|
|
|
213
212
|
### Adding New Features
|
|
214
213
|
|
|
215
|
-
1. **
|
|
216
|
-
2. **
|
|
217
|
-
3. **
|
|
218
|
-
4. **
|
|
219
|
-
5. **Document**: Update README/specs
|
|
220
|
-
6. **PR**: Submit for review
|
|
214
|
+
1. **Write tests**: Add tests first (TDD approach)
|
|
215
|
+
2. **Implement**: Write the code
|
|
216
|
+
3. **Document**: Update README.md and `docs/`
|
|
217
|
+
4. **PR**: Submit for review
|
|
221
218
|
|
|
222
219
|
## Submitting Issues
|
|
223
220
|
|
|
@@ -260,7 +257,7 @@ Include:
|
|
|
260
257
|
|
|
261
258
|
## Questions?
|
|
262
259
|
|
|
263
|
-
- Open a [Discussion](https://github.com/
|
|
260
|
+
- Open a [Discussion](https://github.com/ruslanfialkovskii/tasktree-manager/discussions)
|
|
264
261
|
- Ask in Pull Request
|
|
265
262
|
- Check existing issues
|
|
266
263
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
[](https://www.python.org/downloads/)
|
|
7
|
-
[](./tests/)
|
|
8
8
|
|
|
9
9
|
**tasktree-manager** is a terminal user interface (TUI) application for managing development tasks that span multiple git repositories. Create tasks, organize worktrees, and track status across your entire codebase—all from a beautiful, keyboard-driven interface inspired by [lazygit](https://github.com/jesseduffield/lazygit).
|
|
10
10
|
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
- 🛰️ **Agent Dashboard** - Live Claude Code session state per worktree (from `claude agents`), plus one-key background-agent dispatch — the multi-repo agent view Claude Code lacks natively
|
|
23
23
|
- ✻ **Session Recap** - The Info panel shows each task's latest Claude Code session: title, last turn duration, and Claude's own idle recap of what was done and what comes next — read straight from the transcript, no CLI call
|
|
24
24
|
- 🔀 **Forge-Aware Safety** - MR/PR and CI state via `glab`/`gh`; squash- and rebase-merged branches count as merged instead of blocking deletion forever
|
|
25
|
-
- 📦 **Finish Flow with Archives** - Ending a task saves its combined diff (committed-but-unmerged + uncommitted) to an archive before deleting
|
|
25
|
+
- 📦 **Finish Flow with Archives** - Ending a task saves its combined diff (committed-but-unmerged + uncommitted) and the task-folder files outside the worktrees (plans, ADRs, notes) to an archive before deleting
|
|
26
26
|
|
|
27
27
|
## 📊 Status Indicators
|
|
28
28
|
|
|
@@ -82,15 +82,23 @@ tasktree-manager list --json # tasks with repos and state: clean / dirty / e
|
|
|
82
82
|
tasktree-manager repos # available repos in REPOS_DIR
|
|
83
83
|
tasktree-manager add-repo DIC-1901-argocd-tls infra
|
|
84
84
|
tasktree-manager status # current task inferred from $PWD; also --json / --oneline / --forge
|
|
85
|
-
tasktree-manager finish DIC-1901-argocd-tls # safety sweep -> archive diff -> delete; --push / --no-archive / --force
|
|
86
|
-
tasktree-manager delete DIC-1901-argocd-tls # archive diff -> delete; refuses on unfinished work; --force overrides
|
|
85
|
+
tasktree-manager finish DIC-1901-argocd-tls # safety sweep -> archive diff + task files -> delete; --push / --no-archive / --force
|
|
86
|
+
tasktree-manager delete DIC-1901-argocd-tls # archive diff + task files -> delete; refuses on unfinished work; --force overrides
|
|
87
87
|
```
|
|
88
88
|
|
|
89
89
|
Exit code is 0 on success, 1 on failure with the reason on stderr. `delete` and `finish` run
|
|
90
90
|
the same safety check as the TUI (uncommitted, unpushed, or unmerged work blocks deletion);
|
|
91
91
|
with `glab`/`gh` installed, squash/rebase-merged branches are recognized as merged. If the
|
|
92
92
|
diff cannot be archived for any repo, the CLI stops before deleting and prints the path of
|
|
93
|
-
the partial archive; the TUI warns and deletes anyway.
|
|
93
|
+
the partial archive; the TUI warns and deletes anyway. Files in the task folder outside the
|
|
94
|
+
worktrees are copied to `<task>-<timestamp>.files/` next to the patch:
|
|
95
|
+
|
|
96
|
+
- Skipped silently: hidden files, symlinks, `node_modules`/`vendor`/`.terraform` folders and
|
|
97
|
+
an unedited generated `CLAUDE.md`.
|
|
98
|
+
- Left out with a warning: key material (`*.pem`, `*.key`, `id_rsa`, kubeconfigs,
|
|
99
|
+
`*.tfstate`, `credentials*.json`), files over 10 MB and files past 100 MB in total.
|
|
100
|
+
- A file that cannot be read or copied stops `delete`/`finish` before anything is removed,
|
|
101
|
+
unless `--force`; the TUI warns and deletes anyway.
|
|
94
102
|
`status --oneline` emits a compact `TASK repoA✓ repoB●2↑1` summary that slots straight into
|
|
95
103
|
a Claude Code statusline or shell prompt.
|
|
96
104
|
|
|
@@ -147,7 +155,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed development setup and guidel
|
|
|
147
155
|
|
|
148
156
|
**Quick Start:**
|
|
149
157
|
```bash
|
|
150
|
-
git clone https://github.com/
|
|
158
|
+
git clone https://github.com/ruslanfialkovskii/tasktree-manager.git
|
|
151
159
|
cd tasktree-manager
|
|
152
160
|
mise install
|
|
153
161
|
mise run install
|
|
@@ -197,14 +205,13 @@ For comprehensive troubleshooting, see the [Troubleshooting Guide](docs/troubles
|
|
|
197
205
|
|
|
198
206
|
Recently shipped: agent dashboard (per-worktree Claude session badges + background dispatch),
|
|
199
207
|
forge-aware merged detection (glab/gh, squash/rebase merges), MR/CI status badges,
|
|
200
|
-
guided `finish` flow
|
|
208
|
+
guided `finish` flow that archives the diff and the task-folder files, `status` subcommand
|
|
209
|
+
for scripts and statuslines, Claude session recap in the Info panel.
|
|
201
210
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
- **v0.4.0** - JIRA status column, git hooks, automation
|
|
205
|
-
- **v1.0.0** - Stable release
|
|
211
|
+
Next: epics, a layer that groups related tasks (for example a Jira epic and its child
|
|
212
|
+
tasks) in the task list.
|
|
206
213
|
|
|
207
|
-
See [
|
|
214
|
+
See [CHANGELOG.md](CHANGELOG.md) for the release history.
|
|
208
215
|
|
|
209
216
|
## 📄 License
|
|
210
217
|
|
|
@@ -218,11 +225,11 @@ MIT License - see [LICENSE](./LICENSE) for details.
|
|
|
218
225
|
|
|
219
226
|
## 💬 Support & Community
|
|
220
227
|
|
|
221
|
-
- 🐛 [Bug Reports](https://github.com/
|
|
222
|
-
- 💡 [Feature Requests](https://github.com/
|
|
223
|
-
- 💬 [Discussions](https://github.com/
|
|
228
|
+
- 🐛 [Bug Reports](https://github.com/ruslanfialkovskii/tasktree-manager/issues)
|
|
229
|
+
- 💡 [Feature Requests](https://github.com/ruslanfialkovskii/tasktree-manager/issues)
|
|
230
|
+
- 💬 [Discussions](https://github.com/ruslanfialkovskii/tasktree-manager/discussions)
|
|
224
231
|
- 📖 [Documentation](docs/)
|
|
225
232
|
|
|
226
233
|
---
|
|
227
234
|
|
|
228
|
-
**Made with ❤️ by developers, for developers** | [⭐ Star this repo](https://github.com/
|
|
235
|
+
**Made with ❤️ by developers, for developers** | [⭐ Star this repo](https://github.com/ruslanfialkovskii/tasktree-manager)
|
|
@@ -86,7 +86,7 @@ repos_dir = "/Users/username/repos"
|
|
|
86
86
|
# Created automatically if it doesn't exist
|
|
87
87
|
tasks_dir = "/Users/username/tasks"
|
|
88
88
|
|
|
89
|
-
# Directory for finished-task
|
|
89
|
+
# Directory for finished-task archives (diffs and task-folder files)
|
|
90
90
|
# Relative paths resolve under tasks_dir (never the current directory)
|
|
91
91
|
# Default: "" (uses <tasks_dir>/.archive)
|
|
92
92
|
archive_dir = ""
|
|
@@ -305,7 +305,7 @@ blocklist = [
|
|
|
305
305
|
|------------|------|-----------|---------------------|------------------------------------------------|
|
|
306
306
|
| `repos_dir` | path | `~/repos` | `REPOS_DIR` | Directory containing your git repositories |
|
|
307
307
|
| `tasks_dir` | path | `~/tasks` | `TASKS_DIR` | Directory where task worktrees will be created |
|
|
308
|
-
| `archive_dir` | path | `""` (`<tasks_dir>/.archive`) | - | Directory for finished-task
|
|
308
|
+
| `archive_dir` | path | `""` (`<tasks_dir>/.archive`) | - | Directory for finished-task archives (diffs and task-folder files) |
|
|
309
309
|
|
|
310
310
|
### Details
|
|
311
311
|
|
|
@@ -921,7 +921,7 @@ Yes! Each task creates its own worktree and branch:
|
|
|
921
921
|
|
|
922
922
|
- Worktrees are removed from `TASKS_DIR`
|
|
923
923
|
- The local task branch is deleted from the main repository (`git branch -D`);
|
|
924
|
-
the remaining diff
|
|
924
|
+
the remaining diff and the task-folder files are archived under `archive_dir` first
|
|
925
925
|
- Remote branches remain on GitHub/GitLab (if pushed)
|
|
926
926
|
|
|
927
927
|
**Manual cleanup (only for branches created outside tasktree):**
|
|
@@ -687,6 +687,12 @@ d (delete task)
|
|
|
687
687
|
- ✅ The task's remaining diff (committed-but-unmerged + uncommitted work) is archived to
|
|
688
688
|
`archive_dir` (default `<tasks_dir>/.archive/<task>-<timestamp>.patch`) before anything
|
|
689
689
|
is removed — the messages panel (`m`) shows the archive path
|
|
690
|
+
- ✅ Files in the task folder outside the worktrees (`PLAN.md`, ADRs, notes, scripts) are
|
|
691
|
+
copied to `<task>-<timestamp>.files/` next to the patch. Hidden files, symlinks,
|
|
692
|
+
`node_modules`/`vendor`/`.terraform` folders and an unedited generated `CLAUDE.md` are
|
|
693
|
+
skipped silently. Key material (`*.pem`, `*.key`, `id_rsa`, kubeconfigs, `*.tfstate`,
|
|
694
|
+
`credentials*.json`), files over 10 MB, files past 100 MB in total and files that cannot
|
|
695
|
+
be read or copied are listed in the messages panel
|
|
690
696
|
- ✅ Worktrees removed from `TASKS_DIR`
|
|
691
697
|
- ✅ Branches remain on remote (if pushed)
|
|
692
698
|
- ✅ Original repos in `REPOS_DIR` untouched
|
|
@@ -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.13.
|
|
74
|
+
version = "2.13.3"
|
|
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.13.
|
|
22
|
-
__version_tuple__ = version_tuple = (2, 13,
|
|
21
|
+
__version__ = version = '2.13.3'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 13, 3)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -24,8 +24,8 @@ from .services.claude_sessions import ClaudeRecap, read_claude_recap, transcript
|
|
|
24
24
|
from .services.config import Config, ConfigError
|
|
25
25
|
from .services.forge import Forge, ForgeStatus, get_forge_status
|
|
26
26
|
from .services.git_ops import GitOps
|
|
27
|
-
from .services.models import GitStatus, Task, TaskSafetyReport, Worktree
|
|
28
|
-
from .services.task_manager import TaskManager
|
|
27
|
+
from .services.models import ArchiveResult, GitStatus, Task, TaskSafetyReport, Worktree
|
|
28
|
+
from .services.task_manager import ARCHIVE_NOTES_SHOWN, TaskManager
|
|
29
29
|
from .themes import ALL_THEMES, THEME_CYCLE
|
|
30
30
|
from .widgets.app_header import AppHeader
|
|
31
31
|
from .widgets.create_modal import (
|
|
@@ -1378,14 +1378,15 @@ class TaskTreeApp(App):
|
|
|
1378
1378
|
|
|
1379
1379
|
Removing worktrees runs git per repo and rmtree over trees that can
|
|
1380
1380
|
be huge (node_modules etc.) - never block the UI thread on it.
|
|
1381
|
-
The task's remaining diff (committed-but-unmerged + uncommitted)
|
|
1382
|
-
archived first — finish_task rmtrees the
|
|
1381
|
+
The task's remaining diff (committed-but-unmerged + uncommitted) and
|
|
1382
|
+
its task-folder files are archived first — finish_task rmtrees the
|
|
1383
|
+
task directory.
|
|
1383
1384
|
"""
|
|
1384
1385
|
error: str | None = None
|
|
1385
|
-
|
|
1386
|
+
archive: ArchiveResult | None = None
|
|
1386
1387
|
try:
|
|
1387
1388
|
try:
|
|
1388
|
-
|
|
1389
|
+
archive = self.task_manager.archive_task(task)
|
|
1389
1390
|
except Exception as e:
|
|
1390
1391
|
# A failed archive must not block deletion, but it must be
|
|
1391
1392
|
# loud — the archive is the safety net for the work the
|
|
@@ -1394,6 +1395,8 @@ class TaskTreeApp(App):
|
|
|
1394
1395
|
detail = f"Archive failed for '{task.name}': {type(e).__name__}: {e}"
|
|
1395
1396
|
self.call_from_thread(self.notify, escape(detail), severity="warning", timeout=10)
|
|
1396
1397
|
self.call_from_thread(self._log_activity, detail, MessageLevel.ERROR, task.name)
|
|
1398
|
+
# Still report the files that were saved and the ones left out
|
|
1399
|
+
archive = getattr(e, "result", None)
|
|
1397
1400
|
self.task_manager.finish_task(task)
|
|
1398
1401
|
except ValueError as e:
|
|
1399
1402
|
# finish_task raises ValueError (nothing removed) when worktrees
|
|
@@ -1402,37 +1405,66 @@ class TaskTreeApp(App):
|
|
|
1402
1405
|
error = str(e)
|
|
1403
1406
|
except Exception as e:
|
|
1404
1407
|
error = f"{type(e).__name__}: {e}"
|
|
1405
|
-
self.call_from_thread(self._apply_finish_task_result, task.name, force, error,
|
|
1408
|
+
self.call_from_thread(self._apply_finish_task_result, task.name, force, error, archive)
|
|
1406
1409
|
|
|
1407
1410
|
def _apply_finish_task_result(
|
|
1408
1411
|
self,
|
|
1409
1412
|
task_name: str,
|
|
1410
1413
|
force: bool,
|
|
1411
1414
|
error: str | None,
|
|
1412
|
-
|
|
1415
|
+
archive: ArchiveResult | None = None,
|
|
1413
1416
|
) -> None:
|
|
1414
1417
|
"""Report the delete outcome and reload (runs on the main thread)."""
|
|
1415
1418
|
if error:
|
|
1416
1419
|
self.notify(escape(f"Failed to delete task: {error}"), severity="error")
|
|
1417
1420
|
self._log_activity(f"Failed to delete task: {error}", MessageLevel.ERROR, task_name)
|
|
1418
|
-
if
|
|
1421
|
+
if archive is not None:
|
|
1419
1422
|
# The archive succeeded before the delete itself failed - the
|
|
1420
1423
|
# user's work is not lost, but they should know where it is
|
|
1421
|
-
self.
|
|
1422
|
-
f"Archived diff to {archive_path} before the failed delete",
|
|
1423
|
-
MessageLevel.INFO,
|
|
1424
|
-
task_name,
|
|
1425
|
-
)
|
|
1424
|
+
self._log_archive(task_name, archive, " before the failed delete")
|
|
1426
1425
|
else:
|
|
1427
1426
|
self.notify(escape(f"Deleted task: {task_name}"))
|
|
1428
1427
|
suffix = " (force)" if force else ""
|
|
1429
1428
|
self._log_activity(
|
|
1430
1429
|
f"Task '{task_name}' deleted{suffix}", MessageLevel.SUCCESS, task_name
|
|
1431
1430
|
)
|
|
1432
|
-
if
|
|
1433
|
-
self.
|
|
1431
|
+
if archive is not None:
|
|
1432
|
+
self._log_archive(task_name, archive)
|
|
1434
1433
|
self._load_tasks()
|
|
1435
1434
|
|
|
1435
|
+
def _log_archive(self, task_name: str, archive: ArchiveResult, context: str = "") -> None:
|
|
1436
|
+
"""Log where a task's work was archived and warn about files left out.
|
|
1437
|
+
|
|
1438
|
+
The left-out notes come first and are capped, so the archive
|
|
1439
|
+
location stays among the latest entries of the bounded panel.
|
|
1440
|
+
"""
|
|
1441
|
+
notes = [f"Not archived: {item}" for item in archive.skipped]
|
|
1442
|
+
notes += [f"Could not archive: {item}" for item in archive.failed]
|
|
1443
|
+
for note in notes[:ARCHIVE_NOTES_SHOWN]:
|
|
1444
|
+
self._log_activity(note, MessageLevel.WARNING, task_name)
|
|
1445
|
+
if len(notes) > ARCHIVE_NOTES_SHOWN:
|
|
1446
|
+
self._log_activity(
|
|
1447
|
+
f"... and {len(notes) - ARCHIVE_NOTES_SHOWN} more not archived",
|
|
1448
|
+
MessageLevel.WARNING,
|
|
1449
|
+
task_name,
|
|
1450
|
+
)
|
|
1451
|
+
if archive.patch is not None:
|
|
1452
|
+
self._log_activity(
|
|
1453
|
+
f"Archived diff to {archive.patch}{context}", MessageLevel.INFO, task_name
|
|
1454
|
+
)
|
|
1455
|
+
if archive.files_dir is not None:
|
|
1456
|
+
self._log_activity(
|
|
1457
|
+
f"Archived {len(archive.files)} task file(s) to {archive.files_dir}{context}",
|
|
1458
|
+
MessageLevel.INFO,
|
|
1459
|
+
task_name,
|
|
1460
|
+
)
|
|
1461
|
+
if notes:
|
|
1462
|
+
self.notify(
|
|
1463
|
+
f"{len(notes)} task file note(s) - see messages",
|
|
1464
|
+
severity="warning",
|
|
1465
|
+
timeout=10,
|
|
1466
|
+
)
|
|
1467
|
+
|
|
1436
1468
|
def _handle_safe_delete_action(
|
|
1437
1469
|
self, task: Task, safety_report: TaskSafetyReport, action: str | None
|
|
1438
1470
|
) -> None:
|
|
@@ -1615,10 +1647,13 @@ class TaskTreeApp(App):
|
|
|
1615
1647
|
def _remove_worktree_worker(self, task: Task, worktree: Worktree, force: bool = False) -> None:
|
|
1616
1648
|
"""Remove a single worktree in a background thread (archive, git, rmtree)."""
|
|
1617
1649
|
error: str | None = None
|
|
1618
|
-
|
|
1650
|
+
archive: ArchiveResult | None = None
|
|
1619
1651
|
try:
|
|
1620
1652
|
try:
|
|
1621
|
-
|
|
1653
|
+
# Diff only: the task folder and its files stay in place
|
|
1654
|
+
archive = self.task_manager.archive_task(
|
|
1655
|
+
self._worktree_view(task, worktree), include_files=False
|
|
1656
|
+
)
|
|
1622
1657
|
except Exception as e:
|
|
1623
1658
|
# Loud but non-blocking, same policy as task deletion
|
|
1624
1659
|
detail = f"Archive failed for '{worktree.name}': {type(e).__name__}: {e}"
|
|
@@ -1628,7 +1663,7 @@ class TaskTreeApp(App):
|
|
|
1628
1663
|
except Exception as e:
|
|
1629
1664
|
error = f"{type(e).__name__}: {e}"
|
|
1630
1665
|
self.call_from_thread(
|
|
1631
|
-
self._apply_remove_worktree_result, task.name, worktree.name, force, error,
|
|
1666
|
+
self._apply_remove_worktree_result, task.name, worktree.name, force, error, archive
|
|
1632
1667
|
)
|
|
1633
1668
|
|
|
1634
1669
|
def _apply_remove_worktree_result(
|
|
@@ -1637,7 +1672,7 @@ class TaskTreeApp(App):
|
|
|
1637
1672
|
worktree_name: str,
|
|
1638
1673
|
force: bool,
|
|
1639
1674
|
error: str | None,
|
|
1640
|
-
|
|
1675
|
+
archive: ArchiveResult | None = None,
|
|
1641
1676
|
) -> None:
|
|
1642
1677
|
"""Report the worktree removal outcome and reload (main thread)."""
|
|
1643
1678
|
if error:
|
|
@@ -1647,12 +1682,8 @@ class TaskTreeApp(App):
|
|
|
1647
1682
|
MessageLevel.ERROR,
|
|
1648
1683
|
task_name,
|
|
1649
1684
|
)
|
|
1650
|
-
if
|
|
1651
|
-
self.
|
|
1652
|
-
f"Archived diff to {archive_path} before the failed delete",
|
|
1653
|
-
MessageLevel.INFO,
|
|
1654
|
-
task_name,
|
|
1655
|
-
)
|
|
1685
|
+
if archive is not None:
|
|
1686
|
+
self._log_archive(task_name, archive, " before the failed delete")
|
|
1656
1687
|
else:
|
|
1657
1688
|
self.notify(escape(f"Deleted worktree: {worktree_name}"))
|
|
1658
1689
|
suffix = " (force)" if force else ""
|
|
@@ -1661,8 +1692,8 @@ class TaskTreeApp(App):
|
|
|
1661
1692
|
MessageLevel.SUCCESS,
|
|
1662
1693
|
task_name,
|
|
1663
1694
|
)
|
|
1664
|
-
if
|
|
1665
|
-
self.
|
|
1695
|
+
if archive is not None:
|
|
1696
|
+
self._log_archive(task_name, archive)
|
|
1666
1697
|
self._load_tasks()
|
|
1667
1698
|
|
|
1668
1699
|
def _open_lazygit_for_task(self, task: Task, safety_report: TaskSafetyReport) -> None:
|
|
@@ -16,8 +16,8 @@ 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 Task
|
|
20
|
-
from .services.task_manager import TaskManager
|
|
19
|
+
from .services.models import ArchiveResult, Task
|
|
20
|
+
from .services.task_manager import ARCHIVE_NOTES_SHOWN, ArchiveIncompleteError, TaskManager
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
def build_parser() -> argparse.ArgumentParser:
|
|
@@ -55,7 +55,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
55
55
|
delete.add_argument(
|
|
56
56
|
"--force",
|
|
57
57
|
action="store_true",
|
|
58
|
-
help="Delete even with uncommitted, unpushed or unmerged work"
|
|
58
|
+
help="Delete even with uncommitted, unpushed or unmerged work, or task files "
|
|
59
|
+
"that could not be archived",
|
|
59
60
|
)
|
|
60
61
|
delete.set_defaults(func=cmd_delete)
|
|
61
62
|
|
|
@@ -66,7 +67,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
66
67
|
add_repo.set_defaults(func=cmd_add_repo)
|
|
67
68
|
|
|
68
69
|
finish = sub.add_parser(
|
|
69
|
-
"finish", help="Finish a task: safety sweep, archive its diff, then delete"
|
|
70
|
+
"finish", help="Finish a task: safety sweep, archive its diff and files, then delete"
|
|
70
71
|
)
|
|
71
72
|
finish.add_argument("name", help="Task name")
|
|
72
73
|
finish.add_argument("--push", action="store_true", help="Push unpushed branches first")
|
|
@@ -74,12 +75,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
74
75
|
"--no-archive",
|
|
75
76
|
action="store_true",
|
|
76
77
|
dest="no_archive",
|
|
77
|
-
help="Skip
|
|
78
|
+
help="Skip the archive: the diff and the task-folder files are deleted unsaved",
|
|
78
79
|
)
|
|
79
80
|
finish.add_argument(
|
|
80
81
|
"--force",
|
|
81
82
|
action="store_true",
|
|
82
|
-
help="Finish even with unfinished work
|
|
83
|
+
help="Finish even with unfinished work or task files that could not be archived "
|
|
84
|
+
"(the archive is still written)",
|
|
83
85
|
)
|
|
84
86
|
finish.set_defaults(func=cmd_finish)
|
|
85
87
|
|
|
@@ -201,6 +203,50 @@ def cmd_repos(manager: TaskManager, config: Config, args: argparse.Namespace) ->
|
|
|
201
203
|
return 0
|
|
202
204
|
|
|
203
205
|
|
|
206
|
+
def _print_archive(archive: ArchiveResult | None) -> bool:
|
|
207
|
+
"""Report where a task's work was archived; True if anything was saved."""
|
|
208
|
+
if archive is None:
|
|
209
|
+
return False
|
|
210
|
+
notes = [f"not archived: {item}" for item in archive.skipped]
|
|
211
|
+
notes += [f"could not archive: {item}" for item in archive.failed]
|
|
212
|
+
for note in notes[:ARCHIVE_NOTES_SHOWN]:
|
|
213
|
+
print(f"warning: {note}", file=sys.stderr)
|
|
214
|
+
if len(notes) > ARCHIVE_NOTES_SHOWN:
|
|
215
|
+
print(f"warning: ... and {len(notes) - ARCHIVE_NOTES_SHOWN} more", file=sys.stderr)
|
|
216
|
+
if archive.patch is not None:
|
|
217
|
+
print(f"Archived diff to {archive.patch}")
|
|
218
|
+
if archive.files_dir is not None:
|
|
219
|
+
print(f"Archived {len(archive.files)} task file(s) to {archive.files_dir}")
|
|
220
|
+
return archive.saved
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _archive_for_delete(
|
|
224
|
+
manager: TaskManager, task: Task, force: bool, verb: str, notes: list[str] | None = None
|
|
225
|
+
) -> tuple[bool, bool]:
|
|
226
|
+
"""Archive a task about to be deleted; returns (may delete, anything saved).
|
|
227
|
+
|
|
228
|
+
Any archive error stops the delete. Task files that could not be read or
|
|
229
|
+
copied stop it too unless --force: the task folder is their only copy.
|
|
230
|
+
"""
|
|
231
|
+
try:
|
|
232
|
+
archive = manager.archive_task(task, notes=notes)
|
|
233
|
+
except ArchiveIncompleteError as e:
|
|
234
|
+
_print_archive(e.result)
|
|
235
|
+
_error(f"archive failed: {e}")
|
|
236
|
+
return False, False
|
|
237
|
+
except Exception as e:
|
|
238
|
+
_error(f"archive failed: {e}")
|
|
239
|
+
return False, False
|
|
240
|
+
saved = _print_archive(archive)
|
|
241
|
+
if archive is not None and archive.failed and not force:
|
|
242
|
+
_error(
|
|
243
|
+
f"{len(archive.failed)} task file(s) could not be archived; "
|
|
244
|
+
f"use --force to {verb} anyway"
|
|
245
|
+
)
|
|
246
|
+
return False, saved
|
|
247
|
+
return True, saved
|
|
248
|
+
|
|
249
|
+
|
|
204
250
|
def cmd_delete(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
|
|
205
251
|
"""Delete a task after a safety check (bypass with --force)."""
|
|
206
252
|
task = manager.get_task(args.name)
|
|
@@ -215,14 +261,11 @@ def cmd_delete(manager: TaskManager, config: Config, args: argparse.Namespace) -
|
|
|
215
261
|
print("use --force to delete anyway", file=sys.stderr)
|
|
216
262
|
return 1
|
|
217
263
|
|
|
218
|
-
# Same safety net as the TUI: the remaining diff
|
|
219
|
-
# worktrees and branches are removed, --force included
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
return _error(f"archive failed: {e}")
|
|
224
|
-
if archive_path is not None:
|
|
225
|
-
print(f"Archived diff to {archive_path}")
|
|
264
|
+
# Same safety net as the TUI: the remaining diff and the task files are
|
|
265
|
+
# archived before the worktrees and branches are removed, --force included
|
|
266
|
+
may_delete, _ = _archive_for_delete(manager, task, args.force, "delete")
|
|
267
|
+
if not may_delete:
|
|
268
|
+
return 1
|
|
226
269
|
manager.finish_task(task)
|
|
227
270
|
print(f"Deleted task {args.name}")
|
|
228
271
|
return 0
|
|
@@ -262,13 +305,12 @@ def cmd_finish(manager: TaskManager, config: Config, args: argparse.Namespace) -
|
|
|
262
305
|
|
|
263
306
|
if not args.no_archive:
|
|
264
307
|
notes = [f"{issue.repo_name}: {issue.details}" for issue in report.merged_via_forge]
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
else:
|
|
308
|
+
may_delete, saved = _archive_for_delete(
|
|
309
|
+
manager, task, args.force, "finish", notes=notes or None
|
|
310
|
+
)
|
|
311
|
+
if not may_delete:
|
|
312
|
+
return 1
|
|
313
|
+
if not saved:
|
|
272
314
|
print("Nothing to archive")
|
|
273
315
|
|
|
274
316
|
manager.finish_task(task)
|
|
@@ -95,6 +95,19 @@ def _toml_table(config_data: dict, key: str) -> dict:
|
|
|
95
95
|
return value
|
|
96
96
|
|
|
97
97
|
|
|
98
|
+
# Private keys and certificates: never symlinked into worktrees and never
|
|
99
|
+
# copied into finished-task archives
|
|
100
|
+
KEY_MATERIAL_PATTERNS: list[str] = [
|
|
101
|
+
"*.pem",
|
|
102
|
+
"*.key",
|
|
103
|
+
"*.p12",
|
|
104
|
+
"*.pfx",
|
|
105
|
+
"*.keystore",
|
|
106
|
+
"id_rsa",
|
|
107
|
+
"id_ecdsa",
|
|
108
|
+
"id_ed25519",
|
|
109
|
+
]
|
|
110
|
+
|
|
98
111
|
# Default patterns excluded when symlinking gitignored files into worktrees.
|
|
99
112
|
# Caches/build artifacts are excluded as noise; key material is excluded so
|
|
100
113
|
# private keys and certificates never spread beyond the main checkout
|
|
@@ -116,14 +129,7 @@ DEFAULT_SYMLINK_BLOCKLIST: list[str] = [
|
|
|
116
129
|
".nox",
|
|
117
130
|
"*.so",
|
|
118
131
|
"*.dylib",
|
|
119
|
-
|
|
120
|
-
"*.key",
|
|
121
|
-
"*.p12",
|
|
122
|
-
"*.pfx",
|
|
123
|
-
"*.keystore",
|
|
124
|
-
"id_rsa",
|
|
125
|
-
"id_ecdsa",
|
|
126
|
-
"id_ed25519",
|
|
132
|
+
*KEY_MATERIAL_PATTERNS,
|
|
127
133
|
]
|
|
128
134
|
|
|
129
135
|
|
|
@@ -141,7 +147,8 @@ class Config:
|
|
|
141
147
|
repos_dir: Path = field(default_factory=lambda: Path.home() / "repos")
|
|
142
148
|
tasks_dir: Path = field(default_factory=lambda: Path.home() / "tasks")
|
|
143
149
|
config_dir: Path = field(default_factory=lambda: Path.home() / ".config" / "tasktree-manager")
|
|
144
|
-
# Archive directory for finished-task diffs
|
|
150
|
+
# Archive directory for finished-task diffs and task-folder files
|
|
151
|
+
# ("" = <tasks_dir>/.archive)
|
|
145
152
|
archive_dir: str = ""
|
|
146
153
|
|
|
147
154
|
# UI settings
|
|
@@ -395,7 +402,8 @@ repos_dir = "{self._toml_escape(str(self._persisted("repos_dir")))}"
|
|
|
395
402
|
# Directory for task worktrees
|
|
396
403
|
tasks_dir = "{self._toml_escape(str(self._persisted("tasks_dir")))}"
|
|
397
404
|
|
|
398
|
-
# Directory for finished-task
|
|
405
|
+
# Directory for finished-task archives: diffs and task-folder files
|
|
406
|
+
# ("" = <tasks_dir>/.archive)
|
|
399
407
|
archive_dir = "{self._toml_escape(self.archive_dir)}"
|
|
400
408
|
|
|
401
409
|
# ============================================================================
|
|
@@ -544,7 +552,7 @@ cursor_up = "{self._toml_escape(self.keybindings.get("cursor_up", "k"))}"
|
|
|
544
552
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
545
553
|
|
|
546
554
|
def get_archive_dir(self) -> Path:
|
|
547
|
-
"""Get the archive directory for finished-task diffs.
|
|
555
|
+
"""Get the archive directory for finished-task diffs and files.
|
|
548
556
|
|
|
549
557
|
A relative path is anchored under tasks_dir, never $PWD — `finish`
|
|
550
558
|
may run from inside a worktree, and a CWD-relative archive would be
|
|
@@ -126,6 +126,22 @@ class RepoIssue:
|
|
|
126
126
|
mr_state: str | None = None # "open" | "merged" | "closed" | "none"
|
|
127
127
|
|
|
128
128
|
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class ArchiveResult:
|
|
131
|
+
"""What archive_task saved before a task folder is deleted."""
|
|
132
|
+
|
|
133
|
+
patch: Path | None = None # combined diff of every worktree
|
|
134
|
+
files_dir: Path | None = None # copies of task-folder files outside worktrees
|
|
135
|
+
files: tuple[str, ...] = () # archived files, relative to the task folder
|
|
136
|
+
skipped: tuple[str, ...] = () # "name (reason)": left out by policy (caps, keys)
|
|
137
|
+
failed: tuple[str, ...] = () # "name (reason)": could not be read or copied
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def saved(self) -> bool:
|
|
141
|
+
"""True when anything was written to the archive directory."""
|
|
142
|
+
return self.patch is not None or self.files_dir is not None
|
|
143
|
+
|
|
144
|
+
|
|
129
145
|
@dataclass
|
|
130
146
|
class TaskSafetyReport:
|
|
131
147
|
"""Report of safety issues for a task."""
|