@stylusnexus/work-plan 2026.6.22 → 2026.7.3
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 +8 -1
- package/VERSION +1 -1
- package/bin/work-plan +8 -0
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +5 -1
- package/skills/work-plan/commands/archive_track.py +67 -0
- package/skills/work-plan/commands/delete_track.py +92 -0
- package/skills/work-plan/commands/export.py +12 -3
- package/skills/work-plan/commands/hygiene.py +27 -0
- package/skills/work-plan/commands/mark_cleanup.py +78 -0
- package/skills/work-plan/commands/plan_archive.py +7 -2
- package/skills/work-plan/commands/plan_status.py +14 -7
- package/skills/work-plan/commands/plan_unarchive.py +87 -0
- package/skills/work-plan/commands/unarchive_track.py +64 -0
- package/skills/work-plan/lib/archive.py +57 -11
- package/skills/work-plan/lib/export_model.py +8 -0
- package/skills/work-plan/lib/git_state.py +22 -0
- package/skills/work-plan/lib/render.py +16 -0
- package/skills/work-plan/tests/test_archive_lib.py +108 -14
- package/skills/work-plan/tests/test_archive_track.py +100 -0
- package/skills/work-plan/tests/test_delete_track.py +108 -0
- package/skills/work-plan/tests/test_export.py +41 -0
- package/skills/work-plan/tests/test_mark_cleanup.py +88 -0
- package/skills/work-plan/tests/test_plan_archive.py +3 -1
- package/skills/work-plan/tests/test_plan_status_archive.py +5 -1
- package/skills/work-plan/tests/test_plan_status_archive_shipped.py +3 -1
- package/skills/work-plan/tests/test_plan_unarchive.py +77 -0
- package/skills/work-plan/work_plan.py +25 -0
|
@@ -1,21 +1,67 @@
|
|
|
1
|
-
"""The archive move primitive: collision-checked
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
"""The archive move primitive: collision-checked move of a doc into
|
|
2
|
+
archive/<kind>/. For tracked files the move is a history-preserving `git mv`
|
|
3
|
+
(staged rename); for untracked/gitignored files it falls back to a plain
|
|
4
|
+
filesystem move. Eligibility (is-this-shipped) is the caller's job — this
|
|
5
|
+
only moves files."""
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
5
8
|
|
|
6
9
|
from lib import git_state
|
|
7
10
|
from lib import reconcile_actions
|
|
8
11
|
|
|
9
12
|
|
|
10
13
|
def move_to_archive(rel: str, repo_root, kind: str):
|
|
11
|
-
"""
|
|
12
|
-
"archived"
|
|
13
|
-
"
|
|
14
|
-
|
|
14
|
+
"""Move `rel` into archive/<kind>/. Returns:
|
|
15
|
+
"archived" tracked file: history-preserving `git mv` (staged rename),
|
|
16
|
+
"archived_local" untracked/gitignored file: plain filesystem move,
|
|
17
|
+
"skipped_collision" destination already exists (never overwrite),
|
|
18
|
+
None hard failure (git mv failed on a tracked file, or OSError
|
|
19
|
+
on an untracked file).
|
|
15
20
|
"""
|
|
16
21
|
dest = reconcile_actions.archive_dest(rel, kind)
|
|
17
22
|
if (Path(repo_root) / dest).exists():
|
|
18
23
|
return "skipped_collision"
|
|
19
|
-
if git_state.
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
if git_state.is_tracked(rel, Path(repo_root)):
|
|
25
|
+
if git_state.git_mv(rel, dest, repo_root):
|
|
26
|
+
return "archived"
|
|
27
|
+
return None
|
|
28
|
+
# Untracked / gitignored — plain filesystem move.
|
|
29
|
+
src_path = Path(repo_root) / rel
|
|
30
|
+
dst_path = Path(repo_root) / dest
|
|
31
|
+
try:
|
|
32
|
+
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
shutil.move(str(src_path), str(dst_path))
|
|
34
|
+
return "archived_local"
|
|
35
|
+
except OSError:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def restore_from_archive(rel: str, repo_root):
|
|
40
|
+
"""Inverse of move_to_archive: move an archived doc back OUT of
|
|
41
|
+
`.../archive/<kind>/<name>` to its original directory (`.../name`). Returns:
|
|
42
|
+
"restored" tracked file: history-preserving `git mv` (staged),
|
|
43
|
+
"restored_local" untracked/gitignored file: plain filesystem move,
|
|
44
|
+
"skipped_collision" a live doc already exists at the destination,
|
|
45
|
+
None `rel` isn't under archive/<kind>/, or a hard move failure.
|
|
46
|
+
Shared by unarchive-track (#328) and plan-unarchive (#388).
|
|
47
|
+
"""
|
|
48
|
+
p = PurePosixPath(rel)
|
|
49
|
+
# Expect .../archive/<kind>/<name>; the grandparent dir must be "archive".
|
|
50
|
+
if p.parent.parent.name != "archive":
|
|
51
|
+
return None
|
|
52
|
+
dest = str(p.parent.parent.parent / p.name)
|
|
53
|
+
if (Path(repo_root) / dest).exists():
|
|
54
|
+
return "skipped_collision"
|
|
55
|
+
if git_state.is_tracked(rel, Path(repo_root)):
|
|
56
|
+
if git_state.git_mv(rel, dest, repo_root):
|
|
57
|
+
return "restored"
|
|
58
|
+
return None
|
|
59
|
+
# Untracked / gitignored — plain filesystem move.
|
|
60
|
+
src_path = Path(repo_root) / rel
|
|
61
|
+
dst_path = Path(repo_root) / dest
|
|
62
|
+
try:
|
|
63
|
+
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
shutil.move(str(src_path), str(dst_path))
|
|
65
|
+
return "restored_local"
|
|
66
|
+
except OSError:
|
|
67
|
+
return None
|
|
@@ -139,6 +139,14 @@ def build_export(tracks, issues_by_track, visibility, now: str,
|
|
|
139
139
|
"folder": getattr(t, "folder", None),
|
|
140
140
|
"tier": getattr(t, "tier", "private") or "private",
|
|
141
141
|
"status": t.meta.get("status"),
|
|
142
|
+
# True for a track in the archive tier (#328), surfaced only when
|
|
143
|
+
# export ran with --include-archived; the viewer greys these.
|
|
144
|
+
"archived": bool(t.meta.get("archived")),
|
|
145
|
+
# Cleanup earmark (#328): a lightweight "candidate for retirement"
|
|
146
|
+
# flag set via `mark-cleanup`, with an optional free-text reason
|
|
147
|
+
# (null when unset). Surfaced by the viewer + hygiene callout.
|
|
148
|
+
"cleanup_candidate": bool(t.meta.get("cleanup_candidate")),
|
|
149
|
+
"cleanup_reason": t.meta.get("cleanup_reason"),
|
|
142
150
|
"launch_priority": t.meta.get("launch_priority"),
|
|
143
151
|
"milestone_alignment": milestone_alignment,
|
|
144
152
|
"visibility": visibility.get(t.repo),
|
|
@@ -358,6 +358,18 @@ def path_committed_since(rel_path: str, since: date, repo_path: Path) -> bool:
|
|
|
358
358
|
return proc is not None and proc.returncode == 0 and bool(proc.stdout.strip())
|
|
359
359
|
|
|
360
360
|
|
|
361
|
+
def is_tracked(rel_path: str, repo_path: Path) -> bool:
|
|
362
|
+
"""True iff `rel_path` is tracked by git (git ls-files --error-unmatch).
|
|
363
|
+
|
|
364
|
+
False for untracked/gitignored paths AND when repo_path isn't a git repo
|
|
365
|
+
or doesn't exist. Never raises.
|
|
366
|
+
"""
|
|
367
|
+
if not repo_path or not Path(repo_path).exists():
|
|
368
|
+
return False
|
|
369
|
+
proc = _git(repo_path, "ls-files", "--error-unmatch", rel_path)
|
|
370
|
+
return proc is not None and proc.returncode == 0
|
|
371
|
+
|
|
372
|
+
|
|
361
373
|
def git_mv(src_rel: str, dst_rel: str, repo_path: Path) -> bool:
|
|
362
374
|
"""git-mv `src_rel` -> `dst_rel` (both repo-relative), creating the dest
|
|
363
375
|
directory first. Returns True on success. History-preserving."""
|
|
@@ -366,3 +378,13 @@ def git_mv(src_rel: str, dst_rel: str, repo_path: Path) -> bool:
|
|
|
366
378
|
(Path(repo_path) / dst_rel).parent.mkdir(parents=True, exist_ok=True)
|
|
367
379
|
proc = _git(repo_path, "mv", src_rel, dst_rel)
|
|
368
380
|
return proc is not None and proc.returncode == 0
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def git_rm(rel_path: str, repo_path: Path) -> bool:
|
|
384
|
+
"""git-rm `rel_path` (repo-relative) — stage its deletion. Returns True on
|
|
385
|
+
success. The removal stays in the index (undoable from git history) until
|
|
386
|
+
the caller commits; #330 delete-track uses it for tracked tracks."""
|
|
387
|
+
if not repo_path or not Path(repo_path).exists():
|
|
388
|
+
return False
|
|
389
|
+
proc = _git(repo_path, "rm", rel_path)
|
|
390
|
+
return proc is not None and proc.returncode == 0
|
|
@@ -90,3 +90,19 @@ def render_track_row(t: dict) -> str:
|
|
|
90
90
|
def render_archived_reopen(repo: str, slug: str, issue: dict) -> str:
|
|
91
91
|
return (f"⚠ archive/{slug}.md (shipped) — new issue #{issue['number']} "
|
|
92
92
|
f"matches this slug. Re-open or slot into a different track?")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def render_cleanup_callout(candidates: list) -> str:
|
|
96
|
+
"""Render the hygiene "marked for cleanup" section, or "" if none.
|
|
97
|
+
|
|
98
|
+
`candidates` is a list of (name, reason) tuples; reason may be None/empty.
|
|
99
|
+
Returns a multi-line block (header + one line per track) or an empty string
|
|
100
|
+
when nothing is marked, so the caller can skip printing entirely.
|
|
101
|
+
"""
|
|
102
|
+
if not candidates:
|
|
103
|
+
return ""
|
|
104
|
+
lines = ["🧹 Marked for cleanup:"]
|
|
105
|
+
for name, reason in candidates:
|
|
106
|
+
suffix = f" — {reason}" if reason else ""
|
|
107
|
+
lines.append(f" {name}{suffix}")
|
|
108
|
+
return "\n".join(lines)
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
"""Pure archive helpers: destination path + shipped-row selection (offline)."""
|
|
2
2
|
import sys
|
|
3
|
+
import tempfile
|
|
3
4
|
import unittest
|
|
4
5
|
from pathlib import Path
|
|
6
|
+
from unittest import mock
|
|
5
7
|
|
|
6
8
|
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
7
9
|
sys.path.insert(0, str(SKILL_ROOT))
|
|
8
10
|
|
|
9
11
|
from lib import reconcile_actions as ra
|
|
12
|
+
from lib import archive as archive_lib
|
|
10
13
|
|
|
11
14
|
|
|
12
15
|
class ArchiveDestTest(unittest.TestCase):
|
|
@@ -46,11 +49,6 @@ class ShippedRowsTest(unittest.TestCase):
|
|
|
46
49
|
self.assertEqual(rels, ["a.md", "b.md"])
|
|
47
50
|
|
|
48
51
|
|
|
49
|
-
import tempfile
|
|
50
|
-
from unittest import mock
|
|
51
|
-
from lib import archive as archive_lib
|
|
52
|
-
|
|
53
|
-
|
|
54
52
|
class MoveToArchiveTest(unittest.TestCase):
|
|
55
53
|
def _repo(self, d):
|
|
56
54
|
root = Path(d)
|
|
@@ -58,32 +56,128 @@ class MoveToArchiveTest(unittest.TestCase):
|
|
|
58
56
|
(root / "docs/plans/x.md").write_text("# x")
|
|
59
57
|
return root
|
|
60
58
|
|
|
61
|
-
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
# tracked path: branches through git_mv
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def test_tracked_archived_calls_git_mv(self):
|
|
64
|
+
"""Tracked file: is_tracked=True -> git_mv called -> 'archived'."""
|
|
62
65
|
with tempfile.TemporaryDirectory() as d:
|
|
63
66
|
root = self._repo(d)
|
|
64
|
-
with mock.patch("lib.archive.git_state.
|
|
67
|
+
with mock.patch("lib.archive.git_state.is_tracked", return_value=True), \
|
|
68
|
+
mock.patch("lib.archive.git_state.git_mv", return_value=True) as mv:
|
|
65
69
|
outcome = archive_lib.move_to_archive("docs/plans/x.md", root, "shipped")
|
|
66
70
|
self.assertEqual(outcome, "archived")
|
|
67
71
|
mv.assert_called_once_with(
|
|
68
72
|
"docs/plans/x.md", "docs/plans/archive/shipped/x.md", root)
|
|
69
73
|
|
|
70
|
-
def
|
|
74
|
+
def test_tracked_git_mv_failure_returns_none(self):
|
|
75
|
+
"""Tracked file where git_mv fails -> None (hard error)."""
|
|
76
|
+
with tempfile.TemporaryDirectory() as d:
|
|
77
|
+
root = self._repo(d)
|
|
78
|
+
with mock.patch("lib.archive.git_state.is_tracked", return_value=True), \
|
|
79
|
+
mock.patch("lib.archive.git_state.git_mv", return_value=False):
|
|
80
|
+
outcome = archive_lib.move_to_archive("docs/plans/x.md", root, "shipped")
|
|
81
|
+
self.assertIsNone(outcome)
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
# untracked/gitignored path: plain filesystem move
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def test_untracked_archived_local_via_filesystem(self):
|
|
88
|
+
"""Untracked file: is_tracked=False -> shutil.move called -> 'archived_local'.
|
|
89
|
+
|
|
90
|
+
The file must actually move on disk (src gone, dest exists)."""
|
|
91
|
+
with tempfile.TemporaryDirectory() as d:
|
|
92
|
+
root = self._repo(d)
|
|
93
|
+
with mock.patch("lib.archive.git_state.is_tracked", return_value=False), \
|
|
94
|
+
mock.patch("lib.archive.git_state.git_mv") as mv:
|
|
95
|
+
outcome = archive_lib.move_to_archive("docs/plans/x.md", root, "shipped")
|
|
96
|
+
self.assertEqual(outcome, "archived_local")
|
|
97
|
+
mv.assert_not_called()
|
|
98
|
+
# Source is gone; dest exists.
|
|
99
|
+
self.assertFalse((root / "docs/plans/x.md").exists())
|
|
100
|
+
self.assertTrue((root / "docs/plans/archive/shipped/x.md").exists())
|
|
101
|
+
|
|
102
|
+
def test_untracked_oserror_returns_none(self):
|
|
103
|
+
"""Untracked file where shutil.move raises OSError -> None."""
|
|
104
|
+
with tempfile.TemporaryDirectory() as d:
|
|
105
|
+
root = self._repo(d)
|
|
106
|
+
with mock.patch("lib.archive.git_state.is_tracked", return_value=False), \
|
|
107
|
+
mock.patch("lib.archive.shutil.move",
|
|
108
|
+
side_effect=OSError("permission denied")):
|
|
109
|
+
outcome = archive_lib.move_to_archive("docs/plans/x.md", root, "shipped")
|
|
110
|
+
self.assertIsNone(outcome)
|
|
111
|
+
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
# collision: checked before tracking branch, git_mv never called
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def test_collision_skips_without_any_move(self):
|
|
117
|
+
"""Destination already exists -> 'skipped_collision', no move attempted."""
|
|
71
118
|
with tempfile.TemporaryDirectory() as d:
|
|
72
119
|
root = self._repo(d)
|
|
73
120
|
dest = root / "docs/plans/archive/shipped"
|
|
74
121
|
dest.mkdir(parents=True)
|
|
75
122
|
(dest / "x.md").write_text("# already here")
|
|
76
|
-
with mock.patch("lib.archive.git_state.
|
|
123
|
+
with mock.patch("lib.archive.git_state.is_tracked") as is_tracked, \
|
|
124
|
+
mock.patch("lib.archive.git_state.git_mv") as mv:
|
|
77
125
|
outcome = archive_lib.move_to_archive("docs/plans/x.md", root, "shipped")
|
|
78
126
|
self.assertEqual(outcome, "skipped_collision")
|
|
127
|
+
is_tracked.assert_not_called()
|
|
79
128
|
mv.assert_not_called()
|
|
80
129
|
|
|
81
|
-
|
|
130
|
+
|
|
131
|
+
class RestoreFromArchiveTest(unittest.TestCase):
|
|
132
|
+
def _archived_repo(self, d):
|
|
133
|
+
root = Path(d)
|
|
134
|
+
(root / "docs/plans/archive/shipped").mkdir(parents=True)
|
|
135
|
+
(root / "docs/plans/archive/shipped/x.md").write_text("# x")
|
|
136
|
+
return root
|
|
137
|
+
|
|
138
|
+
def test_tracked_restore_calls_git_mv_to_original_dir(self):
|
|
139
|
+
"""Tracked archived file → staged git mv back out → 'restored'."""
|
|
82
140
|
with tempfile.TemporaryDirectory() as d:
|
|
83
|
-
root = self.
|
|
84
|
-
with mock.patch("lib.archive.git_state.
|
|
85
|
-
|
|
86
|
-
|
|
141
|
+
root = self._archived_repo(d)
|
|
142
|
+
with mock.patch("lib.archive.git_state.is_tracked", return_value=True), \
|
|
143
|
+
mock.patch("lib.archive.git_state.git_mv", return_value=True) as mv:
|
|
144
|
+
outcome = archive_lib.restore_from_archive(
|
|
145
|
+
"docs/plans/archive/shipped/x.md", root)
|
|
146
|
+
self.assertEqual(outcome, "restored")
|
|
147
|
+
mv.assert_called_once_with(
|
|
148
|
+
"docs/plans/archive/shipped/x.md", "docs/plans/x.md", root)
|
|
149
|
+
|
|
150
|
+
def test_untracked_restore_local_via_filesystem(self):
|
|
151
|
+
"""Untracked archived file → shutil.move back → 'restored_local'; moves on disk."""
|
|
152
|
+
with tempfile.TemporaryDirectory() as d:
|
|
153
|
+
root = self._archived_repo(d)
|
|
154
|
+
with mock.patch("lib.archive.git_state.is_tracked", return_value=False), \
|
|
155
|
+
mock.patch("lib.archive.git_state.git_mv") as mv:
|
|
156
|
+
outcome = archive_lib.restore_from_archive(
|
|
157
|
+
"docs/plans/archive/shipped/x.md", root)
|
|
158
|
+
self.assertEqual(outcome, "restored_local")
|
|
159
|
+
mv.assert_not_called()
|
|
160
|
+
self.assertFalse((root / "docs/plans/archive/shipped/x.md").exists())
|
|
161
|
+
self.assertTrue((root / "docs/plans/x.md").exists())
|
|
162
|
+
|
|
163
|
+
def test_collision_refuses_when_live_doc_exists(self):
|
|
164
|
+
"""A live doc already at the destination → 'skipped_collision', no move."""
|
|
165
|
+
with tempfile.TemporaryDirectory() as d:
|
|
166
|
+
root = self._archived_repo(d)
|
|
167
|
+
(root / "docs/plans/x.md").write_text("# live already")
|
|
168
|
+
with mock.patch("lib.archive.git_state.is_tracked") as is_tracked, \
|
|
169
|
+
mock.patch("lib.archive.git_state.git_mv") as mv:
|
|
170
|
+
outcome = archive_lib.restore_from_archive(
|
|
171
|
+
"docs/plans/archive/shipped/x.md", root)
|
|
172
|
+
self.assertEqual(outcome, "skipped_collision")
|
|
173
|
+
is_tracked.assert_not_called()
|
|
174
|
+
mv.assert_not_called()
|
|
175
|
+
|
|
176
|
+
def test_not_under_archive_returns_none(self):
|
|
177
|
+
"""A path not under archive/<kind>/ → None (nothing to restore)."""
|
|
178
|
+
with tempfile.TemporaryDirectory() as d:
|
|
179
|
+
root = Path(d)
|
|
180
|
+
self.assertIsNone(archive_lib.restore_from_archive("docs/plans/x.md", root))
|
|
87
181
|
|
|
88
182
|
|
|
89
183
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""archive-track / unarchive-track commands (offline; move primitives mocked)."""
|
|
2
|
+
import io
|
|
3
|
+
import sys
|
|
4
|
+
import unittest
|
|
5
|
+
from contextlib import redirect_stdout
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import SimpleNamespace
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
11
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
12
|
+
|
|
13
|
+
from commands import archive_track, unarchive_track
|
|
14
|
+
from lib.write_guard import make_token
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _t(name="ph", repo="o/r", path="/tmp/notes/proj/ph.md", tier="private"):
|
|
18
|
+
return SimpleNamespace(name=name, repo=repo, path=Path(path), tier=tier,
|
|
19
|
+
folder="proj", has_frontmatter=True, meta={"status": "active"})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ArchiveTrackTest(unittest.TestCase):
|
|
23
|
+
def _drive(self, args, track=None, outcome="archived", vis="PRIVATE"):
|
|
24
|
+
track = track or _t()
|
|
25
|
+
with patch("commands.archive_track.load_config", return_value={"notes_root": "/tmp/notes"}), \
|
|
26
|
+
patch("commands.archive_track.discover_tracks", return_value=[track]), \
|
|
27
|
+
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
28
|
+
patch("commands.archive_track.move_to_archive", return_value=outcome) as mv:
|
|
29
|
+
buf = io.StringIO()
|
|
30
|
+
with redirect_stdout(buf):
|
|
31
|
+
rc = archive_track.run(args)
|
|
32
|
+
return rc, mv, buf.getvalue()
|
|
33
|
+
|
|
34
|
+
def test_archives_private_track(self):
|
|
35
|
+
rc, mv, out = self._drive(["ph"])
|
|
36
|
+
self.assertEqual(rc, 0)
|
|
37
|
+
mv.assert_called_once_with("ph.md", Path("/tmp/notes/proj"), "parked")
|
|
38
|
+
self.assertIn("archived", out)
|
|
39
|
+
|
|
40
|
+
def test_shared_track_prints_push_hint(self):
|
|
41
|
+
rc, mv, out = self._drive(["ph"], track=_t(tier="shared"))
|
|
42
|
+
self.assertEqual(rc, 0)
|
|
43
|
+
self.assertIn("commit & push", out)
|
|
44
|
+
|
|
45
|
+
def test_collision_is_reported_not_errored(self):
|
|
46
|
+
rc, mv, out = self._drive(["ph"], outcome="skipped_collision")
|
|
47
|
+
self.assertEqual(rc, 0)
|
|
48
|
+
self.assertIn("already exists", out)
|
|
49
|
+
|
|
50
|
+
def test_hard_failure_returns_1(self):
|
|
51
|
+
rc, mv, out = self._drive(["ph"], outcome=None)
|
|
52
|
+
self.assertEqual(rc, 1)
|
|
53
|
+
|
|
54
|
+
def test_public_blocks_without_confirm(self):
|
|
55
|
+
rc, mv, out = self._drive(["ph"], vis="PUBLIC")
|
|
56
|
+
self.assertEqual(rc, 0)
|
|
57
|
+
mv.assert_not_called()
|
|
58
|
+
self.assertIn("needs_confirm", out)
|
|
59
|
+
|
|
60
|
+
def test_public_with_confirm_archives(self):
|
|
61
|
+
tok = make_token("o/r", "ph")
|
|
62
|
+
rc, mv, out = self._drive(["ph", f"--confirm={tok}"], vis="PUBLIC")
|
|
63
|
+
self.assertEqual(rc, 0)
|
|
64
|
+
mv.assert_called_once()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class UnarchiveTrackTest(unittest.TestCase):
|
|
68
|
+
def _drive(self, args, track=None, outcome="restored", vis="PRIVATE"):
|
|
69
|
+
track = track or _t(path="/tmp/notes/proj/archive/parked/ph.md")
|
|
70
|
+
with patch("commands.unarchive_track.load_config", return_value={"notes_root": "/tmp/notes"}), \
|
|
71
|
+
patch("commands.unarchive_track.discover_archived_tracks", return_value=[track]), \
|
|
72
|
+
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
73
|
+
patch("commands.unarchive_track.restore_from_archive", return_value=outcome) as rs:
|
|
74
|
+
buf = io.StringIO()
|
|
75
|
+
with redirect_stdout(buf):
|
|
76
|
+
rc = unarchive_track.run(args)
|
|
77
|
+
return rc, rs, buf.getvalue()
|
|
78
|
+
|
|
79
|
+
def test_restores_from_archive_with_correct_rel_and_base(self):
|
|
80
|
+
rc, rs, out = self._drive(["ph"])
|
|
81
|
+
self.assertEqual(rc, 0)
|
|
82
|
+
rs.assert_called_once_with("archive/parked/ph.md", Path("/tmp/notes/proj"))
|
|
83
|
+
self.assertIn("restored", out)
|
|
84
|
+
|
|
85
|
+
def test_collision_reported(self):
|
|
86
|
+
rc, rs, out = self._drive(["ph"], outcome="skipped_collision")
|
|
87
|
+
self.assertEqual(rc, 0)
|
|
88
|
+
self.assertIn("already exists", out)
|
|
89
|
+
|
|
90
|
+
def test_not_found_returns_1(self):
|
|
91
|
+
with patch("commands.unarchive_track.load_config", return_value={"notes_root": "/tmp/notes"}), \
|
|
92
|
+
patch("commands.unarchive_track.discover_archived_tracks", return_value=[]):
|
|
93
|
+
buf = io.StringIO()
|
|
94
|
+
with redirect_stdout(buf):
|
|
95
|
+
rc = unarchive_track.run(["nope"])
|
|
96
|
+
self.assertEqual(rc, 1)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
unittest.main()
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""delete-track (offline; git mocked). Verifies the destructive-but-bounded
|
|
2
|
+
behavior AND the never-touch-GitHub guarantee."""
|
|
3
|
+
import io
|
|
4
|
+
import sys
|
|
5
|
+
import unittest
|
|
6
|
+
from contextlib import redirect_stdout
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import SimpleNamespace
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
12
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
13
|
+
|
|
14
|
+
from commands import delete_track
|
|
15
|
+
from lib.write_guard import make_token
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _t(name="ph", repo="o/r", path="/tmp/notes/proj/ph.md", tier="private"):
|
|
19
|
+
return SimpleNamespace(name=name, repo=repo, path=Path(path), tier=tier,
|
|
20
|
+
folder="proj", has_frontmatter=True, meta={"status": "active"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _drive(args, track=None, vis="PRIVATE", tracked=True, git_rm_ok=True,
|
|
24
|
+
archived=None):
|
|
25
|
+
track = track or _t()
|
|
26
|
+
with patch("commands.delete_track.load_config", return_value={"notes_root": "/tmp/notes"}), \
|
|
27
|
+
patch("commands.delete_track.discover_tracks", return_value=[track]), \
|
|
28
|
+
patch("commands.delete_track.discover_archived_tracks", return_value=archived or []), \
|
|
29
|
+
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
30
|
+
patch("commands.delete_track.git_state.is_tracked", return_value=tracked), \
|
|
31
|
+
patch("commands.delete_track.git_state.git_rm", return_value=git_rm_ok) as grm, \
|
|
32
|
+
patch("pathlib.Path.unlink") as unlink:
|
|
33
|
+
buf = io.StringIO()
|
|
34
|
+
with redirect_stdout(buf):
|
|
35
|
+
rc = delete_track.run(args)
|
|
36
|
+
return rc, grm, unlink, buf.getvalue()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DeleteTrackTest(unittest.TestCase):
|
|
40
|
+
def test_private_tracked_git_rm_and_undo_message(self):
|
|
41
|
+
rc, grm, unlink, out = _drive(["ph"])
|
|
42
|
+
self.assertEqual(rc, 0)
|
|
43
|
+
grm.assert_called_once_with("ph.md", Path("/tmp/notes/proj"))
|
|
44
|
+
unlink.assert_not_called()
|
|
45
|
+
self.assertIn("deleted track", out)
|
|
46
|
+
self.assertIn("GitHub issues are untouched", out)
|
|
47
|
+
self.assertIn("Recoverable via notes-vcs", out)
|
|
48
|
+
|
|
49
|
+
def test_shared_tracked_prints_commit_push(self):
|
|
50
|
+
rc, grm, unlink, out = _drive(["ph"], track=_t(tier="shared"))
|
|
51
|
+
self.assertEqual(rc, 0)
|
|
52
|
+
grm.assert_called_once()
|
|
53
|
+
self.assertIn("commit & push", out)
|
|
54
|
+
|
|
55
|
+
def test_untracked_uses_filesystem_unlink_and_warns_permanent(self):
|
|
56
|
+
# notes-vcs off (untracked) → unlink → the message must say PERMANENT,
|
|
57
|
+
# NOT promise notes-vcs undo (the review's borderline-critical finding).
|
|
58
|
+
rc, grm, unlink, out = _drive(["ph"], tracked=False)
|
|
59
|
+
self.assertEqual(rc, 0)
|
|
60
|
+
grm.assert_not_called()
|
|
61
|
+
unlink.assert_called_once()
|
|
62
|
+
self.assertIn("PERMANENT", out)
|
|
63
|
+
self.assertNotIn("Recoverable via notes-vcs", out)
|
|
64
|
+
|
|
65
|
+
def test_private_tracked_message_is_recoverable_not_permanent(self):
|
|
66
|
+
# notes-vcs on (tracked) → git rm → recoverable, and NOT flagged permanent.
|
|
67
|
+
rc, grm, unlink, out = _drive(["ph"], tracked=True)
|
|
68
|
+
self.assertEqual(rc, 0)
|
|
69
|
+
self.assertIn("Recoverable via notes-vcs", out)
|
|
70
|
+
self.assertNotIn("PERMANENT", out)
|
|
71
|
+
|
|
72
|
+
def test_git_rm_failure_returns_1(self):
|
|
73
|
+
rc, grm, unlink, out = _drive(["ph"], git_rm_ok=False)
|
|
74
|
+
self.assertEqual(rc, 1)
|
|
75
|
+
|
|
76
|
+
def test_public_blocks_without_confirm(self):
|
|
77
|
+
rc, grm, unlink, out = _drive(["ph"], vis="PUBLIC")
|
|
78
|
+
self.assertEqual(rc, 0)
|
|
79
|
+
grm.assert_not_called()
|
|
80
|
+
unlink.assert_not_called()
|
|
81
|
+
self.assertIn("needs_confirm", out)
|
|
82
|
+
|
|
83
|
+
def test_public_with_confirm_deletes(self):
|
|
84
|
+
tok = make_token("o/r", "ph")
|
|
85
|
+
rc, grm, unlink, out = _drive(["ph", f"--confirm={tok}"], vis="PUBLIC")
|
|
86
|
+
self.assertEqual(rc, 0)
|
|
87
|
+
grm.assert_called_once()
|
|
88
|
+
|
|
89
|
+
def test_not_found_returns_1(self):
|
|
90
|
+
with patch("commands.delete_track.load_config", return_value={"notes_root": "/tmp"}), \
|
|
91
|
+
patch("commands.delete_track.discover_tracks", return_value=[]), \
|
|
92
|
+
patch("commands.delete_track.discover_archived_tracks", return_value=[]):
|
|
93
|
+
buf = io.StringIO()
|
|
94
|
+
with redirect_stdout(buf):
|
|
95
|
+
rc = delete_track.run(["nope"])
|
|
96
|
+
self.assertEqual(rc, 1)
|
|
97
|
+
|
|
98
|
+
def test_module_never_touches_github(self):
|
|
99
|
+
"""Guardrail: delete-track must not import a GitHub-mutating helper.
|
|
100
|
+
The issues outlive the track (#330)."""
|
|
101
|
+
src = (SKILL_ROOT / "commands" / "delete_track.py").read_text()
|
|
102
|
+
self.assertNotIn("github_state", src)
|
|
103
|
+
self.assertNotIn("gh issue", src)
|
|
104
|
+
self.assertNotIn("subprocess", src)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
unittest.main()
|
|
@@ -38,8 +38,18 @@ class BuildExportTest(unittest.TestCase):
|
|
|
38
38
|
# Phase 2: next_up_preset must be present in every track
|
|
39
39
|
self.assertIn("next_up_preset", t)
|
|
40
40
|
self.assertEqual(t["next_up_preset"], "flow") # default when no next_up_order in meta
|
|
41
|
+
# archived defaults false (active tracks); #328.
|
|
42
|
+
self.assertFalse(t["archived"])
|
|
41
43
|
json.dumps(out) # must be serializable
|
|
42
44
|
|
|
45
|
+
def test_archived_flag_emitted_when_meta_set(self):
|
|
46
|
+
"""A track tagged archived (the --include-archived union sets meta) exports
|
|
47
|
+
archived:true so the viewer can grey it (#328)."""
|
|
48
|
+
tr = _track("old", "o/r", [1])
|
|
49
|
+
tr.meta["archived"] = True
|
|
50
|
+
out = build_export([tr], {("o/r", "old"): []}, {"o/r": "PRIVATE"}, now="t")
|
|
51
|
+
self.assertTrue(out["tracks"][0]["archived"])
|
|
52
|
+
|
|
43
53
|
def test_path_is_null_when_track_has_no_path(self):
|
|
44
54
|
"""A track object without a `path` attribute exports path=None, so the
|
|
45
55
|
viewer disables its open-file affordance instead of erroring (#211)."""
|
|
@@ -381,6 +391,37 @@ class BuildExportPlanTest(unittest.TestCase):
|
|
|
381
391
|
self.assertEqual(out["tracks"][0]["plan"], {"rel": "docs/plans/p.md", "resolved": False})
|
|
382
392
|
|
|
383
393
|
|
|
394
|
+
class BuildExportCleanupCandidateTest(unittest.TestCase):
|
|
395
|
+
"""cleanup_candidate / cleanup_reason surface in the export JSON (#328)."""
|
|
396
|
+
|
|
397
|
+
def test_cleanup_absent_defaults(self):
|
|
398
|
+
"""A track with no cleanup marker → candidate False, reason None."""
|
|
399
|
+
tracks = [_track("alpha", "o/r", [1])]
|
|
400
|
+
out = build_export(tracks, {("o/r", "alpha"): []}, {"o/r": "PRIVATE"}, now="t")
|
|
401
|
+
tr = out["tracks"][0]
|
|
402
|
+
self.assertFalse(tr["cleanup_candidate"])
|
|
403
|
+
self.assertIsNone(tr["cleanup_reason"])
|
|
404
|
+
|
|
405
|
+
def test_cleanup_candidate_and_reason_exported(self):
|
|
406
|
+
"""cleanup_candidate: true + cleanup_reason → both surface in export."""
|
|
407
|
+
t = _track("alpha", "o/r", [1])
|
|
408
|
+
t.meta["cleanup_candidate"] = True
|
|
409
|
+
t.meta["cleanup_reason"] = "superseded by v2"
|
|
410
|
+
out = build_export([t], {("o/r", "alpha"): []}, {"o/r": "PRIVATE"}, now="t")
|
|
411
|
+
tr = out["tracks"][0]
|
|
412
|
+
self.assertTrue(tr["cleanup_candidate"])
|
|
413
|
+
self.assertEqual(tr["cleanup_reason"], "superseded by v2")
|
|
414
|
+
|
|
415
|
+
def test_cleanup_candidate_without_reason(self):
|
|
416
|
+
"""Marked but no reason → candidate True, reason None."""
|
|
417
|
+
t = _track("alpha", "o/r", [1])
|
|
418
|
+
t.meta["cleanup_candidate"] = True
|
|
419
|
+
out = build_export([t], {("o/r", "alpha"): []}, {"o/r": "PRIVATE"}, now="t")
|
|
420
|
+
tr = out["tracks"][0]
|
|
421
|
+
self.assertTrue(tr["cleanup_candidate"])
|
|
422
|
+
self.assertIsNone(tr["cleanup_reason"])
|
|
423
|
+
|
|
424
|
+
|
|
384
425
|
class BuildExportDependsOnTest(unittest.TestCase):
|
|
385
426
|
"""Tests that depends_on is surfaced in the export JSON (#102)."""
|
|
386
427
|
|