@stylusnexus/work-plan 2026.6.11 → 2026.6.13
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.
- package/README.md +26 -4
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/commands/export.py +20 -2
- package/skills/work-plan/commands/group.py +5 -1
- package/skills/work-plan/commands/init_repo.py +84 -14
- package/skills/work-plan/commands/list_open_issues.py +52 -0
- package/skills/work-plan/commands/new_track.py +8 -2
- package/skills/work-plan/commands/plan_branch.py +314 -0
- package/skills/work-plan/commands/plan_status.py +76 -9
- package/skills/work-plan/commands/reconcile.py +49 -34
- package/skills/work-plan/commands/refresh_md.py +49 -1
- package/skills/work-plan/commands/remove_repo.py +69 -0
- package/skills/work-plan/lib/export_model.py +21 -4
- package/skills/work-plan/lib/git_state.py +22 -0
- package/skills/work-plan/lib/manifest.py +10 -0
- package/skills/work-plan/lib/plan_worktree.py +288 -0
- package/skills/work-plan/lib/tracks.py +6 -2
- package/skills/work-plan/lib/verdict.py +1 -0
- package/skills/work-plan/tests/test_export.py +40 -0
- package/skills/work-plan/tests/test_export_command.py +19 -0
- package/skills/work-plan/tests/test_init_repo.py +100 -1
- package/skills/work-plan/tests/test_list_open_issues.py +83 -0
- package/skills/work-plan/tests/test_notes_vcs_command.py +77 -0
- package/skills/work-plan/tests/test_plan_branch.py +279 -0
- package/skills/work-plan/tests/test_plan_status_stalled.py +219 -0
- package/skills/work-plan/tests/test_plan_worktree.py +378 -0
- package/skills/work-plan/tests/test_reconcile_dup_slug.py +138 -0
- package/skills/work-plan/tests/test_refresh_md.py +75 -0
- package/skills/work-plan/tests/test_remove_repo.py +77 -0
- package/skills/work-plan/work_plan.py +95 -6
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Tests for the plan-status staleness clock (#164).
|
|
2
|
+
|
|
3
|
+
The clock keys off a plan's DECLARED manifest files (which get committed) — not
|
|
4
|
+
the plan doc's own git date, which is null because plan docs are gitignored.
|
|
5
|
+
All git is mocked; these run offline.
|
|
6
|
+
"""
|
|
7
|
+
import sys
|
|
8
|
+
import unittest
|
|
9
|
+
from datetime import date, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from unittest import mock
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
14
|
+
|
|
15
|
+
from lib import git_state
|
|
16
|
+
from lib import manifest
|
|
17
|
+
from lib import verdict as verdict_mod
|
|
18
|
+
from commands import plan_status
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestPathsLastCommitDate(unittest.TestCase):
|
|
22
|
+
def test_returns_max_date_over_paths(self):
|
|
23
|
+
proc = mock.Mock(returncode=0, stdout="2026-06-10T12:00:00+00:00")
|
|
24
|
+
with mock.patch.object(Path, "exists", return_value=True), \
|
|
25
|
+
mock.patch.object(git_state, "_git", return_value=proc):
|
|
26
|
+
got = git_state.paths_last_commit_date(
|
|
27
|
+
["a.py", "b.py"], Path("/repo"))
|
|
28
|
+
self.assertEqual(got, datetime(2026, 6, 10, 12, 0, 0))
|
|
29
|
+
|
|
30
|
+
def test_empty_paths_is_none(self):
|
|
31
|
+
with mock.patch.object(Path, "exists", return_value=True), \
|
|
32
|
+
mock.patch.object(git_state, "_git") as g:
|
|
33
|
+
self.assertIsNone(git_state.paths_last_commit_date([], Path("/repo")))
|
|
34
|
+
g.assert_not_called()
|
|
35
|
+
|
|
36
|
+
def test_empty_stdout_is_none(self):
|
|
37
|
+
proc = mock.Mock(returncode=0, stdout="")
|
|
38
|
+
with mock.patch.object(Path, "exists", return_value=True), \
|
|
39
|
+
mock.patch.object(git_state, "_git", return_value=proc):
|
|
40
|
+
self.assertIsNone(
|
|
41
|
+
git_state.paths_last_commit_date(["a.py"], Path("/repo")))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TestStallDaysConstant(unittest.TestCase):
|
|
45
|
+
def test_default_is_14(self):
|
|
46
|
+
self.assertEqual(verdict_mod.STALL_DAYS, 14)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestUncheckedCheckboxLabels(unittest.TestCase):
|
|
50
|
+
def test_captures_unticked_labels_in_order(self):
|
|
51
|
+
text = (
|
|
52
|
+
"- [x] Phase 1 — git helper\n"
|
|
53
|
+
"- [x] Phase 2 — manifest\n"
|
|
54
|
+
"- [ ] Phase 4 — tests\n"
|
|
55
|
+
"- [ ] Phase 5 — docs\n"
|
|
56
|
+
)
|
|
57
|
+
self.assertEqual(
|
|
58
|
+
manifest.unchecked_checkbox_labels(text),
|
|
59
|
+
["Phase 4 — tests", "Phase 5 — docs"],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def test_cap_limits_results(self):
|
|
63
|
+
text = "\n".join(f"- [ ] item {i}" for i in range(20))
|
|
64
|
+
got = manifest.unchecked_checkbox_labels(text)
|
|
65
|
+
self.assertEqual(len(got), 10)
|
|
66
|
+
self.assertEqual(got[0], "item 0")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _FakePath:
|
|
70
|
+
def __init__(self, name):
|
|
71
|
+
self.name = name
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _Doc:
|
|
75
|
+
@classmethod
|
|
76
|
+
def make(cls, rel="plans/p.md", kind="plan", name="2026-05-01-p.md"):
|
|
77
|
+
d = cls.__new__(cls)
|
|
78
|
+
d.rel = rel
|
|
79
|
+
d.kind = kind
|
|
80
|
+
d.path = _FakePath(name)
|
|
81
|
+
return d
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _decl(path):
|
|
85
|
+
return manifest.DeclaredPath(kind="create", path=path)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TestEvaluateStaleness(unittest.TestCase):
|
|
89
|
+
"""The staleness ladder fires only for partial verdicts and keys off the
|
|
90
|
+
manifest files' commit date, not the plan doc's own (gitignored) date."""
|
|
91
|
+
|
|
92
|
+
def setUp(self):
|
|
93
|
+
self.today = date(2026, 6, 12)
|
|
94
|
+
self.partial = verdict_mod.Verdict("partial", "\U0001f7e1", "files")
|
|
95
|
+
self.decls = [_decl("src/a.py"), _decl("src/b.py")]
|
|
96
|
+
|
|
97
|
+
def _evaluate(self, manifest_date, on_disk, verdict=None, text="body"):
|
|
98
|
+
"""Run _evaluate with manifest.* / git_state.* / classify mocked.
|
|
99
|
+
|
|
100
|
+
manifest_date: what paths_last_commit_date returns.
|
|
101
|
+
on_disk: which declared paths _declared_paths_on_disk reports present.
|
|
102
|
+
"""
|
|
103
|
+
v = verdict or self.partial
|
|
104
|
+
doc = _Doc.make()
|
|
105
|
+
with mock.patch.object(plan_status, "_read", return_value=text), \
|
|
106
|
+
mock.patch.object(manifest, "parse_declared_paths", return_value=self.decls), \
|
|
107
|
+
mock.patch.object(manifest, "plan_date_from_filename", return_value=None), \
|
|
108
|
+
mock.patch.object(manifest, "score_manifest",
|
|
109
|
+
return_value=manifest.ManifestScore(2, 1, {})), \
|
|
110
|
+
mock.patch.object(manifest, "count_checkboxes", return_value=(1, 4)), \
|
|
111
|
+
mock.patch.object(manifest, "out_of_tree_ratio", return_value=0.0), \
|
|
112
|
+
mock.patch.object(manifest, "unchecked_checkbox_labels",
|
|
113
|
+
return_value=["do x"]), \
|
|
114
|
+
mock.patch.object(plan_status, "_declared_paths_on_disk",
|
|
115
|
+
return_value=on_disk), \
|
|
116
|
+
mock.patch.object(git_state, "path_last_commit_date", return_value=None), \
|
|
117
|
+
mock.patch.object(git_state, "paths_last_commit_date",
|
|
118
|
+
return_value=manifest_date), \
|
|
119
|
+
mock.patch.object(verdict_mod, "classify", return_value=v):
|
|
120
|
+
return plan_status._evaluate(doc, Path("/repo"), self.today, 60, 14)
|
|
121
|
+
|
|
122
|
+
def test_partial_cold_is_stalled(self):
|
|
123
|
+
cold = datetime(2026, 5, 1, 12, 0, 0) # 42 days before today
|
|
124
|
+
row = self._evaluate(cold, ["src/a.py", "src/b.py"])
|
|
125
|
+
self.assertTrue(row["stalled"])
|
|
126
|
+
self.assertEqual(row["manifest_last_touched"], "2026-05-01")
|
|
127
|
+
|
|
128
|
+
def test_partial_warm_is_not_stalled(self):
|
|
129
|
+
warm = datetime(2026, 6, 10, 12, 0, 0) # 2 days before today
|
|
130
|
+
row = self._evaluate(warm, ["src/a.py"])
|
|
131
|
+
self.assertFalse(row["stalled"])
|
|
132
|
+
|
|
133
|
+
def test_partial_no_files_on_disk_is_not_stalled(self):
|
|
134
|
+
row = self._evaluate(None, [])
|
|
135
|
+
self.assertFalse(row["stalled"])
|
|
136
|
+
self.assertIsNone(row["manifest_last_touched"])
|
|
137
|
+
|
|
138
|
+
def test_doc_uncommitted_but_manifest_committed_is_stalled(self):
|
|
139
|
+
# path_last_commit_date (doc) is None, but manifest committed 42d ago.
|
|
140
|
+
cold = datetime(2026, 5, 1, 12, 0, 0)
|
|
141
|
+
row = self._evaluate(cold, ["src/a.py"])
|
|
142
|
+
self.assertTrue(row["stalled"])
|
|
143
|
+
self.assertIsNone(row["last_touched"]) # doc date stays None
|
|
144
|
+
|
|
145
|
+
def test_present_but_never_committed_is_stalled(self):
|
|
146
|
+
# files exist on disk but manifest date is None -> never committed
|
|
147
|
+
row = self._evaluate(None, ["src/a.py"])
|
|
148
|
+
self.assertTrue(row["stalled"])
|
|
149
|
+
|
|
150
|
+
def test_emits_unchecked_items_and_stall_days(self):
|
|
151
|
+
row = self._evaluate(datetime(2026, 6, 10), ["src/a.py"])
|
|
152
|
+
self.assertEqual(row["unchecked_items"], ["do x"])
|
|
153
|
+
self.assertEqual(row["stall_days"], 14)
|
|
154
|
+
|
|
155
|
+
def test_non_partial_is_never_stalled(self):
|
|
156
|
+
shipped = verdict_mod.Verdict("shipped", "✅", "files")
|
|
157
|
+
row = self._evaluate(None, [], verdict=shipped)
|
|
158
|
+
self.assertFalse(row["stalled"])
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class TestResolveStallDays(unittest.TestCase):
|
|
162
|
+
def test_known_flag_set_includes_stall_days(self):
|
|
163
|
+
self.assertIn("--stall-days", plan_status.KNOWN)
|
|
164
|
+
|
|
165
|
+
def test_flag_beats_config_beats_default(self):
|
|
166
|
+
with mock.patch.object(plan_status.config_mod, "load_config",
|
|
167
|
+
return_value={"stall_days": 30}):
|
|
168
|
+
self.assertEqual(
|
|
169
|
+
plan_status._resolve_stall_days({"--stall-days": "45"}), 45)
|
|
170
|
+
|
|
171
|
+
def test_config_beats_default(self):
|
|
172
|
+
with mock.patch.object(plan_status.config_mod, "load_config",
|
|
173
|
+
return_value={"stall_days": 30}):
|
|
174
|
+
self.assertEqual(plan_status._resolve_stall_days({}), 30)
|
|
175
|
+
|
|
176
|
+
def test_default_when_unset(self):
|
|
177
|
+
with mock.patch.object(plan_status.config_mod, "load_config",
|
|
178
|
+
return_value={}):
|
|
179
|
+
self.assertEqual(plan_status._resolve_stall_days({}), 14)
|
|
180
|
+
|
|
181
|
+
def test_non_integer_flag_falls_through(self):
|
|
182
|
+
with mock.patch.object(plan_status.config_mod, "load_config",
|
|
183
|
+
return_value={}):
|
|
184
|
+
self.assertEqual(
|
|
185
|
+
plan_status._resolve_stall_days({"--stall-days": "abc"}), 14)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class TestDeclaredPathsOnDiskGuards(unittest.TestCase):
|
|
189
|
+
"""A junk declared path ('/'), a directory, or an out-of-tree '../x' must be
|
|
190
|
+
excluded — otherwise they poison `git log -- <paths>` and falsely stall an
|
|
191
|
+
actively-built plan (regression from the smoke test for #164)."""
|
|
192
|
+
|
|
193
|
+
def test_excludes_root_slash_dirs_and_escapes_keeps_real_files(self):
|
|
194
|
+
import tempfile, os
|
|
195
|
+
from lib.manifest import DeclaredPath
|
|
196
|
+
with tempfile.TemporaryDirectory() as td:
|
|
197
|
+
root = Path(td)
|
|
198
|
+
(root / "src").mkdir()
|
|
199
|
+
real = "src/a.py"
|
|
200
|
+
(root / real).write_text("x")
|
|
201
|
+
# a sibling file outside the repo root
|
|
202
|
+
outside = Path(td).parent / "escape_probe_164.py"
|
|
203
|
+
try:
|
|
204
|
+
outside.write_text("x")
|
|
205
|
+
decls = [
|
|
206
|
+
DeclaredPath(kind="create", path=real), # real file -> kept
|
|
207
|
+
DeclaredPath(kind="create", path="/"), # resolves to FS root dir -> dropped
|
|
208
|
+
DeclaredPath(kind="create", path="src"), # a directory -> dropped
|
|
209
|
+
DeclaredPath(kind="modify", path=f"../{outside.name}"), # out-of-tree -> dropped
|
|
210
|
+
]
|
|
211
|
+
got = plan_status._declared_paths_on_disk(decls, root)
|
|
212
|
+
self.assertEqual(got, [real])
|
|
213
|
+
finally:
|
|
214
|
+
if outside.exists():
|
|
215
|
+
outside.unlink()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
if __name__ == "__main__":
|
|
219
|
+
unittest.main()
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Tests for lib/plan_worktree — shared-tier path resolution + plan-branch
|
|
2
|
+
worktree (#260). git is mocked (offline); never shells out.
|
|
3
|
+
"""
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest.mock import patch, MagicMock
|
|
9
|
+
|
|
10
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
11
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
12
|
+
|
|
13
|
+
from lib import plan_worktree as pw
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _ok(stdout=""):
|
|
17
|
+
return MagicMock(returncode=0, stdout=stdout, stderr="")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _fail():
|
|
21
|
+
return MagicMock(returncode=1, stdout="", stderr="boom")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _FakeGit:
|
|
25
|
+
"""Stand-in for plan_worktree._git.
|
|
26
|
+
|
|
27
|
+
branch_exists: whether `rev-parse --verify` succeeds for the branch refs.
|
|
28
|
+
add_ok: whether `worktree add` succeeds.
|
|
29
|
+
missing: if True, every call returns None (git absent / timeout).
|
|
30
|
+
staged: whether the staged-paths diff reports changes.
|
|
31
|
+
on_branch: the branch the worktree reports for `rev-parse --abbrev-ref
|
|
32
|
+
HEAD` (cached-worktree reuse verifies this matches).
|
|
33
|
+
porcelain: stdout for `status --porcelain -- .work-plan` (dirty paths).
|
|
34
|
+
"""
|
|
35
|
+
def __init__(self, *, branch_exists=True, add_ok=True, missing=False,
|
|
36
|
+
staged=False, head="abc1234", on_branch="plan", porcelain="",
|
|
37
|
+
orphan_ok=True, rm_ok=True, fetch_ok=True, push_ok=True,
|
|
38
|
+
oneline=""):
|
|
39
|
+
self.branch_exists = branch_exists
|
|
40
|
+
self.add_ok = add_ok
|
|
41
|
+
self.missing = missing
|
|
42
|
+
self.staged = staged # whether the scoped diff reports staged changes
|
|
43
|
+
self.head = head
|
|
44
|
+
self.on_branch = on_branch
|
|
45
|
+
self.porcelain = porcelain
|
|
46
|
+
self.orphan_ok = orphan_ok
|
|
47
|
+
self.rm_ok = rm_ok
|
|
48
|
+
self.fetch_ok = fetch_ok
|
|
49
|
+
self.push_ok = push_ok
|
|
50
|
+
self.oneline = oneline # stdout for `log --oneline`
|
|
51
|
+
self.calls = []
|
|
52
|
+
|
|
53
|
+
def __call__(self, cwd, *args, timeout=None):
|
|
54
|
+
self.calls.append(args)
|
|
55
|
+
if self.missing:
|
|
56
|
+
return None
|
|
57
|
+
sub = args[0] if args else ""
|
|
58
|
+
if sub == "rev-parse" and "--short" in args:
|
|
59
|
+
return _ok(self.head + "\n")
|
|
60
|
+
if sub == "rev-parse" and "--abbrev-ref" in args:
|
|
61
|
+
return _ok(self.on_branch + "\n")
|
|
62
|
+
if sub == "rev-parse": # --verify --quiet <ref>
|
|
63
|
+
return _ok("deadbee\n") if self.branch_exists else _fail()
|
|
64
|
+
if sub == "worktree" and len(args) > 1 and args[1] == "add":
|
|
65
|
+
return _ok() if self.add_ok else _fail()
|
|
66
|
+
if sub == "-c" and "status" in args: # -c core.quotepath=false status -z …
|
|
67
|
+
return _ok(self.porcelain)
|
|
68
|
+
if sub == "worktree" and len(args) > 1 and args[1] == "remove":
|
|
69
|
+
return _ok()
|
|
70
|
+
if sub == "checkout" and "--orphan" in args:
|
|
71
|
+
return _ok() if self.orphan_ok else _fail()
|
|
72
|
+
if sub == "rm":
|
|
73
|
+
return _ok() if self.rm_ok else _fail()
|
|
74
|
+
if sub == "fetch":
|
|
75
|
+
return _ok() if self.fetch_ok else _fail()
|
|
76
|
+
if sub == "push":
|
|
77
|
+
return MagicMock(returncode=0 if self.push_ok else 1,
|
|
78
|
+
stdout="", stderr="" if self.push_ok else "denied")
|
|
79
|
+
if sub == "log" and "--oneline" in args:
|
|
80
|
+
return _ok(self.oneline)
|
|
81
|
+
if sub == "diff" and "--cached" in args:
|
|
82
|
+
return MagicMock(returncode=1 if self.staged else 0, stdout="", stderr="")
|
|
83
|
+
if sub == "commit":
|
|
84
|
+
self.staged = False
|
|
85
|
+
return _ok()
|
|
86
|
+
return _ok() # add, etc.
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SharedTierDirTest(unittest.TestCase):
|
|
90
|
+
def test_no_local_returns_none(self):
|
|
91
|
+
self.assertIsNone(pw.shared_tier_dir({"github": "o/r"}))
|
|
92
|
+
self.assertIsNone(pw.shared_tier_dir({}))
|
|
93
|
+
|
|
94
|
+
def test_no_plan_branch_uses_working_tree(self):
|
|
95
|
+
# Legacy behaviour: <local>/.work-plan, no git involved.
|
|
96
|
+
with tempfile.TemporaryDirectory() as d:
|
|
97
|
+
got = pw.shared_tier_dir({"local": d})
|
|
98
|
+
self.assertEqual(got, Path(d).expanduser() / ".work-plan")
|
|
99
|
+
|
|
100
|
+
def test_plan_branch_uses_worktree_when_present(self):
|
|
101
|
+
with tempfile.TemporaryDirectory() as d:
|
|
102
|
+
dest = Path(d) / "wt"
|
|
103
|
+
dest.mkdir()
|
|
104
|
+
(dest / ".git").write_text("gitdir: ...\n") # a worktree gitdir pointer
|
|
105
|
+
with patch.object(pw, "_worktree_dir", return_value=dest), \
|
|
106
|
+
patch.object(pw, "_git", _FakeGit()):
|
|
107
|
+
got = pw.shared_tier_dir({"local": d, "plan_branch": "plan"})
|
|
108
|
+
self.assertEqual(got, dest / ".work-plan")
|
|
109
|
+
|
|
110
|
+
def test_plan_branch_none_when_branch_missing(self):
|
|
111
|
+
with tempfile.TemporaryDirectory() as d:
|
|
112
|
+
dest = Path(d) / "wt" # no .git → must be created, but branch missing
|
|
113
|
+
with patch.object(pw, "_worktree_dir", return_value=dest), \
|
|
114
|
+
patch.object(pw, "_git", _FakeGit(branch_exists=False)):
|
|
115
|
+
got = pw.shared_tier_dir({"local": d, "plan_branch": "plan"})
|
|
116
|
+
self.assertIsNone(got)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class EnsureWorktreeTest(unittest.TestCase):
|
|
120
|
+
def test_empty_branch_returns_none(self):
|
|
121
|
+
with tempfile.TemporaryDirectory() as d:
|
|
122
|
+
self.assertIsNone(pw.ensure_worktree(Path(d), ""))
|
|
123
|
+
|
|
124
|
+
def test_returns_existing_worktree_without_adding(self):
|
|
125
|
+
with tempfile.TemporaryDirectory() as d:
|
|
126
|
+
dest = Path(d) / "wt"
|
|
127
|
+
dest.mkdir()
|
|
128
|
+
(dest / ".git").write_text("gitdir: ...\n")
|
|
129
|
+
fake = _FakeGit()
|
|
130
|
+
with patch.object(pw, "_worktree_dir", return_value=dest), \
|
|
131
|
+
patch.object(pw, "_git", fake):
|
|
132
|
+
got = pw.ensure_worktree(Path(d), "plan")
|
|
133
|
+
self.assertEqual(got, dest)
|
|
134
|
+
self.assertNotIn(("worktree", "add"), [tuple(c[:2]) for c in fake.calls])
|
|
135
|
+
|
|
136
|
+
def test_creates_worktree_when_branch_exists(self):
|
|
137
|
+
with tempfile.TemporaryDirectory() as d:
|
|
138
|
+
dest = Path(d) / "cache" / "wt" # parent created by ensure_worktree
|
|
139
|
+
fake = _FakeGit(branch_exists=True, add_ok=True)
|
|
140
|
+
with patch.object(pw, "_worktree_dir", return_value=dest), \
|
|
141
|
+
patch.object(pw, "_git", fake):
|
|
142
|
+
got = pw.ensure_worktree(Path(d), "plan")
|
|
143
|
+
self.assertEqual(got, dest)
|
|
144
|
+
self.assertIn(("worktree", "add"),
|
|
145
|
+
[(c[0], c[1]) for c in fake.calls if len(c) > 1])
|
|
146
|
+
|
|
147
|
+
def test_none_when_branch_missing(self):
|
|
148
|
+
with tempfile.TemporaryDirectory() as d:
|
|
149
|
+
dest = Path(d) / "wt"
|
|
150
|
+
with patch.object(pw, "_worktree_dir", return_value=dest), \
|
|
151
|
+
patch.object(pw, "_git", _FakeGit(branch_exists=False)):
|
|
152
|
+
self.assertIsNone(pw.ensure_worktree(Path(d), "plan"))
|
|
153
|
+
|
|
154
|
+
def test_none_when_add_fails(self):
|
|
155
|
+
with tempfile.TemporaryDirectory() as d:
|
|
156
|
+
dest = Path(d) / "cache" / "wt"
|
|
157
|
+
with patch.object(pw, "_worktree_dir", return_value=dest), \
|
|
158
|
+
patch.object(pw, "_git", _FakeGit(branch_exists=True, add_ok=False)):
|
|
159
|
+
self.assertIsNone(pw.ensure_worktree(Path(d), "plan"))
|
|
160
|
+
|
|
161
|
+
def test_none_when_git_missing(self):
|
|
162
|
+
with tempfile.TemporaryDirectory() as d:
|
|
163
|
+
dest = Path(d) / "wt"
|
|
164
|
+
with patch.object(pw, "_worktree_dir", return_value=dest), \
|
|
165
|
+
patch.object(pw, "_git", _FakeGit(missing=True)):
|
|
166
|
+
self.assertIsNone(pw.ensure_worktree(Path(d), "plan"))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class ReuseBranchVerifyTest(unittest.TestCase):
|
|
170
|
+
"""A cached worktree is reused ONLY when still on `plan_branch` (#260)."""
|
|
171
|
+
def test_refuses_cached_worktree_on_wrong_branch(self):
|
|
172
|
+
with tempfile.TemporaryDirectory() as d:
|
|
173
|
+
dest = Path(d) / "wt"
|
|
174
|
+
dest.mkdir()
|
|
175
|
+
(dest / ".git").write_text("gitdir: ...\n")
|
|
176
|
+
# Worktree was manually checked out to 'main' — must refuse, not
|
|
177
|
+
# commit plan churn on the wrong branch.
|
|
178
|
+
fake = _FakeGit(on_branch="main")
|
|
179
|
+
with patch.object(pw, "_worktree_dir", return_value=dest), \
|
|
180
|
+
patch.object(pw, "_git", fake):
|
|
181
|
+
self.assertIsNone(pw.ensure_worktree(Path(d), "plan"))
|
|
182
|
+
self.assertNotIn(("worktree", "add"),
|
|
183
|
+
[tuple(c[:2]) for c in fake.calls])
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class DirtyWorkPlanPathsTest(unittest.TestCase):
|
|
187
|
+
# Fixtures are NUL-delimited porcelain (`status --porcelain -z`).
|
|
188
|
+
def test_parses_porcelain_paths(self):
|
|
189
|
+
with tempfile.TemporaryDirectory() as d:
|
|
190
|
+
fake = _FakeGit(porcelain=" M .work-plan/a.md\0?? .work-plan/b.md\0")
|
|
191
|
+
with patch.object(pw, "_git", fake):
|
|
192
|
+
got = pw.dirty_work_plan_paths(Path(d))
|
|
193
|
+
self.assertEqual(got, [".work-plan/a.md", ".work-plan/b.md"])
|
|
194
|
+
|
|
195
|
+
def test_spaced_and_unicode_paths_roundtrip_verbatim(self):
|
|
196
|
+
# -z never quote-wraps: a space / non-ASCII path comes through clean.
|
|
197
|
+
with tempfile.TemporaryDirectory() as d:
|
|
198
|
+
fake = _FakeGit(porcelain="?? .work-plan/new café.md\0 M .work-plan/a b.md\0")
|
|
199
|
+
with patch.object(pw, "_git", fake):
|
|
200
|
+
got = pw.dirty_work_plan_paths(Path(d))
|
|
201
|
+
self.assertEqual(got, [".work-plan/new café.md", ".work-plan/a b.md"])
|
|
202
|
+
|
|
203
|
+
def test_rename_captures_both_dest_and_source(self):
|
|
204
|
+
# Staged rename: "R <dest>\0<source>" — both committed so it lands atomically.
|
|
205
|
+
with tempfile.TemporaryDirectory() as d:
|
|
206
|
+
fake = _FakeGit(porcelain="R .work-plan/new.md\0.work-plan/old.md\0?? .work-plan/c.md\0")
|
|
207
|
+
with patch.object(pw, "_git", fake):
|
|
208
|
+
got = pw.dirty_work_plan_paths(Path(d))
|
|
209
|
+
self.assertEqual(got, [".work-plan/new.md", ".work-plan/old.md", ".work-plan/c.md"])
|
|
210
|
+
|
|
211
|
+
def test_empty_on_clean_or_failure(self):
|
|
212
|
+
with tempfile.TemporaryDirectory() as d:
|
|
213
|
+
with patch.object(pw, "_git", _FakeGit(porcelain="")):
|
|
214
|
+
self.assertEqual(pw.dirty_work_plan_paths(Path(d)), [])
|
|
215
|
+
with patch.object(pw, "_git", _FakeGit(missing=True)):
|
|
216
|
+
self.assertEqual(pw.dirty_work_plan_paths(Path(d)), [])
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class CommitSharedTierTest(unittest.TestCase):
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _wt(d):
|
|
222
|
+
wt = Path(d)
|
|
223
|
+
(wt / ".work-plan").mkdir(parents=True, exist_ok=True)
|
|
224
|
+
return wt
|
|
225
|
+
|
|
226
|
+
def test_commits_only_given_paths_when_dirty(self):
|
|
227
|
+
with tempfile.TemporaryDirectory() as d:
|
|
228
|
+
wt = self._wt(d)
|
|
229
|
+
fake = _FakeGit(staged=True, head="sh4r3d1")
|
|
230
|
+
paths = [".work-plan/feature.md"]
|
|
231
|
+
with patch.object(pw, "_git", fake):
|
|
232
|
+
self.assertEqual(
|
|
233
|
+
pw.commit_shared_tier(wt, "work-plan slot 1 t", paths), "sh4r3d1")
|
|
234
|
+
# Scoped add/commit of the explicit path only — never a blanket
|
|
235
|
+
# `.work-plan` add that would sweep in unrelated dirty files.
|
|
236
|
+
self.assertIn(("add", "--", ".work-plan/feature.md"), fake.calls)
|
|
237
|
+
self.assertIn(("commit", "-m", "work-plan slot 1 t", "--",
|
|
238
|
+
".work-plan/feature.md"), fake.calls)
|
|
239
|
+
self.assertNotIn(("add", "--", ".work-plan"), fake.calls)
|
|
240
|
+
|
|
241
|
+
def test_noop_when_paths_empty(self):
|
|
242
|
+
with tempfile.TemporaryDirectory() as d:
|
|
243
|
+
wt = self._wt(d)
|
|
244
|
+
fake = _FakeGit(staged=True)
|
|
245
|
+
with patch.object(pw, "_git", fake):
|
|
246
|
+
self.assertIsNone(pw.commit_shared_tier(wt, "msg", []))
|
|
247
|
+
self.assertNotIn(("commit", "-m", "msg"), fake.calls)
|
|
248
|
+
|
|
249
|
+
def test_noop_when_nothing_staged(self):
|
|
250
|
+
with tempfile.TemporaryDirectory() as d:
|
|
251
|
+
wt = self._wt(d)
|
|
252
|
+
fake = _FakeGit(staged=False)
|
|
253
|
+
with patch.object(pw, "_git", fake):
|
|
254
|
+
self.assertIsNone(pw.commit_shared_tier(wt, "msg", [".work-plan/x.md"]))
|
|
255
|
+
self.assertNotIn(("commit", "-m", "msg", "--", ".work-plan/x.md"), fake.calls)
|
|
256
|
+
|
|
257
|
+
def test_noop_when_no_work_plan_dir(self):
|
|
258
|
+
with tempfile.TemporaryDirectory() as d:
|
|
259
|
+
with patch.object(pw, "_git", _FakeGit(staged=True)):
|
|
260
|
+
self.assertIsNone(
|
|
261
|
+
pw.commit_shared_tier(Path(d), "msg", [".work-plan/x.md"]))
|
|
262
|
+
|
|
263
|
+
def test_none_when_git_missing(self):
|
|
264
|
+
with tempfile.TemporaryDirectory() as d:
|
|
265
|
+
wt = self._wt(d)
|
|
266
|
+
with patch.object(pw, "_git", _FakeGit(missing=True)):
|
|
267
|
+
self.assertIsNone(
|
|
268
|
+
pw.commit_shared_tier(wt, "msg", [".work-plan/x.md"]))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class BranchExistsHelpersTest(unittest.TestCase):
|
|
272
|
+
def test_local_and_remote_split(self):
|
|
273
|
+
with tempfile.TemporaryDirectory() as d:
|
|
274
|
+
with patch.object(pw, "_git", _FakeGit(branch_exists=True)):
|
|
275
|
+
self.assertTrue(pw.local_branch_exists(Path(d), "b"))
|
|
276
|
+
self.assertTrue(pw.remote_branch_exists(Path(d), "b"))
|
|
277
|
+
self.assertTrue(pw.is_published(Path(d), "b"))
|
|
278
|
+
with patch.object(pw, "_git", _FakeGit(branch_exists=False)):
|
|
279
|
+
self.assertFalse(pw.local_branch_exists(Path(d), "b"))
|
|
280
|
+
self.assertFalse(pw.is_published(Path(d), "b"))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class FetchBranchTest(unittest.TestCase):
|
|
284
|
+
def test_true_on_success_false_on_failure(self):
|
|
285
|
+
with tempfile.TemporaryDirectory() as d:
|
|
286
|
+
with patch.object(pw, "_git", _FakeGit(fetch_ok=True)):
|
|
287
|
+
self.assertTrue(pw.fetch_branch(Path(d), "b"))
|
|
288
|
+
with patch.object(pw, "_git", _FakeGit(fetch_ok=False)):
|
|
289
|
+
self.assertFalse(pw.fetch_branch(Path(d), "b"))
|
|
290
|
+
with patch.object(pw, "_git", _FakeGit(missing=True)):
|
|
291
|
+
self.assertFalse(pw.fetch_branch(Path(d), "b"))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class CreateOrphanWorktreeTest(unittest.TestCase):
|
|
295
|
+
def test_creates_worktree_and_work_plan_dir(self):
|
|
296
|
+
with tempfile.TemporaryDirectory() as d:
|
|
297
|
+
dest = Path(d) / "cache" / "wt"
|
|
298
|
+
with patch.object(pw, "_worktree_dir", return_value=dest), \
|
|
299
|
+
patch.object(pw, "_git", _FakeGit()):
|
|
300
|
+
got = pw.create_orphan_worktree(Path(d), "work-plan/plan")
|
|
301
|
+
self.assertEqual(got, dest)
|
|
302
|
+
self.assertTrue((dest / ".work-plan").is_dir())
|
|
303
|
+
|
|
304
|
+
def test_none_when_worktree_already_cached(self):
|
|
305
|
+
with tempfile.TemporaryDirectory() as d:
|
|
306
|
+
dest = Path(d) / "wt"
|
|
307
|
+
dest.mkdir()
|
|
308
|
+
(dest / ".git").write_text("gitdir: ...\n")
|
|
309
|
+
with patch.object(pw, "_worktree_dir", return_value=dest), \
|
|
310
|
+
patch.object(pw, "_git", _FakeGit()):
|
|
311
|
+
self.assertIsNone(pw.create_orphan_worktree(Path(d), "b"))
|
|
312
|
+
|
|
313
|
+
def test_none_and_rollback_when_orphan_checkout_fails(self):
|
|
314
|
+
with tempfile.TemporaryDirectory() as d:
|
|
315
|
+
dest = Path(d) / "cache" / "wt"
|
|
316
|
+
fake = _FakeGit(orphan_ok=False)
|
|
317
|
+
with patch.object(pw, "_worktree_dir", return_value=dest), \
|
|
318
|
+
patch.object(pw, "_git", fake):
|
|
319
|
+
self.assertIsNone(pw.create_orphan_worktree(Path(d), "b"))
|
|
320
|
+
self.assertIn(("worktree", "remove"),
|
|
321
|
+
[(c[0], c[1]) for c in fake.calls if len(c) > 1])
|
|
322
|
+
|
|
323
|
+
def test_none_when_git_missing(self):
|
|
324
|
+
with tempfile.TemporaryDirectory() as d:
|
|
325
|
+
dest = Path(d) / "cache" / "wt"
|
|
326
|
+
with patch.object(pw, "_worktree_dir", return_value=dest), \
|
|
327
|
+
patch.object(pw, "_git", _FakeGit(missing=True)):
|
|
328
|
+
self.assertIsNone(pw.create_orphan_worktree(Path(d), "b"))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class UnpushedOnelineTest(unittest.TestCase):
|
|
332
|
+
def test_uses_range_when_published(self):
|
|
333
|
+
with tempfile.TemporaryDirectory() as d:
|
|
334
|
+
fake = _FakeGit(branch_exists=True, oneline="a1 one\nb2 two\n")
|
|
335
|
+
with patch.object(pw, "_git", fake):
|
|
336
|
+
got = pw.unpushed_oneline(Path(d), "work-plan/plan")
|
|
337
|
+
self.assertEqual(got, ["a1 one", "b2 two"])
|
|
338
|
+
# range form origin/<branch>..<branch> when remote ref exists
|
|
339
|
+
logcall = [c for c in fake.calls if c and c[0] == "log"][0]
|
|
340
|
+
self.assertIn("origin/work-plan/plan..work-plan/plan", logcall)
|
|
341
|
+
|
|
342
|
+
def test_all_commits_when_unpublished(self):
|
|
343
|
+
with tempfile.TemporaryDirectory() as d:
|
|
344
|
+
fake = _FakeGit(branch_exists=False, oneline="a1 only\n")
|
|
345
|
+
with patch.object(pw, "_git", fake):
|
|
346
|
+
got = pw.unpushed_oneline(Path(d), "wp")
|
|
347
|
+
self.assertEqual(got, ["a1 only"])
|
|
348
|
+
logcall = [c for c in fake.calls if c and c[0] == "log"][0]
|
|
349
|
+
self.assertIn("wp", logcall)
|
|
350
|
+
self.assertNotIn("origin/wp..wp", logcall)
|
|
351
|
+
|
|
352
|
+
def test_empty_on_failure(self):
|
|
353
|
+
with tempfile.TemporaryDirectory() as d:
|
|
354
|
+
with patch.object(pw, "_git", _FakeGit(missing=True)):
|
|
355
|
+
self.assertEqual(pw.unpushed_oneline(Path(d), "b"), [])
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class PushPlanBranchTest(unittest.TestCase):
|
|
359
|
+
def test_returns_completed_process(self):
|
|
360
|
+
with tempfile.TemporaryDirectory() as d:
|
|
361
|
+
with patch.object(pw, "_git", _FakeGit(push_ok=True)):
|
|
362
|
+
proc = pw.push_plan_branch(Path(d), "b")
|
|
363
|
+
self.assertEqual(proc.returncode, 0)
|
|
364
|
+
|
|
365
|
+
def test_surfaces_failure(self):
|
|
366
|
+
with tempfile.TemporaryDirectory() as d:
|
|
367
|
+
with patch.object(pw, "_git", _FakeGit(push_ok=False)):
|
|
368
|
+
proc = pw.push_plan_branch(Path(d), "b")
|
|
369
|
+
self.assertEqual(proc.returncode, 1)
|
|
370
|
+
|
|
371
|
+
def test_none_when_git_missing(self):
|
|
372
|
+
with tempfile.TemporaryDirectory() as d:
|
|
373
|
+
with patch.object(pw, "_git", _FakeGit(missing=True)):
|
|
374
|
+
self.assertIsNone(pw.push_plan_branch(Path(d), "b"))
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
if __name__ == "__main__":
|
|
378
|
+
unittest.main()
|