@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.
Files changed (31) hide show
  1. package/README.md +26 -4
  2. package/VERSION +1 -1
  3. package/package.json +1 -1
  4. package/skills/work-plan/commands/export.py +20 -2
  5. package/skills/work-plan/commands/group.py +5 -1
  6. package/skills/work-plan/commands/init_repo.py +84 -14
  7. package/skills/work-plan/commands/list_open_issues.py +52 -0
  8. package/skills/work-plan/commands/new_track.py +8 -2
  9. package/skills/work-plan/commands/plan_branch.py +314 -0
  10. package/skills/work-plan/commands/plan_status.py +76 -9
  11. package/skills/work-plan/commands/reconcile.py +49 -34
  12. package/skills/work-plan/commands/refresh_md.py +49 -1
  13. package/skills/work-plan/commands/remove_repo.py +69 -0
  14. package/skills/work-plan/lib/export_model.py +21 -4
  15. package/skills/work-plan/lib/git_state.py +22 -0
  16. package/skills/work-plan/lib/manifest.py +10 -0
  17. package/skills/work-plan/lib/plan_worktree.py +288 -0
  18. package/skills/work-plan/lib/tracks.py +6 -2
  19. package/skills/work-plan/lib/verdict.py +1 -0
  20. package/skills/work-plan/tests/test_export.py +40 -0
  21. package/skills/work-plan/tests/test_export_command.py +19 -0
  22. package/skills/work-plan/tests/test_init_repo.py +100 -1
  23. package/skills/work-plan/tests/test_list_open_issues.py +83 -0
  24. package/skills/work-plan/tests/test_notes_vcs_command.py +77 -0
  25. package/skills/work-plan/tests/test_plan_branch.py +279 -0
  26. package/skills/work-plan/tests/test_plan_status_stalled.py +219 -0
  27. package/skills/work-plan/tests/test_plan_worktree.py +378 -0
  28. package/skills/work-plan/tests/test_reconcile_dup_slug.py +138 -0
  29. package/skills/work-plan/tests/test_refresh_md.py +75 -0
  30. package/skills/work-plan/tests/test_remove_repo.py +77 -0
  31. 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()