tasks-toolkit 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. scripts/__init__.py +0 -0
  2. scripts/agents_sync.py +61 -0
  3. scripts/apply-plan.sh +51 -0
  4. scripts/build.sh +7 -0
  5. scripts/codex-task.sh +198 -0
  6. scripts/install-hooks.sh +9 -0
  7. scripts/junie-status.sh +52 -0
  8. scripts/junie-task.sh +183 -0
  9. scripts/kilo-task.sh +226 -0
  10. scripts/needs-dotnet-checks.sh +24 -0
  11. scripts/plan-sprint.sh +92 -0
  12. scripts/run-task.sh +79 -0
  13. scripts/run.sh +7 -0
  14. scripts/run_stats.py +141 -0
  15. scripts/run_summary.py +94 -0
  16. scripts/spec-lint.sh +139 -0
  17. scripts/sprint-auto.sh +79 -0
  18. scripts/sprint_auto_execute.py +160 -0
  19. scripts/sprint_auto_preflight.py +309 -0
  20. scripts/sprint_auto_prepare.py +271 -0
  21. scripts/sprint_auto_resume.py +153 -0
  22. scripts/sprint_auto_state.py +191 -0
  23. scripts/sprint_plan.py +238 -0
  24. scripts/task-report.sh +45 -0
  25. scripts/tasks.py +1813 -0
  26. scripts/tasks_cli.py +14 -0
  27. scripts/tasks_commands.py +11 -0
  28. scripts/tasks_init.py +140 -0
  29. scripts/tasks_mcp.py +309 -0
  30. scripts/tasks_model.py +173 -0
  31. scripts/tasks_plan_import.py +269 -0
  32. scripts/tasks_render.py +148 -0
  33. scripts/tasks_storage.py +373 -0
  34. scripts/tasks_watch.py +159 -0
  35. scripts/test.sh +43 -0
  36. scripts/test_agent_config.py +97 -0
  37. scripts/test_plan_sprint.sh +172 -0
  38. scripts/test_python_fast.sh +26 -0
  39. scripts/test_python_lint.sh +22 -0
  40. scripts/test_python_slow.sh +14 -0
  41. scripts/test_run_stats.py +64 -0
  42. scripts/test_runners.sh +588 -0
  43. scripts/test_spec_lint.sh +438 -0
  44. scripts/test_sprint_auto.py +50 -0
  45. scripts/test_sprint_auto_execute.py +112 -0
  46. scripts/test_sprint_auto_preflight.py +143 -0
  47. scripts/test_sprint_auto_prepare.py +150 -0
  48. scripts/test_sprint_auto_resume.py +104 -0
  49. scripts/test_sprint_auto_state.py +157 -0
  50. scripts/test_tasks.py +1678 -0
  51. scripts/test_tasks_mcp.py +233 -0
  52. scripts/test_tasks_plan_import.py +129 -0
  53. scripts/test_tasks_storage.py +67 -0
  54. scripts/test_tasks_watch.py +239 -0
  55. scripts/test_worktree.sh +2112 -0
  56. scripts/worktree.sh +992 -0
  57. scripts/worktree_mr.py +133 -0
  58. scripts/worktree_path.py +70 -0
  59. scripts/worktree_sprint.py +70 -0
  60. tasks_toolkit-0.1.3.dist-info/METADATA +121 -0
  61. tasks_toolkit-0.1.3.dist-info/RECORD +65 -0
  62. tasks_toolkit-0.1.3.dist-info/WHEEL +5 -0
  63. tasks_toolkit-0.1.3.dist-info/entry_points.txt +4 -0
  64. tasks_toolkit-0.1.3.dist-info/licenses/LICENSE +21 -0
  65. tasks_toolkit-0.1.3.dist-info/top_level.txt +1 -0
scripts/test_tasks.py ADDED
@@ -0,0 +1,1678 @@
1
+ """Tests for scripts/tasks.py. Run: python3 -m unittest scripts/test_tasks.py
2
+
3
+ RULE: Every module-level path constant in tasks.py that points to a real file must be
4
+ redirected to a temp directory in setUp and restored in tearDown. The constants to audit
5
+ are: DATA_FILE, VIEW_DIR, README_FILE, VIEW_FILE, LOCK_FILE, SNAPSHOT_FILE, RECOVERY_DIR — and any new ones added later.
6
+ If a constant is missed, tests will silently overwrite the real tracker files in the
7
+ main checkout, because tasks.py resolves ROOT to the main checkout from inside worktrees.
8
+ The env var VIBE_TASKS_TESTING=1 (set below) provides a safety net in tasks.py that
9
+ refuses to write outside a temp dir when testing.
10
+ """
11
+ import contextlib
12
+ import io
13
+ import json
14
+ import os
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ import tempfile
19
+ import unittest
20
+ from pathlib import Path
21
+ from unittest import mock
22
+
23
+ # Safety net: tasks.py checks this flag before any write operation.
24
+ os.environ["VIBE_TASKS_TESTING"] = "1"
25
+
26
+ import tasks
27
+ import tasks_storage
28
+ import tasks_render
29
+
30
+
31
+ class TasksTestCase(unittest.TestCase):
32
+ def setUp(self):
33
+ self.tmp = tempfile.TemporaryDirectory()
34
+ root = Path(self.tmp.name)
35
+ self.saved = (tasks.DATA_FILE, tasks.VIEW_DIR, tasks.README_FILE, tasks.VIEW_FILE,
36
+ tasks.LOCK_FILE, tasks.SNAPSHOT_FILE, tasks.RECOVERY_DIR, tasks.BACKUP_DIR)
37
+ tasks.DATA_FILE = root / "tasks" / "tasks.db"
38
+ tasks.VIEW_DIR = root / "tasks"
39
+ tasks.README_FILE = tasks.VIEW_DIR / "README.md"
40
+ tasks.VIEW_FILE = tasks.README_FILE
41
+ tasks.LOCK_FILE = root / "tasks" / ".lock"
42
+ tasks.SNAPSHOT_FILE = root / "tasks" / "tasks.snapshot.json"
43
+ tasks.RECOVERY_DIR = root / "tasks" / "removed"
44
+ tasks.BACKUP_DIR = root / "backup.d"
45
+ tasks_storage.DATA_FILE = tasks.DATA_FILE
46
+ tasks_storage.VIEW_DIR = tasks.VIEW_DIR
47
+ tasks_storage.README_FILE = tasks.README_FILE
48
+ tasks_storage.VIEW_FILE = tasks.VIEW_FILE
49
+ tasks_storage.LOCK_FILE = tasks.LOCK_FILE
50
+ tasks_storage.SNAPSHOT_FILE = tasks.SNAPSHOT_FILE
51
+ tasks_storage.RECOVERY_DIR = tasks.RECOVERY_DIR
52
+ tasks_storage.BACKUP_DIR = tasks.BACKUP_DIR
53
+ tasks_render.VIEW_DIR = tasks.VIEW_DIR
54
+ tasks_render.README_FILE = tasks.README_FILE
55
+ (root / "tasks").mkdir(parents=True, exist_ok=True)
56
+
57
+ def tearDown(self):
58
+ (tasks.DATA_FILE, tasks.VIEW_DIR, tasks.README_FILE, tasks.VIEW_FILE,
59
+ tasks.LOCK_FILE, tasks.SNAPSHOT_FILE, tasks.RECOVERY_DIR, tasks.BACKUP_DIR) = self.saved
60
+ (tasks_storage.DATA_FILE, tasks_storage.VIEW_DIR, tasks_storage.README_FILE, tasks_storage.VIEW_FILE,
61
+ tasks_storage.LOCK_FILE, tasks_storage.SNAPSHOT_FILE, tasks_storage.RECOVERY_DIR, tasks_storage.BACKUP_DIR) = self.saved
62
+ (tasks_render.VIEW_DIR, tasks_render.README_FILE) = (tasks.VIEW_DIR, tasks.README_FILE)
63
+ self.tmp.cleanup()
64
+
65
+ def run_cli(self, *argv):
66
+ out, err = io.StringIO(), io.StringIO()
67
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
68
+ code = tasks.main(list(argv))
69
+ return code, out.getvalue(), err.getvalue()
70
+
71
+ def ok(self, *argv):
72
+ code, out, err = self.run_cli(*argv)
73
+ self.assertEqual(code, 0, err)
74
+ return out
75
+
76
+ def status(self, node_id):
77
+ return tasks.effective(tasks.find(tasks.load(), node_id))
78
+
79
+ def build(self):
80
+ self.ok("add-sprint", "Sprint one", "--goal", "g")
81
+ self.ok("add", "1", "Task A")
82
+ self.ok("add", "1.1", "Sub 1")
83
+ self.ok("add", "1.1", "Sub 2")
84
+ self.ok("add", "1", "Task B")
85
+
86
+
87
+ class CascadeTests(TasksTestCase):
88
+ def test_ids_are_hierarchical(self):
89
+ self.build()
90
+ ids = [n["id"] for n in tasks.walk(tasks.load()["sprints"])]
91
+ self.assertEqual(ids, ["1", "1.1", "1.1.1", "1.1.2", "1.2"])
92
+
93
+ def test_everything_starts_as_none(self):
94
+ self.build()
95
+ self.assertEqual({self.status(i) for i in ("1", "1.1", "1.2")}, {"none"})
96
+
97
+ def test_started_subtask_makes_task_and_sprint_in_progress(self):
98
+ self.build()
99
+ self.ok("start", "1.1.1")
100
+ self.assertEqual(self.status("1.1"), "in_progress")
101
+ self.assertEqual(self.status("1"), "in_progress")
102
+ self.assertEqual(self.status("1.2"), "none")
103
+
104
+ def test_one_done_subtask_is_in_progress_for_parents(self):
105
+ self.build()
106
+ self.ok("done", "1.1.1")
107
+ self.assertEqual(self.status("1.1"), "in_progress")
108
+
109
+ def test_all_subtasks_done_completes_task_but_not_sprint(self):
110
+ self.build()
111
+ self.ok("done", "1.1.1")
112
+ self.ok("done", "1.1.2")
113
+ self.assertEqual(self.status("1.1"), "done")
114
+ self.assertEqual(self.status("1"), "in_progress")
115
+ self.ok("done", "1.2")
116
+ self.assertEqual(self.status("1"), "done")
117
+
118
+ def test_blocked_wins_over_in_progress_and_done(self):
119
+ self.build()
120
+ self.ok("done", "1.1.1")
121
+ self.ok("start", "1.2")
122
+ self.ok("block", "1.1.2", "--reason", "waiting for API key")
123
+ self.assertEqual(self.status("1.1"), "blocked")
124
+ self.assertEqual(self.status("1"), "blocked")
125
+
126
+ def test_unblocking_restores_derived_status(self):
127
+ self.build()
128
+ self.ok("block", "1.1.1", "--reason", "x")
129
+ self.ok("start", "1.1.1")
130
+ self.assertEqual(self.status("1"), "in_progress")
131
+ self.assertEqual(tasks.find(tasks.load(), "1.1.1")["reason"], "")
132
+
133
+ def test_reopening_a_done_subtask_reopens_parents(self):
134
+ self.build()
135
+ self.ok("done", "1.1", "--all")
136
+ self.assertEqual(self.status("1.1"), "done")
137
+ self.ok("reset", "1.1.2")
138
+ self.assertEqual(self.status("1.1"), "in_progress")
139
+
140
+
141
+ class RuleTests(TasksTestCase):
142
+ def test_blocking_requires_reason(self):
143
+ self.build()
144
+ code, _, err = self.run_cli("status", "1.2", "blocked")
145
+ self.assertEqual(code, 1)
146
+ self.assertIn("--reason", err)
147
+
148
+ def test_parent_status_cannot_be_set_directly(self):
149
+ self.build()
150
+ code, _, err = self.run_cli("status", "1.1", "done")
151
+ self.assertEqual(code, 1)
152
+ self.assertIn("derived", err)
153
+
154
+ def test_polish_aliases(self):
155
+ self.build()
156
+ self.ok("status", "1.2", "w-trakcie")
157
+ self.assertEqual(self.status("1.2"), "in_progress")
158
+ self.ok("status", "1.2", "gotowe")
159
+ self.assertEqual(self.status("1.2"), "done")
160
+ self.ok("status", "1.2", "brak")
161
+ self.assertEqual(self.status("1.2"), "none")
162
+
163
+ def test_subtasks_cannot_have_children(self):
164
+ self.build()
165
+ code, _, err = self.run_cli("add", "1.1.1", "too deep")
166
+ self.assertEqual(code, 1)
167
+ self.assertIn("subtask", err)
168
+
169
+ def test_dependency_cycle_is_rejected(self):
170
+ self.build()
171
+ self.ok("edit", "1.1", "--after", "1.2")
172
+ code, _, err = self.run_cli("edit", "1.2", "--after", "1.1")
173
+ self.assertEqual(code, 1)
174
+ self.assertIn("cycle", err)
175
+
176
+ def test_unknown_dependency_is_rejected(self):
177
+ self.build()
178
+ code, _, _ = self.run_cli("add", "1", "x", "--after", "9.9")
179
+ self.assertEqual(code, 1)
180
+
181
+ def test_removed_dependency_target_is_protected(self):
182
+ self.build()
183
+ self.ok("edit", "1.2", "--after", "1.1")
184
+ code, _, err = self.run_cli("rm", "1.1", "--yes")
185
+ self.assertEqual(code, 1)
186
+ self.assertIn("depends on", err)
187
+
188
+ def test_new_id_is_one_after_the_highest_sibling(self):
189
+ self.build()
190
+ self.ok("rm", "1.2", "--yes")
191
+ self.ok("add", "1", "Task C")
192
+ self.assertEqual(tasks.find(tasks.load(), "1.2")["title"], "Task C")
193
+ self.ok("rm", "1.1", "--yes")
194
+ self.ok("add", "1", "Task D")
195
+ self.assertEqual(tasks.find(tasks.load(), "1.3")["title"], "Task D")
196
+
197
+
198
+ class WorkflowTests(TasksTestCase):
199
+ def test_next_skips_unmet_dependencies_and_finished_work(self):
200
+ self.build()
201
+ self.ok("edit", "1.2", "--after", "1.1")
202
+ self.assertIn("1.1.1", self.ok("next"))
203
+ self.ok("done", "1.1", "--all")
204
+ self.assertIn("1.2", self.ok("next"))
205
+ self.ok("done", "1.2")
206
+ self.assertIn("nothing ready", self.ok("next"))
207
+
208
+ def test_next_filters_by_assignee(self):
209
+ self.build()
210
+ self.ok("edit", "1.2", "--assignee", "kilo")
211
+ self.assertIn("1.2", self.ok("next", "--assignee", "kilo"))
212
+
213
+ def test_view_is_rendered_after_every_change(self):
214
+ self.build()
215
+ self.ok("start", "1.1.1")
216
+ detail = next(tasks.VIEW_DIR.glob("sprint-1-*.md")).read_text(encoding="utf-8")
217
+ self.assertIn("**1.1.1** Sub 1 — 🔄 in progress", detail)
218
+ self.assertIn("**1.1** Task A — 🔄 in progress (0/2)", detail)
219
+ self.ok("block", "1.2", "--reason", "needs decision")
220
+ detail = next(tasks.VIEW_DIR.glob("sprint-1-*.md")).read_text(encoding="utf-8")
221
+ self.assertIn("needs decision", detail)
222
+ readme = tasks.README_FILE.read_text(encoding="utf-8")
223
+ self.assertIn("## ⛔ Blockers", readme)
224
+ self.assertIn("needs decision", readme)
225
+
226
+ def test_description_bullets_render_without_double_dash(self):
227
+ """Description lines already starting with a list marker are not prefixed twice, and a blank
228
+ description line does not render as a lone "-"."""
229
+ self.build()
230
+ self.ok("edit", "1.2", "-d", "Intro line\n\n- item one\n- item two\n - nested")
231
+ text = next(tasks.VIEW_DIR.glob("sprint-1-*.md")).read_text(encoding="utf-8")
232
+ self.assertNotIn("- - ", text)
233
+ self.assertIn("- item one", text)
234
+ self.assertIn("- item two", text)
235
+ self.assertIn("nested", text)
236
+ self.assertNotIn("\n -\n", text)
237
+ self.assertNotIn("\n - \n", text)
238
+
239
+ def test_check_detects_stale_view(self):
240
+ self.build()
241
+ self.ok("check")
242
+ tasks.VIEW_FILE.write_text("stale", encoding="utf-8")
243
+ code, _, err = self.run_cli("check")
244
+ self.assertEqual(code, 1)
245
+ self.assertIn("out of date", err)
246
+ self.ok("render")
247
+ self.ok("check")
248
+
249
+ def test_notes_and_timestamps(self):
250
+ self.build()
251
+ self.ok("start", "1.2")
252
+ self.ok("note", "1.2", "picked up by kilo")
253
+ self.ok("done", "1.2")
254
+ node = tasks.find(tasks.load(), "1.2")
255
+ self.assertTrue(node["started"] and node["finished"])
256
+ self.assertIn("picked up by kilo", node["notes"][0])
257
+
258
+
259
+ class DatabaseTests(TasksTestCase):
260
+ def test_failed_change_is_rolled_back(self):
261
+ self.build()
262
+ self.ok("edit", "1.1", "--after", "1.2")
263
+ before = tasks.load()
264
+ code, _, _ = self.run_cli("edit", "1.2", "--after", "1.1") # would create a cycle
265
+ self.assertEqual(code, 1)
266
+ self.assertEqual(tasks.load(), before)
267
+
268
+ def test_state_survives_reopening_the_database(self):
269
+ self.build()
270
+ self.ok("block", "1.2", "--reason", "x")
271
+ self.assertEqual(self.status("1.2"), "blocked")
272
+ self.assertTrue(tasks.DATA_FILE.exists())
273
+
274
+ def test_removing_a_task_cascades_to_children_notes_and_deps(self):
275
+ self.build()
276
+ self.ok("note", "1.1.1", "n")
277
+ self.ok("edit", "1.1.2", "--after", "1.1.1")
278
+ self.ok("rm", "1.1", "--yes")
279
+ conn = tasks.connect()
280
+ try:
281
+ for table in ("notes", "deps"):
282
+ self.assertEqual(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0], 0)
283
+ self.assertEqual(conn.execute("SELECT COUNT(*) FROM nodes").fetchone()[0], 2)
284
+ finally:
285
+ conn.close()
286
+
287
+ def test_export_import_roundtrip(self):
288
+ self.build()
289
+ self.ok("edit", "1.2", "--after", "1.1", "--assignee", "kilo")
290
+ self.ok("note", "1.2", "hello")
291
+ self.ok("block", "1.1.1", "--reason", "r")
292
+ before = tasks.load()
293
+ dump = Path(self.tmp.name) / "dump.json"
294
+ self.ok("export-json", "--file", str(dump))
295
+ self.ok("rm", "1", "--yes")
296
+ self.assertEqual(tasks.load()["sprints"], [])
297
+ self.ok("import-json", str(dump))
298
+ self.assertEqual(tasks.load(), before)
299
+
300
+ def test_list_filters(self):
301
+ self.build()
302
+ self.ok("edit", "1.2", "--assignee", "kilo", "-d", "needle text")
303
+ self.ok("start", "1.2")
304
+ self.assertEqual(self.ok("list", "--assignee", "kilo").count("\n"), 1)
305
+ self.assertIn("1.2", self.ok("list", "--text", "NEEDLE"))
306
+ self.assertNotIn("1.1 ", self.ok("list", "--status", "in_progress", "--leaves"))
307
+
308
+ def test_many_tasks(self):
309
+ self.ok("add-sprint", "Big")
310
+ for i in range(300):
311
+ self.ok("add", "1", f"Task {i}")
312
+ self.ok("done", "1", "--all")
313
+ self.assertEqual(self.status("1"), "done")
314
+ self.assertIn("done: 300", self.ok("stats"))
315
+ self.assertEqual(self.ok("next", "-n", "3"), "nothing ready\n")
316
+
317
+
318
+ class InitTests(TasksTestCase):
319
+ def test_missing_database_is_created_empty_on_first_change(self):
320
+ self.assertFalse(tasks.DATA_FILE.exists())
321
+ code, out, err = self.run_cli("add-sprint", "First")
322
+ self.assertEqual(code, 0)
323
+ self.assertIn("created a new empty task database", err)
324
+ self.assertTrue(tasks.DATA_FILE.exists())
325
+ self.assertEqual(tasks.load()["sprints"][0]["title"], "First")
326
+
327
+ def test_reading_never_creates_the_database(self):
328
+ for argv in (["list"], ["stats"], ["next"], ["check"]):
329
+ self.ok(*argv)
330
+ self.assertFalse(tasks.DATA_FILE.exists())
331
+
332
+ def test_render_refuses_without_a_database(self):
333
+ tasks.VIEW_FILE.write_text("committed view", encoding="utf-8")
334
+ code, _, err = self.run_cli("render")
335
+ self.assertEqual(code, 1)
336
+ self.assertIn("tasks_init.py", err)
337
+ self.assertEqual(tasks.VIEW_FILE.read_text(encoding="utf-8"), "committed view")
338
+
339
+ def test_init_script_creates_schema_and_does_not_overwrite(self):
340
+ import tasks_init
341
+ target = Path(self.tmp.name) / "fresh" / "x.db"
342
+ self.assertEqual(tasks_init.main(["--path", str(target), "--project", "P"]), 0)
343
+ self.assertEqual(tasks_init.main(["--path", str(target)]), 1) # exists, no --force
344
+ conn = tasks.sqlite3.connect(target)
345
+ names = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
346
+ conn.close()
347
+ self.assertTrue({"meta", "nodes", "deps", "notes"} <= names)
348
+ self.assertEqual(tasks_init.main(["--path", str(target), "--force"]), 0)
349
+
350
+
351
+ class SnapshotTests(TasksTestCase):
352
+ # SNAPSHOT_FILE is already redirected by the base TasksTestCase.setUp.
353
+ # This class no longer needs its own setUp/tearDown for the snapshot path.
354
+
355
+ def test_snapshot_written_on_change(self):
356
+ """A change command writes the snapshot file alongside TASKS.md."""
357
+ self.ok("add-sprint", "Sprint one")
358
+ self.assertTrue(tasks.SNAPSHOT_FILE.exists())
359
+ data = tasks.load()
360
+ self.assertEqual(tasks.SNAPSHOT_FILE.read_text(encoding="utf-8"), tasks.snapshot_json(data))
361
+
362
+ def test_snapshot_is_deterministic(self):
363
+ """Two consecutive snapshots of the same state are byte-identical."""
364
+ self.ok("add-sprint", "S")
365
+ self.ok("add", "1", "Task A")
366
+ self.ok("done", "1.1")
367
+ snap1 = tasks.SNAPSHOT_FILE.read_text(encoding="utf-8")
368
+ self.ok("snapshot")
369
+ snap2 = tasks.SNAPSHOT_FILE.read_text(encoding="utf-8")
370
+ self.assertEqual(snap1, snap2)
371
+
372
+ def test_snapshot_format_has_sorted_keys(self):
373
+ """Snapshot JSON uses sort_keys=True so output is deterministic."""
374
+ self.ok("add-sprint", "S")
375
+ text = tasks.SNAPSHOT_FILE.read_text(encoding="utf-8")
376
+ # "project" should come before "sprints" alphabetically
377
+ project_pos = text.index('"project"')
378
+ sprints_pos = text.index('"sprints"')
379
+ self.assertLess(project_pos, sprints_pos)
380
+
381
+ def test_rollback_leaves_snapshot_untouched(self):
382
+ """If a transaction rolls back, the snapshot file is not modified."""
383
+ self.ok("add-sprint", "S")
384
+ self.ok("add", "1", "Task A")
385
+ self.ok("add", "1", "Task B")
386
+ self.ok("edit", "1.1", "--after", "1.2")
387
+ before = tasks.SNAPSHOT_FILE.read_text(encoding="utf-8")
388
+ # This should fail (cycle) and roll back
389
+ code, _, _ = self.run_cli("edit", "1.2", "--after", "1.1")
390
+ self.assertEqual(code, 1)
391
+ after = tasks.SNAPSHOT_FILE.read_text(encoding="utf-8")
392
+ self.assertEqual(before, after)
393
+
394
+ def test_check_fails_when_snapshot_missing(self):
395
+ """check fails if the DB exists but the snapshot file is missing."""
396
+ self.ok("add-sprint", "S")
397
+ tasks.SNAPSHOT_FILE.unlink()
398
+ code, _, err = self.run_cli("check")
399
+ self.assertEqual(code, 1)
400
+ self.assertIn("snapshot", err)
401
+
402
+ def test_check_fails_when_snapshot_stale(self):
403
+ """check fails if the snapshot content does not match the DB."""
404
+ self.ok("add-sprint", "S")
405
+ tasks.SNAPSHOT_FILE.write_text('{"version": 2, "project": "old", "sprints": []}\n', encoding="utf-8")
406
+ code, _, err = self.run_cli("check")
407
+ self.assertEqual(code, 1)
408
+ self.assertIn("out of date", err)
409
+
410
+ def test_check_passes_after_change(self):
411
+ """check passes after a change command that re-writes the snapshot."""
412
+ self.ok("add-sprint", "S")
413
+ self.ok("add", "1", "Task A")
414
+ self.ok("start", "1.1")
415
+ self.ok("check")
416
+
417
+ def test_snapshot_command(self):
418
+ """Explicit snapshot command writes the file."""
419
+ self.ok("add-sprint", "S")
420
+ tasks.SNAPSHOT_FILE.unlink()
421
+ out = self.ok("snapshot")
422
+ self.assertIn("wrote", out)
423
+ self.assertTrue(tasks.SNAPSHOT_FILE.exists())
424
+
425
+ def test_snapshot_command_no_database(self):
426
+ """snapshot command fails when there is no database."""
427
+ code, _, err = self.run_cli("snapshot")
428
+ self.assertEqual(code, 1)
429
+ self.assertIn("no database", err)
430
+
431
+ def test_import_json_roundtrip_via_snapshot(self):
432
+ """Export to snapshot, wipe DB, import from snapshot — state is restored."""
433
+ self.ok("add-sprint", "Sprint one", "--goal", "g")
434
+ self.ok("add", "1", "Task A")
435
+ self.ok("add", "1.1", "Sub 1")
436
+ self.ok("add", "1", "Task B")
437
+ self.ok("done", "1.1.1")
438
+ self.ok("note", "1", "a note")
439
+ self.ok("edit", "1.2", "--after", "1.1")
440
+ before = tasks.load()
441
+ # Save the snapshot content before we wipe the DB (rm would overwrite it)
442
+ backup = Path(self.tmp.name) / "backup.json"
443
+ backup.write_text(tasks.SNAPSHOT_FILE.read_text(encoding="utf-8"), encoding="utf-8")
444
+ # Wipe the database
445
+ self.ok("rm", "1", "--yes")
446
+ self.assertEqual(tasks.load()["sprints"], [])
447
+ # Import from the saved snapshot backup
448
+ self.ok("import-json", str(backup))
449
+ after = tasks.load()
450
+ self.assertEqual(after, before)
451
+
452
+ def test_check_skips_snapshot_when_no_db(self):
453
+ """check skips snapshot comparison when the DB does not exist (fresh clone)."""
454
+ # No DB created yet — read-only check should pass
455
+ code, out, err = self.run_cli("check")
456
+ self.assertEqual(code, 0)
457
+ self.assertIn("no database", out)
458
+
459
+
460
+ class MoveTests(TasksTestCase):
461
+ """Tests for the mv command: moving items between parents with subtree renumbering."""
462
+
463
+ def _setup_two_sprints(self):
464
+ """Create two sprints with tasks and subtasks."""
465
+ self.ok("add-sprint", "Sprint A")
466
+ self.ok("add", "1", "Task A1")
467
+ self.ok("add", "1.1", "Sub A1.1")
468
+ self.ok("add", "1.1", "Sub A1.2")
469
+ self.ok("add", "1", "Task A2")
470
+ self.ok("add-sprint", "Sprint B")
471
+ self.ok("add", "2", "Task B1")
472
+
473
+ def test_move_leaf_across_tasks(self):
474
+ """Move a leaf subtask from one task to another."""
475
+ self._setup_two_sprints()
476
+ out = self.ok("mv", "1.1.1", "1.2")
477
+ self.assertIn("moved 1.1.1 -> 1.2.1", out)
478
+ data = tasks.load()
479
+ # 1.2 should now have child 1.2.1 (formerly 1.1.1)
480
+ self.assertEqual(tasks.find(data, "1.2.1")["title"], "Sub A1.1")
481
+ # 1.1 should only have 1.1.2
482
+ self.assertEqual(len(tasks.find(data, "1.1")["children"]), 1)
483
+ self.assertEqual(tasks.find(data, "1.1")["children"][0]["id"], "1.1.2")
484
+
485
+ def test_move_task_across_sprints(self):
486
+ """Move a task with children from one sprint to another."""
487
+ self._setup_two_sprints()
488
+ out = self.ok("mv", "1.1", "2")
489
+ self.assertIn("moved 1.1 -> 2.2", out)
490
+ self.assertIn("1.1.1 -> 2.2.1", out)
491
+ self.assertIn("1.1.2 -> 2.2.2", out)
492
+ data = tasks.load()
493
+ # Sprint 2 should have Task B1 (2.1) and the moved task (2.2)
494
+ sprint_b = tasks.find(data, "2")
495
+ self.assertEqual(len(sprint_b["children"]), 2)
496
+ self.assertEqual(sprint_b["children"][1]["id"], "2.2")
497
+ self.assertEqual(sprint_b["children"][1]["title"], "Task A1")
498
+ # Children should be renumbered
499
+ self.assertEqual(tasks.find(data, "2.2.1")["title"], "Sub A1.1")
500
+ self.assertEqual(tasks.find(data, "2.2.2")["title"], "Sub A1.2")
501
+ # Sprint 1 should only have Task A2
502
+ sprint_a = tasks.find(data, "1")
503
+ self.assertEqual(len(sprint_a["children"]), 1)
504
+ self.assertEqual(sprint_a["children"][0]["id"], "1.2")
505
+
506
+ def test_move_preserves_status(self):
507
+ """Moving a leaf keeps its status."""
508
+ self._setup_two_sprints()
509
+ self.ok("done", "1.1.1")
510
+ self.ok("mv", "1.1.1", "1.2")
511
+ self.assertEqual(self.status("1.2.1"), "done")
512
+
513
+ def test_move_preserves_notes(self):
514
+ """Notes follow the moved item."""
515
+ self._setup_two_sprints()
516
+ self.ok("note", "1.1.1", "important note")
517
+ self.ok("mv", "1.1.1", "1.2")
518
+ node = tasks.find(tasks.load(), "1.2.1")
519
+ self.assertIn("important note", node["notes"][0])
520
+
521
+ def test_move_rewrites_dependencies(self):
522
+ """Dependencies referencing moved items are rewritten."""
523
+ self._setup_two_sprints()
524
+ # External dep: 1.2 depends on 1.1.1
525
+ self.ok("edit", "1.2", "--after", "1.1.1")
526
+ # Internal dep: 1.1.2 depends on 1.1.1 (both in subtree of 1.1)
527
+ self.ok("edit", "1.1.2", "--after", "1.1.1")
528
+ self.ok("mv", "1.1", "2")
529
+ data = tasks.load()
530
+ # 1.1 -> 2.2, 1.1.1 -> 2.2.1, 1.1.2 -> 2.2.2
531
+ # 1.2's dep on 1.1.1 -> 2.2.1 (external dep rewritten)
532
+ self.assertEqual(tasks.find(data, "1.2")["depends_on"], ["2.2.1"])
533
+ # 2.2.2's dep on 2.2.1 (internal dep rewritten)
534
+ self.assertEqual(tasks.find(data, "2.2.2")["depends_on"], ["2.2.1"])
535
+
536
+ def test_move_with_deps_on_both_sides(self):
537
+ """Move a task with children where deps exist on both sides."""
538
+ self._setup_two_sprints()
539
+ # B1 depends on Sub A1.1
540
+ self.ok("edit", "2.1", "--after", "1.1.1")
541
+ # Sub A1.2 depends on B1
542
+ self.ok("edit", "1.1.2", "--after", "2.1")
543
+ self.ok("mv", "1.1", "2")
544
+ data = tasks.load()
545
+ # 2.1 depends on 2.2.1 (was 1.1.1)
546
+ self.assertEqual(tasks.find(data, "2.1")["depends_on"], ["2.2.1"])
547
+ # 2.2.2 depends on 2.1 (was 1.1.2 depends on 2.1)
548
+ self.assertEqual(tasks.find(data, "2.2.2")["depends_on"], ["2.1"])
549
+
550
+ def test_mv_pos_ordering(self):
551
+ """--pos places the moved item at the specified position."""
552
+ self._setup_two_sprints()
553
+ self.ok("mv", "1.1.1", "1.2", "--pos", "1")
554
+ data = tasks.load()
555
+ # 1.2.1 should be the moved item
556
+ self.assertEqual(tasks.find(data, "1.2.1")["title"], "Sub A1.1")
557
+
558
+ def test_mv_cannot_move_sprint(self):
559
+ """Moving a sprint (level 1) is rejected."""
560
+ self._setup_two_sprints()
561
+ code, _, err = self.run_cli("mv", "1", "2")
562
+ self.assertEqual(code, 1)
563
+ self.assertIn("cannot move a sprint", err)
564
+
565
+ def test_mv_cannot_move_into_own_subtree(self):
566
+ """Moving a node into its own descendant is rejected."""
567
+ self._setup_two_sprints()
568
+ code, _, err = self.run_cli("mv", "1.1", "1.1.1")
569
+ self.assertEqual(code, 1)
570
+ # The error could be "own subtree" or "subtask" depending on which check fires first
571
+ self.assertTrue("own subtree" in err or "subtask" in err, err)
572
+
573
+ def test_mv_cannot_exceed_max_depth(self):
574
+ """Moving a task with subtasks under another task is rejected (would exceed depth 3)."""
575
+ self._setup_two_sprints()
576
+ code, _, err = self.run_cli("mv", "1.1", "2.1")
577
+ self.assertEqual(code, 1)
578
+ self.assertIn("max depth", err)
579
+
580
+ def test_mv_same_parent_without_pos(self):
581
+ """Moving to the same parent without --pos is rejected."""
582
+ self._setup_two_sprints()
583
+ code, _, err = self.run_cli("mv", "1.1.1", "1.1")
584
+ self.assertEqual(code, 1)
585
+ self.assertIn("already under", err)
586
+
587
+ def test_mv_same_parent_with_pos_is_refused_and_loses_nothing(self):
588
+ """Reordering siblings is refused (ids encode the position); the subtree, notes and db stay intact.
589
+
590
+ Regression: it used to DELETE the node (cascading to its subtree) and re-insert only the root.
591
+ """
592
+ self._setup_two_sprints()
593
+ self.ok("add", "1.1", "sibling-with-children")
594
+ self.ok("add", "1.2", "child of 1.2")
595
+ self.ok("note", "1.2.1", "keep me")
596
+ before = tasks.export_data() if hasattr(tasks, "export_data") else tasks.load()
597
+ code, _, err = self.run_cli("mv", "1.2", "1", "--pos", "1")
598
+ self.assertEqual(code, 1)
599
+ self.assertIn("reordering siblings is not supported", err)
600
+ after = tasks.export_data() if hasattr(tasks, "export_data") else tasks.load()
601
+ self.assertEqual(before, after)
602
+ data = tasks.load()
603
+ self.assertEqual(tasks.find(data, "1.2")["children"][0]["id"], "1.2.1")
604
+ self.assertIn("keep me", tasks.find(data, "1.2.1")["notes"][0])
605
+
606
+ def test_mv_error_leaves_db_unchanged(self):
607
+ """Every error case leaves the database byte-identical."""
608
+ self._setup_two_sprints()
609
+ self.ok("done", "1.1.1")
610
+ before = tasks.load()
611
+ code, _, _ = self.run_cli("mv", "1", "2") # can't move sprint
612
+ self.assertEqual(code, 1)
613
+ after = tasks.load()
614
+ self.assertEqual(after, before)
615
+
616
+ def test_mv_unknown_source_id(self):
617
+ """Moving an unknown id is rejected."""
618
+ self._setup_two_sprints()
619
+ code, _, err = self.run_cli("mv", "9.9", "1")
620
+ self.assertEqual(code, 1)
621
+ self.assertIn("no such id", err)
622
+
623
+ def test_mv_unknown_dest_id(self):
624
+ """Moving to an unknown parent is rejected."""
625
+ self._setup_two_sprints()
626
+ code, _, err = self.run_cli("mv", "1.1", "9")
627
+ self.assertEqual(code, 1)
628
+ self.assertIn("no such id", err)
629
+
630
+ def test_mv_task_to_sprint_root(self):
631
+ """Move a leaf task from one sprint to another sprint."""
632
+ self._setup_two_sprints()
633
+ out = self.ok("mv", "1.2", "2")
634
+ self.assertIn("moved 1.2 -> 2.2", out)
635
+ data = tasks.load()
636
+ self.assertEqual(tasks.find(data, "2.2")["title"], "Task A2")
637
+
638
+ def test_mv_recomputes_parent_status(self):
639
+ """Parent derived statuses are recomputed after move."""
640
+ self._setup_two_sprints()
641
+ self.ok("done", "1.1.1")
642
+ self.ok("done", "1.1.2")
643
+ self.assertEqual(self.status("1.1"), "done")
644
+ self.ok("mv", "1.1", "2")
645
+ # Sprint 1 has no children now -> none
646
+ # Sprint 2 has 2.1 (none) and 2.2 (done) -> in_progress
647
+ self.assertEqual(self.status("2"), "in_progress")
648
+
649
+ def test_mv_snapshot_and_view_in_sync(self):
650
+ """After mv, TASKS.md and snapshot are in sync."""
651
+ self._setup_two_sprints()
652
+ self.ok("mv", "1.1", "2")
653
+ self.ok("check")
654
+
655
+
656
+ class BulkEditTests(TasksTestCase):
657
+ """Tests for bulk edit and multi-id status commands."""
658
+
659
+ def test_bulk_status_all_done(self):
660
+ """Setting status on multiple ids in one transaction."""
661
+ self.build()
662
+ out = self.ok("done", "1.1.1", "1.1.2", "1.2")
663
+ self.assertIn("1.1.1", out)
664
+ self.assertIn("1.2", out)
665
+ self.assertEqual(self.status("1"), "done")
666
+
667
+ def test_bulk_status_one_bad_id(self):
668
+ """If one id is bad, nothing changes (all-or-nothing)."""
669
+ self.build()
670
+ self.ok("start", "1.1.1")
671
+ before = tasks.load()
672
+ code, _, _ = self.run_cli("done", "1.1.2", "9.9")
673
+ self.assertEqual(code, 1)
674
+ after = tasks.load()
675
+ self.assertEqual(after, before)
676
+
677
+ def test_bulk_edit_assignee(self):
678
+ """Edit assignee on multiple items."""
679
+ self.build()
680
+ out = self.ok("edit", "1.1", "1.2", "-a", "junie")
681
+ self.assertIn("1.1", out)
682
+ self.assertIn("1.2", out)
683
+ data = tasks.load()
684
+ self.assertEqual(tasks.find(data, "1.1")["assignee"], "junie")
685
+ self.assertEqual(tasks.find(data, "1.2")["assignee"], "junie")
686
+
687
+ def test_bulk_edit_title_rejected(self):
688
+ """--title with multiple ids is rejected."""
689
+ self.build()
690
+ code, _, err = self.run_cli("edit", "1.1", "1.2", "--title", "X")
691
+ self.assertEqual(code, 1)
692
+ self.assertIn("--title", err)
693
+
694
+ def test_bulk_edit_one_bad_id(self):
695
+ """Bulk edit with one bad id leaves nothing changed."""
696
+ self.build()
697
+ before = tasks.load()
698
+ code, _, _ = self.run_cli("edit", "1.1", "9.9", "-a", "junie")
699
+ self.assertEqual(code, 1)
700
+ after = tasks.load()
701
+ self.assertEqual(after, before)
702
+
703
+ def test_bulk_start(self):
704
+ """Start multiple items at once."""
705
+ self.build()
706
+ self.ok("start", "1.1.1", "1.2")
707
+ self.assertEqual(self.status("1.1.1"), "in_progress")
708
+ self.assertEqual(self.status("1.2"), "in_progress")
709
+
710
+ def test_bulk_reset(self):
711
+ """Reset multiple items at once."""
712
+ self.build()
713
+ self.ok("done", "1.1.1", "1.2")
714
+ self.ok("reset", "1.1.1", "1.2")
715
+ self.assertEqual(self.status("1.1.1"), "none")
716
+ self.assertEqual(self.status("1.2"), "none")
717
+
718
+ def test_bulk_status_command(self):
719
+ """The status command with multiple ids — status is the last positional."""
720
+ self.build()
721
+ self.ok("status", "1.1.1", "1.2", "in_progress")
722
+ self.assertEqual(self.status("1.1.1"), "in_progress")
723
+ self.assertEqual(self.status("1.2"), "in_progress")
724
+
725
+ def test_single_id_behavior_unchanged(self):
726
+ """Single-id status/edit still works as before."""
727
+ self.build()
728
+ self.ok("start", "1.1.1")
729
+ self.ok("edit", "1.2", "-a", "kilo")
730
+ self.assertEqual(self.status("1.1.1"), "in_progress")
731
+ self.assertEqual(tasks.find(tasks.load(), "1.2")["assignee"], "kilo")
732
+
733
+
734
+ class OriginalSyntaxRegressionTests(TasksTestCase):
735
+ """Regression tests that run the original documented one-liners through tasks.main()."""
736
+
737
+ def test_status_positional_single_id(self):
738
+ """status 1.1 in_progress — original positional syntax."""
739
+ self.build()
740
+ self.ok("status", "1.2", "in_progress")
741
+ self.assertEqual(self.status("1.2"), "in_progress")
742
+
743
+ def test_status_positional_with_cascade(self):
744
+ """status 1.1 done --all — cascade down with positional syntax."""
745
+ self.build()
746
+ self.ok("status", "1.1", "done", "--all")
747
+ self.assertEqual(self.status("1.1"), "done")
748
+ self.assertEqual(self.status("1.1.1"), "done")
749
+ self.assertEqual(self.status("1.1.2"), "done")
750
+
751
+ def test_status_positional_blocked_with_reason(self):
752
+ """status 1.2 blocked --reason x — blocked with reason."""
753
+ self.build()
754
+ self.ok("status", "1.2", "blocked", "--reason", "x")
755
+ self.assertEqual(self.status("1.2"), "blocked")
756
+
757
+ def test_start_shortcut(self):
758
+ """start 1.1 — original start shortcut."""
759
+ self.build()
760
+ self.ok("start", "1.2")
761
+ self.assertEqual(self.status("1.2"), "in_progress")
762
+
763
+ def test_done_shortcut(self):
764
+ """done 1.1 — original done shortcut."""
765
+ self.build()
766
+ self.ok("done", "1.2")
767
+ self.assertEqual(self.status("1.2"), "done")
768
+
769
+ def test_status_too_few_positionals(self):
770
+ """status with fewer than 2 positionals is a usage error."""
771
+ self.build()
772
+ code, _, err = self.run_cli("status", "1.2")
773
+ self.assertEqual(code, 1)
774
+ self.assertIn("usage", err)
775
+
776
+ def test_status_no_positionals(self):
777
+ """status with no positionals is a usage error."""
778
+ self.build()
779
+ code, _, err = self.run_cli("status")
780
+ self.assertEqual(code, 1)
781
+ self.assertIn("usage", err)
782
+
783
+
784
+ class RootResolutionTests(unittest.TestCase):
785
+ def setUp(self):
786
+ self.tmp = tempfile.TemporaryDirectory()
787
+ self.main = Path(self.tmp.name).resolve() / "main"
788
+ self.main.mkdir()
789
+ self.addCleanup(self.tmp.cleanup)
790
+
791
+ def git(self, *args, cwd=None):
792
+ import subprocess
793
+ subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", *args], cwd=cwd or self.main,
794
+ check=True, capture_output=True)
795
+
796
+ def test_git_common_dir_finds_main_from_any_worktree_depth(self):
797
+ import shutil
798
+ self.git("init", "-q")
799
+ (self.main / "README").write_text("x")
800
+ self.git("add", "-A")
801
+ self.git("commit", "-qm", "init")
802
+ deep = self.main / "tmp" / "a" / "b" / "wt"
803
+ self.git("worktree", "add", "-q", "-b", "agent/deep", str(deep))
804
+ shutil.copytree(Path(tasks.__file__).parent, deep / "scripts",
805
+ ignore=shutil.ignore_patterns("__pycache__"))
806
+ self.assertEqual(tasks.tasks_init.resolve_root(deep / "scripts"), self.main)
807
+ self.assertEqual(tasks.tasks_init.resolve_root(deep), self.main)
808
+ self.assertEqual(tasks.tasks_init.resolve_root(self.main), self.main)
809
+ import subprocess
810
+ import sys
811
+ run = subprocess.run([sys.executable, str(deep / "scripts" / "tasks.py"), "add-sprint", "From worktree"],
812
+ capture_output=True, text=True, env={"PATH": __import__("os").environ["PATH"]})
813
+ self.assertEqual(run.returncode, 0, run.stderr)
814
+ self.assertTrue((self.main / "tasks" / "tasks.db").exists())
815
+ self.assertTrue((self.main / "tasks" / "README.md").exists())
816
+ self.assertFalse((deep / "tasks").exists())
817
+
818
+ def test_fallback_without_git_uses_nearest_tmp_ancestor(self):
819
+ (self.main / ".git").mkdir()
820
+ nested = self.main / "tmp" / "worktrees" / "x" / "sub"
821
+ nested.mkdir(parents=True)
822
+ self.assertEqual(tasks.tasks_init.resolve_root(nested, use_git=False), self.main)
823
+
824
+ def test_plain_checkout_resolves_to_itself(self):
825
+ self.assertEqual(tasks.tasks_init.resolve_root(self.main, use_git=False), self.main)
826
+
827
+ def test_environment_override_wins(self):
828
+ import os
829
+ os.environ["VIBE_TASKS_ROOT"] = str(self.main)
830
+ self.addCleanup(os.environ.pop, "VIBE_TASKS_ROOT", None)
831
+ self.assertEqual(tasks.tasks_init.resolve_root(Path("/")), self.main)
832
+
833
+
834
+ class RealFileProtectionTests(unittest.TestCase):
835
+ """Regression tests: the test suite must not overwrite real tracker files.
836
+
837
+ These tests verify that setUp/tearDown redirects ALL module-level path constants
838
+ (DATA_FILE, VIEW_FILE, LOCK_FILE, SNAPSHOT_FILE) so no test data leaks into the
839
+ main checkout. See the RULE comment at the top of this file.
840
+ """
841
+
842
+ def setUp(self):
843
+ # Simulate the full setUp pattern used by TasksTestCase
844
+ self.tmp = tempfile.TemporaryDirectory()
845
+ root = Path(self.tmp.name)
846
+ self.saved = (tasks.DATA_FILE, tasks.VIEW_DIR, tasks.README_FILE, tasks.VIEW_FILE,
847
+ tasks.LOCK_FILE, tasks.SNAPSHOT_FILE, tasks.RECOVERY_DIR, tasks.BACKUP_DIR)
848
+ tasks.DATA_FILE = root / "tasks" / "tasks.db"
849
+ tasks.VIEW_DIR = root / "tasks"
850
+ tasks.README_FILE = tasks.VIEW_DIR / "README.md"
851
+ tasks.VIEW_FILE = tasks.README_FILE
852
+ tasks.LOCK_FILE = root / "tasks" / ".lock"
853
+ tasks.SNAPSHOT_FILE = root / "tasks" / "tasks.snapshot.json"
854
+ tasks.RECOVERY_DIR = root / "tasks" / "removed"
855
+ tasks.BACKUP_DIR = root / "backup.d"
856
+ tasks_storage.DATA_FILE = tasks.DATA_FILE
857
+ tasks_storage.VIEW_DIR = tasks.VIEW_DIR
858
+ tasks_storage.README_FILE = tasks.README_FILE
859
+ tasks_storage.VIEW_FILE = tasks.VIEW_FILE
860
+ tasks_storage.LOCK_FILE = tasks.LOCK_FILE
861
+ tasks_storage.SNAPSHOT_FILE = tasks.SNAPSHOT_FILE
862
+ tasks_storage.RECOVERY_DIR = tasks.RECOVERY_DIR
863
+ tasks_storage.BACKUP_DIR = tasks.BACKUP_DIR
864
+ tasks_render.VIEW_DIR = tasks.VIEW_DIR
865
+ tasks_render.README_FILE = tasks.README_FILE
866
+ (root / "tasks").mkdir(parents=True, exist_ok=True)
867
+
868
+ def tearDown(self):
869
+ (tasks.DATA_FILE, tasks.VIEW_DIR, tasks.README_FILE, tasks.VIEW_FILE,
870
+ tasks.LOCK_FILE, tasks.SNAPSHOT_FILE, tasks.RECOVERY_DIR, tasks.BACKUP_DIR) = self.saved
871
+ (tasks_storage.DATA_FILE, tasks_storage.VIEW_DIR, tasks_storage.README_FILE, tasks_storage.VIEW_FILE,
872
+ tasks_storage.LOCK_FILE, tasks_storage.SNAPSHOT_FILE, tasks_storage.RECOVERY_DIR, tasks_storage.BACKUP_DIR) = self.saved
873
+ (tasks_render.VIEW_DIR, tasks_render.README_FILE) = (tasks.VIEW_DIR, tasks.README_FILE)
874
+ self.tmp.cleanup()
875
+
876
+ def _real_file_stats(self, path):
877
+ try:
878
+ return (path.stat().st_mtime_ns, path.stat().st_size, path.read_bytes())
879
+ except FileNotFoundError:
880
+ return None
881
+
882
+ def test_real_snapshot_unchanged_after_mutating_test(self):
883
+ """Running a full build() cycle must not change the real snapshot file."""
884
+ real_snapshot = Path(tasks.ROOT) / "tasks" / "tasks.snapshot.json"
885
+ before = self._real_file_stats(real_snapshot)
886
+
887
+ # Simulate a mutating test
888
+ out, err = io.StringIO(), io.StringIO()
889
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
890
+ tasks.main(["add-sprint", "Test sprint"])
891
+
892
+ after = self._real_file_stats(real_snapshot)
893
+ if before is not None:
894
+ self.assertIsNotNone(after, "real snapshot was deleted by test")
895
+ self.assertEqual(before[0], after[0], "real snapshot mtime changed")
896
+ self.assertEqual(before[1], after[1], "real snapshot size changed")
897
+ self.assertEqual(before[2], after[2], "real snapshot content changed")
898
+
899
+ def test_real_tasks_md_unchanged_after_mutating_test(self):
900
+ """Running a full build() cycle must not change the real TASKS.md."""
901
+ real_view = Path(tasks.ROOT) / "tasks" / "README.md"
902
+ before = self._real_file_stats(real_view)
903
+
904
+ out, err = io.StringIO(), io.StringIO()
905
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
906
+ tasks.main(["add-sprint", "Test sprint"])
907
+
908
+ after = self._real_file_stats(real_view)
909
+ if before is not None:
910
+ self.assertIsNotNone(after, "real TASKS.md was deleted by test")
911
+ self.assertEqual(before[0], after[0], "real TASKS.md mtime changed")
912
+ self.assertEqual(before[1], after[1], "real TASKS.md size changed")
913
+
914
+ def test_real_db_unchanged_after_mutating_test(self):
915
+ """Running a full build() cycle must not change the real tasks.db."""
916
+ real_db = Path(tasks.ROOT) / "tasks" / "tasks.db"
917
+ before = self._real_file_stats(real_db)
918
+
919
+ out, err = io.StringIO(), io.StringIO()
920
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
921
+ tasks.main(["add-sprint", "Test sprint"])
922
+
923
+ after = self._real_file_stats(real_db)
924
+ if before is not None:
925
+ self.assertIsNotNone(after, "real tasks.db was deleted by test")
926
+ self.assertEqual(before[0], after[0], "real tasks.db mtime changed")
927
+ self.assertEqual(before[1], after[1], "real tasks.db size changed")
928
+
929
+ def test_real_removed_dir_unchanged_after_rm(self):
930
+ """Running rm --yes must not write into the real tasks/removed directory."""
931
+ real_dir = Path(tasks.ROOT) / "tasks" / "removed"
932
+ before = sorted((p.name, p.read_bytes()) for p in real_dir.glob("*")) if real_dir.exists() else None
933
+
934
+ out, err = io.StringIO(), io.StringIO()
935
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
936
+ tasks.main(["add-sprint", "S"])
937
+ tasks.main(["add", "1", "T"])
938
+ tasks.main(["rm", "1.1", "--yes"])
939
+
940
+ after = sorted((p.name, p.read_bytes()) for p in real_dir.glob("*")) if real_dir.exists() else None
941
+ if before is None:
942
+ self.assertIsNone(after, "real tasks/removed was created by test")
943
+ else:
944
+ self.assertEqual(after, before, "real tasks/removed changed by test")
945
+
946
+
947
+ class CancelledStatusTests(TasksTestCase):
948
+ """Task 3.17: 'cancelled' status, its cascade, and the migration from an older schema."""
949
+
950
+ def test_cancelled_round_trip(self):
951
+ self.build()
952
+ self.ok("status", "1.1.1", "cancelled")
953
+ self.assertEqual(self.status("1.1.1"), "cancelled")
954
+ self.assertIn("🚫", tasks.render(tasks.load()))
955
+
956
+ def test_cancelled_does_not_block_parent_completion(self):
957
+ self.build()
958
+ self.ok("done", "1.1.1")
959
+ self.ok("status", "1.1.2", "cancelled")
960
+ self.ok("done", "1.2")
961
+ self.assertEqual(self.status("1.1"), "done")
962
+ self.assertEqual(self.status("1"), "done")
963
+
964
+ def test_all_cancelled_parent_is_cancelled(self):
965
+ self.build()
966
+ self.ok("status", "1.1.1", "cancelled")
967
+ self.ok("status", "1.1.2", "cancelled")
968
+ self.assertEqual(self.status("1.1"), "cancelled")
969
+
970
+ def test_migration_from_old_schema(self):
971
+ """A database whose nodes CHECK predates 'cancelled' is rebuilt without losing rows."""
972
+ import sqlite3
973
+
974
+ old = """
975
+ CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
976
+ CREATE TABLE nodes (
977
+ id TEXT PRIMARY KEY, parent_id TEXT REFERENCES nodes(id) ON DELETE CASCADE,
978
+ position INTEGER NOT NULL, level INTEGER NOT NULL, title TEXT NOT NULL,
979
+ description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'none'
980
+ CHECK (status IN ('none','in_progress','blocked','done')),
981
+ reason TEXT NOT NULL DEFAULT '', assignee TEXT NOT NULL DEFAULT '', goal TEXT NOT NULL DEFAULT '',
982
+ start TEXT NOT NULL DEFAULT '', end TEXT NOT NULL DEFAULT '', created TEXT NOT NULL,
983
+ updated TEXT NOT NULL, started TEXT NOT NULL DEFAULT '', finished TEXT NOT NULL DEFAULT '',
984
+ UNIQUE (parent_id, position));
985
+ CREATE TABLE deps (node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
986
+ depends_on TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, PRIMARY KEY (node_id, depends_on));
987
+ CREATE TABLE notes (seq INTEGER PRIMARY KEY AUTOINCREMENT,
988
+ node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, text TEXT NOT NULL);
989
+ """
990
+ tasks.DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
991
+ c = sqlite3.connect(tasks.DATA_FILE)
992
+ c.executescript(old)
993
+ c.execute("INSERT INTO meta (key,value) VALUES ('project','Test')")
994
+ c.execute("INSERT INTO nodes (id,parent_id,position,level,title,created,updated) "
995
+ "VALUES ('1',NULL,1,1,'Sprint','2026-01-01','2026-01-01')")
996
+ c.execute("INSERT INTO nodes (id,parent_id,position,level,title,created,updated) "
997
+ "VALUES ('1.1','1',1,2,'Task','2026-01-01','2026-01-01')")
998
+ c.commit()
999
+ c.close()
1000
+
1001
+ self.ok("status", "1.1", "cancelled")
1002
+ self.assertEqual(self.status("1.1"), "cancelled")
1003
+ self.assertEqual(tasks.find(tasks.load(), "1")["title"], "Sprint")
1004
+ self.assertTrue(tasks.find(tasks.load(), "1.1")["uid"].startswith("T-"))
1005
+
1006
+
1007
+ class AtomicWriteTests(TasksTestCase):
1008
+ """Task 3.18: TASKS.md and the snapshot are written atomically (temp file + os.replace)."""
1009
+
1010
+ def test_write_leaves_no_temp_files(self):
1011
+ self.ok("add-sprint", "S")
1012
+ self.assertEqual(list(tasks.VIEW_FILE.parent.glob("*.tmp")), [])
1013
+ self.assertEqual(list(tasks.SNAPSHOT_FILE.parent.glob("*.tmp")), [])
1014
+
1015
+ def test_failed_replace_keeps_previous_content(self):
1016
+ self.ok("add-sprint", "S")
1017
+ before = tasks.VIEW_FILE.read_text(encoding="utf-8")
1018
+ with mock.patch.object(tasks.os, "replace", side_effect=OSError("boom")):
1019
+ with self.assertRaises(OSError):
1020
+ tasks.write_view(tasks.load())
1021
+ self.assertEqual(tasks.VIEW_FILE.read_text(encoding="utf-8"), before)
1022
+ self.assertEqual(list(tasks.VIEW_FILE.parent.glob("*.tmp")), [])
1023
+
1024
+
1025
+ class ViewSplitTests(TasksTestCase):
1026
+ """Task 8.1: the view is a directory (README index + one file per sprint)."""
1027
+
1028
+ def test_readme_has_links_and_no_inline_sprint(self):
1029
+ self.build()
1030
+ readme = tasks.README_FILE.read_text(encoding="utf-8")
1031
+ self.assertIn("## Sprints", readme)
1032
+ self.assertNotIn("**1.1.1**", readme) # task detail lives in the per-sprint file, not the README
1033
+ self.assertNotIn("## Sprint 1 —", readme)
1034
+ links = re.findall(r"\]\((sprint-[^)]+\.md)\)", readme)
1035
+ self.assertTrue(links, "README has no per-sprint links")
1036
+ for link in links:
1037
+ self.assertTrue((tasks.VIEW_DIR / link).is_file(), f"dangling link {link}")
1038
+
1039
+ def test_sprint_file_holds_the_detail(self):
1040
+ self.build()
1041
+ files = sorted(p.name for p in tasks.VIEW_DIR.glob("sprint-*.md"))
1042
+ self.assertEqual(len(files), 1)
1043
+ self.assertIn("sprint-1-", files[0])
1044
+ detail = (tasks.VIEW_DIR / files[0]).read_text(encoding="utf-8")
1045
+ self.assertIn("**1.1.1** Sub 1", detail)
1046
+
1047
+ def test_stale_sprint_file_is_removed_on_render(self):
1048
+ self.build()
1049
+ stale = tasks.VIEW_DIR / "sprint-99-gone.md"
1050
+ stale.write_text("old\n", encoding="utf-8")
1051
+ self.ok("render")
1052
+ self.assertFalse(stale.exists())
1053
+
1054
+ def test_check_detects_a_stale_sprint_file(self):
1055
+ self.build()
1056
+ victim = next(tasks.VIEW_DIR.glob("sprint-*.md"))
1057
+ victim.write_text("stale\n", encoding="utf-8")
1058
+ code, _, err = self.run_cli("check")
1059
+ self.assertEqual(code, 1)
1060
+ self.assertIn("out of date", err)
1061
+
1062
+
1063
+ class ReadCommandsTests(TasksTestCase):
1064
+ """Task 4.1: cheap single-command reads (list subtree + --desc/--notes, show multi/--brief)."""
1065
+
1066
+ def test_list_subtree_with_desc_and_notes(self):
1067
+ self.build()
1068
+ self.ok("edit", "1.2", "-d", "needle description")
1069
+ self.ok("note", "1.2", "a note")
1070
+ out = self.ok("list", "1.2", "--desc", "--notes")
1071
+ self.assertIn("needle description", out)
1072
+ self.assertIn("a note", out)
1073
+ self.assertNotIn("1.1.1", out) # only the requested subtree
1074
+
1075
+ def test_list_without_id_still_prints_everything(self):
1076
+ self.build()
1077
+ self.assertIn("1.1.1", self.ok("list"))
1078
+
1079
+ def test_show_multi_and_brief(self):
1080
+ self.build()
1081
+ brief = self.ok("show", "1.1", "1.2", "--brief")
1082
+ self.assertIn("1.1", brief)
1083
+ self.assertIn("1.2", brief)
1084
+ self.assertNotIn("{", brief) # brief is one line per item, not JSON
1085
+ full = self.ok("show", "1.2")
1086
+ self.assertIn('"title"', full)
1087
+
1088
+ def test_sprint_table(self):
1089
+ self.build()
1090
+ out = self.ok("sprint-table", "1")
1091
+ self.assertIn("| 1.1 |", out)
1092
+ self.assertIn("Task A", out)
1093
+ code, _, err = self.run_cli("sprint-table", "1.1")
1094
+ self.assertEqual(code, 1)
1095
+ self.assertIn("not a sprint", err)
1096
+
1097
+ def test_overview(self):
1098
+ self.build()
1099
+ out = self.ok("overview")
1100
+ self.assertIn("Sprint one", out)
1101
+ self.assertIn("done 0/", out)
1102
+
1103
+
1104
+ class ReviewStatusTests(TasksTestCase):
1105
+ """Task 4.8: the 'review' status (awaiting merge)."""
1106
+
1107
+ def test_review_round_trip(self):
1108
+ self.build()
1109
+ self.ok("status", "1.1.1", "review")
1110
+ self.assertEqual(self.status("1.1.1"), "review")
1111
+ self.assertIn("🔎", tasks.render(tasks.load()))
1112
+
1113
+ def test_review_counts_as_in_progress_for_the_parent(self):
1114
+ self.build()
1115
+ self.ok("status", "1.1.1", "review")
1116
+ self.assertEqual(self.status("1.1"), "in_progress")
1117
+
1118
+ def test_next_never_offers_review(self):
1119
+ self.build()
1120
+ self.ok("status", "1.1.1", "review")
1121
+ self.assertNotIn("1.1.1", self.ok("next", "-n", "5"))
1122
+
1123
+
1124
+ class UidLinkTests(TasksTestCase):
1125
+ """Task 4.2: stable uid + structured links."""
1126
+
1127
+ def test_every_node_gets_a_unique_uid(self):
1128
+ self.build()
1129
+ uids = [n["uid"] for n in tasks.walk(tasks.load()["sprints"])]
1130
+ self.assertTrue(all(u.startswith("T-") and len(u) == 6 for u in uids), uids)
1131
+ self.assertEqual(len(uids), len(set(uids)))
1132
+
1133
+ def test_find_and_show_accept_a_uid(self):
1134
+ self.build()
1135
+ uid = tasks.find(tasks.load(), "1.1")["uid"]
1136
+ self.assertIn("Task A", self.ok("show", uid))
1137
+
1138
+ def test_link_and_unlink_round_trip(self):
1139
+ self.build()
1140
+ self.ok("link", "1.2", "spec", "tmp/tasks/x.md")
1141
+ links = tasks.find(tasks.load(), "1.2")["links"]
1142
+ self.assertEqual([(link["kind"], link["value"]) for link in links], [("spec", "tmp/tasks/x.md")])
1143
+ self.ok("unlink", "1.2", "spec", "tmp/tasks/x.md")
1144
+ self.assertEqual(tasks.find(tasks.load(), "1.2")["links"], [])
1145
+
1146
+ def test_unknown_link_kind_is_rejected(self):
1147
+ self.build()
1148
+ code, _, err = self.run_cli("link", "1.2", "bogus", "x")
1149
+ self.assertEqual(code, 1)
1150
+ self.assertIn("unknown link kind", err)
1151
+
1152
+ def test_uid_survives_mv(self):
1153
+ self.build()
1154
+ uid = tasks.find(tasks.load(), "1.1.1")["uid"]
1155
+ self.ok("mv", "1.1.1", "1.2")
1156
+ self.assertEqual(tasks.find(tasks.load(), "1.2.1")["uid"], uid)
1157
+
1158
+ def test_snapshot_round_trips_uid_and_links(self):
1159
+ self.build()
1160
+ self.ok("link", "1.2", "commit", "abc123")
1161
+ tasks.SNAPSHOT_FILE.write_text(tasks.snapshot_json(tasks.load()), encoding="utf-8")
1162
+ self.ok("import-json", str(tasks.SNAPSHOT_FILE))
1163
+ self.assertEqual(tasks.find(tasks.load(), "1.2")["links"][0]["value"], "abc123")
1164
+
1165
+
1166
+ class BriefTests(TasksTestCase):
1167
+ """Task 4.9: tasks.py brief — the context bundle for one item."""
1168
+
1169
+ def test_brief_shows_deps_and_spec(self):
1170
+ self.build()
1171
+ self.ok("edit", "1.2", "-d", "do the thing")
1172
+ self.ok("edit", "1.2", "--after", "1.1")
1173
+ self.ok("link", "1.2", "spec", "tmp/tasks/1.2.md")
1174
+ self.ok("link", "1.1", "commit", "cafe1234")
1175
+ out = self.ok("brief", "1.2")
1176
+ self.assertIn("do the thing", out)
1177
+ self.assertIn("Depends on", out)
1178
+ self.assertIn("cafe1234", out)
1179
+ self.assertIn("tmp/tasks/1.2.md", out)
1180
+
1181
+
1182
+ class AuditLogTests(TasksTestCase):
1183
+ """Task 4.3: events table + tasks.py log + actor."""
1184
+
1185
+ def test_status_change_writes_an_event(self):
1186
+ self.build()
1187
+ self.ok("start", "1.1.1")
1188
+ out = self.ok("log", "1.1.1")
1189
+ self.assertIn("status", out)
1190
+ self.assertIn("in_progress", out)
1191
+
1192
+ def test_add_and_remove_are_recorded(self):
1193
+ self.build()
1194
+ self.ok("add", "1", "New task")
1195
+ self.assertIn("add", self.ok("log"))
1196
+
1197
+ def test_log_since_filters(self):
1198
+ self.build()
1199
+ self.ok("start", "1.1.1")
1200
+ self.assertIn("no events", self.ok("log", "--since", "2999-01-01"))
1201
+
1202
+ def test_link_is_logged(self):
1203
+ self.build()
1204
+ self.ok("link", "1.2", "commit", "abc")
1205
+ self.assertIn("commit", self.ok("log", "1.2"))
1206
+
1207
+ def test_vibe_actor_is_recorded(self):
1208
+ os.environ["VIBE_ACTOR"] = "tester"
1209
+ self.addCleanup(os.environ.pop, "VIBE_ACTOR", None)
1210
+ self.build()
1211
+ self.ok("start", "1.1.1")
1212
+ self.assertIn("tester", self.ok("log", "1.1.1"))
1213
+
1214
+
1215
+ class PlanBatchTests(TasksTestCase):
1216
+ """Task 4.10: plan-batch picks ready tasks with disjoint scope links."""
1217
+
1218
+ def test_disjoint_scopes_are_batched(self):
1219
+ self.build()
1220
+ self.ok("link", "1.1.1", "scope", "src/a")
1221
+ self.ok("link", "1.1.2", "scope", "src/b")
1222
+ out = self.ok("plan-batch")
1223
+ self.assertIn("1.1.1", out)
1224
+ self.assertIn("1.1.2", out)
1225
+
1226
+ def test_overlapping_scopes_pick_only_one(self):
1227
+ self.build()
1228
+ self.ok("link", "1.1.1", "scope", "src/shared")
1229
+ self.ok("link", "1.1.2", "scope", "src/shared")
1230
+ out = self.ok("plan-batch")
1231
+ self.assertNotEqual("1.1.1" in out, "1.1.2" in out)
1232
+
1233
+ def test_tasks_without_scopes_are_skipped(self):
1234
+ self.build()
1235
+ self.assertIn("no disjoint ready tasks", self.ok("plan-batch"))
1236
+
1237
+ def test_skipped_task_without_scope_is_warned_on_stderr(self):
1238
+ self.build()
1239
+ code, _, err = self.run_cli("plan-batch")
1240
+ self.assertEqual(code, 0)
1241
+ self.assertIn("skipped: no scope link", err)
1242
+
1243
+ def test_nested_scopes_overlap(self):
1244
+ self.build()
1245
+ self.ok("link", "1.1.1", "scope", "src/a")
1246
+ self.ok("link", "1.1.2", "scope", "src/a/b")
1247
+ out = self.ok("plan-batch")
1248
+ self.assertNotEqual("1.1.1" in out, "1.1.2" in out)
1249
+
1250
+ def test_similar_prefix_is_not_overlap(self):
1251
+ self.build()
1252
+ self.ok("link", "1.1.1", "scope", "src/a")
1253
+ self.ok("link", "1.1.2", "scope", "src/ab")
1254
+ out = self.ok("plan-batch")
1255
+ self.assertIn("1.1.1", out)
1256
+ self.assertIn("1.1.2", out)
1257
+
1258
+ def test_batch_prefers_less_conflicted_tasks(self):
1259
+ self.build()
1260
+ self.ok("link", "1.1.1", "scope", "src/a")
1261
+ self.ok("link", "1.1.1", "scope", "src/b")
1262
+ self.ok("link", "1.1.2", "scope", "src/a")
1263
+ self.ok("link", "1.2", "scope", "src/b")
1264
+
1265
+ out = self.ok("plan-batch")
1266
+
1267
+ self.assertNotIn("1.1.1", out)
1268
+ self.assertIn("1.1.2", out)
1269
+ self.assertIn("1.2", out)
1270
+
1271
+ def test_tasks_with_incomplete_dependencies_are_not_batched(self):
1272
+ self.build()
1273
+ self.ok("link", "1.1.1", "scope", "src/a")
1274
+ self.ok("link", "1.1.2", "scope", "src/b")
1275
+ self.ok("edit", "1.1.2", "--after", "1.1.1")
1276
+
1277
+ out = self.ok("plan-batch")
1278
+
1279
+ self.assertIn("1.1.1", out)
1280
+ self.assertNotIn("1.1.2", out)
1281
+
1282
+
1283
+ class BackupTests(TasksTestCase):
1284
+ """Task 4.4: backup clone with history (local bare repo as remote), explicit push/restore."""
1285
+
1286
+ def setUp(self):
1287
+ super().setUp()
1288
+ self.remote = Path(self.tmp.name) / "remote.git"
1289
+ self.backup = Path(self.tmp.name) / "backup"
1290
+ subprocess.run(["git", "init", "--bare", "-q", str(self.remote)], check=True)
1291
+ subprocess.run(["git", "init", "-q", str(self.backup)], check=True)
1292
+ subprocess.run(["git", "-C", str(self.backup), "remote", "add", "origin", str(self.remote)], check=True)
1293
+ tasks.BACKUP_DIR = self.backup
1294
+
1295
+ def test_backup_writes_snapshot_and_commits(self):
1296
+ self.build()
1297
+ self.ok("backup")
1298
+ self.assertTrue((self.backup / "snapshot.json").exists())
1299
+ self.assertTrue((self.backup / "README.md").exists())
1300
+ log = subprocess.run(["git", "-C", str(self.backup), "log", "--oneline"],
1301
+ capture_output=True, text=True).stdout
1302
+ self.assertTrue(log.strip(), "backup made no commit")
1303
+
1304
+ def test_backup_push_reaches_the_remote(self):
1305
+ self.build()
1306
+ self.ok("backup", "--push")
1307
+ remote_log = subprocess.run(["git", "-C", str(self.remote), "log", "--oneline"],
1308
+ capture_output=True, text=True).stdout
1309
+ self.assertTrue(remote_log.strip(), "remote has no commits")
1310
+
1311
+ def test_failed_push_warns_on_stderr(self):
1312
+ self.build()
1313
+ shutil.rmtree(self.remote)
1314
+ code, _, err = self.run_cli("backup", "--push")
1315
+ self.assertEqual(code, 0)
1316
+ self.assertIn("warning: backup: git push failed", err)
1317
+
1318
+ def test_restore_refuses_stale_snapshot_when_pull_fails(self):
1319
+ self.build()
1320
+ self.ok("backup")
1321
+ old_snapshot = (self.backup / "snapshot.json").read_text(encoding="utf-8")
1322
+ self.ok("add-sprint", "Newer sprint")
1323
+ before = tasks.load()
1324
+ (self.backup / "snapshot.json").write_text(old_snapshot, encoding="utf-8")
1325
+ shutil.rmtree(self.remote)
1326
+
1327
+ code, _, err = self.run_cli("restore", "--pull")
1328
+
1329
+ self.assertEqual(code, 1)
1330
+ self.assertIn("warning: backup: git pull failed", err)
1331
+ self.assertIn("refusing to restore", err)
1332
+ self.assertEqual(tasks.load(), before)
1333
+
1334
+ def test_restore_replaces_the_database(self):
1335
+ self.build()
1336
+ self.ok("backup")
1337
+ # Hand-written older snapshot (a single empty sprint) to prove restore replaces the DB.
1338
+ snapshot = {"version": 2, "project": "T", "sprints": [
1339
+ {"id": "1", "uid": "T-zzzz", "title": "Only sprint", "children": []}]}
1340
+ (self.backup / "snapshot.json").write_text(json.dumps(snapshot), encoding="utf-8")
1341
+ self.ok("restore")
1342
+ out = self.ok("list")
1343
+ self.assertIn("Only sprint", out)
1344
+ self.assertNotIn("Task A", out)
1345
+
1346
+
1347
+ class Task3_9Tests(TasksTestCase):
1348
+ """Tests for Task 3.9: TASKS.md is git-ignored; check regenerates it when missing."""
1349
+
1350
+ def test_check_regenerates_missing_tasks_md(self):
1351
+ """When DB exists but TASKS.md is missing, check regenerates it silently."""
1352
+ self.ok("add-sprint", "S")
1353
+ tasks.VIEW_FILE.unlink()
1354
+ self.assertFalse(tasks.VIEW_FILE.exists())
1355
+ out = self.ok("check")
1356
+ self.assertIn("view regenerated", out)
1357
+ self.assertTrue(tasks.VIEW_FILE.exists())
1358
+
1359
+ def test_check_still_fails_on_stale_tasks_md(self):
1360
+ """A stale TASKS.md still causes check to fail."""
1361
+ self.ok("add-sprint", "S")
1362
+ tasks.VIEW_FILE.write_text("stale content", encoding="utf-8")
1363
+ code, _, err = self.run_cli("check")
1364
+ self.assertEqual(code, 1)
1365
+ self.assertIn("out of date", err)
1366
+
1367
+ def test_check_skips_when_no_db(self):
1368
+ """check skips all comparisons when the DB does not exist."""
1369
+ code, out, err = self.run_cli("check")
1370
+ self.assertEqual(code, 0)
1371
+ self.assertIn("no database", out)
1372
+
1373
+ def test_change_command_recreates_deleted_tasks_md(self):
1374
+ """A change command (e.g., add-sprint) recreates TASKS.md if it was deleted."""
1375
+ self.ok("add-sprint", "S")
1376
+ tasks.VIEW_FILE.unlink()
1377
+ self.assertFalse(tasks.VIEW_FILE.exists())
1378
+ self.ok("add-sprint", "S2")
1379
+ self.assertTrue(tasks.VIEW_FILE.exists())
1380
+ self.assertIn("S2", tasks.VIEW_FILE.read_text(encoding="utf-8"))
1381
+
1382
+ def test_git_ignores_view(self):
1383
+ """The generated view (tasks/README.md + per-sprint files) is listed in .gitignore."""
1384
+ import subprocess
1385
+ # Find the repo root via git (works in worktrees too)
1386
+ try:
1387
+ result = subprocess.run(
1388
+ ["git", "rev-parse", "--show-toplevel"],
1389
+ capture_output=True, text=True, timeout=10
1390
+ )
1391
+ if result.returncode != 0:
1392
+ self.skipTest("not in a git repository")
1393
+ repo_root = Path(result.stdout.strip())
1394
+ except (FileNotFoundError, subprocess.TimeoutExpired):
1395
+ self.skipTest("git not available")
1396
+
1397
+ gitignore = repo_root / ".gitignore"
1398
+ if gitignore.exists():
1399
+ content = gitignore.read_text(encoding="utf-8")
1400
+ self.assertIn("tasks/README.md", content,
1401
+ ".gitignore should ignore tasks/README.md")
1402
+ self.assertIn("tasks/sprint-*.md", content,
1403
+ ".gitignore should ignore the per-sprint files")
1404
+
1405
+
1406
+ class SafeRmTests(TasksTestCase):
1407
+ """Safe deletion: preview, --dry-run, confirmation question, recovery file, restore-removed."""
1408
+
1409
+ def rich_build(self):
1410
+ """Sprint 1 with Task A (subtasks, notes, an internal dep and a started leaf) and Task B."""
1411
+ self.ok("add-sprint", "Sprint one")
1412
+ self.ok("add", "1", "Task A")
1413
+ self.ok("add", "1.1", "Sub 1")
1414
+ self.ok("add", "1.1", "Sub 2")
1415
+ self.ok("add", "1", "Task B")
1416
+ self.ok("note", "1.1", "note root")
1417
+ self.ok("note", "1.1.1", "note sub")
1418
+ self.ok("edit", "1.1.2", "--after", "1.1.1")
1419
+ self.ok("start", "1.1.1")
1420
+
1421
+ def exists(self, node_id):
1422
+ try:
1423
+ tasks.find(tasks.load(), node_id)
1424
+ return True
1425
+ except tasks.TaskError:
1426
+ return False
1427
+
1428
+ def recovery_file(self):
1429
+ files = sorted(tasks.RECOVERY_DIR.glob("*.json"))
1430
+ self.assertEqual(len(files), 1, "expected exactly one recovery file")
1431
+ return files[0]
1432
+
1433
+ def test_preview_content_and_counts(self):
1434
+ self.rich_build()
1435
+ self.ok("edit", "1.2", "--after", "1.1") # outside depends on the subtree
1436
+ code, out, err = self.run_cli("rm", "1.1", "--dry-run")
1437
+ self.assertEqual(code, 0)
1438
+ self.assertIn("1.1 «Task A»", err)
1439
+ self.assertIn("status=in_progress", err)
1440
+ self.assertIn("subtasks=2", err)
1441
+ self.assertIn("notes=1", err)
1442
+ self.assertIn("depends-on-me=1.2", err)
1443
+ self.assertIn("3 items, 2 notes will be deleted", err)
1444
+ self.assertIn("warning: some items are in progress or done, have notes, "
1445
+ "or are depended on from outside", err)
1446
+ self.assertEqual(out, "") # preview goes to stderr; stdout stays machine-readable
1447
+
1448
+ def test_preview_plain_leaf_has_no_warning(self):
1449
+ self.rich_build()
1450
+ code, out, err = self.run_cli("rm", "1.2", "--dry-run")
1451
+ self.assertEqual(code, 0)
1452
+ self.assertIn("1.2 «Task B» status=none subtasks=0 notes=0", err)
1453
+ self.assertIn("1 items, 0 notes will be deleted", err)
1454
+ self.assertNotIn("warning:", err)
1455
+
1456
+ def test_dry_run_changes_nothing(self):
1457
+ self.rich_build()
1458
+ before = tasks.load()
1459
+ self.assertFalse(tasks.RECOVERY_DIR.exists())
1460
+ code, _, err = self.run_cli("rm", "1.1", "--dry-run")
1461
+ self.assertEqual(code, 0)
1462
+ self.assertEqual(tasks.load(), before)
1463
+ self.assertFalse(tasks.RECOVERY_DIR.exists())
1464
+
1465
+ def test_non_tty_without_yes_refuses_with_exit_2(self):
1466
+ self.rich_build()
1467
+ before = tasks.load()
1468
+ with mock.patch.object(tasks, "_tty", return_value=False):
1469
+ code, out, err = self.run_cli("rm", "1.1")
1470
+ self.assertEqual(code, 2)
1471
+ self.assertIn("refusing to delete without confirmation: pass --yes (see --dry-run for the preview)",
1472
+ err)
1473
+ self.assertIn("will be deleted", err) # preview always comes first
1474
+ self.assertEqual(tasks.load(), before)
1475
+ self.assertFalse(tasks.RECOVERY_DIR.exists())
1476
+
1477
+ def test_question_typed_id_confirms(self):
1478
+ self.rich_build()
1479
+ with mock.patch.object(tasks, "_tty", return_value=True), \
1480
+ mock.patch.object(tasks, "input", return_value="1.1"):
1481
+ code, out, err = self.run_cli("rm", "1.1")
1482
+ self.assertEqual(code, 0)
1483
+ self.assertIn('Type the id "1.1" to confirm, or press Enter to cancel:', err)
1484
+ self.assertNotIn("cancelled", err)
1485
+ self.assertFalse(self.exists("1.1"))
1486
+ self.assertTrue(tasks.RECOVERY_DIR.exists())
1487
+
1488
+ def test_question_wrong_text_cancels(self):
1489
+ self.rich_build()
1490
+ before = tasks.load()
1491
+ with mock.patch.object(tasks, "_tty", return_value=True), \
1492
+ mock.patch.object(tasks, "input", return_value="nope"):
1493
+ code, out, err = self.run_cli("rm", "1.1")
1494
+ self.assertEqual(code, 1)
1495
+ self.assertIn("cancelled", err)
1496
+ self.assertEqual(tasks.load(), before)
1497
+ self.assertFalse(tasks.RECOVERY_DIR.exists())
1498
+
1499
+ def test_question_empty_answer_cancels(self):
1500
+ self.rich_build()
1501
+ before = tasks.load()
1502
+ with mock.patch.object(tasks, "_tty", return_value=True), \
1503
+ mock.patch.object(tasks, "input", return_value=""):
1504
+ code, out, err = self.run_cli("rm", "1.1")
1505
+ self.assertEqual(code, 1)
1506
+ self.assertIn("cancelled", err)
1507
+ self.assertEqual(tasks.load(), before)
1508
+
1509
+ def test_plain_leaf_accepts_y(self):
1510
+ self.rich_build()
1511
+ with mock.patch.object(tasks, "_tty", return_value=True), \
1512
+ mock.patch.object(tasks, "input", return_value="y"):
1513
+ code, out, err = self.run_cli("rm", "1.2")
1514
+ self.assertEqual(code, 0)
1515
+ self.assertFalse(self.exists("1.2"))
1516
+
1517
+ def test_non_plain_item_requires_typed_id_not_y(self):
1518
+ self.rich_build()
1519
+ before = tasks.load()
1520
+ with mock.patch.object(tasks, "_tty", return_value=True), \
1521
+ mock.patch.object(tasks, "input", return_value="y"):
1522
+ code, out, err = self.run_cli("rm", "1.1") # has children, notes, a started leaf
1523
+ self.assertEqual(code, 1)
1524
+ self.assertIn("cancelled", err)
1525
+ self.assertEqual(tasks.load(), before)
1526
+
1527
+ def test_question_prompt_text(self):
1528
+ self.rich_build()
1529
+ prompt = tasks._rm_question(tasks.load(), ["1.1"])
1530
+ self.assertIn("Delete 1.1 «Task A» and everything below it (3 items, 2 notes)?", prompt)
1531
+ self.assertIn("This cannot be undone from the database (a recovery file is written).", prompt)
1532
+ self.assertIn('Type the id "1.1" to confirm, or press Enter to cancel:', prompt)
1533
+
1534
+ def test_multi_id_requires_word_delete(self):
1535
+ self.rich_build()
1536
+ before = tasks.load()
1537
+ with mock.patch.object(tasks, "_tty", return_value=True), \
1538
+ mock.patch.object(tasks, "input", return_value="y"):
1539
+ code, out, err = self.run_cli("rm", "1.1", "1.2")
1540
+ self.assertEqual(code, 1)
1541
+ self.assertIn("cancelled", err)
1542
+ self.assertEqual(tasks.load(), before)
1543
+ with mock.patch.object(tasks, "_tty", return_value=True), \
1544
+ mock.patch.object(tasks, "input", return_value="delete"):
1545
+ code, out, err = self.run_cli("rm", "1.1", "1.2")
1546
+ self.assertEqual(code, 0)
1547
+ self.assertFalse(self.exists("1.1"))
1548
+ self.assertFalse(self.exists("1.2"))
1549
+
1550
+ def test_yes_skips_question_but_never_preview_or_recovery(self):
1551
+ self.rich_build()
1552
+ code, out, err = self.run_cli("rm", "1.1", "--yes")
1553
+ self.assertEqual(code, 0)
1554
+ self.assertIn("1.1 «Task A»", err) # preview still printed with --yes
1555
+ self.assertIn("removed 1.1 and 2 item(s) below it", out)
1556
+ recovery = self.recovery_file()
1557
+ data = json.loads(recovery.read_text(encoding="utf-8"))
1558
+ self.assertEqual(data["removed"][0]["parent"], "1")
1559
+ node = data["removed"][0]["node"]
1560
+ self.assertEqual(node["id"], "1.1")
1561
+ self.assertEqual(node["children"][0]["id"], "1.1.1")
1562
+ self.assertEqual(node["children"][1]["depends_on"], ["1.1.1"])
1563
+ self.assertIn("note root", node["notes"][0])
1564
+ self.assertIn("note sub", node["children"][0]["notes"][0])
1565
+ self.assertFalse(self.exists("1.1"))
1566
+ self.ok("check") # snapshot and TASKS.md regenerated by the normal mutate path
1567
+
1568
+ def test_multi_id_recovery_filename_contains_ids(self):
1569
+ self.rich_build()
1570
+ self.ok("rm", "1.1", "1.2", "--yes")
1571
+ files = list(tasks.RECOVERY_DIR.glob("*.json"))
1572
+ self.assertEqual(len(files), 1)
1573
+ self.assertIn("1.1", files[0].name)
1574
+ self.assertIn("1.2", files[0].name)
1575
+
1576
+ def test_recovery_write_failure_aborts_deletion(self):
1577
+ self.rich_build()
1578
+ tasks.RECOVERY_DIR.write_text("I am a file, not a directory", encoding="utf-8")
1579
+ before = tasks.load()
1580
+ code, out, err = self.run_cli("rm", "1.1", "--yes")
1581
+ self.assertEqual(code, 1)
1582
+ self.assertIn("could not write recovery file", err)
1583
+ self.assertEqual(tasks.load(), before)
1584
+ self.assertTrue(self.exists("1.1"))
1585
+
1586
+ def test_old_single_id_message_preserved(self):
1587
+ self.build()
1588
+ out = self.ok("rm", "1.2", "--yes")
1589
+ self.assertEqual(out, "removed 1.2 and 0 item(s) below it\n")
1590
+ out = self.ok("rm", "1.1", "--yes")
1591
+ self.assertEqual(out, "removed 1.1 and 2 item(s) below it\n")
1592
+
1593
+ def test_dependency_blocked_rm_writes_no_recovery(self):
1594
+ self.rich_build()
1595
+ self.ok("edit", "1.2", "--after", "1.1")
1596
+ before = tasks.load()
1597
+ code, out, err = self.run_cli("rm", "1.1", "--yes")
1598
+ self.assertEqual(code, 1)
1599
+ self.assertIn("depends on", err)
1600
+ self.assertEqual(tasks.load(), before)
1601
+ self.assertFalse(tasks.RECOVERY_DIR.exists())
1602
+
1603
+ def test_restore_removed_round_trip(self):
1604
+ self.rich_build()
1605
+ self.ok("rm", "1.1", "--yes")
1606
+ recovery = self.recovery_file()
1607
+ out = self.ok("restore-removed", str(recovery))
1608
+ self.assertIn("restored 1.1 -> 1.3", out)
1609
+ self.assertIn("restored 1.1.1 -> 1.3.1", out)
1610
+ self.assertIn("restored 1.1.2 -> 1.3.2", out)
1611
+ data = tasks.load()
1612
+ node = tasks.find(data, "1.3")
1613
+ self.assertEqual(node["title"], "Task A")
1614
+ self.assertIn("note root", node["notes"][0])
1615
+ self.assertEqual(self.status("1.3.1"), "in_progress") # stored status kept
1616
+ self.assertIn("note sub", tasks.find(data, "1.3.1")["notes"][0])
1617
+ self.assertEqual(tasks.find(data, "1.3.2")["depends_on"], ["1.3.1"]) # internal dep mapped
1618
+ self.assertEqual(tasks.find(data, "1.2")["depends_on"], [])
1619
+ self.ok("check")
1620
+
1621
+ def test_restore_skips_missing_dependency_target(self):
1622
+ self.rich_build()
1623
+ self.ok("edit", "1.1.1", "--after", "1.2") # internal dep on an external id
1624
+ self.ok("rm", "1.1", "--yes")
1625
+ recovery = self.recovery_file()
1626
+ self.ok("rm", "1.2", "--yes") # the dep target no longer exists
1627
+ out = self.ok("restore-removed", str(recovery))
1628
+ self.assertIn("skipped dependency:", out)
1629
+ self.assertIn("1.2 (id does not exist)", out)
1630
+ self.assertEqual(tasks.find(tasks.load(), "1.1.1")["depends_on"], [])
1631
+
1632
+ def test_restore_original_parent_missing_requires_parent(self):
1633
+ self.rich_build()
1634
+ self.ok("add-sprint", "Two")
1635
+ self.ok("rm", "1.1", "--yes")
1636
+ recovery = self.recovery_file()
1637
+ self.ok("rm", "1", "--yes") # original parent sprint 1 is gone
1638
+ code, out, err = self.run_cli("restore-removed", str(recovery))
1639
+ self.assertEqual(code, 1)
1640
+ self.assertIn("original parent 1 no longer exists", err)
1641
+ out = self.ok("restore-removed", str(recovery), "--parent", "2")
1642
+ self.assertIn("restored 1.1 -> 2.1", out)
1643
+ self.assertIn("restored 1.1.1 -> 2.1.1", out)
1644
+
1645
+ def test_restore_sprint_with_parent_refused(self):
1646
+ self.rich_build()
1647
+ self.ok("add-sprint", "Two")
1648
+ self.ok("rm", "1", "--yes")
1649
+ recovery = self.recovery_file()
1650
+ code, out, err = self.run_cli("restore-removed", str(recovery), "--parent", "2")
1651
+ self.assertEqual(code, 1)
1652
+ self.assertIn("cannot be restored under", err)
1653
+
1654
+ def test_restore_too_deep_refused(self):
1655
+ self.rich_build()
1656
+ self.ok("add-sprint", "Two")
1657
+ self.ok("add", "2", "Task C")
1658
+ self.ok("rm", "1.1", "--yes")
1659
+ recovery = self.recovery_file()
1660
+ code, out, err = self.run_cli("restore-removed", str(recovery), "--parent", "2.1")
1661
+ self.assertEqual(code, 1)
1662
+ self.assertIn("too deep", err)
1663
+
1664
+ def test_restore_a_sprint(self):
1665
+ self.ok("add-sprint", "S1")
1666
+ self.ok("add", "1", "T")
1667
+ self.ok("rm", "1", "--yes")
1668
+ recovery = self.recovery_file()
1669
+ out = self.ok("restore-removed", str(recovery))
1670
+ self.assertIn("restored 1 -> 1", out)
1671
+ self.assertIn("restored 1.1 -> 1.1", out)
1672
+ data = tasks.load()
1673
+ self.assertEqual(tasks.find(data, "1")["title"], "S1")
1674
+ self.assertEqual(tasks.find(data, "1.1")["title"], "T")
1675
+
1676
+
1677
+ if __name__ == "__main__":
1678
+ unittest.main()