@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
@@ -16,6 +16,8 @@ def _track(name, repo, issues, *, has_frontmatter=True, status="active"):
16
16
  name=name,
17
17
  repo=repo,
18
18
  tier="private",
19
+ path=Path(f"/tmp/notes/{name}.md"),
20
+ folder="myrepo",
19
21
  has_frontmatter=has_frontmatter,
20
22
  meta={
21
23
  "status": status,
@@ -71,6 +73,23 @@ class ExportRunJsonTest(unittest.TestCase):
71
73
  self.assertEqual(rc, 0)
72
74
  self.assertEqual(out["schema"], 1)
73
75
 
76
+ def test_track_file_path_is_emitted(self):
77
+ """The export carries each track's .md path end-to-end (#211)."""
78
+ tracks = [_track("alpha", _SHARED_REPO, [1])]
79
+ rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP)
80
+ self.assertEqual(rc, 0)
81
+ # str(Path(...)) so the expected separator matches the platform (Windows
82
+ # backslashes). The path is whatever os.sep the fixture's Path produces.
83
+ self.assertEqual(out["tracks"][0]["path"], str(Path("/tmp/notes/alpha.md")))
84
+
85
+ def test_track_folder_key_is_emitted(self):
86
+ """The export carries each track's config folder key end-to-end for the
87
+ Plans view's `plan-status --repo=<key>` arg (#164)."""
88
+ tracks = [_track("alpha", _SHARED_REPO, [1])]
89
+ rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP)
90
+ self.assertEqual(rc, 0)
91
+ self.assertEqual(out["tracks"][0]["folder"], "myrepo")
92
+
74
93
  def test_track_issues_assembled_in_declared_order(self):
75
94
  # Issues are milestone-sorted (#101): null-milestone group sorts by number.
76
95
  tracks = [_track("alpha", _SHARED_REPO, [2, 1])]
@@ -136,12 +136,55 @@ class InitRepoNonInteractiveTest(unittest.TestCase):
136
136
  # ------------------------------------------------------------------
137
137
 
138
138
  def test_repo_already_exists_returns_rc1(self):
139
- """Key already in config.repos → rc 1, yq NOT called."""
139
+ """Key already in config.repos (no --update) → rc 1, yq NOT called,
140
+ and the message points at --update."""
140
141
  existing = {"mykey": {"github": "org/myrepo", "local": None}}
141
142
  rc, msub, out = _drive(["mykey", "--github=org/myrepo"], existing_repos=existing)
142
143
  self.assertEqual(rc, 1)
143
144
  msub.assert_not_called()
144
145
  self.assertIn("already exists", out)
146
+ self.assertIn("--update", out)
147
+
148
+ # ------------------------------------------------------------------
149
+ # --update on an existing key → updates its local path
150
+ # ------------------------------------------------------------------
151
+
152
+ def test_update_existing_sets_local(self):
153
+ """Existing key + --update --local=/new/path → yq merges local into the
154
+ existing block; rc 0; no 'already exists' error."""
155
+ existing = {"mykey": {"github": "org/myrepo"}}
156
+ rc, msub, out = _drive(
157
+ ["mykey", "--github=org/myrepo", "--local=/new/path", "--update"],
158
+ existing_repos=existing,
159
+ )
160
+ self.assertEqual(rc, 0)
161
+ msub.assert_called_once()
162
+ yq_args = msub.call_args[0][0]
163
+ self.assertEqual(yq_args[0], "yq")
164
+ self.assertEqual(yq_args[1], "-i")
165
+ # Merge expression preserves other keys via `* env(...)`.
166
+ expr = yq_args[2]
167
+ self.assertIn(".repos.mykey", expr)
168
+ self.assertIn("env(WP_REPO_UPDATES)", expr)
169
+ updates = json.loads(msub.call_args.kwargs["env"]["WP_REPO_UPDATES"])
170
+ self.assertEqual(updates["local"], "/new/path")
171
+ self.assertIn("Updated", out)
172
+ self.assertNotIn("already exists", out)
173
+
174
+ def test_update_nonexistent_key_falls_back_to_add(self):
175
+ """--update on a key NOT in config → behaves as a plain add (creates the
176
+ block via the add path), rc 0."""
177
+ rc, msub, out = _drive(
178
+ ["mykey", "--github=org/myrepo", "--local=/some/path", "--update"],
179
+ existing_repos={},
180
+ )
181
+ self.assertEqual(rc, 0)
182
+ msub.assert_called_once()
183
+ expr = msub.call_args[0][0][2]
184
+ # Add path uses the assignment form, not the merge form.
185
+ self.assertEqual(expr, ".repos.mykey = env(WP_REPO_BLOCK)")
186
+ block = json.loads(msub.call_args.kwargs["env"]["WP_REPO_BLOCK"])
187
+ self.assertEqual(block["local"], "/some/path")
145
188
 
146
189
  # ------------------------------------------------------------------
147
190
  # No key → rc 2
@@ -153,6 +196,62 @@ class InitRepoNonInteractiveTest(unittest.TestCase):
153
196
  self.assertEqual(rc, 2)
154
197
  msub.assert_not_called()
155
198
 
199
+ # ------------------------------------------------------------------
200
+ # --clear-local: sets local to null on an existing key
201
+ # ------------------------------------------------------------------
202
+
203
+ def test_clear_local_sets_local_null(self):
204
+ """--update --clear-local on an existing key → yq merges {local: null};
205
+ rc 0; '✓ Cleared local path' printed."""
206
+ existing = {"mykey": {"github": "org/myrepo", "local": "/old/path"}}
207
+ rc, msub, out = _drive(
208
+ ["mykey", "--update", "--clear-local"],
209
+ existing_repos=existing,
210
+ )
211
+ self.assertEqual(rc, 0)
212
+ msub.assert_called_once()
213
+ expr = msub.call_args[0][0][2]
214
+ self.assertIn(".repos.mykey", expr)
215
+ self.assertIn("env(WP_REPO_UPDATES)", expr)
216
+ updates = json.loads(msub.call_args.kwargs["env"]["WP_REPO_UPDATES"])
217
+ self.assertIn("local", updates)
218
+ self.assertIsNone(updates["local"])
219
+ # github not passed → not in the merge (other fields preserved by yq).
220
+ self.assertNotIn("github", updates)
221
+ self.assertIn("Cleared local path", out)
222
+
223
+ def test_clear_local_with_local_is_mutually_exclusive(self):
224
+ """--clear-local + --local=<path> → rc 2, yq NOT called."""
225
+ existing = {"mykey": {"github": "org/myrepo", "local": "/old/path"}}
226
+ rc, msub, out = _drive(
227
+ ["mykey", "--update", "--clear-local", "--local=/new/path"],
228
+ existing_repos=existing,
229
+ )
230
+ self.assertEqual(rc, 2)
231
+ msub.assert_not_called()
232
+ self.assertIn("mutually exclusive", out)
233
+
234
+ def test_clear_local_without_update_returns_rc2(self):
235
+ """--clear-local without --update → rc 2, yq NOT called."""
236
+ existing = {"mykey": {"github": "org/myrepo", "local": "/old/path"}}
237
+ rc, msub, out = _drive(
238
+ ["mykey", "--clear-local"],
239
+ existing_repos=existing,
240
+ )
241
+ self.assertEqual(rc, 2)
242
+ msub.assert_not_called()
243
+ self.assertIn("requires --update", out)
244
+
245
+ def test_clear_local_nonexistent_key_returns_rc1(self):
246
+ """--update --clear-local on a key NOT in config → rc 1, yq NOT called."""
247
+ rc, msub, out = _drive(
248
+ ["mykey", "--update", "--clear-local"],
249
+ existing_repos={},
250
+ )
251
+ self.assertEqual(rc, 1)
252
+ msub.assert_not_called()
253
+ self.assertIn("not found", out)
254
+
156
255
  # ------------------------------------------------------------------
157
256
  # Invalid key format → rc 2
158
257
  # ------------------------------------------------------------------
@@ -0,0 +1,83 @@
1
+ """Tests for the list-open-issues subcommand (#282).
2
+
3
+ Mocks fetch_open_issues — runs offline, never touches the network.
4
+ """
5
+ import io
6
+ import json
7
+ import sys
8
+ import unittest
9
+ from contextlib import redirect_stdout
10
+ from pathlib import Path
11
+ from unittest.mock import patch
12
+
13
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
14
+ sys.path.insert(0, str(SKILL_ROOT))
15
+
16
+ from commands import list_open_issues
17
+
18
+
19
+ def _row(number, title="t", state="OPEN", logins=(), milestone=None):
20
+ """A raw gh issue row as fetch_open_issues returns."""
21
+ d = {"number": number, "title": title, "state": state,
22
+ "assignees": [{"login": l} for l in logins]}
23
+ if milestone:
24
+ d["milestone"] = {"title": milestone}
25
+ return d
26
+
27
+
28
+ def _run(args, rows):
29
+ with patch("commands.list_open_issues.fetch_open_issues", return_value=rows):
30
+ buf = io.StringIO()
31
+ with redirect_stdout(buf):
32
+ rc = list_open_issues.run(args)
33
+ out = buf.getvalue()
34
+ try:
35
+ parsed = json.loads(out)
36
+ except json.JSONDecodeError:
37
+ parsed = None
38
+ return rc, parsed
39
+
40
+
41
+ class ListOpenIssuesTest(unittest.TestCase):
42
+ def test_emits_repo_and_normalized_issues(self):
43
+ rows = [_row(91, "Rate-limit login", "OPEN", ["eve"], "v0.6"),
44
+ _row(87, "Fix auth", "OPEN")]
45
+ rc, out = _run(["--repo=o/r"], rows)
46
+ self.assertEqual(rc, 0)
47
+ self.assertEqual(out["repo"], "o/r")
48
+ # Same Issue shape as the export (number/title/state/assignee/milestone).
49
+ self.assertEqual(
50
+ out["issues"][0],
51
+ {"number": 91, "title": "Rate-limit login", "state": "open",
52
+ "assignee": "@eve", "milestone": "v0.6"},
53
+ )
54
+ self.assertEqual(out["issues"][1],
55
+ {"number": 87, "title": "Fix auth", "state": "open",
56
+ "assignee": "—", "milestone": None})
57
+
58
+ def test_exclude_filters_given_numbers(self):
59
+ rows = [_row(1), _row(2), _row(3)]
60
+ rc, out = _run(["--repo=o/r", "--exclude=1,3"], rows)
61
+ self.assertEqual(rc, 0)
62
+ self.assertEqual([i["number"] for i in out["issues"]], [2])
63
+
64
+ def test_exclude_tolerates_blanks_and_nonnumeric(self):
65
+ rows = [_row(1), _row(2)]
66
+ rc, out = _run(["--repo=o/r", "--exclude=1, ,x,"], rows)
67
+ self.assertEqual(rc, 0)
68
+ self.assertEqual([i["number"] for i in out["issues"]], [2])
69
+
70
+ def test_missing_repo_is_usage_error(self):
71
+ rc, out = _run([], [])
72
+ self.assertEqual(rc, 2)
73
+ self.assertIn("error", out)
74
+
75
+ def test_empty_fetch_yields_empty_issue_list(self):
76
+ # fetch_open_issues returns [] on a bad/unreachable repo — not an error.
77
+ rc, out = _run(["--repo=o/r"], [])
78
+ self.assertEqual(rc, 0)
79
+ self.assertEqual(out, {"repo": "o/r", "issues": []})
80
+
81
+
82
+ if __name__ == "__main__":
83
+ unittest.main()
@@ -308,5 +308,82 @@ class DispatcherHookTest(unittest.TestCase):
308
308
  self.assertIsNone(work_plan._notes_precommit_state("slot"))
309
309
 
310
310
 
311
+ class SharedDispatcherHookTest(unittest.TestCase):
312
+ """The shared-tier (#260) auto-commit hook mirrors the notes one: snapshot
313
+ each plan_branch repo's dirty .work-plan/ paths BEFORE the command, then
314
+ commit only that repo's delta — never another repo's pre-existing edits.
315
+ """
316
+
317
+ @staticmethod
318
+ def _cfg_two():
319
+ return {"notes_root": "/tmp/notes", "repos": {
320
+ "alpha": {"github": "o/alpha", "local": "/tmp/alpha", "plan_branch": "wp-plan"},
321
+ "beta": {"github": "o/beta", "local": "/tmp/beta", "plan_branch": "wp-plan"},
322
+ "gamma": {"github": "o/gamma", "local": "/tmp/gamma"}, # legacy, no plan_branch
323
+ }}
324
+
325
+ def test_commits_only_the_touched_repo(self):
326
+ cfg = self._cfg_two()
327
+ # alpha gains a file; beta has a pre-existing dirty file that must NOT
328
+ # be committed; gamma has no plan_branch so it's never visited. Keyed by
329
+ # Path.name (not str) so the test is OS path-separator agnostic.
330
+ dirty = {
331
+ "alpha": [[], [".work-plan/x.md"]], # before, after
332
+ "beta": [[".work-plan/stale.md"], [".work-plan/stale.md"]],
333
+ }
334
+ def fake_ensure(local, branch):
335
+ return Path(local) # use the repo's local path as the worktree handle
336
+ def fake_dirty(wt):
337
+ return dirty[Path(wt).name].pop(0)
338
+ commits = []
339
+ def fake_commit(wt, msg, paths):
340
+ commits.append((Path(wt).name, tuple(paths)))
341
+ return "deadbee"
342
+ with patch("lib.config.load_config", return_value=cfg), \
343
+ patch("lib.plan_worktree.ensure_worktree", side_effect=fake_ensure), \
344
+ patch("lib.plan_worktree.dirty_work_plan_paths", side_effect=fake_dirty), \
345
+ patch("lib.plan_worktree.commit_shared_tier", side_effect=fake_commit):
346
+ err = io.StringIO()
347
+ with redirect_stderr(err):
348
+ pre = work_plan._shared_precommit_state("slot")
349
+ work_plan._commit_shared_writes(pre, ["slot", "1", "x"])
350
+ out = err.getvalue()
351
+ # Only alpha committed, and only its new path.
352
+ self.assertEqual(commits, [("alpha", (".work-plan/x.md",))])
353
+ self.assertIn("alpha", out)
354
+ self.assertNotIn("beta", out)
355
+
356
+ def test_skips_read_only_command(self):
357
+ with patch("lib.config.load_config", return_value=self._cfg_two()) as mload:
358
+ self.assertIsNone(work_plan._shared_precommit_state("brief"))
359
+ mload.assert_not_called()
360
+
361
+ def test_none_when_no_plan_branch_repos(self):
362
+ cfg = {"notes_root": "/tmp/n", "repos": {
363
+ "gamma": {"github": "o/gamma", "local": "/tmp/gamma"}}}
364
+ with patch("lib.config.load_config", return_value=cfg):
365
+ self.assertIsNone(work_plan._shared_precommit_state("slot"))
366
+
367
+ def test_precommit_never_raises_on_failure(self):
368
+ with patch("lib.config.load_config", side_effect=RuntimeError("boom")):
369
+ self.assertIsNone(work_plan._shared_precommit_state("slot"))
370
+
371
+ def test_warns_when_changes_present_but_commit_refused(self):
372
+ cfg = {"notes_root": "/tmp/n", "repos": {
373
+ "alpha": {"github": "o/alpha", "local": "/tmp/alpha", "plan_branch": "wp"}}}
374
+ seq = {"alpha": [[], [".work-plan/x.md"]]}
375
+ with patch("lib.config.load_config", return_value=cfg), \
376
+ patch("lib.plan_worktree.ensure_worktree",
377
+ side_effect=lambda l, b: Path(l)), \
378
+ patch("lib.plan_worktree.dirty_work_plan_paths",
379
+ side_effect=lambda wt: seq[Path(wt).name].pop(0)), \
380
+ patch("lib.plan_worktree.commit_shared_tier", return_value=None):
381
+ err = io.StringIO()
382
+ with redirect_stderr(err):
383
+ pre = work_plan._shared_precommit_state("slot")
384
+ work_plan._commit_shared_writes(pre, ["slot", "1", "x"])
385
+ self.assertIn("NOT", err.getvalue())
386
+
387
+
311
388
  if __name__ == "__main__":
312
389
  unittest.main()
@@ -0,0 +1,279 @@
1
+ """Tests for the plan-branch command (#260 Phase 3). All git + config writes
2
+ are mocked — offline, no real repo or yq touched.
3
+ """
4
+ import io
5
+ import json
6
+ import sys
7
+ import unittest
8
+ from contextlib import redirect_stdout
9
+ from pathlib import Path
10
+ from unittest.mock import patch, MagicMock
11
+
12
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
13
+ sys.path.insert(0, str(SKILL_ROOT))
14
+
15
+ from commands import plan_branch as pb
16
+
17
+
18
+ def _cfg(plan_branch=None, local="/tmp/repo", github="o/r", visibility=None):
19
+ entry = {"github": github}
20
+ if local is not None:
21
+ entry["local"] = local
22
+ if plan_branch is not None:
23
+ entry["plan_branch"] = plan_branch
24
+ return {"notes_root": "/tmp/notes", "repos": {"myrepo": entry}}
25
+
26
+
27
+ def _run(args, *, cfg, pw=None, needs=False, valid=True, repo_ok=True,
28
+ set_ok=True):
29
+ """Drive plan_branch.run with the lib + guards mocked. `pw` is a dict of
30
+ plan_worktree function-name → MagicMock/return overrides."""
31
+ pwmod = MagicMock()
32
+ # sensible defaults
33
+ pwmod._branch_exists.return_value = False
34
+ pwmod.local_branch_exists.return_value = True
35
+ pwmod.is_published.return_value = False
36
+ pwmod.unpushed_oneline.return_value = []
37
+ pwmod.fetch_branch.return_value = True
38
+ pwmod.ensure_worktree.return_value = Path("/wt")
39
+ pwmod.create_orphan_worktree.return_value = Path("/wt")
40
+ pwmod.dirty_work_plan_paths.return_value = [".work-plan/README.md"]
41
+ pwmod.commit_shared_tier.return_value = "abc1234"
42
+ pwmod.push_plan_branch.return_value = MagicMock(returncode=0, stderr="")
43
+ # Overrides set each function's return_value (the function stays a MagicMock
44
+ # so call-count assertions still work).
45
+ for k, v in (pw or {}).items():
46
+ getattr(pwmod, k).return_value = v
47
+ with patch("commands.plan_branch.load_config", return_value=cfg), \
48
+ patch("commands.plan_branch.is_valid_git_repo", return_value=repo_ok), \
49
+ patch("commands.plan_branch.seed_readme", return_value=True), \
50
+ patch("commands.plan_branch.needs_confirm", return_value=needs), \
51
+ patch("commands.plan_branch.valid_token", return_value=valid), \
52
+ patch("commands.plan_branch.make_token", return_value="tok123"), \
53
+ patch("commands.plan_branch._set_plan_branch", return_value=set_ok), \
54
+ patch("commands.plan_branch.pw", pwmod):
55
+ buf = io.StringIO()
56
+ with redirect_stdout(buf):
57
+ rc = pb.run(args)
58
+ return rc, buf.getvalue(), pwmod
59
+
60
+
61
+ class UsageTest(unittest.TestCase):
62
+ def test_bad_action_rc2(self):
63
+ rc, out, _ = _run(["frobnicate", "myrepo"], cfg=_cfg())
64
+ self.assertEqual(rc, 2)
65
+ self.assertIn("usage", out)
66
+
67
+ def test_no_action_rc2(self):
68
+ rc, out, _ = _run([], cfg=_cfg())
69
+ self.assertEqual(rc, 2)
70
+
71
+
72
+ class ResolveRepoTest(unittest.TestCase):
73
+ def test_unconfigured_repo_errs(self):
74
+ rc, out, _ = _run(["status", "nope"], cfg=_cfg())
75
+ self.assertEqual(rc, 1)
76
+ self.assertIn("not a configured repo", out)
77
+
78
+ def test_missing_local_path_errs(self):
79
+ rc, out, _ = _run(["status", "myrepo"], cfg=_cfg(local=None))
80
+ self.assertEqual(rc, 1)
81
+ self.assertIn("no local clone path", out)
82
+
83
+ def test_not_a_git_repo_errs(self):
84
+ rc, out, _ = _run(["status", "myrepo"], cfg=_cfg(), repo_ok=False)
85
+ self.assertEqual(rc, 1)
86
+ self.assertIn("not a git repository", out)
87
+
88
+ def test_single_repo_inferred_when_arg_omitted(self):
89
+ rc, out, _ = _run(["status"], cfg=_cfg(plan_branch="work-plan/plan"))
90
+ self.assertEqual(rc, 0)
91
+
92
+ def test_ambiguous_repo_requires_arg(self):
93
+ cfg = _cfg(plan_branch="x")
94
+ cfg["repos"]["other"] = {"github": "o/o", "local": "/tmp/o"}
95
+ rc, out, _ = _run(["status"], cfg=cfg)
96
+ self.assertEqual(rc, 1)
97
+ self.assertIn("specify which repo", out)
98
+
99
+
100
+ class InitTest(unittest.TestCase):
101
+ def test_create_orphan_when_branch_absent(self):
102
+ rc, out, pwmod = _run(["init", "myrepo"], cfg=_cfg(),
103
+ pw={"_branch_exists": False})
104
+ self.assertEqual(rc, 0)
105
+ pwmod.create_orphan_worktree.assert_called_once()
106
+ pwmod.commit_shared_tier.assert_called_once()
107
+ pwmod.ensure_worktree.assert_not_called()
108
+ self.assertIn("Created orphan branch 'work-plan/plan'", out)
109
+ self.assertIn("Recorded plan_branch", out)
110
+
111
+ def test_connect_when_branch_exists(self):
112
+ rc, out, pwmod = _run(["init", "myrepo"], cfg=_cfg(),
113
+ pw={"_branch_exists": True})
114
+ self.assertEqual(rc, 0)
115
+ pwmod.ensure_worktree.assert_called_once()
116
+ pwmod.create_orphan_worktree.assert_not_called()
117
+ self.assertIn("Connected", out)
118
+
119
+ def test_custom_branch_name(self):
120
+ rc, out, pwmod = _run(["init", "myrepo", "--branch=wp/custom"],
121
+ cfg=_cfg(), pw={"_branch_exists": False})
122
+ self.assertEqual(rc, 0)
123
+ self.assertIn("wp/custom", out)
124
+
125
+ def test_invalid_branch_rc2(self):
126
+ for bad in ["--branch=-evil", "--branch=a..b", "--branch=/lead",
127
+ "--branch=trail/", "--branch=a//b"]:
128
+ rc, out, _ = _run(["init", "myrepo", bad], cfg=_cfg())
129
+ self.assertEqual(rc, 2, bad)
130
+ self.assertIn("not a valid branch", out)
131
+
132
+ def test_refuses_switching_existing_plan_branch(self):
133
+ rc, out, _ = _run(["init", "myrepo", "--branch=work-plan/other"],
134
+ cfg=_cfg(plan_branch="work-plan/plan"))
135
+ self.assertEqual(rc, 1)
136
+ self.assertIn("already has plan_branch", out)
137
+
138
+ def test_commit_failure_errs(self):
139
+ rc, out, _ = _run(["init", "myrepo"], cfg=_cfg(),
140
+ pw={"_branch_exists": False, "commit_shared_tier": None})
141
+ self.assertEqual(rc, 1)
142
+ self.assertIn("initial commit failed", out)
143
+
144
+ def test_orphan_creation_failure_errs(self):
145
+ rc, out, _ = _run(["init", "myrepo"], cfg=_cfg(),
146
+ pw={"_branch_exists": False,
147
+ "create_orphan_worktree": None})
148
+ self.assertEqual(rc, 1)
149
+ self.assertIn("could not create the plan worktree", out)
150
+
151
+ def test_public_repo_push_warning_shown(self):
152
+ rc, out, _ = _run(["init", "myrepo"], cfg=_cfg(),
153
+ pw={"_branch_exists": False}, needs=True)
154
+ self.assertEqual(rc, 0)
155
+ self.assertIn("public", out.lower())
156
+
157
+
158
+ class StatusTest(unittest.TestCase):
159
+ def test_no_plan_branch(self):
160
+ rc, out, _ = _run(["status", "myrepo"], cfg=_cfg())
161
+ self.assertEqual(rc, 0)
162
+ self.assertIn("no plan_branch configured", out)
163
+
164
+ def test_no_plan_branch_json(self):
165
+ rc, out, _ = _run(["status", "myrepo", "--json"], cfg=_cfg())
166
+ self.assertEqual(rc, 0)
167
+ self.assertEqual(json.loads(out)["configured"], False)
168
+
169
+ def test_published_with_unpushed(self):
170
+ rc, out, _ = _run(["status", "myrepo"], cfg=_cfg(plan_branch="work-plan/plan"),
171
+ pw={"is_published": True,
172
+ "unpushed_oneline": ["a1 one", "b2 two"]})
173
+ self.assertEqual(rc, 0)
174
+ self.assertIn("on origin", out)
175
+ self.assertIn("2 commit(s)", out)
176
+
177
+ def test_local_only_json_shape(self):
178
+ rc, out, _ = _run(["status", "myrepo", "--json"],
179
+ cfg=_cfg(plan_branch="work-plan/plan"),
180
+ pw={"is_published": False, "local_branch_exists": True,
181
+ "unpushed_oneline": ["a1 x"]})
182
+ d = json.loads(out)
183
+ self.assertEqual(d["plan_branch"], "work-plan/plan")
184
+ self.assertTrue(d["configured"])
185
+ self.assertFalse(d["published"])
186
+ self.assertEqual(d["unpushed_count"], 1)
187
+
188
+
189
+ class PushTest(unittest.TestCase):
190
+ def test_no_plan_branch_errs(self):
191
+ rc, out, _ = _run(["push", "myrepo"], cfg=_cfg())
192
+ self.assertEqual(rc, 1)
193
+ self.assertIn("no plan_branch", out)
194
+
195
+ def test_local_branch_missing_errs(self):
196
+ rc, out, _ = _run(["push", "myrepo"], cfg=_cfg(plan_branch="work-plan/plan"),
197
+ pw={"local_branch_exists": False})
198
+ self.assertEqual(rc, 1)
199
+ self.assertIn("doesn't exist locally", out)
200
+
201
+ def test_nothing_to_push(self):
202
+ rc, out, pwmod = _run(["push", "myrepo"],
203
+ cfg=_cfg(plan_branch="work-plan/plan"),
204
+ pw={"unpushed_oneline": []})
205
+ self.assertEqual(rc, 0)
206
+ self.assertIn("Nothing to push", out)
207
+ pwmod.push_plan_branch.assert_not_called()
208
+
209
+ def test_dry_run_lists_without_pushing(self):
210
+ rc, out, pwmod = _run(["push", "myrepo", "--dry-run"],
211
+ cfg=_cfg(plan_branch="work-plan/plan"),
212
+ pw={"unpushed_oneline": ["a1 one", "b2 two"]})
213
+ self.assertEqual(rc, 0)
214
+ self.assertIn("Would push 2 commit(s)", out)
215
+ pwmod.push_plan_branch.assert_not_called()
216
+
217
+ def test_public_repo_blocks_without_confirm(self):
218
+ rc, out, pwmod = _run(["push", "myrepo"],
219
+ cfg=_cfg(plan_branch="work-plan/plan"),
220
+ pw={"unpushed_oneline": ["a1 x"]},
221
+ needs=True, valid=False)
222
+ self.assertEqual(rc, 0)
223
+ d = json.loads(out)
224
+ self.assertTrue(d["needs_confirm"])
225
+ self.assertEqual(d["token"], "tok123")
226
+ self.assertIn("visible to anyone on the internet", d["reason"])
227
+ pwmod.push_plan_branch.assert_not_called()
228
+
229
+ def test_public_repo_pushes_with_valid_confirm(self):
230
+ rc, out, pwmod = _run(["push", "myrepo", "--confirm=tok123"],
231
+ cfg=_cfg(plan_branch="work-plan/plan"),
232
+ pw={"unpushed_oneline": ["a1 x"]},
233
+ needs=True, valid=True)
234
+ self.assertEqual(rc, 0)
235
+ pwmod.push_plan_branch.assert_called_once()
236
+ self.assertIn("Pushed", out)
237
+
238
+ def test_empty_github_still_gated(self):
239
+ # Regression: a config entry with an empty/unknown github must NOT skip
240
+ # the gate. needs_confirm fails CLOSED on unknown visibility; the old
241
+ # `github and needs_confirm(...)` short-circuit defeated that and pushed
242
+ # unguarded. With the guard removed the gate fires.
243
+ rc, out, pwmod = _run(["push", "myrepo"],
244
+ cfg=_cfg(plan_branch="work-plan/plan", github=""),
245
+ pw={"unpushed_oneline": ["a1 x"]},
246
+ needs=True, valid=False)
247
+ self.assertEqual(rc, 0)
248
+ self.assertIn("needs_confirm", out)
249
+ pwmod.push_plan_branch.assert_not_called()
250
+
251
+ def test_private_repo_pushes_without_gate(self):
252
+ rc, out, pwmod = _run(["push", "myrepo"],
253
+ cfg=_cfg(plan_branch="work-plan/plan"),
254
+ pw={"unpushed_oneline": ["a1 x"]}, needs=False)
255
+ self.assertEqual(rc, 0)
256
+ pwmod.push_plan_branch.assert_called_once()
257
+
258
+ def test_protected_branch_failure_actionable(self):
259
+ proc = MagicMock(returncode=1, stderr="remote: protected branch hook declined")
260
+ rc, out, _ = _run(["push", "myrepo"],
261
+ cfg=_cfg(plan_branch="work-plan/plan"),
262
+ pw={"unpushed_oneline": ["a1 x"],
263
+ "push_plan_branch": proc}, needs=False)
264
+ self.assertEqual(rc, 1)
265
+ self.assertIn("protected", out.lower())
266
+ self.assertIn("work-plan/**", out)
267
+
268
+ def test_generic_push_failure(self):
269
+ proc = MagicMock(returncode=1, stderr="fatal: unable to access")
270
+ rc, out, _ = _run(["push", "myrepo"],
271
+ cfg=_cfg(plan_branch="work-plan/plan"),
272
+ pw={"unpushed_oneline": ["a1 x"],
273
+ "push_plan_branch": proc}, needs=False)
274
+ self.assertEqual(rc, 1)
275
+ self.assertIn("push failed", out)
276
+
277
+
278
+ if __name__ == "__main__":
279
+ unittest.main()