@stylusnexus/work-plan 2026.6.9-1

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +554 -0
  3. package/VERSION +1 -0
  4. package/bin/work-plan +59 -0
  5. package/bin/work-plan.cmd +9 -0
  6. package/package.json +43 -0
  7. package/scripts/npm-check-deps.js +44 -0
  8. package/skills/work-plan/SKILL.md +152 -0
  9. package/skills/work-plan/commands/__init__.py +0 -0
  10. package/skills/work-plan/commands/auto_triage.py +230 -0
  11. package/skills/work-plan/commands/brief.py +247 -0
  12. package/skills/work-plan/commands/canonicalize.py +139 -0
  13. package/skills/work-plan/commands/close.py +98 -0
  14. package/skills/work-plan/commands/coverage.py +100 -0
  15. package/skills/work-plan/commands/duplicates.py +124 -0
  16. package/skills/work-plan/commands/export.py +69 -0
  17. package/skills/work-plan/commands/group.py +272 -0
  18. package/skills/work-plan/commands/handoff.py +867 -0
  19. package/skills/work-plan/commands/hygiene.py +128 -0
  20. package/skills/work-plan/commands/init.py +128 -0
  21. package/skills/work-plan/commands/init_repo.py +132 -0
  22. package/skills/work-plan/commands/list_cmd.py +39 -0
  23. package/skills/work-plan/commands/new_track.py +225 -0
  24. package/skills/work-plan/commands/plan_status.py +296 -0
  25. package/skills/work-plan/commands/reconcile.py +225 -0
  26. package/skills/work-plan/commands/refresh_md.py +145 -0
  27. package/skills/work-plan/commands/set_field.py +61 -0
  28. package/skills/work-plan/commands/set_notes_root.py +53 -0
  29. package/skills/work-plan/commands/slot.py +154 -0
  30. package/skills/work-plan/commands/suggest_priorities.py +132 -0
  31. package/skills/work-plan/commands/where_was_i.py +325 -0
  32. package/skills/work-plan/lib/__init__.py +0 -0
  33. package/skills/work-plan/lib/closure.py +72 -0
  34. package/skills/work-plan/lib/config.py +88 -0
  35. package/skills/work-plan/lib/doc_discovery.py +41 -0
  36. package/skills/work-plan/lib/drift.py +32 -0
  37. package/skills/work-plan/lib/export_model.py +42 -0
  38. package/skills/work-plan/lib/frontmatter.py +48 -0
  39. package/skills/work-plan/lib/git_state.py +180 -0
  40. package/skills/work-plan/lib/github_state.py +296 -0
  41. package/skills/work-plan/lib/llm_evidence.py +45 -0
  42. package/skills/work-plan/lib/manifest.py +164 -0
  43. package/skills/work-plan/lib/new_issues.py +69 -0
  44. package/skills/work-plan/lib/next_up.py +98 -0
  45. package/skills/work-plan/lib/notes_readme.py +38 -0
  46. package/skills/work-plan/lib/prompts.py +68 -0
  47. package/skills/work-plan/lib/reconcile_actions.py +34 -0
  48. package/skills/work-plan/lib/render.py +83 -0
  49. package/skills/work-plan/lib/scratch.py +14 -0
  50. package/skills/work-plan/lib/session_log.py +39 -0
  51. package/skills/work-plan/lib/status_header.py +60 -0
  52. package/skills/work-plan/lib/status_table.py +227 -0
  53. package/skills/work-plan/lib/tracks.py +248 -0
  54. package/skills/work-plan/lib/verdict.py +51 -0
  55. package/skills/work-plan/lib/write_guard.py +39 -0
  56. package/skills/work-plan/tests/__init__.py +0 -0
  57. package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
  58. package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
  59. package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
  60. package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
  61. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
  62. package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
  63. package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
  64. package/skills/work-plan/tests/test_auto_triage.py +324 -0
  65. package/skills/work-plan/tests/test_close.py +273 -0
  66. package/skills/work-plan/tests/test_close_tier.py +166 -0
  67. package/skills/work-plan/tests/test_closure.py +51 -0
  68. package/skills/work-plan/tests/test_config.py +85 -0
  69. package/skills/work-plan/tests/test_config_seed.py +41 -0
  70. package/skills/work-plan/tests/test_config_shared.py +57 -0
  71. package/skills/work-plan/tests/test_coverage.py +192 -0
  72. package/skills/work-plan/tests/test_doc_discovery.py +51 -0
  73. package/skills/work-plan/tests/test_drift.py +38 -0
  74. package/skills/work-plan/tests/test_export.py +169 -0
  75. package/skills/work-plan/tests/test_export_command.py +295 -0
  76. package/skills/work-plan/tests/test_frontmatter.py +52 -0
  77. package/skills/work-plan/tests/test_git_state.py +51 -0
  78. package/skills/work-plan/tests/test_git_state_paths.py +51 -0
  79. package/skills/work-plan/tests/test_github_state.py +508 -0
  80. package/skills/work-plan/tests/test_group_apply.py +348 -0
  81. package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
  82. package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
  83. package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
  84. package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
  85. package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
  86. package/skills/work-plan/tests/test_init.py +289 -0
  87. package/skills/work-plan/tests/test_init_repo.py +379 -0
  88. package/skills/work-plan/tests/test_init_shared.py +185 -0
  89. package/skills/work-plan/tests/test_llm_evidence.py +77 -0
  90. package/skills/work-plan/tests/test_manifest.py +162 -0
  91. package/skills/work-plan/tests/test_new_issues.py +130 -0
  92. package/skills/work-plan/tests/test_new_track.py +610 -0
  93. package/skills/work-plan/tests/test_next_up.py +149 -0
  94. package/skills/work-plan/tests/test_notes_readme.py +78 -0
  95. package/skills/work-plan/tests/test_plan_status.py +68 -0
  96. package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
  97. package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
  98. package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
  99. package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
  100. package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
  101. package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
  102. package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
  103. package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
  104. package/skills/work-plan/tests/test_reconcile_readonly.py +239 -0
  105. package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
  106. package/skills/work-plan/tests/test_refresh_md.py +98 -0
  107. package/skills/work-plan/tests/test_render.py +110 -0
  108. package/skills/work-plan/tests/test_repo_filter.py +52 -0
  109. package/skills/work-plan/tests/test_security_hardening.py +117 -0
  110. package/skills/work-plan/tests/test_session_log.py +39 -0
  111. package/skills/work-plan/tests/test_set_field.py +77 -0
  112. package/skills/work-plan/tests/test_set_notes_root.py +292 -0
  113. package/skills/work-plan/tests/test_slot.py +243 -0
  114. package/skills/work-plan/tests/test_slot_move.py +128 -0
  115. package/skills/work-plan/tests/test_smoke.py +46 -0
  116. package/skills/work-plan/tests/test_status_header.py +79 -0
  117. package/skills/work-plan/tests/test_status_table.py +162 -0
  118. package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
  119. package/skills/work-plan/tests/test_track_resolution.py +295 -0
  120. package/skills/work-plan/tests/test_tracks.py +385 -0
  121. package/skills/work-plan/tests/test_verdict.py +60 -0
  122. package/skills/work-plan/tests/test_where_was_i.py +382 -0
  123. package/skills/work-plan/tests/test_write_guard.py +53 -0
  124. package/skills/work-plan/work_plan.py +220 -0
@@ -0,0 +1,610 @@
1
+ """Tests for the non-interactive new-track command (issue #87, Phase 3a).
2
+
3
+ Covers:
4
+ - Creates a track for a config-key repo on a PRIVATE repo → write_file called
5
+ with the right meta (github.repo = key's github value; status active;
6
+ priority/milestone defaults), rc 0.
7
+ - Creates a track for a bare org/repo slug → folder = name part after '/',
8
+ github = full slug, rc 0.
9
+ - Public repo, no token → needs_confirm JSON, write_file NOT called, rc 0;
10
+ token == make_token(github, slug).
11
+ - Public repo, valid --confirm=<token> → creates (write_file called), rc 0.
12
+ - Existing path → rc 2, no write.
13
+ - Unknown repo (not a config key, no slash) → rc 1, no write.
14
+ - Invalid slug → rc 2.
15
+ - --priority=P1 --milestone=v2 reflected in meta; defaults applied when absent.
16
+ - --private accepted without error, creates normally, rc 0.
17
+ - "new-track" in SUBCOMMANDS and appears in DESCRIPTIONS.
18
+ """
19
+ import io
20
+ import json
21
+ import subprocess
22
+ import sys
23
+ import unittest
24
+ from contextlib import redirect_stdout
25
+ from pathlib import Path
26
+ from unittest.mock import patch, MagicMock, call
27
+
28
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
29
+ sys.path.insert(0, str(SKILL_ROOT))
30
+
31
+ from commands import new_track
32
+ from lib.write_guard import make_token
33
+ import work_plan
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Helpers
38
+ # ---------------------------------------------------------------------------
39
+
40
+ NOTES_ROOT = "/tmp/fake-notes"
41
+
42
+ def _make_cfg(*, repos=None):
43
+ if repos is None:
44
+ repos = {
45
+ "myrepo": {"github": "org/myrepo", "local": None},
46
+ "critforge": {"github": "stylusnexus/critforge", "local": None},
47
+ }
48
+ return {"notes_root": NOTES_ROOT, "repos": repos}
49
+
50
+
51
+ def _drive(args, *, vis="PRIVATE", notes_root_exists=True, target_path_exists=False):
52
+ """Run new_track.run(args) with all external I/O mocked.
53
+
54
+ vis: what repo_visibility returns for needs_confirm.
55
+ notes_root_exists: whether notes_root directory exists.
56
+ target_path_exists: whether the target .md path already exists.
57
+ """
58
+ cfg = _make_cfg()
59
+
60
+ def _path_exists(self):
61
+ # notes_root itself → notes_root_exists; target path → target_path_exists.
62
+ # Compare with Path equality (not str ==): on Windows str(Path("/tmp/x"))
63
+ # uses backslashes, so an exact "/tmp/fake-notes" string match never fires
64
+ # and the notes_root-missing case can't be simulated.
65
+ if self == Path(NOTES_ROOT):
66
+ return notes_root_exists
67
+ if self.suffix == ".md":
68
+ return target_path_exists
69
+ # archive dirs and parent dirs default to True
70
+ return True
71
+
72
+ with patch("commands.new_track.load_config", return_value=cfg), \
73
+ patch("commands.new_track.write_file") as mw, \
74
+ patch("lib.write_guard.repo_visibility", return_value=vis), \
75
+ patch("pathlib.Path.exists", _path_exists), \
76
+ patch("pathlib.Path.mkdir"):
77
+ buf = io.StringIO()
78
+ with redirect_stdout(buf):
79
+ rc = new_track.run(args)
80
+ return rc, mw, buf.getvalue()
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Test cases
85
+ # ---------------------------------------------------------------------------
86
+
87
+ class NewTrackCommandTest(unittest.TestCase):
88
+
89
+ # ------------------------------------------------------------------
90
+ # Registry checks
91
+ # ------------------------------------------------------------------
92
+
93
+ def test_subcommand_registered_in_subcommands(self):
94
+ """'new-track' must be in work_plan.SUBCOMMANDS."""
95
+ self.assertIn("new-track", work_plan.SUBCOMMANDS)
96
+
97
+ def test_subcommand_appears_in_descriptions(self):
98
+ """'new-track' must appear in work_plan.DESCRIPTIONS."""
99
+ names = [entry[0] for entry in work_plan.DESCRIPTIONS]
100
+ self.assertIn("new-track", names)
101
+
102
+ # ------------------------------------------------------------------
103
+ # Config-key repo (PRIVATE)
104
+ # ------------------------------------------------------------------
105
+
106
+ def test_config_key_private_creates_track(self):
107
+ """Config-key repo 'myrepo' (PRIVATE) → write_file called with
108
+ github.repo = 'org/myrepo', status = 'active', rc 0."""
109
+ rc, mw, out = _drive(["myrepo", "my-feature"], vis="PRIVATE")
110
+ self.assertEqual(rc, 0)
111
+ mw.assert_called_once()
112
+ meta = mw.call_args[0][1]
113
+ self.assertEqual(meta["github"]["repo"], "org/myrepo")
114
+ self.assertEqual(meta["status"], "active")
115
+
116
+ def test_config_key_folder_resolves_correctly(self):
117
+ """Config-key 'critforge' → folder = 'critforge',
118
+ github.repo = 'stylusnexus/critforge'."""
119
+ rc, mw, out = _drive(["critforge", "encounter-builder"], vis="PRIVATE")
120
+ self.assertEqual(rc, 0)
121
+ mw.assert_called_once()
122
+ meta = mw.call_args[0][1]
123
+ self.assertEqual(meta["github"]["repo"], "stylusnexus/critforge")
124
+ # Track name from slug
125
+ self.assertEqual(meta["track"], "encounter-builder")
126
+ # Path passed to write_file should be under critforge folder
127
+ path_arg = mw.call_args[0][0]
128
+ self.assertIn("critforge", str(path_arg))
129
+
130
+ # ------------------------------------------------------------------
131
+ # Bare org/repo slug
132
+ # ------------------------------------------------------------------
133
+
134
+ def test_bare_org_repo_slug_uses_name_as_folder(self):
135
+ """'stylusnexus/work-plan-toolkit' → folder = 'work-plan-toolkit',
136
+ github.repo = 'stylusnexus/work-plan-toolkit', rc 0."""
137
+ rc, mw, out = _drive(
138
+ ["stylusnexus/work-plan-toolkit", "my-feature"], vis="PRIVATE"
139
+ )
140
+ self.assertEqual(rc, 0)
141
+ mw.assert_called_once()
142
+ meta = mw.call_args[0][1]
143
+ self.assertEqual(meta["github"]["repo"], "stylusnexus/work-plan-toolkit")
144
+ path_arg = mw.call_args[0][0]
145
+ self.assertIn("work-plan-toolkit", str(path_arg))
146
+
147
+ # ------------------------------------------------------------------
148
+ # Public repo — confirm gate
149
+ # ------------------------------------------------------------------
150
+
151
+ def test_public_repo_no_token_returns_needs_confirm_json(self):
152
+ """Public repo, no token → prints needs_confirm JSON, write_file
153
+ NOT called, rc 0; token == make_token(github, slug)."""
154
+ rc, mw, out = _drive(["myrepo", "my-feature"], vis="PUBLIC")
155
+ self.assertEqual(rc, 0)
156
+ mw.assert_not_called()
157
+ data = json.loads(out.strip())
158
+ self.assertTrue(data["needs_confirm"])
159
+ self.assertEqual(data["token"], make_token("org/myrepo", "my-feature"))
160
+ self.assertIn("PUBLIC", data["reason"])
161
+
162
+ def test_public_repo_unknown_vis_returns_needs_confirm_json(self):
163
+ """Unknown visibility (None) also requires confirm."""
164
+ rc, mw, out = _drive(["myrepo", "my-feature"], vis=None)
165
+ self.assertEqual(rc, 0)
166
+ mw.assert_not_called()
167
+ data = json.loads(out.strip())
168
+ self.assertTrue(data["needs_confirm"])
169
+
170
+ def test_public_repo_wrong_token_blocked(self):
171
+ """Wrong --confirm token → blocked, no write, rc 0."""
172
+ rc, mw, out = _drive(
173
+ ["myrepo", "my-feature", "--confirm=wrongtoken"], vis="PUBLIC"
174
+ )
175
+ self.assertEqual(rc, 0)
176
+ mw.assert_not_called()
177
+ data = json.loads(out.strip())
178
+ self.assertTrue(data["needs_confirm"])
179
+
180
+ def test_public_repo_valid_token_writes(self):
181
+ """Valid --confirm=<token> on a public repo → creates track, rc 0."""
182
+ tok = make_token("org/myrepo", "my-feature")
183
+ rc, mw, out = _drive(
184
+ ["myrepo", "my-feature", f"--confirm={tok}"], vis="PUBLIC"
185
+ )
186
+ self.assertEqual(rc, 0)
187
+ mw.assert_called_once()
188
+
189
+ def test_bare_slug_public_token_uses_full_github(self):
190
+ """Public bare slug: token is make_token(full-github, slug)."""
191
+ tok = make_token("org/other-repo", "new-slug")
192
+ rc, mw, out = _drive(
193
+ ["org/other-repo", "new-slug", f"--confirm={tok}"], vis="PUBLIC"
194
+ )
195
+ self.assertEqual(rc, 0)
196
+ mw.assert_called_once()
197
+
198
+ # ------------------------------------------------------------------
199
+ # Existing path → rc 2
200
+ # ------------------------------------------------------------------
201
+
202
+ def test_existing_path_returns_rc2_no_write(self):
203
+ """Target .md already exists → rc 2, no write."""
204
+ rc, mw, out = _drive(
205
+ ["myrepo", "existing-track"], vis="PRIVATE", target_path_exists=True
206
+ )
207
+ self.assertEqual(rc, 2)
208
+ mw.assert_not_called()
209
+
210
+ # ------------------------------------------------------------------
211
+ # Unknown repo → rc 1
212
+ # ------------------------------------------------------------------
213
+
214
+ def test_unknown_repo_not_key_no_slash_returns_rc1(self):
215
+ """Repo not a config key and no slash → rc 1, no write."""
216
+ rc, mw, out = _drive(["unknown-repo", "my-feature"])
217
+ self.assertEqual(rc, 1)
218
+ mw.assert_not_called()
219
+ self.assertIn("unknown repo", out.lower())
220
+
221
+ # ------------------------------------------------------------------
222
+ # Invalid slug → rc 2
223
+ # ------------------------------------------------------------------
224
+
225
+ def test_invalid_slug_uppercase_returns_rc2(self):
226
+ """Slug with uppercase letters → rc 2."""
227
+ rc, mw, out = _drive(["myrepo", "MyFeature"])
228
+ self.assertEqual(rc, 2)
229
+ mw.assert_not_called()
230
+
231
+ def test_invalid_slug_spaces_returns_rc2(self):
232
+ """Slug with spaces → rc 2."""
233
+ rc, mw, out = _drive(["myrepo", "my feature"])
234
+ self.assertEqual(rc, 2)
235
+ mw.assert_not_called()
236
+
237
+ def test_invalid_slug_special_chars_returns_rc2(self):
238
+ """Slug with special chars (underscore) → rc 2."""
239
+ rc, mw, out = _drive(["myrepo", "my_feature"])
240
+ self.assertEqual(rc, 2)
241
+ mw.assert_not_called()
242
+
243
+ # ------------------------------------------------------------------
244
+ # Missing positionals → rc 2
245
+ # ------------------------------------------------------------------
246
+
247
+ def test_no_args_returns_rc2(self):
248
+ """No positional args at all → rc 2 (usage error)."""
249
+ rc, mw, out = _drive([])
250
+ self.assertEqual(rc, 2)
251
+ mw.assert_not_called()
252
+
253
+ def test_only_one_positional_returns_rc2(self):
254
+ """Only repo, no slug → rc 2 (usage error)."""
255
+ rc, mw, out = _drive(["myrepo"])
256
+ self.assertEqual(rc, 2)
257
+ mw.assert_not_called()
258
+
259
+ # ------------------------------------------------------------------
260
+ # Priority and milestone flags
261
+ # ------------------------------------------------------------------
262
+
263
+ def test_explicit_priority_and_milestone_in_meta(self):
264
+ """--priority=P1 --milestone=v2 → reflected in meta."""
265
+ rc, mw, out = _drive(
266
+ ["myrepo", "my-feature", "--priority=P1", "--milestone=v2"],
267
+ vis="PRIVATE"
268
+ )
269
+ self.assertEqual(rc, 0)
270
+ mw.assert_called_once()
271
+ meta = mw.call_args[0][1]
272
+ self.assertEqual(meta["launch_priority"], "P1")
273
+ self.assertEqual(meta["milestone_alignment"], "v2")
274
+
275
+ def test_defaults_p2_and_v100_when_absent(self):
276
+ """No flags → launch_priority=P2, milestone_alignment=v1.0.0."""
277
+ rc, mw, out = _drive(["myrepo", "my-feature"], vis="PRIVATE")
278
+ self.assertEqual(rc, 0)
279
+ meta = mw.call_args[0][1]
280
+ self.assertEqual(meta["launch_priority"], "P2")
281
+ self.assertEqual(meta["milestone_alignment"], "v1.0.0")
282
+
283
+ def test_invalid_priority_falls_back_to_p2(self):
284
+ """Invalid --priority=P9 → silently falls back to P2."""
285
+ rc, mw, out = _drive(
286
+ ["myrepo", "my-feature", "--priority=P9"], vis="PRIVATE"
287
+ )
288
+ self.assertEqual(rc, 0)
289
+ meta = mw.call_args[0][1]
290
+ self.assertEqual(meta["launch_priority"], "P2")
291
+
292
+ def test_priority_uppercased(self):
293
+ """--priority=p1 (lowercase) → P1 after uppercasing."""
294
+ rc, mw, out = _drive(
295
+ ["myrepo", "my-feature", "--priority=p1"], vis="PRIVATE"
296
+ )
297
+ self.assertEqual(rc, 0)
298
+ meta = mw.call_args[0][1]
299
+ self.assertEqual(meta["launch_priority"], "P1")
300
+
301
+ # ------------------------------------------------------------------
302
+ # Frontmatter structure
303
+ # ------------------------------------------------------------------
304
+
305
+ def test_meta_has_expected_keys(self):
306
+ """Written meta contains all required frontmatter keys."""
307
+ rc, mw, out = _drive(["myrepo", "my-feature"], vis="PRIVATE")
308
+ self.assertEqual(rc, 0)
309
+ meta = mw.call_args[0][1]
310
+ for key in ("track", "status", "launch_priority", "milestone_alignment",
311
+ "github", "related_tracks", "last_touched", "last_handoff",
312
+ "next_up", "blockers"):
313
+ self.assertIn(key, meta, f"meta missing key: {key}")
314
+
315
+ def test_meta_github_issues_and_branches_empty(self):
316
+ """New track starts with github.issues=[] and github.branches=[]."""
317
+ rc, mw, out = _drive(["myrepo", "my-feature"], vis="PRIVATE")
318
+ self.assertEqual(rc, 0)
319
+ meta = mw.call_args[0][1]
320
+ self.assertEqual(meta["github"]["issues"], [])
321
+ self.assertEqual(meta["github"]["branches"], [])
322
+
323
+ def test_meta_related_tracks_next_up_blockers_empty(self):
324
+ """New track starts with empty related_tracks, next_up, blockers."""
325
+ rc, mw, out = _drive(["myrepo", "my-feature"], vis="PRIVATE")
326
+ self.assertEqual(rc, 0)
327
+ meta = mw.call_args[0][1]
328
+ self.assertEqual(meta["related_tracks"], [])
329
+ self.assertEqual(meta["next_up"], [])
330
+ self.assertEqual(meta["blockers"], [])
331
+
332
+ def test_body_contains_slug_heading(self):
333
+ """Body passed to write_file contains a heading with the slug."""
334
+ rc, mw, out = _drive(["myrepo", "my-feature"], vis="PRIVATE")
335
+ self.assertEqual(rc, 0)
336
+ body = mw.call_args[0][2]
337
+ self.assertIn("my-feature", body)
338
+
339
+ # ------------------------------------------------------------------
340
+ # --private flag (no-op, accepted without error)
341
+ # ------------------------------------------------------------------
342
+
343
+ def test_private_flag_accepted_no_error(self):
344
+ """--private accepted without error, creates normally, rc 0."""
345
+ rc, mw, out = _drive(
346
+ ["myrepo", "my-feature", "--private"], vis="PRIVATE"
347
+ )
348
+ self.assertEqual(rc, 0)
349
+ mw.assert_called_once()
350
+
351
+ def test_private_flag_on_public_repo_still_gated(self):
352
+ """--private on a public repo: confirm gate still fires (gate is by
353
+ visibility, not --private flag)."""
354
+ rc, mw, out = _drive(
355
+ ["myrepo", "my-feature", "--private"], vis="PUBLIC"
356
+ )
357
+ self.assertEqual(rc, 0)
358
+ mw.assert_not_called()
359
+ data = json.loads(out.strip())
360
+ self.assertTrue(data["needs_confirm"])
361
+
362
+ # ------------------------------------------------------------------
363
+ # notes_root missing → rc 1
364
+ # ------------------------------------------------------------------
365
+
366
+ def test_missing_notes_root_returns_rc1(self):
367
+ """notes_root directory does not exist → rc 1, no write."""
368
+ rc, mw, out = _drive(
369
+ ["myrepo", "my-feature"], vis="PRIVATE", notes_root_exists=False
370
+ )
371
+ self.assertEqual(rc, 1)
372
+ mw.assert_not_called()
373
+
374
+ # ------------------------------------------------------------------
375
+ # Success output
376
+ # ------------------------------------------------------------------
377
+
378
+ def test_success_prints_created_line(self):
379
+ """On success, output contains 'Created track' and the slug."""
380
+ rc, mw, out = _drive(["myrepo", "my-feature"], vis="PRIVATE")
381
+ self.assertEqual(rc, 0)
382
+ self.assertIn("my-feature", out)
383
+
384
+ # ------------------------------------------------------------------
385
+ # Gate fires BEFORE any FS write
386
+ # ------------------------------------------------------------------
387
+
388
+ def test_gate_before_mkdir_on_public(self):
389
+ """On a public repo without a token, mkdir is NOT called
390
+ (gate fires before any filesystem operation)."""
391
+ cfg = _make_cfg()
392
+
393
+ def _path_exists(self):
394
+ if self == Path(NOTES_ROOT):
395
+ return True
396
+ if self.suffix == ".md":
397
+ return False
398
+ return True
399
+
400
+ with patch("commands.new_track.load_config", return_value=cfg), \
401
+ patch("commands.new_track.write_file") as mw, \
402
+ patch("lib.write_guard.repo_visibility", return_value="PUBLIC"), \
403
+ patch("pathlib.Path.exists", _path_exists), \
404
+ patch("pathlib.Path.mkdir") as mmkdir:
405
+ buf = io.StringIO()
406
+ with redirect_stdout(buf):
407
+ rc = new_track.run(["myrepo", "my-feature"])
408
+ self.assertEqual(rc, 0)
409
+ mw.assert_not_called()
410
+ mmkdir.assert_not_called()
411
+
412
+ # ------------------------------------------------------------------
413
+ # No input() on non-interactive paths
414
+ # ------------------------------------------------------------------
415
+
416
+ def test_no_input_called_on_private_repo(self):
417
+ """Private repo with valid flags never calls input() or prompt_input —
418
+ proving no prompt is hit on the non-interactive code path."""
419
+ cfg = _make_cfg()
420
+
421
+ def _raise(*a, **kw):
422
+ raise AssertionError("input() must not be called — command must be non-interactive")
423
+
424
+ def _path_exists(self):
425
+ if self == Path(NOTES_ROOT):
426
+ return True
427
+ if self.suffix == ".md":
428
+ return False
429
+ return True
430
+
431
+ with patch("builtins.input", side_effect=_raise), \
432
+ patch("lib.prompts.prompt_input", side_effect=_raise):
433
+ with patch("commands.new_track.load_config", return_value=cfg), \
434
+ patch("commands.new_track.write_file") as mw, \
435
+ patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
436
+ patch("pathlib.Path.exists", _path_exists), \
437
+ patch("pathlib.Path.mkdir"):
438
+ buf = io.StringIO()
439
+ with redirect_stdout(buf):
440
+ rc = new_track.run(["myrepo", "my-feature"])
441
+ self.assertEqual(rc, 0)
442
+ mw.assert_called_once()
443
+
444
+
445
+ # ---------------------------------------------------------------------------
446
+ # Phase D: --commit flag tests
447
+ # ---------------------------------------------------------------------------
448
+
449
+ CLONE_ROOT = "/tmp/fake-clone"
450
+
451
+
452
+ def _make_cfg_with_local(*, local=CLONE_ROOT):
453
+ """Config with a repo entry that has a local clone path."""
454
+ return {
455
+ "notes_root": NOTES_ROOT,
456
+ "repos": {
457
+ "myrepo": {"github": "org/myrepo", "local": local},
458
+ },
459
+ }
460
+
461
+
462
+ class NewTrackCommitFlagTest(unittest.TestCase):
463
+ """Tests for --commit flag on new-track (Phase D)."""
464
+
465
+ def _drive_shared(self, args, *, git_returncode=0, path_exists=False):
466
+ """Drive new-track with a shared-tier setup (local clone is a valid git repo)."""
467
+ cfg = _make_cfg_with_local()
468
+
469
+ def _path_exists(self):
470
+ # NOTES_ROOT itself exists
471
+ if self == Path(NOTES_ROOT):
472
+ return True
473
+ # .git dir inside the clone root exists (valid git repo)
474
+ if str(self) == f"{CLONE_ROOT}/.git":
475
+ return True
476
+ # The clone root itself exists
477
+ if str(self) == CLONE_ROOT:
478
+ return True
479
+ # The target .md path: controlled by path_exists
480
+ if self.suffix == ".md":
481
+ return path_exists
482
+ return True
483
+
484
+ def _is_dir(self):
485
+ s = str(self)
486
+ if s.endswith(".md"):
487
+ return False
488
+ return True
489
+
490
+ # git subprocess: first call (rev-parse), then add, then commit
491
+ git_results = [
492
+ MagicMock(returncode=0, stdout="main\n", stderr=""), # rev-parse
493
+ MagicMock(returncode=git_returncode, stdout="", stderr="error msg"), # add
494
+ MagicMock(returncode=git_returncode, stdout="", stderr=""), # commit
495
+ ]
496
+ git_call_index = {"n": 0}
497
+
498
+ def _git_run(cmd, **kwargs):
499
+ idx = git_call_index["n"]
500
+ git_call_index["n"] += 1
501
+ if git_returncode != 0 and idx > 0:
502
+ raise subprocess.CalledProcessError(git_returncode, cmd, stderr="error msg")
503
+ return git_results[min(idx, len(git_results) - 1)]
504
+
505
+ with patch("commands.new_track.load_config", return_value=cfg), \
506
+ patch("commands.new_track.write_file") as mw, \
507
+ patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
508
+ patch("pathlib.Path.exists", _path_exists), \
509
+ patch("pathlib.Path.is_dir", _is_dir), \
510
+ patch("pathlib.Path.mkdir"), \
511
+ patch("commands.new_track.subprocess.run", side_effect=_git_run) as msub:
512
+ buf = io.StringIO()
513
+ with redirect_stdout(buf):
514
+ rc = new_track.run(args)
515
+ return rc, mw, msub, buf.getvalue()
516
+
517
+ def test_commit_shared_track_calls_git_add_then_commit(self):
518
+ """--commit on a shared track: git -C <clone_root> add <file> called,
519
+ then git -C <clone_root> commit called; path-scoped (not git add .)."""
520
+ rc, mw, msub, out = self._drive_shared(
521
+ ["myrepo", "my-feature", "--commit"]
522
+ )
523
+ self.assertEqual(rc, 0)
524
+ mw.assert_called_once()
525
+ # Should have made git calls: rev-parse, add, commit
526
+ calls = msub.call_args_list
527
+ # Find the add and commit calls (skip rev-parse at index 0)
528
+ git_cmds = [c[0][0] for c in calls]
529
+ add_calls = [c for c in git_cmds if "add" in c]
530
+ commit_calls = [c for c in git_cmds if "commit" in c]
531
+ self.assertEqual(len(add_calls), 1, "exactly one git add call expected")
532
+ self.assertEqual(len(commit_calls), 1, "exactly one git commit call expected")
533
+ # Verify add is path-scoped (not "git add .")
534
+ add_argv = add_calls[0]
535
+ self.assertNotIn(".", add_argv, "git add must be path-scoped, not 'git add .'")
536
+ self.assertIn("-C", add_argv)
537
+ # The file argument should end in .md
538
+ file_arg = add_argv[-1]
539
+ self.assertTrue(file_arg.endswith(".md"), f"expected .md path, got: {file_arg}")
540
+ # Commit message should mention the slug
541
+ commit_argv = commit_calls[0]
542
+ msg_idx = commit_argv.index("-m") + 1
543
+ self.assertIn("my-feature", commit_argv[msg_idx])
544
+
545
+ def test_commit_shared_track_path_scoped_not_git_add_dot(self):
546
+ """The git add call must never use '.' as the file argument."""
547
+ rc, mw, msub, out = self._drive_shared(
548
+ ["myrepo", "path-scoped-test", "--commit"]
549
+ )
550
+ self.assertEqual(rc, 0)
551
+ git_cmds = [c[0][0] for c in msub.call_args_list]
552
+ add_calls = [c for c in git_cmds if "add" in c]
553
+ self.assertEqual(len(add_calls), 1)
554
+ self.assertNotIn(".", add_calls[0])
555
+
556
+ def test_commit_private_track_warns_and_skips_git(self):
557
+ """--commit on a private track (notes_root, not .work-plan) → warning
558
+ printed, git NOT called."""
559
+ cfg = _make_cfg() # no local clone → private route
560
+
561
+ def _path_exists(self):
562
+ if self == Path(NOTES_ROOT):
563
+ return True
564
+ if self.suffix == ".md":
565
+ return False
566
+ return True
567
+
568
+ with patch("commands.new_track.load_config", return_value=cfg), \
569
+ patch("commands.new_track.write_file") as mw, \
570
+ patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
571
+ patch("pathlib.Path.exists", _path_exists), \
572
+ patch("pathlib.Path.mkdir"), \
573
+ patch("commands.new_track.subprocess.run") as msub:
574
+ buf = io.StringIO()
575
+ with redirect_stdout(buf):
576
+ rc = new_track.run(["myrepo", "private-track", "--commit"])
577
+ self.assertEqual(rc, 0)
578
+ mw.assert_called_once()
579
+ msub.assert_not_called()
580
+ self.assertIn("--commit ignored", buf.getvalue())
581
+
582
+ def test_commit_git_failure_is_non_fatal(self):
583
+ """--commit with git add failing → rc still 0, warning printed."""
584
+ rc, mw, msub, out = self._drive_shared(
585
+ ["myrepo", "my-feature", "--commit"],
586
+ git_returncode=1,
587
+ )
588
+ self.assertEqual(rc, 0, "git failure must be non-fatal")
589
+ mw.assert_called_once()
590
+ self.assertIn("⚠", out)
591
+
592
+ def test_no_commit_flag_no_git_calls(self):
593
+ """Without --commit: git is never called, even for a shared track."""
594
+ rc, mw, msub, out = self._drive_shared(["myrepo", "my-feature"])
595
+ self.assertEqual(rc, 0)
596
+ mw.assert_called_once()
597
+ msub.assert_not_called()
598
+
599
+ def test_commit_success_prints_committed_line(self):
600
+ """Successful --commit → output contains 'committed' and the slug."""
601
+ rc, mw, msub, out = self._drive_shared(
602
+ ["myrepo", "my-feature", "--commit"]
603
+ )
604
+ self.assertEqual(rc, 0)
605
+ self.assertIn("committed", out)
606
+ self.assertIn("my-feature", out)
607
+
608
+
609
+ if __name__ == "__main__":
610
+ unittest.main()