@stylusnexus/work-plan 2026.6.15 → 2026.6.18

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 (39) hide show
  1. package/README.md +14 -12
  2. package/VERSION +1 -1
  3. package/package.json +1 -5
  4. package/skills/work-plan/SKILL.md +2 -0
  5. package/skills/work-plan/commands/auto_triage.py +187 -35
  6. package/skills/work-plan/commands/batch_slot.py +58 -33
  7. package/skills/work-plan/commands/brief.py +29 -3
  8. package/skills/work-plan/commands/dedupe_tiers.py +104 -0
  9. package/skills/work-plan/commands/export.py +29 -4
  10. package/skills/work-plan/commands/handoff.py +90 -26
  11. package/skills/work-plan/commands/hygiene.py +24 -9
  12. package/skills/work-plan/commands/set_next_up.py +64 -8
  13. package/skills/work-plan/commands/slot.py +53 -24
  14. package/skills/work-plan/commands/which_repo.py +52 -0
  15. package/skills/work-plan/lib/cwd_repo.py +133 -0
  16. package/skills/work-plan/lib/drift.py +6 -1
  17. package/skills/work-plan/lib/export_model.py +27 -4
  18. package/skills/work-plan/lib/heuristic_triage.py +134 -0
  19. package/skills/work-plan/lib/membership_guard.py +152 -0
  20. package/skills/work-plan/lib/plan_worktree.py +37 -0
  21. package/skills/work-plan/lib/tracks.py +64 -2
  22. package/skills/work-plan/tests/test_auto_triage.py +103 -0
  23. package/skills/work-plan/tests/test_batch_slot.py +19 -2
  24. package/skills/work-plan/tests/test_brief_autoscope.py +126 -0
  25. package/skills/work-plan/tests/test_cwd_repo.py +164 -0
  26. package/skills/work-plan/tests/test_dedupe_tiers.py +147 -0
  27. package/skills/work-plan/tests/test_drift.py +32 -0
  28. package/skills/work-plan/tests/test_export.py +63 -0
  29. package/skills/work-plan/tests/test_export_command.py +56 -0
  30. package/skills/work-plan/tests/test_handoff_suggest_next.py +130 -0
  31. package/skills/work-plan/tests/test_heuristic_triage.py +114 -0
  32. package/skills/work-plan/tests/test_membership_guard.py +116 -0
  33. package/skills/work-plan/tests/test_register_which_repo.py +22 -0
  34. package/skills/work-plan/tests/test_set_next_up.py +95 -0
  35. package/skills/work-plan/tests/test_shared_rebase_guard.py +154 -0
  36. package/skills/work-plan/tests/test_slot.py +94 -2
  37. package/skills/work-plan/tests/test_slot_move.py +10 -1
  38. package/skills/work-plan/work_plan.py +17 -7
  39. package/scripts/npm-check-deps.js +0 -44
@@ -267,6 +267,43 @@ def is_published(local_path: Path, branch: str) -> bool:
267
267
  return remote_branch_exists(local_path, branch)
268
268
 
269
269
 
270
+ def rebase_onto_origin(worktree: Path, branch: str) -> bool:
271
+ """Fetch origin/<branch> and rebase the worktree (checked out at <branch>)
272
+ onto it, so a shared-tier write lands on top of any teammate's pushed plan
273
+ changes instead of diverging (#241).
274
+
275
+ Returns:
276
+ True — the worktree is now at-or-ahead of origin: rebase succeeded,
277
+ there was nothing to replay, or the branch isn't published yet
278
+ (local is authoritative — nothing to rebase onto).
279
+ False — the rebase hit a conflict (it is ABORTED so the worktree is left
280
+ clean, never half-rebased) or git couldn't run. The caller must
281
+ NOT write — it surfaces {needs_rebase} and bails.
282
+
283
+ Never raises.
284
+ """
285
+ wt = Path(worktree).expanduser()
286
+ # Fetch so origin/<branch> is authoritative before we compare.
287
+ fetch_branch(wt, branch)
288
+ if not remote_branch_exists(wt, branch):
289
+ return True # unpublished — no upstream to rebase onto; local wins
290
+ # --autostash: the normal flow is write-file-then-commit, so the worktree's
291
+ # .work-plan/ is routinely dirty when we rebase. Without autostash git would
292
+ # refuse the rebase on the dirty precondition and we'd report a spurious
293
+ # {needs_rebase}; autostash shelves the local edits, rebases, and reapplies
294
+ # them on top — so a clean rebase succeeds even with pending plan edits.
295
+ proc = _git(wt, "rebase", "--autostash", f"origin/{branch}")
296
+ if proc is None:
297
+ return False
298
+ if proc.returncode == 0:
299
+ return True
300
+ # True conflict (autostash reapply or replayed commits): abort so the
301
+ # worktree is never left in a partially-rebased state. A blind write over a
302
+ # diverged shared branch is exactly what this guard exists to prevent.
303
+ _git(wt, "rebase", "--abort")
304
+ return False
305
+
306
+
270
307
  def unpushed_oneline(local_path: Path, branch: str) -> list:
271
308
  """One-line summaries of commits on local `branch` not yet on origin
272
309
  (`origin/<branch>..<branch>`). If origin/<branch> doesn't exist, every commit
@@ -1,4 +1,5 @@
1
1
  """Discover tracks under notes_root and shared .work-plan/ dirs."""
2
+ import re
2
3
  import sys
3
4
  from dataclasses import dataclass, field
4
5
  from pathlib import Path
@@ -74,9 +75,11 @@ def discover_tracks(cfg: dict) -> list[Track]:
74
75
  for t in private:
75
76
  key = (t.repo, t.name)
76
77
  if key in shared_keys:
78
+ hint = f" --repo={t.folder}" if t.folder else ""
77
79
  print(
78
80
  f"WARN: track {t.name!r} (repo={t.repo!r}) exists in both shared"
79
- f" ({shared_keys[key].path}) and private ({t.path}); using shared.",
81
+ f" ({shared_keys[key].path}) and private ({t.path}); using shared."
82
+ f" → resolve with `/work-plan dedupe-tiers{hint}`.",
80
83
  file=sys.stderr,
81
84
  )
82
85
  else:
@@ -85,6 +88,63 @@ def discover_tracks(cfg: dict) -> list[Track]:
85
88
  return merged
86
89
 
87
90
 
91
+ def issue_refs(track: "Track") -> set:
92
+ """All GitHub issue numbers a track references: the union of its frontmatter
93
+ `github.issues` list and every `#NNNN` token in its body. Used by
94
+ dedupe-tiers as the no-data-loss invariant — a private copy is only safe to
95
+ drop when its issue refs are a subset of the shared twin's.
96
+ """
97
+ refs: set = set()
98
+ fm_issues = (track.meta.get("github") or {}).get("issues") or []
99
+ for n in fm_issues:
100
+ try:
101
+ refs.add(int(n))
102
+ except (TypeError, ValueError):
103
+ continue
104
+ for m in re.finditer(r"#(\d+)", track.body or ""):
105
+ refs.add(int(m.group(1)))
106
+ return refs
107
+
108
+
109
+ def find_tier_duplicates(cfg: dict) -> list:
110
+ """Return (shared, private) Track pairs that collide on (repo, name) across
111
+ BOTH the active and archived tiers. Unlike discover_tracks, this does NOT
112
+ print a warning and does NOT drop the private side — it hands both copies to
113
+ the caller (dedupe-tiers) so the orphan can be inspected and removed.
114
+ """
115
+ pairs: list = []
116
+
117
+ # A duplicate requires a PRIVATE copy, which lives under notes_root. With no
118
+ # notes_root configured there can be no private tier and thus no duplicates —
119
+ # return early (also keeps the helper safe to call with a bare cfg).
120
+ if not cfg.get("notes_root"):
121
+ return pairs
122
+
123
+ shared_active = {(t.repo, t.name): t
124
+ for t in _discover_shared_tracks(cfg, include_archive=False)}
125
+ for t in _discover_private_tracks(cfg, include_archive=False):
126
+ s = shared_active.get((t.repo, t.name))
127
+ if s is not None:
128
+ pairs.append((s, t))
129
+
130
+ shared_arch = {(t.repo, t.name): t
131
+ for t in _discover_shared_tracks(cfg, include_archive=True,
132
+ archive_only=True)}
133
+ notes_root = Path(cfg["notes_root"]).expanduser()
134
+ if notes_root.exists():
135
+ for md_path in sorted(notes_root.rglob("*.md")):
136
+ if "archive" not in md_path.parts:
137
+ continue
138
+ if md_path.name.startswith((".", "_", "-")):
139
+ continue
140
+ t = _build_track(md_path, notes_root, cfg)
141
+ s = shared_arch.get((t.repo, t.name))
142
+ if s is not None:
143
+ pairs.append((s, t))
144
+
145
+ return pairs
146
+
147
+
88
148
  def filter_tracks_by_repo(tracks: list[Track], key: str) -> list[Track]:
89
149
  """Filter tracks by repo. Matches the config-key folder name OR the
90
150
  `org/repo` GitHub slug, so users can pass either. Case-insensitive."""
@@ -176,10 +236,12 @@ def discover_archived_tracks(cfg: dict) -> list[Track]:
176
236
  for t in private_archived:
177
237
  key = (t.repo, t.name)
178
238
  if key in shared_keys:
239
+ hint = f" --repo={t.folder}" if t.folder else ""
179
240
  print(
180
241
  f"WARN: archived track {t.name!r} (repo={t.repo!r}) exists in"
181
242
  f" both shared ({shared_keys[key].path}) and private"
182
- f" ({t.path}); using shared.",
243
+ f" ({t.path}); using shared."
244
+ f" → resolve with `/work-plan dedupe-tiers{hint}`.",
183
245
  file=sys.stderr,
184
246
  )
185
247
  else:
@@ -347,5 +347,108 @@ class AutoTriageApplyTest(unittest.TestCase):
347
347
  self.assertIn("0 issue(s) assigned", buf.getvalue())
348
348
 
349
349
 
350
+ class AutoTriageJsonScanTest(unittest.TestCase):
351
+ """--json scan mode (#241): emit a machine batch with a batch_id."""
352
+
353
+ def test_json_mode_emits_batch_with_id_and_prompt(self):
354
+ cfg = _make_cfg()
355
+ tracks = [_make_track("auth-flow", "org/myrepo", [])]
356
+ rc, out, _ = _drive_prepare(["--json"], cfg=cfg, tracks=tracks,
357
+ open_issues=_open_issues(4501, 4502))
358
+ self.assertEqual(rc, 0)
359
+ data = json.loads(out.strip()) # stdout is a single JSON object
360
+ self.assertIn("batch_id", data)
361
+ self.assertTrue(data["batch_id"])
362
+ self.assertEqual({i["number"] for i in data["untracked"]}, {4501, 4502})
363
+ self.assertIn("prompt", data)
364
+ self.assertIn("answers_path", data)
365
+ # Track entries carry scope text for grounded matching.
366
+ self.assertIn("scope", data["tracks"][0])
367
+
368
+
369
+ class AutoTriageHeuristicTest(unittest.TestCase):
370
+ """--heuristic (#373): the CLI writes the v2 answers file itself, no LLM."""
371
+
372
+ def test_heuristic_writes_answers_file_with_source_and_batch_id(self):
373
+ cfg = _make_cfg()
374
+ track = _make_track("auth-flow", "org/myrepo", [], slug="auth-flow")
375
+ track.meta["milestone_alignment"] = "v0.4"
376
+ open_issues = [{"number": 4501, "title": "auth thing", "state": "OPEN",
377
+ "milestone": {"title": "v0.4"}, "labels": []}]
378
+ with tempfile.TemporaryDirectory() as tmp:
379
+ batch_file = Path(tmp) / "auto_triage.org_myrepo.json"
380
+ answers_file = Path(tmp) / "auto_triage.org_myrepo.answers.json"
381
+ with patch("commands.auto_triage.load_config", return_value=cfg), \
382
+ patch("commands.auto_triage.discover_tracks", return_value=[track]), \
383
+ patch("commands.auto_triage.fetch_open_issues", return_value=open_issues), \
384
+ patch("commands.auto_triage._batch_path", return_value=batch_file), \
385
+ patch("commands.auto_triage._answers_path", return_value=answers_file):
386
+ buf = io.StringIO()
387
+ with redirect_stdout(buf):
388
+ rc = auto_triage.run(["--heuristic"])
389
+ self.assertEqual(rc, 0)
390
+ self.assertTrue(answers_file.exists(), "heuristic must write the answers file")
391
+ doc = json.loads(answers_file.read_text())
392
+ self.assertEqual(doc["version"], 2)
393
+ self.assertEqual(doc["source"], "heuristic")
394
+ batch = json.loads(batch_file.read_text())
395
+ self.assertEqual(doc["batch_id"], batch["batch_id"]) # correlates
396
+ sug = doc["suggestions"][0]
397
+ self.assertEqual(sug["issue"], 4501)
398
+ self.assertEqual(sug["verdict"], "suggest")
399
+ self.assertEqual(sug["track"], "auth-flow")
400
+
401
+
402
+ class AutoTriageV2AnswersTest(unittest.TestCase):
403
+ """v2 abstain-first answers schema (#241) + back-compat with v1."""
404
+
405
+ def _batch(self, nums, batch_id="abc123"):
406
+ return {"batch_id": batch_id, "repo": "org/myrepo", "folder": "myrepo",
407
+ "untracked": [{"number": n} for n in nums]}
408
+
409
+ def test_v2_applies_only_clear_suggestions(self):
410
+ cfg = _make_cfg()
411
+ track = _make_track("auth-flow", "org/myrepo", [], slug="auth-flow")
412
+ answers = {"version": 2, "batch_id": "abc123", "suggestions": [
413
+ {"issue": 4501, "verdict": "suggest", "track": "auth-flow",
414
+ "margin": "clear", "confidence": 0.9, "rationale": "label area/auth"},
415
+ {"issue": 4502, "verdict": "suggest", "track": "auth-flow",
416
+ "margin": "narrow", "confidence": 0.55, "rationale": "maybe"},
417
+ {"issue": 4507, "verdict": "abstain", "rationale": "no fit"},
418
+ ]}
419
+ rc, mwrite, out = _drive_apply(cfg=cfg, tracks=[track],
420
+ batch=self._batch([4501, 4502, 4507]),
421
+ answers=answers)
422
+ self.assertEqual(rc, 0)
423
+ mwrite.assert_called_once() # only the clear suggestion is written
424
+ written = mwrite.call_args[0][1]["github"]["issues"]
425
+ self.assertIn(4501, written)
426
+ self.assertNotIn(4502, written) # narrow margin → left untracked
427
+ self.assertNotIn(4507, written) # abstained → left untracked
428
+
429
+ def test_v1_answers_still_apply(self):
430
+ cfg = _make_cfg()
431
+ track = _make_track("auth-flow", "org/myrepo", [], slug="auth-flow")
432
+ answers = [{"track": "auth-flow", "issues": [4501]}] # legacy shape
433
+ rc, mwrite, out = _drive_apply(cfg=cfg, tracks=[track],
434
+ batch=self._batch([4501]), answers=answers)
435
+ self.assertEqual(rc, 0)
436
+ mwrite.assert_called_once()
437
+ self.assertIn(4501, mwrite.call_args[0][1]["github"]["issues"])
438
+
439
+ def test_batch_id_mismatch_warns_but_applies(self):
440
+ cfg = _make_cfg()
441
+ track = _make_track("auth-flow", "org/myrepo", [], slug="auth-flow")
442
+ answers = {"version": 2, "batch_id": "STALE", "suggestions": [
443
+ {"issue": 4501, "verdict": "suggest", "track": "auth-flow",
444
+ "margin": "clear", "rationale": "label area/auth"}]}
445
+ rc, mwrite, out = _drive_apply(cfg=cfg, tracks=[track],
446
+ batch=self._batch([4501], batch_id="abc123"),
447
+ answers=answers)
448
+ self.assertEqual(rc, 0)
449
+ self.assertIn("older scan", out)
450
+ mwrite.assert_called_once()
451
+
452
+
350
453
  if __name__ == "__main__":
351
454
  unittest.main()
@@ -52,11 +52,21 @@ def _drive(args, tracks=None, vis="PRIVATE"):
52
52
  cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
53
53
  gh_proc = MagicMock(returncode=0, stdout="{}", stderr="")
54
54
 
55
+ # Writes go through lib.membership_guard (re-read via parse_file, write via
56
+ # write_file). Returning each track's own meta/body lets the guard mutate
57
+ # them in place, so assertions on track.meta still observe the merge.
58
+ by_path = {str(t.path): t for t in tracks}
59
+
60
+ def fake_parse(p):
61
+ t = by_path[str(p)]
62
+ return (t.meta, t.body)
63
+
55
64
  with patch("commands.batch_slot.load_config", return_value=cfg), \
56
65
  patch("commands.batch_slot.discover_tracks", return_value=tracks), \
57
66
  patch("commands.batch_slot.subprocess.run", return_value=gh_proc), \
58
67
  patch("lib.write_guard.repo_visibility", return_value=vis), \
59
- patch("commands.batch_slot.write_file") as mw:
68
+ patch("lib.membership_guard.parse_file", side_effect=fake_parse), \
69
+ patch("lib.membership_guard.write_file") as mw:
60
70
  buf = io.StringIO()
61
71
  with redirect_stdout(buf):
62
72
  rc = batch_slot.run(args)
@@ -272,6 +282,12 @@ class BatchSlotTest(unittest.TestCase):
272
282
  def _raise(*a, **kw):
273
283
  raise AssertionError("input() must not be called on non-interactive path")
274
284
 
285
+ by_path = {str(t.path): t for t in (source, target)}
286
+
287
+ def fake_parse(p):
288
+ t = by_path[str(p)]
289
+ return (t.meta, t.body)
290
+
275
291
  with patch("builtins.input", side_effect=_raise), \
276
292
  patch("lib.prompts.prompt_input", side_effect=_raise):
277
293
  cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
@@ -280,7 +296,8 @@ class BatchSlotTest(unittest.TestCase):
280
296
  patch("commands.batch_slot.discover_tracks", return_value=[source, target]), \
281
297
  patch("commands.batch_slot.subprocess.run", return_value=gh_proc), \
282
298
  patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
283
- patch("commands.batch_slot.write_file"):
299
+ patch("lib.membership_guard.parse_file", side_effect=fake_parse), \
300
+ patch("lib.membership_guard.write_file"):
284
301
  buf = io.StringIO()
285
302
  with redirect_stdout(buf):
286
303
  rc = batch_slot.run(["42", "beta", "--move"])
@@ -0,0 +1,126 @@
1
+ """brief cwd auto-scope (#358 Phase 2).
2
+
3
+ Exercises brief.run()'s scope resolution with the resolver, config loader,
4
+ track discovery, and the archived-reopen pass all mocked — so no network/git.
5
+ Tracks are inactive (status 'shipped') so the render loop stays trivial; we
6
+ assert behavior through the banner text and the `repo_key` threaded into
7
+ `_surface_archived_reopens` (which is exactly the value used to scope both the
8
+ track list and the archived callouts).
9
+ """
10
+ import io
11
+ import sys
12
+ import types
13
+ import unittest
14
+ from contextlib import redirect_stdout
15
+ from pathlib import Path
16
+ from unittest import mock
17
+
18
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
19
+ sys.path.insert(0, str(SKILL_ROOT))
20
+
21
+ from commands import brief
22
+
23
+
24
+ def _track(folder, repo):
25
+ return types.SimpleNamespace(
26
+ has_frontmatter=True,
27
+ meta={"status": "shipped"}, # inactive → no per-track render
28
+ needs_init=False,
29
+ needs_filing=False,
30
+ folder=folder,
31
+ repo=repo,
32
+ path=Path(f"/notes/{folder}/{folder}.md"),
33
+ )
34
+
35
+
36
+ CFG = {"repos": {
37
+ "work-plan-toolkit": {"local": "/code/wpt", "github": "stylusnexus/work-plan-toolkit"},
38
+ "defect-scan": {"local": "/code/ds", "github": "stylusnexus/defect-scan"},
39
+ }}
40
+
41
+ TRACKS = [
42
+ _track("work-plan-toolkit", "stylusnexus/work-plan-toolkit"),
43
+ _track("defect-scan", "stylusnexus/defect-scan"),
44
+ ]
45
+
46
+ BANNER = "Scoped to repo"
47
+
48
+
49
+ def _run(args, cfg=None, resolve_return=mock.DEFAULT):
50
+ """Run brief.run(args) with collaborators mocked. Returns (stdout, archived_mock, resolve_mock)."""
51
+ cfg = CFG if cfg is None else cfg
52
+ buf = io.StringIO()
53
+ with mock.patch.object(brief, "load_config", return_value=cfg), \
54
+ mock.patch.object(brief, "discover_tracks", return_value=list(TRACKS)), \
55
+ mock.patch.object(brief, "_surface_archived_reopens") as archived, \
56
+ mock.patch.object(brief, "resolve_repo_for_dir") as resolve:
57
+ if resolve_return is not mock.DEFAULT:
58
+ resolve.return_value = resolve_return
59
+ with redirect_stdout(buf):
60
+ brief.run(args)
61
+ return buf.getvalue(), archived, resolve
62
+
63
+
64
+ def _archived_repo_key(archived):
65
+ self_call = archived.call_args
66
+ return self_call.kwargs.get("repo_key")
67
+
68
+
69
+ class BriefAutoScopeTest(unittest.TestCase):
70
+ # --- auto-detect on (default) -------------------------------------------
71
+
72
+ def test_autoscope_prints_banner_and_scopes(self):
73
+ out, archived, _ = _run(
74
+ [], resolve_return={"key": "work-plan-toolkit",
75
+ "github": "stylusnexus/work-plan-toolkit",
76
+ "matched_by": "local"})
77
+ self.assertIn(BANNER, out)
78
+ self.assertIn("work-plan-toolkit", out)
79
+ # archived-reopen pass scoped to the same detected key
80
+ self.assertEqual(_archived_repo_key(archived), "work-plan-toolkit")
81
+
82
+ def test_archived_reopens_scoped_to_detected_repo(self):
83
+ _, archived, _ = _run(
84
+ [], resolve_return={"key": "defect-scan",
85
+ "github": "stylusnexus/defect-scan",
86
+ "matched_by": "local"})
87
+ self.assertEqual(_archived_repo_key(archived), "defect-scan")
88
+
89
+ def test_banner_printed_exactly_once(self):
90
+ out, _, _ = _run(
91
+ [], resolve_return={"key": "work-plan-toolkit",
92
+ "github": "stylusnexus/work-plan-toolkit",
93
+ "matched_by": "local"})
94
+ self.assertEqual(out.count(BANNER), 1)
95
+
96
+ def test_no_match_shows_all_no_banner(self):
97
+ out, archived, _ = _run([], resolve_return=None)
98
+ self.assertNotIn(BANNER, out)
99
+ self.assertIsNone(_archived_repo_key(archived))
100
+
101
+ # --- explicit --repo / escape hatch -------------------------------------
102
+
103
+ def test_repo_all_shows_everything_no_banner_no_autodetect(self):
104
+ out, archived, resolve = _run(["--repo=all"])
105
+ self.assertNotIn(BANNER, out)
106
+ self.assertIsNone(_archived_repo_key(archived))
107
+ resolve.assert_not_called() # --repo=all must short-circuit auto-detect
108
+
109
+ def test_explicit_repo_scopes_without_banner_or_autodetect(self):
110
+ out, archived, resolve = _run(["--repo=defect-scan"])
111
+ self.assertNotIn(BANNER, out)
112
+ self.assertEqual(_archived_repo_key(archived), "defect-scan")
113
+ resolve.assert_not_called()
114
+
115
+ # --- opt-out -------------------------------------------------------------
116
+
117
+ def test_optout_disables_autoscope(self):
118
+ cfg = {"repos": CFG["repos"], "brief_auto_scope": False}
119
+ out, archived, resolve = _run([], cfg=cfg)
120
+ self.assertNotIn(BANNER, out)
121
+ self.assertIsNone(_archived_repo_key(archived))
122
+ resolve.assert_not_called()
123
+
124
+
125
+ if __name__ == "__main__":
126
+ unittest.main()
@@ -0,0 +1,164 @@
1
+ """cwd → configured-repo resolution (#358/#357 Phase 1).
2
+
3
+ All git calls are mocked, so these run offline. The resolver shells `git` via
4
+ `lib.git_state._git`, imported into `lib.cwd_repo`'s namespace — so we patch
5
+ `lib.cwd_repo._git`.
6
+ """
7
+ import sys
8
+ import types
9
+ import unittest
10
+ from pathlib import Path
11
+ from unittest import mock
12
+
13
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
14
+ sys.path.insert(0, str(SKILL_ROOT))
15
+
16
+ from lib import cwd_repo
17
+ from lib.cwd_repo import resolve_repo_for_dir, _normalize_remote_url
18
+
19
+
20
+ def _proc(stdout="", returncode=0):
21
+ """A stand-in for subprocess.CompletedProcess as `_git` returns it."""
22
+ return types.SimpleNamespace(stdout=stdout, returncode=returncode)
23
+
24
+
25
+ def _fake_git(toplevel=None, origin=None):
26
+ """Build a `_git` replacement keyed on the git subcommand.
27
+
28
+ `toplevel` / `origin` are raw stdout strings (or None → non-zero exit, i.e.
29
+ git failed / not a repo / no remote).
30
+ """
31
+ def _g(repo_path, *args, **kwargs):
32
+ if args[:2] == ("rev-parse", "--show-toplevel"):
33
+ return _proc(toplevel, 0) if toplevel is not None else _proc("", 128)
34
+ if args[:2] == ("remote", "get-url"):
35
+ return _proc(origin, 0) if origin is not None else _proc("", 2)
36
+ return _proc("", 0)
37
+ return _g
38
+
39
+
40
+ # Absolute, non-symlinked paths so .resolve() is a no-op-equal on both sides.
41
+ CFG = {
42
+ "repos": {
43
+ "work-plan-toolkit": {
44
+ "local": "/code/work-plan-toolkit",
45
+ "github": "stylusnexus/work-plan-toolkit",
46
+ },
47
+ "defect-scan": {
48
+ "local": "/code/defect-scan",
49
+ "github": "stylusnexus/defect-scan",
50
+ },
51
+ # A repo with no local clone — only a remote can match it.
52
+ "remote-only": {
53
+ "local": None,
54
+ "github": "stylusnexus/remote-only",
55
+ },
56
+ }
57
+ }
58
+
59
+
60
+ class NormalizeRemoteUrlTest(unittest.TestCase):
61
+ def test_scp_form(self):
62
+ self.assertEqual(
63
+ _normalize_remote_url("git@github.com:Org/Repo.git"), "org/repo")
64
+
65
+ def test_https_with_git_suffix(self):
66
+ self.assertEqual(
67
+ _normalize_remote_url("https://github.com/org/repo.git"), "org/repo")
68
+
69
+ def test_https_without_suffix(self):
70
+ self.assertEqual(
71
+ _normalize_remote_url("https://github.com/org/repo"), "org/repo")
72
+
73
+ def test_ssh_url_form(self):
74
+ self.assertEqual(
75
+ _normalize_remote_url("ssh://git@github.com/org/repo.git"), "org/repo")
76
+
77
+ def test_all_forms_land_on_same_slug(self):
78
+ forms = [
79
+ "git@github.com:org/repo.git",
80
+ "https://github.com/org/repo.git",
81
+ "https://github.com/org/repo",
82
+ "ssh://git@github.com/org/repo.git",
83
+ ]
84
+ slugs = {_normalize_remote_url(f) for f in forms}
85
+ self.assertEqual(slugs, {"org/repo"})
86
+
87
+ def test_garbage_returns_none(self):
88
+ self.assertIsNone(_normalize_remote_url(""))
89
+ self.assertIsNone(_normalize_remote_url("not-a-url"))
90
+
91
+
92
+ class ResolveRepoForDirTest(unittest.TestCase):
93
+ def test_local_match_at_clone_root(self):
94
+ with mock.patch.object(cwd_repo, "_git",
95
+ _fake_git(toplevel="/code/work-plan-toolkit")):
96
+ got = resolve_repo_for_dir(CFG, "/code/work-plan-toolkit")
97
+ self.assertEqual(got, {
98
+ "key": "work-plan-toolkit",
99
+ "github": "stylusnexus/work-plan-toolkit",
100
+ "matched_by": "local",
101
+ })
102
+
103
+ def test_local_match_from_nested_subdir(self):
104
+ # cwd is deep inside the clone; toplevel still resolves to the root.
105
+ with mock.patch.object(cwd_repo, "_git",
106
+ _fake_git(toplevel="/code/work-plan-toolkit")):
107
+ got = resolve_repo_for_dir(
108
+ CFG, "/code/work-plan-toolkit/skills/work-plan/lib")
109
+ self.assertIsNotNone(got)
110
+ self.assertEqual(got["key"], "work-plan-toolkit")
111
+ self.assertEqual(got["matched_by"], "local")
112
+
113
+ def test_remote_match_when_local_is_null(self):
114
+ # Not a configured local path, but origin matches the remote-only repo.
115
+ with mock.patch.object(cwd_repo, "_git",
116
+ _fake_git(toplevel="/somewhere/else",
117
+ origin="git@github.com:stylusnexus/remote-only.git")):
118
+ got = resolve_repo_for_dir(CFG, "/somewhere/else")
119
+ self.assertEqual(got, {
120
+ "key": "remote-only",
121
+ "github": "stylusnexus/remote-only",
122
+ "matched_by": "remote",
123
+ })
124
+
125
+ def test_local_wins_over_remote_when_they_disagree(self):
126
+ # toplevel == defect-scan's clone, but origin points at work-plan-toolkit.
127
+ # The local-path key must win.
128
+ with mock.patch.object(cwd_repo, "_git",
129
+ _fake_git(toplevel="/code/defect-scan",
130
+ origin="git@github.com:stylusnexus/work-plan-toolkit.git")):
131
+ got = resolve_repo_for_dir(CFG, "/code/defect-scan")
132
+ self.assertEqual(got["key"], "defect-scan")
133
+ self.assertEqual(got["matched_by"], "local")
134
+
135
+ def test_no_match_inside_unconfigured_repo(self):
136
+ with mock.patch.object(cwd_repo, "_git",
137
+ _fake_git(toplevel="/code/unknown",
138
+ origin="git@github.com:someone/unknown.git")):
139
+ self.assertIsNone(resolve_repo_for_dir(CFG, "/code/unknown"))
140
+
141
+ def test_no_match_when_not_a_git_repo(self):
142
+ # git rev-parse fails AND no remote — resolver returns None, no raise.
143
+ with mock.patch.object(cwd_repo, "_git",
144
+ _fake_git(toplevel=None, origin=None)):
145
+ self.assertIsNone(resolve_repo_for_dir(CFG, "/tmp/plain-dir"))
146
+
147
+ def test_none_when_two_repos_share_a_local_path(self):
148
+ # Pathological config: two keys point at the same clone. Refuse to guess.
149
+ cfg = {"repos": {
150
+ "a": {"local": "/code/dup", "github": "org/a"},
151
+ "b": {"local": "/code/dup", "github": "org/b"},
152
+ }}
153
+ with mock.patch.object(cwd_repo, "_git",
154
+ _fake_git(toplevel="/code/dup")):
155
+ self.assertIsNone(resolve_repo_for_dir(cfg, "/code/dup"))
156
+
157
+ def test_none_when_no_repos_configured(self):
158
+ with mock.patch.object(cwd_repo, "_git",
159
+ _fake_git(toplevel="/code/x")):
160
+ self.assertIsNone(resolve_repo_for_dir({"repos": {}}, "/code/x"))
161
+
162
+
163
+ if __name__ == "__main__":
164
+ unittest.main()