@stylusnexus/work-plan 2026.7.10 → 2026.7.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +1 -0
- package/skills/work-plan/commands/doctor.py +529 -0
- package/skills/work-plan/commands/export.py +14 -11
- package/skills/work-plan/commands/init_repo.py +3 -6
- package/skills/work-plan/commands/new_track.py +21 -2
- package/skills/work-plan/commands/plan_status.py +48 -6
- package/skills/work-plan/lib/config.py +29 -0
- package/skills/work-plan/lib/doc_discovery.py +22 -2
- package/skills/work-plan/lib/export_model.py +16 -3
- package/skills/work-plan/lib/github_state.py +25 -0
- package/skills/work-plan/lib/notes_vcs.py +21 -9
- package/skills/work-plan/lib/plan_worktree.py +47 -8
- package/skills/work-plan/lib/tracks.py +17 -3
- package/skills/work-plan/tests/test_config.py +59 -0
- package/skills/work-plan/tests/test_discover_archived.py +15 -0
- package/skills/work-plan/tests/test_doc_discovery.py +11 -0
- package/skills/work-plan/tests/test_doctor.py +852 -0
- package/skills/work-plan/tests/test_export.py +34 -8
- package/skills/work-plan/tests/test_export_command.py +101 -0
- package/skills/work-plan/tests/test_github_state.py +41 -1
- package/skills/work-plan/tests/test_group_apply.py +4 -0
- package/skills/work-plan/tests/test_new_track.py +79 -0
- package/skills/work-plan/tests/test_notes_vcs.py +30 -0
- package/skills/work-plan/tests/test_plan_status_stamp.py +68 -0
- package/skills/work-plan/tests/test_plan_worktree.py +31 -0
- package/skills/work-plan/tests/test_tracks.py +55 -1
- package/skills/work-plan/work_plan.py +20 -0
|
@@ -3,18 +3,44 @@ import sys, json, unittest
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from types import SimpleNamespace
|
|
5
5
|
SKILL_ROOT = Path(__file__).resolve().parents[1]; sys.path.insert(0, str(SKILL_ROOT))
|
|
6
|
-
from lib.export_model import build_export
|
|
6
|
+
from lib.export_model import build_export, track_key
|
|
7
7
|
import commands.export as export_cmd
|
|
8
8
|
|
|
9
9
|
def _track(name, repo, issues, blockers=None, next_up=None, status="active", depends_on=None):
|
|
10
10
|
return SimpleNamespace(name=name, repo=repo, tier="private",
|
|
11
|
-
path=Path(f"/tmp/notes/{name}.md"), folder=
|
|
11
|
+
path=Path(f"/tmp/notes/{name}.md"), folder=repo,
|
|
12
12
|
meta={"status": status, "launch_priority": "P2", "milestone_alignment": "v1",
|
|
13
13
|
"blockers": blockers or [], "next_up": next_up or [],
|
|
14
14
|
"depends_on": depends_on or [],
|
|
15
15
|
"github": {"repo": repo, "issues": issues}})
|
|
16
16
|
|
|
17
17
|
class BuildExportTest(unittest.TestCase):
|
|
18
|
+
def test_track_key_prefers_config_folder_over_repo_slug(self):
|
|
19
|
+
track = SimpleNamespace(name="alpha", repo="org/repo", folder="config-folder")
|
|
20
|
+
self.assertEqual(track_key(track), ("config-folder", "alpha"))
|
|
21
|
+
|
|
22
|
+
def test_track_key_falls_back_to_folder_without_repo(self):
|
|
23
|
+
track = SimpleNamespace(name="alpha", repo=None, folder="config-folder")
|
|
24
|
+
self.assertEqual(track_key(track), ("config-folder", "alpha"))
|
|
25
|
+
|
|
26
|
+
def test_folder_identity_is_shared_by_issue_hot_and_plan_maps(self):
|
|
27
|
+
track = _track("alpha", None, [7])
|
|
28
|
+
track.folder = "config-folder"
|
|
29
|
+
key = ("config-folder", "alpha")
|
|
30
|
+
badge = {"rel": "docs/plans/p.md", "resolved": False}
|
|
31
|
+
issues = [{"number": 7, "title": "folder-only", "state": "OPEN",
|
|
32
|
+
"assignees": [], "labels": []}]
|
|
33
|
+
|
|
34
|
+
out = build_export(
|
|
35
|
+
[track], {key: issues}, {}, now="t",
|
|
36
|
+
plan_by_track={key: badge}, hot_by_track={key: {7}},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
exported = out["tracks"][0]
|
|
40
|
+
self.assertEqual(exported["plan"], badge)
|
|
41
|
+
self.assertEqual(exported["issues"][0]["number"], 7)
|
|
42
|
+
self.assertTrue(exported["issues"][0]["in_progress"])
|
|
43
|
+
|
|
18
44
|
def test_schema_and_shape(self):
|
|
19
45
|
tracks = [_track("ph", "o/r", [1, 2], blockers=[9], next_up=[1])]
|
|
20
46
|
issues_by_track = {("o/r", "ph"): [
|
|
@@ -31,7 +57,7 @@ class BuildExportTest(unittest.TestCase):
|
|
|
31
57
|
# the platform — str(Path) yields backslashes on Windows.
|
|
32
58
|
self.assertEqual(t["path"], str(Path("/tmp/notes/ph.md")))
|
|
33
59
|
# Config repo key surfaces for the Plans view's --repo arg (#164).
|
|
34
|
-
self.assertEqual(t["folder"], "
|
|
60
|
+
self.assertEqual(t["folder"], "o/r")
|
|
35
61
|
self.assertEqual(t["blockers"], [9]); self.assertEqual(t["next_up"], [1])
|
|
36
62
|
self.assertEqual(t["rollup"], {"open": 1, "closed": 1})
|
|
37
63
|
self.assertEqual(t["issues"][0], {"number": 1, "title": "a", "state": "open", "assignee": "@eve", "milestone": None, "in_progress": False, "in_progress_label": False, "blocked_by": [], "blocking": []})
|
|
@@ -74,7 +100,7 @@ class BuildExportNextUpAutoTest(unittest.TestCase):
|
|
|
74
100
|
if auto:
|
|
75
101
|
meta["next_up_auto"] = True
|
|
76
102
|
return SimpleNamespace(name="t", repo="o/r", tier="private",
|
|
77
|
-
path=Path("/tmp/notes/t.md"), folder="
|
|
103
|
+
path=Path("/tmp/notes/t.md"), folder="o/r", meta=meta)
|
|
78
104
|
|
|
79
105
|
def _issues(self):
|
|
80
106
|
# P0 #3, P1 #2, P2 #1 — flow ranks by priority within the same milestone.
|
|
@@ -113,7 +139,7 @@ class BuildExportNextUpAutoTest(unittest.TestCase):
|
|
|
113
139
|
from types import SimpleNamespace
|
|
114
140
|
from pathlib import Path
|
|
115
141
|
t = SimpleNamespace(name="t", repo="o/r", tier="private",
|
|
116
|
-
path=Path("/tmp/notes/t.md"), folder="
|
|
142
|
+
path=Path("/tmp/notes/t.md"), folder="o/r", meta=meta)
|
|
117
143
|
out = build_export([t],
|
|
118
144
|
{("o/r", "t"): []}, # zero issues fetched
|
|
119
145
|
{"o/r": "PRIVATE"}, now="t")
|
|
@@ -381,13 +407,13 @@ class BuildExportPlanTest(unittest.TestCase):
|
|
|
381
407
|
"checkboxes_done": 0, "checkboxes_total": 24, "lie_gap": False,
|
|
382
408
|
"stalled": False, "override": "shipped"}
|
|
383
409
|
out = build_export(tracks, {("o/r", "alpha"): []}, {"o/r": "PRIVATE"}, now="t",
|
|
384
|
-
plan_by_track={"alpha": badge})
|
|
410
|
+
plan_by_track={("o/r", "alpha"): badge})
|
|
385
411
|
self.assertEqual(out["tracks"][0]["plan"], badge)
|
|
386
412
|
|
|
387
413
|
def test_unresolved_badge_passed_through(self):
|
|
388
414
|
tracks = [_track("alpha", "o/r", [1])]
|
|
389
415
|
out = build_export(tracks, {("o/r", "alpha"): []}, {"o/r": "PRIVATE"}, now="t",
|
|
390
|
-
plan_by_track={"alpha": {"rel": "docs/plans/p.md", "resolved": False}})
|
|
416
|
+
plan_by_track={("o/r", "alpha"): {"rel": "docs/plans/p.md", "resolved": False}})
|
|
391
417
|
self.assertEqual(out["tracks"][0]["plan"], {"rel": "docs/plans/p.md", "resolved": False})
|
|
392
418
|
|
|
393
419
|
|
|
@@ -583,7 +609,7 @@ class BuildExportNextUpPresetTest(unittest.TestCase):
|
|
|
583
609
|
if track_meta_override:
|
|
584
610
|
meta.update(track_meta_override)
|
|
585
611
|
t = SimpleNamespace(name="alpha", repo="o/r", tier="private",
|
|
586
|
-
path=Path("/tmp/notes/alpha.md"), folder="
|
|
612
|
+
path=Path("/tmp/notes/alpha.md"), folder="o/r",
|
|
587
613
|
meta=meta)
|
|
588
614
|
out = build_export([t], {("o/r", "alpha"): []}, {"o/r": "PRIVATE"},
|
|
589
615
|
now="2026-06-14T00:00", next_up_default=next_up_default)
|
|
@@ -263,6 +263,53 @@ class ExportRunJsonTest(unittest.TestCase):
|
|
|
263
263
|
# repoB: issue 1 — once
|
|
264
264
|
self.assertEqual(repo_to_numbers[repo_b], [1])
|
|
265
265
|
|
|
266
|
+
def test_same_name_tracks_in_different_repos_keep_distinct_plan_badges(self):
|
|
267
|
+
"""Plan badges must follow track identity, not collide on track name."""
|
|
268
|
+
repo_a = "org/repoA"
|
|
269
|
+
repo_b = "org/repoB"
|
|
270
|
+
track_a = _track("shared-name", repo_a, [])
|
|
271
|
+
track_b = _track("shared-name", repo_b, [])
|
|
272
|
+
track_a.folder = "repo-a"
|
|
273
|
+
track_b.folder = "repo-b"
|
|
274
|
+
track_a.meta["plan"] = "docs/plans/a.md"
|
|
275
|
+
track_b.meta["plan"] = "docs/plans/b.md"
|
|
276
|
+
|
|
277
|
+
def _badge(track, *_args):
|
|
278
|
+
return {"rel": track.meta["plan"], "resolved": False}
|
|
279
|
+
|
|
280
|
+
with patch("commands.export._plan_badge", side_effect=_badge):
|
|
281
|
+
rc, out, _ = self._run_with_mocks(
|
|
282
|
+
[track_a, track_b], {}, vis={repo_a: "PUBLIC", repo_b: "PRIVATE"}
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
self.assertEqual(rc, 0)
|
|
286
|
+
by_repo = {track["repo"]: track for track in out["tracks"]}
|
|
287
|
+
self.assertEqual(by_repo[repo_a]["plan"]["rel"], "docs/plans/a.md")
|
|
288
|
+
self.assertEqual(by_repo[repo_b]["plan"]["rel"], "docs/plans/b.md")
|
|
289
|
+
|
|
290
|
+
def test_same_repo_alias_folders_keep_distinct_plan_badges(self):
|
|
291
|
+
"""Folder keys remain distinct even when aliases share a GitHub slug."""
|
|
292
|
+
repo = "org/shared"
|
|
293
|
+
track_a = _track("shared-name", repo, [])
|
|
294
|
+
track_b = _track("shared-name", repo, [])
|
|
295
|
+
track_a.folder = "alias-a"
|
|
296
|
+
track_b.folder = "alias-b"
|
|
297
|
+
track_a.meta["plan"] = "docs/plans/a.md"
|
|
298
|
+
track_b.meta["plan"] = "docs/plans/b.md"
|
|
299
|
+
|
|
300
|
+
def _badge(track, *_args):
|
|
301
|
+
return {"rel": track.meta["plan"], "resolved": False}
|
|
302
|
+
|
|
303
|
+
with patch("commands.export._plan_badge", side_effect=_badge):
|
|
304
|
+
rc, out, _ = self._run_with_mocks(
|
|
305
|
+
[track_a, track_b], {}, vis={repo: "PRIVATE"}
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
self.assertEqual(rc, 0)
|
|
309
|
+
by_folder = {track["folder"]: track for track in out["tracks"]}
|
|
310
|
+
self.assertEqual(by_folder["alias-a"]["plan"]["rel"], "docs/plans/a.md")
|
|
311
|
+
self.assertEqual(by_folder["alias-b"]["plan"]["rel"], "docs/plans/b.md")
|
|
312
|
+
|
|
266
313
|
|
|
267
314
|
class ExportCommandUntrackedTest(unittest.TestCase):
|
|
268
315
|
"""Verify export.run computes untracked = open issues minus tracked ones."""
|
|
@@ -435,6 +482,60 @@ class ExportPlanBadgeTest(unittest.TestCase):
|
|
|
435
482
|
badge = self._badge(self._track_with_plan(rel="docs/plans/missing.md"), root)
|
|
436
483
|
self.assertEqual(badge, {"rel": "docs/plans/missing.md", "resolved": False})
|
|
437
484
|
|
|
485
|
+
def test_unresolved_when_plan_traverses_outside_repo(self):
|
|
486
|
+
import tempfile
|
|
487
|
+
with tempfile.TemporaryDirectory() as d:
|
|
488
|
+
root = Path(d) / "repo"
|
|
489
|
+
root.mkdir()
|
|
490
|
+
outside = Path(d) / "outside.md"
|
|
491
|
+
outside.write_text(self.BODY)
|
|
492
|
+
rel = "../outside.md"
|
|
493
|
+
|
|
494
|
+
badge = self._badge(self._track_with_plan(rel=rel), root)
|
|
495
|
+
|
|
496
|
+
self.assertEqual(badge, {"rel": rel, "resolved": False})
|
|
497
|
+
|
|
498
|
+
def test_unresolved_when_plan_is_absolute_path(self):
|
|
499
|
+
import tempfile
|
|
500
|
+
with tempfile.TemporaryDirectory() as d:
|
|
501
|
+
root = Path(d) / "repo"
|
|
502
|
+
root.mkdir()
|
|
503
|
+
outside = Path(d) / "outside.md"
|
|
504
|
+
outside.write_text(self.BODY)
|
|
505
|
+
rel = str(outside)
|
|
506
|
+
|
|
507
|
+
badge = self._badge(self._track_with_plan(rel=rel), root)
|
|
508
|
+
|
|
509
|
+
self.assertEqual(badge, {"rel": rel, "resolved": False})
|
|
510
|
+
|
|
511
|
+
def test_unresolved_when_plan_is_absolute_path_inside_repo(self):
|
|
512
|
+
import tempfile
|
|
513
|
+
with tempfile.TemporaryDirectory() as d:
|
|
514
|
+
root = self._repo_with_plan(d, self.BODY)
|
|
515
|
+
rel = str(root / "docs/plans/p.md")
|
|
516
|
+
|
|
517
|
+
badge = self._badge(self._track_with_plan(rel=rel), root)
|
|
518
|
+
|
|
519
|
+
self.assertEqual(badge, {"rel": rel, "resolved": False})
|
|
520
|
+
|
|
521
|
+
def test_unresolved_when_plan_symlink_resolves_outside_repo(self):
|
|
522
|
+
import tempfile
|
|
523
|
+
with tempfile.TemporaryDirectory() as d:
|
|
524
|
+
root = Path(d) / "repo"
|
|
525
|
+
plans = root / "docs/plans"
|
|
526
|
+
plans.mkdir(parents=True)
|
|
527
|
+
outside = Path(d) / "outside.md"
|
|
528
|
+
outside.write_text(self.BODY)
|
|
529
|
+
link = plans / "p.md"
|
|
530
|
+
link.symlink_to(outside)
|
|
531
|
+
|
|
532
|
+
badge = self._badge(self._track_with_plan(), root)
|
|
533
|
+
|
|
534
|
+
self.assertEqual(
|
|
535
|
+
badge,
|
|
536
|
+
{"rel": "docs/plans/p.md", "resolved": False},
|
|
537
|
+
)
|
|
538
|
+
|
|
438
539
|
def test_no_folder_is_unresolved(self):
|
|
439
540
|
t = self._track_with_plan(folder=None)
|
|
440
541
|
from datetime import date
|
|
@@ -12,7 +12,8 @@ from lib.github_state import (
|
|
|
12
12
|
fetch_issues, fetch_issue, fetch_issues_concurrent,
|
|
13
13
|
fetch_repo_issues_graphql, fetch_export_issues, _normalize_gql_node,
|
|
14
14
|
extract_priority, fetch_recent_issues, short_milestone,
|
|
15
|
-
repo_visibility, _VIS_CACHE, fetch_open_issues,
|
|
15
|
+
repo_visibility, _VIS_CACHE, fetch_open_issues, repo_full_name,
|
|
16
|
+
gh_auth_status,
|
|
16
17
|
_GQL_FIELDS_LEAN, _GQL_FIELDS_FULL,
|
|
17
18
|
_gql_query, _GQL_ISSUE_DEPS,
|
|
18
19
|
)
|
|
@@ -608,5 +609,44 @@ class NormalizeDepsTest(unittest.TestCase):
|
|
|
608
609
|
self.assertFalse(out["deps_truncated"])
|
|
609
610
|
|
|
610
611
|
|
|
612
|
+
class TestRepoFullName(unittest.TestCase):
|
|
613
|
+
def test_returns_full_name_on_success(self):
|
|
614
|
+
fake = MagicMock(returncode=0, stdout="org/repo-renamed\n", stderr="")
|
|
615
|
+
with patch("lib.github_state.subprocess.run", return_value=fake) as m:
|
|
616
|
+
result = repo_full_name("org/repo-old")
|
|
617
|
+
self.assertEqual(result, "org/repo-renamed")
|
|
618
|
+
args = m.call_args[0][0]
|
|
619
|
+
self.assertEqual(args, ["gh", "api", "repos/org/repo-old", "--jq", ".full_name"])
|
|
620
|
+
|
|
621
|
+
def test_none_on_nonzero_exit(self):
|
|
622
|
+
fake = MagicMock(returncode=1, stdout="", stderr="not found")
|
|
623
|
+
with patch("lib.github_state.subprocess.run", return_value=fake):
|
|
624
|
+
self.assertIsNone(repo_full_name("org/gone"))
|
|
625
|
+
|
|
626
|
+
def test_none_on_invalid_slug(self):
|
|
627
|
+
self.assertIsNone(repo_full_name("not-a-slug"))
|
|
628
|
+
|
|
629
|
+
def test_none_on_subprocess_exception(self):
|
|
630
|
+
with patch("lib.github_state.subprocess.run", side_effect=OSError("boom")):
|
|
631
|
+
self.assertIsNone(repo_full_name("org/repo"))
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _gh_authenticated():
|
|
635
|
+
return gh_auth_status()["authenticated"]
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
class TestRepoFullNameLiveRedirect(unittest.TestCase):
|
|
639
|
+
def test_musical_family_trees_redirect_is_still_live(self):
|
|
640
|
+
# The actual repo this whole design's incident was about. If this ever
|
|
641
|
+
# stops returning the renamed slug, the redirect has lapsed and the
|
|
642
|
+
# design's core "gh api repos/<slug> follows GitHub's own rename
|
|
643
|
+
# redirect" assumption needs re-verifying before trusting doctor's
|
|
644
|
+
# output on real data.
|
|
645
|
+
if not _gh_authenticated():
|
|
646
|
+
self.skipTest("gh not authenticated — skipping live network check")
|
|
647
|
+
result = repo_full_name("evemcgivern/musical-family-trees")
|
|
648
|
+
self.assertEqual(result, "evemcgivern/soundstellation")
|
|
649
|
+
|
|
650
|
+
|
|
611
651
|
if __name__ == "__main__":
|
|
612
652
|
unittest.main()
|
|
@@ -77,6 +77,8 @@ def _drive_apply(args, *, cfg, batch, answers, vis="PRIVATE"):
|
|
|
77
77
|
patch("commands.group.load_config", return_value=cfg), \
|
|
78
78
|
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
79
79
|
patch("commands.group.is_valid_git_repo", return_value=True), \
|
|
80
|
+
patch("commands.group.shared_tier_dir",
|
|
81
|
+
return_value=Path(cfg["repos"]["myrepo"]["local"]) / ".work-plan"), \
|
|
80
82
|
patch("commands.group.write_file") as mw, \
|
|
81
83
|
patch("commands.group.parse_file", return_value=({}, "")), \
|
|
82
84
|
patch("commands.group.seed_readme") as mseed, \
|
|
@@ -193,6 +195,8 @@ class GroupApplyTierRoutingTest(unittest.TestCase):
|
|
|
193
195
|
patch("commands.group.load_config", return_value=cfg), \
|
|
194
196
|
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
195
197
|
patch("commands.group.is_valid_git_repo", return_value=True), \
|
|
198
|
+
patch("commands.group.shared_tier_dir",
|
|
199
|
+
return_value=Path(cfg["repos"]["myrepo"]["local"]) / ".work-plan"), \
|
|
196
200
|
patch("commands.group.write_file"), \
|
|
197
201
|
patch("commands.group.parse_file", return_value=({}, "")), \
|
|
198
202
|
patch("commands.group.seed_readme") as mseed, \
|
|
@@ -20,6 +20,7 @@ import io
|
|
|
20
20
|
import json
|
|
21
21
|
import subprocess
|
|
22
22
|
import sys
|
|
23
|
+
import tempfile
|
|
23
24
|
import unittest
|
|
24
25
|
from contextlib import redirect_stdout
|
|
25
26
|
from pathlib import Path
|
|
@@ -441,6 +442,82 @@ class NewTrackCommandTest(unittest.TestCase):
|
|
|
441
442
|
self.assertEqual(rc, 0)
|
|
442
443
|
mw.assert_called_once()
|
|
443
444
|
|
|
445
|
+
def test_refuses_shared_track_when_work_plan_root_is_symlinked_outside_repo(self):
|
|
446
|
+
"""A repo-controlled shared root must not redirect creation elsewhere."""
|
|
447
|
+
with tempfile.TemporaryDirectory() as d:
|
|
448
|
+
base = Path(d)
|
|
449
|
+
clone = base / "clone"
|
|
450
|
+
outside = base / "outside"
|
|
451
|
+
notes = base / "notes"
|
|
452
|
+
(clone / ".git").mkdir(parents=True)
|
|
453
|
+
outside.mkdir()
|
|
454
|
+
notes.mkdir()
|
|
455
|
+
try:
|
|
456
|
+
(clone / ".work-plan").symlink_to(outside, target_is_directory=True)
|
|
457
|
+
except OSError as exc:
|
|
458
|
+
self.skipTest(f"directory symlinks unavailable: {exc}")
|
|
459
|
+
cfg = {
|
|
460
|
+
"notes_root": str(notes),
|
|
461
|
+
"repos": {
|
|
462
|
+
"myrepo": {"github": "org/myrepo", "local": str(clone)},
|
|
463
|
+
},
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
with patch("commands.new_track.load_config", return_value=cfg), \
|
|
467
|
+
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"):
|
|
468
|
+
buf = io.StringIO()
|
|
469
|
+
with redirect_stdout(buf):
|
|
470
|
+
rc = new_track.run(["myrepo", "escaped"])
|
|
471
|
+
|
|
472
|
+
self.assertEqual(rc, 1)
|
|
473
|
+
self.assertFalse((outside / "escaped.md").exists())
|
|
474
|
+
self.assertIn("unsafe shared track directory", buf.getvalue())
|
|
475
|
+
|
|
476
|
+
def test_rechecks_shared_root_after_directory_creation_before_write(self):
|
|
477
|
+
"""A root swapped after mkdir is rejected before write_file opens it."""
|
|
478
|
+
with tempfile.TemporaryDirectory() as d:
|
|
479
|
+
base = Path(d)
|
|
480
|
+
clone = base / "clone"
|
|
481
|
+
outside = base / "outside"
|
|
482
|
+
notes = base / "notes"
|
|
483
|
+
(clone / ".git").mkdir(parents=True)
|
|
484
|
+
outside.mkdir()
|
|
485
|
+
notes.mkdir()
|
|
486
|
+
cfg = {
|
|
487
|
+
"notes_root": str(notes),
|
|
488
|
+
"repos": {
|
|
489
|
+
"myrepo": {"github": "org/myrepo", "local": str(clone)},
|
|
490
|
+
},
|
|
491
|
+
}
|
|
492
|
+
real_resolve = new_track.resolve_shared_tier
|
|
493
|
+
calls = 0
|
|
494
|
+
|
|
495
|
+
def _swap_after_mkdir(entry):
|
|
496
|
+
nonlocal calls
|
|
497
|
+
calls += 1
|
|
498
|
+
if calls == 3:
|
|
499
|
+
shared = clone / ".work-plan"
|
|
500
|
+
shared.rmdir()
|
|
501
|
+
try:
|
|
502
|
+
shared.symlink_to(outside, target_is_directory=True)
|
|
503
|
+
except OSError as exc:
|
|
504
|
+
self.skipTest(f"directory symlinks unavailable: {exc}")
|
|
505
|
+
return real_resolve(entry)
|
|
506
|
+
|
|
507
|
+
with patch("commands.new_track.load_config", return_value=cfg), \
|
|
508
|
+
patch("commands.new_track.resolve_shared_tier",
|
|
509
|
+
side_effect=_swap_after_mkdir), \
|
|
510
|
+
patch("commands.new_track.write_file") as write, \
|
|
511
|
+
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"):
|
|
512
|
+
buf = io.StringIO()
|
|
513
|
+
with redirect_stdout(buf):
|
|
514
|
+
rc = new_track.run(["myrepo", "escaped"])
|
|
515
|
+
|
|
516
|
+
self.assertEqual(rc, 1)
|
|
517
|
+
write.assert_not_called()
|
|
518
|
+
self.assertFalse((outside / "escaped.md").exists())
|
|
519
|
+
self.assertIn("unsafe shared track directory", buf.getvalue())
|
|
520
|
+
|
|
444
521
|
|
|
445
522
|
# ---------------------------------------------------------------------------
|
|
446
523
|
# Phase D: --commit flag tests
|
|
@@ -504,6 +581,8 @@ class NewTrackCommitFlagTest(unittest.TestCase):
|
|
|
504
581
|
|
|
505
582
|
with patch("commands.new_track.load_config", return_value=cfg), \
|
|
506
583
|
patch("commands.new_track.write_file") as mw, \
|
|
584
|
+
patch("commands.new_track.resolve_shared_tier",
|
|
585
|
+
return_value=(Path(CLONE_ROOT) / ".work-plan", None)), \
|
|
507
586
|
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
508
587
|
patch("pathlib.Path.exists", _path_exists), \
|
|
509
588
|
patch("pathlib.Path.is_dir", _is_dir), \
|
|
@@ -188,6 +188,36 @@ class RemoteAndOwnershipTest(unittest.TestCase):
|
|
|
188
188
|
self.assertTrue(notes_vcs.is_owned(Path(d)))
|
|
189
189
|
|
|
190
190
|
|
|
191
|
+
class DirtyPathsCheckedTest(unittest.TestCase):
|
|
192
|
+
def test_ok_true_on_clean_tree(self):
|
|
193
|
+
with tempfile.TemporaryDirectory() as d:
|
|
194
|
+
with patch.object(notes_vcs, "_git", _FakeGit(porcelain="")):
|
|
195
|
+
ok, paths = notes_vcs.dirty_paths_checked(Path(d))
|
|
196
|
+
self.assertTrue(ok)
|
|
197
|
+
self.assertEqual(paths, set())
|
|
198
|
+
|
|
199
|
+
def test_ok_true_with_dirty_paths(self):
|
|
200
|
+
with tempfile.TemporaryDirectory() as d:
|
|
201
|
+
with patch.object(notes_vcs, "_git",
|
|
202
|
+
_FakeGit(porcelain=" M foo.md\n?? bar.md\n")):
|
|
203
|
+
ok, paths = notes_vcs.dirty_paths_checked(Path(d))
|
|
204
|
+
self.assertTrue(ok)
|
|
205
|
+
self.assertEqual(paths, {"foo.md", "bar.md"})
|
|
206
|
+
|
|
207
|
+
def test_ok_false_on_git_call_failure(self):
|
|
208
|
+
with tempfile.TemporaryDirectory() as d:
|
|
209
|
+
with patch.object(notes_vcs, "_git", _FakeGit(missing=True)):
|
|
210
|
+
ok, paths = notes_vcs.dirty_paths_checked(Path(d))
|
|
211
|
+
self.assertFalse(ok)
|
|
212
|
+
self.assertEqual(paths, set())
|
|
213
|
+
|
|
214
|
+
def test_dirty_paths_thin_wrapper_matches_checked(self):
|
|
215
|
+
with tempfile.TemporaryDirectory() as d:
|
|
216
|
+
with patch.object(notes_vcs, "_git",
|
|
217
|
+
_FakeGit(porcelain=" M foo.md\n")):
|
|
218
|
+
self.assertEqual(notes_vcs.dirty_paths(Path(d)), {"foo.md"})
|
|
219
|
+
|
|
220
|
+
|
|
191
221
|
class DirtyPathsTest(unittest.TestCase):
|
|
192
222
|
def test_parses_porcelain_into_path_set(self):
|
|
193
223
|
body = " M alpha.md\n?? beta.md\n D gone.md\n"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Stamp / draft behaviour for plan-status (offline)."""
|
|
2
2
|
import io
|
|
3
|
+
import os
|
|
3
4
|
import unittest
|
|
4
5
|
import sys
|
|
5
6
|
import tempfile
|
|
@@ -11,6 +12,7 @@ SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
|
11
12
|
sys.path.insert(0, str(SKILL_ROOT))
|
|
12
13
|
|
|
13
14
|
from commands import plan_status
|
|
15
|
+
from lib.doc_discovery import Doc
|
|
14
16
|
from lib.status_header import BEGIN
|
|
15
17
|
|
|
16
18
|
PLAN = (
|
|
@@ -21,6 +23,14 @@ PLAN = (
|
|
|
21
23
|
"- [ ] Step 1\n"
|
|
22
24
|
)
|
|
23
25
|
|
|
26
|
+
ROW = {
|
|
27
|
+
"glyph": "✅",
|
|
28
|
+
"verdict": "shipped",
|
|
29
|
+
"files_present": 1,
|
|
30
|
+
"files_declared": 1,
|
|
31
|
+
"last_touched": "2026-07-12",
|
|
32
|
+
}
|
|
33
|
+
|
|
24
34
|
|
|
25
35
|
class StampBehaviourTest(unittest.TestCase):
|
|
26
36
|
def _repo(self, d):
|
|
@@ -65,6 +75,64 @@ class StampBehaviourTest(unittest.TestCase):
|
|
|
65
75
|
self._run(root, ["--stamp"])
|
|
66
76
|
self.assertEqual(first, self.plan_path.read_text())
|
|
67
77
|
|
|
78
|
+
def test_stamp_rechecks_symlink_before_write(self):
|
|
79
|
+
with tempfile.TemporaryDirectory() as d:
|
|
80
|
+
root = Path(d) / "repo"
|
|
81
|
+
plans = root / "docs/plans"
|
|
82
|
+
plans.mkdir(parents=True)
|
|
83
|
+
victim = Path(d) / "victim.md"
|
|
84
|
+
victim.write_text("# outside\n")
|
|
85
|
+
link = plans / "evil.md"
|
|
86
|
+
link.symlink_to(victim)
|
|
87
|
+
doc = Doc(path=link, rel="docs/plans/evil.md", kind="plan")
|
|
88
|
+
|
|
89
|
+
buf = io.StringIO()
|
|
90
|
+
with redirect_stdout(buf):
|
|
91
|
+
plan_status._stamp_docs([doc], [ROW], repo_root=root, draft=False)
|
|
92
|
+
|
|
93
|
+
self.assertEqual(victim.read_text(), "# outside\n")
|
|
94
|
+
self.assertIn("refusing to stamp unsafe path", buf.getvalue())
|
|
95
|
+
|
|
96
|
+
def test_stamp_cli_does_not_modify_outside_symlink_target(self):
|
|
97
|
+
with tempfile.TemporaryDirectory() as d:
|
|
98
|
+
root = Path(d) / "repo"
|
|
99
|
+
plans = root / "docs/superpowers/plans"
|
|
100
|
+
plans.mkdir(parents=True)
|
|
101
|
+
victim = Path(d) / "victim.md"
|
|
102
|
+
victim.write_text(PLAN)
|
|
103
|
+
before = victim.read_bytes()
|
|
104
|
+
(plans / "evil.md").symlink_to(victim)
|
|
105
|
+
|
|
106
|
+
rc, out = self._run(root, ["--stamp"])
|
|
107
|
+
|
|
108
|
+
self.assertEqual(rc, 0)
|
|
109
|
+
self.assertEqual(victim.read_bytes(), before)
|
|
110
|
+
self.assertNotIn(BEGIN, victim.read_text())
|
|
111
|
+
self.assertIn("stamped 0 doc(s)", out)
|
|
112
|
+
|
|
113
|
+
def test_stamp_replaces_hard_link_without_modifying_outside_inode(self):
|
|
114
|
+
with tempfile.TemporaryDirectory() as d:
|
|
115
|
+
root = Path(d) / "repo"
|
|
116
|
+
plans = root / "docs/superpowers/plans"
|
|
117
|
+
plans.mkdir(parents=True)
|
|
118
|
+
victim = Path(d) / "victim.md"
|
|
119
|
+
victim.write_text(PLAN)
|
|
120
|
+
linked_plan = plans / "linked.md"
|
|
121
|
+
try:
|
|
122
|
+
os.link(str(victim), str(linked_plan))
|
|
123
|
+
except OSError as exc:
|
|
124
|
+
self.skipTest(f"hard links unavailable: {exc}")
|
|
125
|
+
original_inode = victim.stat().st_ino
|
|
126
|
+
|
|
127
|
+
rc, out = self._run(root, ["--stamp"])
|
|
128
|
+
|
|
129
|
+
self.assertEqual(rc, 0)
|
|
130
|
+
self.assertEqual(victim.read_text(), PLAN)
|
|
131
|
+
self.assertEqual(victim.stat().st_ino, original_inode)
|
|
132
|
+
self.assertNotEqual(linked_plan.stat().st_ino, original_inode)
|
|
133
|
+
self.assertIn(BEGIN, linked_plan.read_text())
|
|
134
|
+
self.assertIn("stamped 1 doc(s)", out)
|
|
135
|
+
|
|
68
136
|
|
|
69
137
|
if __name__ == "__main__":
|
|
70
138
|
unittest.main()
|
|
@@ -97,6 +97,20 @@ class SharedTierDirTest(unittest.TestCase):
|
|
|
97
97
|
got = pw.shared_tier_dir({"local": d})
|
|
98
98
|
self.assertEqual(got, Path(d).expanduser() / ".work-plan")
|
|
99
99
|
|
|
100
|
+
def test_no_plan_branch_refuses_symlinked_work_plan_root(self):
|
|
101
|
+
with tempfile.TemporaryDirectory() as d:
|
|
102
|
+
base = Path(d)
|
|
103
|
+
clone = base / "clone"
|
|
104
|
+
outside = base / "outside"
|
|
105
|
+
clone.mkdir()
|
|
106
|
+
outside.mkdir()
|
|
107
|
+
try:
|
|
108
|
+
(clone / ".work-plan").symlink_to(outside, target_is_directory=True)
|
|
109
|
+
except OSError as exc:
|
|
110
|
+
self.skipTest(f"directory symlinks unavailable: {exc}")
|
|
111
|
+
|
|
112
|
+
self.assertIsNone(pw.shared_tier_dir({"local": str(clone)}))
|
|
113
|
+
|
|
100
114
|
def test_plan_branch_uses_worktree_when_present(self):
|
|
101
115
|
with tempfile.TemporaryDirectory() as d:
|
|
102
116
|
dest = Path(d) / "wt"
|
|
@@ -107,6 +121,23 @@ class SharedTierDirTest(unittest.TestCase):
|
|
|
107
121
|
got = pw.shared_tier_dir({"local": d, "plan_branch": "plan"})
|
|
108
122
|
self.assertEqual(got, dest / ".work-plan")
|
|
109
123
|
|
|
124
|
+
def test_plan_branch_refuses_symlinked_work_plan_root(self):
|
|
125
|
+
with tempfile.TemporaryDirectory() as d:
|
|
126
|
+
base = Path(d)
|
|
127
|
+
dest = base / "wt"
|
|
128
|
+
outside = base / "outside"
|
|
129
|
+
dest.mkdir()
|
|
130
|
+
outside.mkdir()
|
|
131
|
+
(dest / ".git").write_text("gitdir: ...\n")
|
|
132
|
+
try:
|
|
133
|
+
(dest / ".work-plan").symlink_to(outside, target_is_directory=True)
|
|
134
|
+
except OSError as exc:
|
|
135
|
+
self.skipTest(f"directory symlinks unavailable: {exc}")
|
|
136
|
+
with patch.object(pw, "_worktree_dir", return_value=dest), \
|
|
137
|
+
patch.object(pw, "_git", _FakeGit()):
|
|
138
|
+
got = pw.shared_tier_dir({"local": d, "plan_branch": "plan"})
|
|
139
|
+
self.assertIsNone(got)
|
|
140
|
+
|
|
110
141
|
def test_plan_branch_none_when_branch_missing(self):
|
|
111
142
|
with tempfile.TemporaryDirectory() as d:
|
|
112
143
|
dest = Path(d) / "wt" # no .git → must be created, but branch missing
|
|
@@ -9,7 +9,7 @@ from unittest.mock import patch
|
|
|
9
9
|
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
10
10
|
sys.path.insert(0, str(SKILL_ROOT))
|
|
11
11
|
|
|
12
|
-
from lib.tracks import discover_tracks, discover_archived_tracks
|
|
12
|
+
from lib.tracks import discover_tracks, discover_archived_tracks, iter_private_track_paths
|
|
13
13
|
|
|
14
14
|
FIXTURES = Path(__file__).parent / "fixtures" / "notes_root"
|
|
15
15
|
|
|
@@ -120,6 +120,23 @@ class SharedTrackDiscoveryTest(unittest.TestCase):
|
|
|
120
120
|
shared = next(t for t in tracks if t.name == "feat-x")
|
|
121
121
|
self.assertEqual(shared.tier, "shared")
|
|
122
122
|
|
|
123
|
+
def test_symlinked_shared_root_outside_repo_is_not_discovered(self):
|
|
124
|
+
with tempfile.TemporaryDirectory() as d:
|
|
125
|
+
base = Path(d)
|
|
126
|
+
clone = _make_git_repo(base / "clone")
|
|
127
|
+
outside = base / "outside"
|
|
128
|
+
_write_track_md(outside / "escaped.md", "escaped", "org/myrepo")
|
|
129
|
+
try:
|
|
130
|
+
(clone / ".work-plan").symlink_to(outside, target_is_directory=True)
|
|
131
|
+
except OSError as exc:
|
|
132
|
+
self.skipTest(f"directory symlinks unavailable: {exc}")
|
|
133
|
+
notes = base / "notes"
|
|
134
|
+
notes.mkdir()
|
|
135
|
+
|
|
136
|
+
tracks = discover_tracks(self._make_cfg(clone, notes))
|
|
137
|
+
|
|
138
|
+
self.assertNotIn("escaped", [track.name for track in tracks])
|
|
139
|
+
|
|
123
140
|
def test_shared_track_repo_from_folder_config(self):
|
|
124
141
|
"""repo and local_path on a shared track come from folder config, not frontmatter."""
|
|
125
142
|
with tempfile.TemporaryDirectory() as d:
|
|
@@ -446,5 +463,42 @@ class DiscoverArchivedSharedTest(unittest.TestCase):
|
|
|
446
463
|
self.assertNotIn("WARN:", stderr_buf.getvalue())
|
|
447
464
|
|
|
448
465
|
|
|
466
|
+
class TestIterPrivateTrackPaths(unittest.TestCase):
|
|
467
|
+
def _make_tree(self, root: Path):
|
|
468
|
+
(root / "proj-a").mkdir(parents=True)
|
|
469
|
+
(root / "proj-a" / "track-one.md").write_text("---\n---\nbody")
|
|
470
|
+
(root / "proj-a" / "_draft.md").write_text("---\n---\nbody")
|
|
471
|
+
(root / "proj-a" / "archive").mkdir()
|
|
472
|
+
(root / "proj-a" / "archive" / "old-track.md").write_text("---\n---\nbody")
|
|
473
|
+
(root / "proj-a" / ".hidden.md").write_text("---\n---\nbody")
|
|
474
|
+
|
|
475
|
+
def test_excludes_archive_by_default(self):
|
|
476
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
477
|
+
root = Path(tmp)
|
|
478
|
+
self._make_tree(root)
|
|
479
|
+
paths = iter_private_track_paths(root, include_archive=False)
|
|
480
|
+
names = {p.name for p in paths}
|
|
481
|
+
self.assertEqual(names, {"track-one.md"})
|
|
482
|
+
|
|
483
|
+
def test_includes_archive_when_requested(self):
|
|
484
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
485
|
+
root = Path(tmp)
|
|
486
|
+
self._make_tree(root)
|
|
487
|
+
paths = iter_private_track_paths(root, include_archive=True)
|
|
488
|
+
names = {p.name for p in paths}
|
|
489
|
+
self.assertEqual(names, {"track-one.md", "old-track.md"})
|
|
490
|
+
|
|
491
|
+
def test_skips_dotfile_underscore_dash_prefixed_names_in_both_modes(self):
|
|
492
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
493
|
+
root = Path(tmp)
|
|
494
|
+
self._make_tree(root)
|
|
495
|
+
(root / "proj-a" / "-dash.md").write_text("---\n---\nbody")
|
|
496
|
+
for include_archive in (False, True):
|
|
497
|
+
names = {p.name for p in iter_private_track_paths(root, include_archive)}
|
|
498
|
+
self.assertNotIn("_draft.md", names)
|
|
499
|
+
self.assertNotIn(".hidden.md", names)
|
|
500
|
+
self.assertNotIn("-dash.md", names)
|
|
501
|
+
|
|
502
|
+
|
|
449
503
|
if __name__ == "__main__":
|
|
450
504
|
unittest.main()
|