@stylusnexus/work-plan 2026.6.10 → 2026.6.11
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 +13 -7
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +6 -4
- package/skills/work-plan/commands/canonicalize.py +7 -92
- package/skills/work-plan/commands/handoff.py +15 -6
- package/skills/work-plan/commands/init.py +13 -3
- package/skills/work-plan/commands/init_repo.py +8 -2
- package/skills/work-plan/commands/new_track.py +7 -0
- package/skills/work-plan/commands/notes_vcs.py +172 -0
- package/skills/work-plan/commands/refresh_md.py +106 -37
- package/skills/work-plan/commands/rename_track.py +243 -0
- package/skills/work-plan/commands/set_notes_root.py +8 -4
- package/skills/work-plan/commands/suggest_priorities.py +12 -2
- package/skills/work-plan/lib/config.py +11 -0
- package/skills/work-plan/lib/frontmatter.py +12 -3
- package/skills/work-plan/lib/git_state.py +61 -52
- package/skills/work-plan/lib/github_state.py +46 -13
- package/skills/work-plan/lib/notes_vcs.py +276 -0
- package/skills/work-plan/lib/prompts.py +12 -1
- package/skills/work-plan/lib/status_table.py +95 -5
- package/skills/work-plan/lib/tracks.py +9 -4
- package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/archive/shipped/old.md +1 -1
- package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/example.md +1 -1
- package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +1 -1
- package/skills/work-plan/tests/test_config.py +12 -12
- package/skills/work-plan/tests/test_github_state.py +3 -3
- package/skills/work-plan/tests/test_init_repo.py +12 -7
- package/skills/work-plan/tests/test_new_track.py +7 -7
- package/skills/work-plan/tests/test_notes_vcs.py +426 -0
- package/skills/work-plan/tests/test_notes_vcs_command.py +312 -0
- package/skills/work-plan/tests/test_plan_status_issues.py +2 -2
- package/skills/work-plan/tests/test_refresh_md.py +159 -61
- package/skills/work-plan/tests/test_rename_track.py +351 -0
- package/skills/work-plan/tests/test_repo_filter.py +6 -6
- package/skills/work-plan/tests/test_security_cli_hardening.py +142 -0
- package/skills/work-plan/tests/test_set_notes_root.py +6 -2
- package/skills/work-plan/tests/test_status_table.py +61 -0
- package/skills/work-plan/tests/test_track_resolution.py +2 -2
- package/skills/work-plan/tests/test_tracks.py +4 -4
- package/skills/work-plan/work_plan.py +97 -17
- /package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/no_frontmatter.md +0 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""Tests for lib/notes_vcs — opt-in local VCS for notes_root (#103).
|
|
2
|
+
|
|
3
|
+
git itself is mocked (offline, deterministic) by patching notes_vcs._git, so
|
|
4
|
+
these never shell out. notes_root is a real tmpdir so is_dir()/write_text work.
|
|
5
|
+
"""
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from unittest.mock import patch, MagicMock
|
|
11
|
+
|
|
12
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
13
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
14
|
+
|
|
15
|
+
from lib import notes_vcs
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _ok(stdout=""):
|
|
19
|
+
return MagicMock(returncode=0, stdout=stdout, stderr="")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _fail(stderr="boom"):
|
|
23
|
+
return MagicMock(returncode=1, stdout="", stderr=stderr)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _FakeGit:
|
|
27
|
+
"""Configurable stand-in for notes_vcs._git.
|
|
28
|
+
|
|
29
|
+
toplevel: value returned by `rev-parse --show-toplevel` (None → failure).
|
|
30
|
+
dirty: whether `status` reports changes (cleared by `commit`).
|
|
31
|
+
head: short sha returned by `rev-parse --short HEAD`.
|
|
32
|
+
remotes: `git remote` stdout — "" means no remote (the safe state).
|
|
33
|
+
owned: whether the `workplan.localhistory` marker is set; a `config`
|
|
34
|
+
set-call flips it on (mirrors mark_owned).
|
|
35
|
+
parent: short sha returned for `rev-parse --short --verify HEAD^`
|
|
36
|
+
(None → root commit / failure).
|
|
37
|
+
porcelain: explicit `status --porcelain` body (for dirty_paths tests).
|
|
38
|
+
fail_on: set of git subcommands to force-fail.
|
|
39
|
+
missing: if True, every call returns None (git absent / timeout).
|
|
40
|
+
"""
|
|
41
|
+
def __init__(self, *, toplevel=None, dirty=False, head="abc1234",
|
|
42
|
+
inside=True, fail_on=None, missing=False, has_commit=True,
|
|
43
|
+
remotes="", owned=False, parent=None, porcelain=None):
|
|
44
|
+
self.toplevel = toplevel
|
|
45
|
+
self.dirty = dirty
|
|
46
|
+
self.head = head
|
|
47
|
+
self.inside = inside
|
|
48
|
+
self.fail_on = fail_on or set()
|
|
49
|
+
self.missing = missing
|
|
50
|
+
self.has_commit = has_commit
|
|
51
|
+
self.remotes = remotes
|
|
52
|
+
self.owned = owned
|
|
53
|
+
self.parent = parent
|
|
54
|
+
self.porcelain = porcelain
|
|
55
|
+
self.staged = False
|
|
56
|
+
self.calls = []
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _subcommand(args):
|
|
60
|
+
# Skip leading global `-c <value>` pairs (e.g. -c core.quotepath=false).
|
|
61
|
+
a = list(args)
|
|
62
|
+
while len(a) >= 2 and a[0] == "-c":
|
|
63
|
+
a = a[2:]
|
|
64
|
+
return (a[0] if a else "", a)
|
|
65
|
+
|
|
66
|
+
def __call__(self, notes_root, *args, timeout=None):
|
|
67
|
+
self.calls.append(args)
|
|
68
|
+
if self.missing:
|
|
69
|
+
return None
|
|
70
|
+
sub, rest = self._subcommand(args)
|
|
71
|
+
if sub in self.fail_on:
|
|
72
|
+
return _fail()
|
|
73
|
+
if sub == "rev-parse" and "--show-toplevel" in rest:
|
|
74
|
+
return _ok(self.toplevel + "\n") if self.toplevel else _fail()
|
|
75
|
+
if sub == "rev-parse" and "--is-inside-work-tree" in rest:
|
|
76
|
+
return _ok("true\n") if self.inside else _fail()
|
|
77
|
+
if sub == "rev-parse" and "HEAD^" in rest:
|
|
78
|
+
return _ok(self.parent + "\n") if self.parent else _fail()
|
|
79
|
+
if sub == "rev-parse" and "--short" in rest:
|
|
80
|
+
return _ok(self.head + "\n")
|
|
81
|
+
if sub == "remote":
|
|
82
|
+
return _ok(self.remotes)
|
|
83
|
+
if sub == "config":
|
|
84
|
+
if "--get" in rest:
|
|
85
|
+
return _ok("true\n") if self.owned else _fail()
|
|
86
|
+
self.owned = True # `config --local workplan.localhistory true`
|
|
87
|
+
return _ok()
|
|
88
|
+
if sub == "status" and "--porcelain" in rest:
|
|
89
|
+
if self.porcelain is not None:
|
|
90
|
+
return _ok(self.porcelain)
|
|
91
|
+
return _ok(" M track.md\n" if self.dirty else "")
|
|
92
|
+
if sub == "status":
|
|
93
|
+
return _ok(" M track.md\n" if self.dirty else "")
|
|
94
|
+
if sub == "diff" and "--cached" in rest:
|
|
95
|
+
# returncode 1 means there ARE staged changes (git diff --quiet).
|
|
96
|
+
return MagicMock(returncode=1 if self.staged else 0, stdout="", stderr="")
|
|
97
|
+
if sub == "log":
|
|
98
|
+
if not self.has_commit:
|
|
99
|
+
return _ok("")
|
|
100
|
+
fmt = next((a for a in rest if a.startswith("--pretty=format:")), "")
|
|
101
|
+
body = self.head if fmt.endswith("%h") else f"{self.head} subject"
|
|
102
|
+
return _ok(body + "\n")
|
|
103
|
+
if sub == "add":
|
|
104
|
+
if self.dirty:
|
|
105
|
+
self.staged = True
|
|
106
|
+
return _ok()
|
|
107
|
+
if sub == "commit":
|
|
108
|
+
self.dirty = False
|
|
109
|
+
self.staged = False
|
|
110
|
+
self.has_commit = True
|
|
111
|
+
return _ok()
|
|
112
|
+
if sub == "init":
|
|
113
|
+
return _ok()
|
|
114
|
+
return _ok()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class IsGitRootTest(unittest.TestCase):
|
|
118
|
+
def test_true_when_toplevel_equals_notes_root(self):
|
|
119
|
+
with tempfile.TemporaryDirectory() as d:
|
|
120
|
+
fake = _FakeGit(toplevel=str(Path(d).resolve()))
|
|
121
|
+
with patch.object(notes_vcs, "_git", fake):
|
|
122
|
+
self.assertTrue(notes_vcs.is_git_root(Path(d)))
|
|
123
|
+
|
|
124
|
+
def test_false_when_toplevel_is_parent(self):
|
|
125
|
+
with tempfile.TemporaryDirectory() as d:
|
|
126
|
+
sub = Path(d) / "notes"
|
|
127
|
+
sub.mkdir()
|
|
128
|
+
fake = _FakeGit(toplevel=str(Path(d).resolve()))
|
|
129
|
+
with patch.object(notes_vcs, "_git", fake):
|
|
130
|
+
self.assertFalse(notes_vcs.is_git_root(sub))
|
|
131
|
+
|
|
132
|
+
def test_false_when_not_a_repo(self):
|
|
133
|
+
with tempfile.TemporaryDirectory() as d:
|
|
134
|
+
fake = _FakeGit(toplevel=None)
|
|
135
|
+
with patch.object(notes_vcs, "_git", fake):
|
|
136
|
+
self.assertFalse(notes_vcs.is_git_root(Path(d)))
|
|
137
|
+
|
|
138
|
+
def test_false_when_dir_missing(self):
|
|
139
|
+
self.assertFalse(notes_vcs.is_git_root(Path("/nope/does/not/exist")))
|
|
140
|
+
|
|
141
|
+
def test_false_when_none_arg(self):
|
|
142
|
+
self.assertFalse(notes_vcs.is_git_root(None))
|
|
143
|
+
|
|
144
|
+
def test_false_when_git_missing(self):
|
|
145
|
+
with tempfile.TemporaryDirectory() as d:
|
|
146
|
+
with patch.object(notes_vcs, "_git", _FakeGit(missing=True)):
|
|
147
|
+
self.assertFalse(notes_vcs.is_git_root(Path(d)))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class IsUnderGitTest(unittest.TestCase):
|
|
151
|
+
def test_true_inside_work_tree(self):
|
|
152
|
+
with tempfile.TemporaryDirectory() as d:
|
|
153
|
+
with patch.object(notes_vcs, "_git", _FakeGit(inside=True)):
|
|
154
|
+
self.assertTrue(notes_vcs.is_under_git(Path(d)))
|
|
155
|
+
|
|
156
|
+
def test_false_outside_work_tree(self):
|
|
157
|
+
with tempfile.TemporaryDirectory() as d:
|
|
158
|
+
with patch.object(notes_vcs, "_git", _FakeGit(inside=False)):
|
|
159
|
+
self.assertFalse(notes_vcs.is_under_git(Path(d)))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class RemoteAndOwnershipTest(unittest.TestCase):
|
|
163
|
+
def test_has_remotes_true_when_remote_present(self):
|
|
164
|
+
with tempfile.TemporaryDirectory() as d:
|
|
165
|
+
with patch.object(notes_vcs, "_git", _FakeGit(remotes="origin\n")):
|
|
166
|
+
self.assertTrue(notes_vcs.has_remotes(Path(d)))
|
|
167
|
+
|
|
168
|
+
def test_has_remotes_false_when_none(self):
|
|
169
|
+
with tempfile.TemporaryDirectory() as d:
|
|
170
|
+
with patch.object(notes_vcs, "_git", _FakeGit(remotes="")):
|
|
171
|
+
self.assertFalse(notes_vcs.has_remotes(Path(d)))
|
|
172
|
+
|
|
173
|
+
def test_is_owned_true_when_marker_set(self):
|
|
174
|
+
with tempfile.TemporaryDirectory() as d:
|
|
175
|
+
with patch.object(notes_vcs, "_git", _FakeGit(owned=True)):
|
|
176
|
+
self.assertTrue(notes_vcs.is_owned(Path(d)))
|
|
177
|
+
|
|
178
|
+
def test_is_owned_false_when_marker_absent(self):
|
|
179
|
+
with tempfile.TemporaryDirectory() as d:
|
|
180
|
+
with patch.object(notes_vcs, "_git", _FakeGit(owned=False)):
|
|
181
|
+
self.assertFalse(notes_vcs.is_owned(Path(d)))
|
|
182
|
+
|
|
183
|
+
def test_mark_owned_sets_marker(self):
|
|
184
|
+
with tempfile.TemporaryDirectory() as d:
|
|
185
|
+
fake = _FakeGit(owned=False)
|
|
186
|
+
with patch.object(notes_vcs, "_git", fake):
|
|
187
|
+
self.assertTrue(notes_vcs.mark_owned(Path(d)))
|
|
188
|
+
self.assertTrue(notes_vcs.is_owned(Path(d)))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class DirtyPathsTest(unittest.TestCase):
|
|
192
|
+
def test_parses_porcelain_into_path_set(self):
|
|
193
|
+
body = " M alpha.md\n?? beta.md\n D gone.md\n"
|
|
194
|
+
with tempfile.TemporaryDirectory() as d:
|
|
195
|
+
with patch.object(notes_vcs, "_git", _FakeGit(porcelain=body)):
|
|
196
|
+
self.assertEqual(
|
|
197
|
+
notes_vcs.dirty_paths(Path(d)),
|
|
198
|
+
{"alpha.md", "beta.md", "gone.md"},
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def test_rename_collapses_to_destination(self):
|
|
202
|
+
body = "R old.md -> new.md\n"
|
|
203
|
+
with tempfile.TemporaryDirectory() as d:
|
|
204
|
+
with patch.object(notes_vcs, "_git", _FakeGit(porcelain=body)):
|
|
205
|
+
self.assertEqual(notes_vcs.dirty_paths(Path(d)), {"new.md"})
|
|
206
|
+
|
|
207
|
+
def test_empty_when_clean(self):
|
|
208
|
+
with tempfile.TemporaryDirectory() as d:
|
|
209
|
+
with patch.object(notes_vcs, "_git", _FakeGit(porcelain="")):
|
|
210
|
+
self.assertEqual(notes_vcs.dirty_paths(Path(d)), set())
|
|
211
|
+
|
|
212
|
+
def test_empty_on_failure(self):
|
|
213
|
+
with tempfile.TemporaryDirectory() as d:
|
|
214
|
+
with patch.object(notes_vcs, "_git", _FakeGit(missing=True)):
|
|
215
|
+
self.assertEqual(notes_vcs.dirty_paths(Path(d)), set())
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class HeadParentTest(unittest.TestCase):
|
|
219
|
+
def test_returns_parent_sha(self):
|
|
220
|
+
with tempfile.TemporaryDirectory() as d:
|
|
221
|
+
with patch.object(notes_vcs, "_git", _FakeGit(parent="par1234")):
|
|
222
|
+
self.assertEqual(notes_vcs.head_parent_sha(Path(d)), "par1234")
|
|
223
|
+
|
|
224
|
+
def test_none_at_root_commit(self):
|
|
225
|
+
with tempfile.TemporaryDirectory() as d:
|
|
226
|
+
with patch.object(notes_vcs, "_git", _FakeGit(parent=None)):
|
|
227
|
+
self.assertIsNone(notes_vcs.head_parent_sha(Path(d)))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class AutoCommitTest(unittest.TestCase):
|
|
231
|
+
def test_commits_when_dirty_owned_no_remote(self):
|
|
232
|
+
with tempfile.TemporaryDirectory() as d:
|
|
233
|
+
fake = _FakeGit(toplevel=str(Path(d).resolve()), dirty=True,
|
|
234
|
+
head="dead123", owned=True)
|
|
235
|
+
with patch.object(notes_vcs, "_git", fake):
|
|
236
|
+
sha = notes_vcs.auto_commit(Path(d), "work-plan slot 103 t")
|
|
237
|
+
self.assertEqual(sha, "dead123")
|
|
238
|
+
self.assertIn(("commit", "-m", "work-plan slot 103 t"), fake.calls)
|
|
239
|
+
|
|
240
|
+
def test_scoped_paths_only_stage_those_paths(self):
|
|
241
|
+
with tempfile.TemporaryDirectory() as d:
|
|
242
|
+
fake = _FakeGit(toplevel=str(Path(d).resolve()), dirty=True,
|
|
243
|
+
head="sc0pe12", owned=True)
|
|
244
|
+
with patch.object(notes_vcs, "_git", fake):
|
|
245
|
+
sha = notes_vcs.auto_commit(Path(d), "msg", paths=["a.md", "b.md"])
|
|
246
|
+
self.assertEqual(sha, "sc0pe12")
|
|
247
|
+
# `git add -- a.md b.md`, never `git add -A`.
|
|
248
|
+
self.assertIn(("add", "--", "a.md", "b.md"), fake.calls)
|
|
249
|
+
self.assertNotIn(("add", "-A"), fake.calls)
|
|
250
|
+
|
|
251
|
+
def test_noop_when_paths_empty(self):
|
|
252
|
+
with tempfile.TemporaryDirectory() as d:
|
|
253
|
+
fake = _FakeGit(toplevel=str(Path(d).resolve()), dirty=True, owned=True)
|
|
254
|
+
with patch.object(notes_vcs, "_git", fake):
|
|
255
|
+
self.assertIsNone(notes_vcs.auto_commit(Path(d), "msg", paths=[]))
|
|
256
|
+
|
|
257
|
+
def test_noop_when_not_owned(self):
|
|
258
|
+
with tempfile.TemporaryDirectory() as d:
|
|
259
|
+
fake = _FakeGit(toplevel=str(Path(d).resolve()), dirty=True, owned=False)
|
|
260
|
+
with patch.object(notes_vcs, "_git", fake):
|
|
261
|
+
self.assertIsNone(notes_vcs.auto_commit(Path(d), "msg"))
|
|
262
|
+
self.assertNotIn(("commit", "-m", "msg"), fake.calls)
|
|
263
|
+
|
|
264
|
+
def test_noop_when_remote_present(self):
|
|
265
|
+
# A remote-backed repo must never auto-commit (private notes could push).
|
|
266
|
+
with tempfile.TemporaryDirectory() as d:
|
|
267
|
+
fake = _FakeGit(toplevel=str(Path(d).resolve()), dirty=True,
|
|
268
|
+
owned=True, remotes="origin\n")
|
|
269
|
+
with patch.object(notes_vcs, "_git", fake):
|
|
270
|
+
self.assertIsNone(notes_vcs.auto_commit(Path(d), "msg"))
|
|
271
|
+
self.assertNotIn(("commit", "-m", "msg"), fake.calls)
|
|
272
|
+
|
|
273
|
+
def test_noop_when_clean(self):
|
|
274
|
+
with tempfile.TemporaryDirectory() as d:
|
|
275
|
+
fake = _FakeGit(toplevel=str(Path(d).resolve()), dirty=False, owned=True)
|
|
276
|
+
with patch.object(notes_vcs, "_git", fake):
|
|
277
|
+
self.assertIsNone(notes_vcs.auto_commit(Path(d), "msg"))
|
|
278
|
+
self.assertNotIn(("commit", "-m", "msg"), fake.calls)
|
|
279
|
+
|
|
280
|
+
def test_noop_when_not_root(self):
|
|
281
|
+
with tempfile.TemporaryDirectory() as d:
|
|
282
|
+
sub = Path(d) / "notes"
|
|
283
|
+
sub.mkdir()
|
|
284
|
+
fake = _FakeGit(toplevel=str(Path(d).resolve()), dirty=True, owned=True)
|
|
285
|
+
with patch.object(notes_vcs, "_git", fake):
|
|
286
|
+
self.assertIsNone(notes_vcs.auto_commit(sub, "msg"))
|
|
287
|
+
|
|
288
|
+
def test_noop_when_git_missing(self):
|
|
289
|
+
with tempfile.TemporaryDirectory() as d:
|
|
290
|
+
with patch.object(notes_vcs, "_git", _FakeGit(missing=True)):
|
|
291
|
+
self.assertIsNone(notes_vcs.auto_commit(Path(d), "msg"))
|
|
292
|
+
|
|
293
|
+
def test_returns_none_when_commit_fails(self):
|
|
294
|
+
with tempfile.TemporaryDirectory() as d:
|
|
295
|
+
fake = _FakeGit(toplevel=str(Path(d).resolve()), dirty=True,
|
|
296
|
+
owned=True, fail_on={"commit"})
|
|
297
|
+
with patch.object(notes_vcs, "_git", fake):
|
|
298
|
+
self.assertIsNone(notes_vcs.auto_commit(Path(d), "msg"))
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class InitRepoTest(unittest.TestCase):
|
|
302
|
+
def test_init_fresh_dir_writes_gitignore_marks_owned_commits(self):
|
|
303
|
+
# Fresh dir (not yet a repo): toplevel=None until init.
|
|
304
|
+
with tempfile.TemporaryDirectory() as d:
|
|
305
|
+
fake = _FakeGit(toplevel=None, dirty=True, has_commit=False)
|
|
306
|
+
with patch.object(notes_vcs, "_git", fake):
|
|
307
|
+
self.assertTrue(notes_vcs.init_repo(Path(d)))
|
|
308
|
+
self.assertTrue((Path(d) / ".gitignore").exists())
|
|
309
|
+
subs = [c[0] for c in fake.calls]
|
|
310
|
+
self.assertIn("init", subs)
|
|
311
|
+
self.assertIn("commit", subs)
|
|
312
|
+
# Ownership marker stamped (config --local workplan.localhistory true).
|
|
313
|
+
self.assertTrue(any(c[0] == "config" and "--get" not in c for c in fake.calls))
|
|
314
|
+
|
|
315
|
+
def test_init_rejects_existing_repo_with_remote(self):
|
|
316
|
+
with tempfile.TemporaryDirectory() as d:
|
|
317
|
+
fake = _FakeGit(toplevel=str(Path(d).resolve()), owned=True,
|
|
318
|
+
remotes="origin\n")
|
|
319
|
+
with patch.object(notes_vcs, "_git", fake):
|
|
320
|
+
self.assertFalse(notes_vcs.init_repo(Path(d)))
|
|
321
|
+
self.assertNotIn("commit", [c[0] for c in fake.calls])
|
|
322
|
+
|
|
323
|
+
def test_init_rejects_existing_unowned_repo(self):
|
|
324
|
+
with tempfile.TemporaryDirectory() as d:
|
|
325
|
+
fake = _FakeGit(toplevel=str(Path(d).resolve()), owned=False)
|
|
326
|
+
with patch.object(notes_vcs, "_git", fake):
|
|
327
|
+
self.assertFalse(notes_vcs.init_repo(Path(d)))
|
|
328
|
+
self.assertNotIn("commit", [c[0] for c in fake.calls])
|
|
329
|
+
|
|
330
|
+
def test_reinit_owned_clean_repo_is_success_no_commit(self):
|
|
331
|
+
with tempfile.TemporaryDirectory() as d:
|
|
332
|
+
(Path(d) / ".gitignore").write_text("x\n", encoding="utf-8")
|
|
333
|
+
fake = _FakeGit(toplevel=str(Path(d).resolve()), dirty=False,
|
|
334
|
+
has_commit=True, owned=True)
|
|
335
|
+
with patch.object(notes_vcs, "_git", fake):
|
|
336
|
+
self.assertTrue(notes_vcs.init_repo(Path(d)))
|
|
337
|
+
self.assertNotIn("commit", [c[0] for c in fake.calls])
|
|
338
|
+
|
|
339
|
+
def test_init_false_when_dir_missing(self):
|
|
340
|
+
self.assertFalse(notes_vcs.init_repo(Path("/nope/missing")))
|
|
341
|
+
|
|
342
|
+
def test_init_false_when_git_missing(self):
|
|
343
|
+
with tempfile.TemporaryDirectory() as d:
|
|
344
|
+
with patch.object(notes_vcs, "_git", _FakeGit(missing=True)):
|
|
345
|
+
self.assertFalse(notes_vcs.init_repo(Path(d)))
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class LastCommitSummaryTest(unittest.TestCase):
|
|
349
|
+
def test_returns_summary(self):
|
|
350
|
+
with tempfile.TemporaryDirectory() as d:
|
|
351
|
+
with patch.object(notes_vcs, "_git", _FakeGit(head="feed999")):
|
|
352
|
+
self.assertEqual(notes_vcs.last_commit_summary(Path(d)),
|
|
353
|
+
"feed999 subject")
|
|
354
|
+
|
|
355
|
+
def test_none_when_no_commits(self):
|
|
356
|
+
with tempfile.TemporaryDirectory() as d:
|
|
357
|
+
with patch.object(notes_vcs, "_git", _FakeGit(has_commit=False)):
|
|
358
|
+
self.assertIsNone(notes_vcs.last_commit_summary(Path(d)))
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class LastCommitShaTest(unittest.TestCase):
|
|
362
|
+
def test_returns_sha(self):
|
|
363
|
+
with tempfile.TemporaryDirectory() as d:
|
|
364
|
+
with patch.object(notes_vcs, "_git", _FakeGit(head="feed999")):
|
|
365
|
+
self.assertEqual(notes_vcs.last_commit_sha(Path(d)), "feed999")
|
|
366
|
+
|
|
367
|
+
def test_none_when_no_commits(self):
|
|
368
|
+
with tempfile.TemporaryDirectory() as d:
|
|
369
|
+
with patch.object(notes_vcs, "_git", _FakeGit(has_commit=False)):
|
|
370
|
+
self.assertIsNone(notes_vcs.last_commit_sha(Path(d)))
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class RevertTest(unittest.TestCase):
|
|
374
|
+
def test_reverts_head_by_default(self):
|
|
375
|
+
with tempfile.TemporaryDirectory() as d:
|
|
376
|
+
fake = _FakeGit(toplevel=str(Path(d).resolve()), head="rev0001", owned=True)
|
|
377
|
+
with patch.object(notes_vcs, "_git", fake):
|
|
378
|
+
self.assertEqual(notes_vcs.revert(Path(d)), "rev0001")
|
|
379
|
+
self.assertIn(("revert", "--no-edit", "HEAD"), fake.calls)
|
|
380
|
+
|
|
381
|
+
def test_reverts_named_sha(self):
|
|
382
|
+
with tempfile.TemporaryDirectory() as d:
|
|
383
|
+
fake = _FakeGit(toplevel=str(Path(d).resolve()), head="rev0002", owned=True)
|
|
384
|
+
with patch.object(notes_vcs, "_git", fake):
|
|
385
|
+
self.assertEqual(notes_vcs.revert(Path(d), "abc1234"), "rev0002")
|
|
386
|
+
self.assertIn(("revert", "--no-edit", "abc1234"), fake.calls)
|
|
387
|
+
|
|
388
|
+
def test_noop_when_not_root(self):
|
|
389
|
+
with tempfile.TemporaryDirectory() as d:
|
|
390
|
+
fake = _FakeGit(toplevel=None)
|
|
391
|
+
with patch.object(notes_vcs, "_git", fake):
|
|
392
|
+
self.assertIsNone(notes_vcs.revert(Path(d)))
|
|
393
|
+
|
|
394
|
+
def test_refuses_unowned_repo(self):
|
|
395
|
+
# Never rewrite a repo we didn't create — it could be a project clone.
|
|
396
|
+
with tempfile.TemporaryDirectory() as d:
|
|
397
|
+
fake = _FakeGit(toplevel=str(Path(d).resolve()), owned=False)
|
|
398
|
+
with patch.object(notes_vcs, "_git", fake):
|
|
399
|
+
self.assertIsNone(notes_vcs.revert(Path(d)))
|
|
400
|
+
self.assertNotIn("revert", [c[0] for c in fake.calls])
|
|
401
|
+
|
|
402
|
+
def test_refuses_remote_backed_repo(self):
|
|
403
|
+
with tempfile.TemporaryDirectory() as d:
|
|
404
|
+
fake = _FakeGit(toplevel=str(Path(d).resolve()), owned=True,
|
|
405
|
+
remotes="origin\n")
|
|
406
|
+
with patch.object(notes_vcs, "_git", fake):
|
|
407
|
+
self.assertIsNone(notes_vcs.revert(Path(d)))
|
|
408
|
+
self.assertNotIn("revert", [c[0] for c in fake.calls])
|
|
409
|
+
|
|
410
|
+
def test_rejects_dash_led_sha(self):
|
|
411
|
+
with tempfile.TemporaryDirectory() as d:
|
|
412
|
+
fake = _FakeGit(toplevel=str(Path(d).resolve()), owned=True)
|
|
413
|
+
with patch.object(notes_vcs, "_git", fake):
|
|
414
|
+
self.assertIsNone(notes_vcs.revert(Path(d), "--hard"))
|
|
415
|
+
self.assertNotIn("revert", [c[0] for c in fake.calls])
|
|
416
|
+
|
|
417
|
+
def test_none_when_revert_fails(self):
|
|
418
|
+
with tempfile.TemporaryDirectory() as d:
|
|
419
|
+
fake = _FakeGit(toplevel=str(Path(d).resolve()), owned=True,
|
|
420
|
+
fail_on={"revert"})
|
|
421
|
+
with patch.object(notes_vcs, "_git", fake):
|
|
422
|
+
self.assertIsNone(notes_vcs.revert(Path(d)))
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
if __name__ == "__main__":
|
|
426
|
+
unittest.main()
|