@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
|
@@ -4,6 +4,39 @@ from datetime import date, datetime, timedelta
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from typing import Optional
|
|
6
6
|
|
|
7
|
+
# Bound every git subprocess so a hung repo, a stuck lock, or a slow network
|
|
8
|
+
# filesystem can't stall the CLI (or the VS Code extension that spawns it)
|
|
9
|
+
# indefinitely (#196). 20s is generous for local git operations.
|
|
10
|
+
GIT_TIMEOUT = 20
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def is_safe_ref(name: str) -> bool:
|
|
14
|
+
"""True if `name` is safe to pass to git as a positional revision.
|
|
15
|
+
|
|
16
|
+
Rejects empty strings and anything beginning with '-'. A branch/rev that
|
|
17
|
+
starts with a dash is read by git as an OPTION, not a value — e.g. a branch
|
|
18
|
+
named `--output=/path` turns `git log <branch> …` into an arbitrary-file
|
|
19
|
+
write (#192). git refnames cannot legitimately start with '-', so this
|
|
20
|
+
rejects nothing valid. Callers must gate every positional rev on this.
|
|
21
|
+
"""
|
|
22
|
+
return bool(name) and not name.startswith("-")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _git(repo_path, *args, timeout: int = GIT_TIMEOUT):
|
|
26
|
+
"""Run `git -C <repo_path> <args>` with a bounded timeout.
|
|
27
|
+
|
|
28
|
+
Returns the CompletedProcess, or None on timeout / spawn failure — callers
|
|
29
|
+
treat None as "no data", preserving the never-raise contract these helpers
|
|
30
|
+
have always had.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
return subprocess.run(
|
|
34
|
+
["git", "-C", str(repo_path), *args],
|
|
35
|
+
capture_output=True, text=True, timeout=timeout,
|
|
36
|
+
)
|
|
37
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
38
|
+
return None
|
|
39
|
+
|
|
7
40
|
|
|
8
41
|
def gap_seconds_to_label(seconds: int) -> str:
|
|
9
42
|
"""'Nm ago' / 'Nh ago' / 'Nd ago'."""
|
|
@@ -26,11 +59,8 @@ def parse_iso_timestamp(s: str) -> datetime:
|
|
|
26
59
|
def current_branch(repo_path: Path) -> Optional[str]:
|
|
27
60
|
if not repo_path or not Path(repo_path).exists():
|
|
28
61
|
return None
|
|
29
|
-
proc =
|
|
30
|
-
|
|
31
|
-
capture_output=True, text=True,
|
|
32
|
-
)
|
|
33
|
-
if proc.returncode != 0:
|
|
62
|
+
proc = _git(repo_path, "branch", "--show-current")
|
|
63
|
+
if proc is None or proc.returncode != 0:
|
|
34
64
|
return None
|
|
35
65
|
return proc.stdout.strip() or None
|
|
36
66
|
|
|
@@ -38,21 +68,15 @@ def current_branch(repo_path: Path) -> Optional[str]:
|
|
|
38
68
|
def has_uncommitted(repo_path: Path) -> bool:
|
|
39
69
|
if not repo_path or not Path(repo_path).exists():
|
|
40
70
|
return False
|
|
41
|
-
proc =
|
|
42
|
-
|
|
43
|
-
capture_output=True, text=True,
|
|
44
|
-
)
|
|
45
|
-
return proc.returncode == 0 and bool(proc.stdout.strip())
|
|
71
|
+
proc = _git(repo_path, "status", "--short")
|
|
72
|
+
return proc is not None and proc.returncode == 0 and bool(proc.stdout.strip())
|
|
46
73
|
|
|
47
74
|
|
|
48
75
|
def uncommitted_file_count(repo_path: Path) -> int:
|
|
49
76
|
if not repo_path or not Path(repo_path).exists():
|
|
50
77
|
return 0
|
|
51
|
-
proc =
|
|
52
|
-
|
|
53
|
-
capture_output=True, text=True,
|
|
54
|
-
)
|
|
55
|
-
if proc.returncode != 0:
|
|
78
|
+
proc = _git(repo_path, "status", "--short")
|
|
79
|
+
if proc is None or proc.returncode != 0:
|
|
56
80
|
return 0
|
|
57
81
|
return len([l for l in proc.stdout.splitlines() if l.strip()])
|
|
58
82
|
|
|
@@ -60,11 +84,12 @@ def uncommitted_file_count(repo_path: Path) -> int:
|
|
|
60
84
|
def commits_ahead(branch_name: str, base: str, repo_path: Path) -> int:
|
|
61
85
|
if not repo_path or not Path(repo_path).exists():
|
|
62
86
|
return 0
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
87
|
+
# Both refs are interpolated into a single `base..branch` positional, so a
|
|
88
|
+
# dash-led value would be read as a git option — reject before use (#192).
|
|
89
|
+
if not is_safe_ref(branch_name) or not is_safe_ref(base):
|
|
90
|
+
return 0
|
|
91
|
+
proc = _git(repo_path, "rev-list", "--count", f"{base}..{branch_name}")
|
|
92
|
+
if proc is None or proc.returncode != 0:
|
|
68
93
|
return 0
|
|
69
94
|
try:
|
|
70
95
|
return int(proc.stdout.strip())
|
|
@@ -75,11 +100,10 @@ def commits_ahead(branch_name: str, base: str, repo_path: Path) -> int:
|
|
|
75
100
|
def branch_exists(branch_name: str, repo_path: Path) -> bool:
|
|
76
101
|
if not repo_path or not Path(repo_path).exists():
|
|
77
102
|
return False
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return proc.returncode == 0
|
|
103
|
+
if not is_safe_ref(branch_name):
|
|
104
|
+
return False
|
|
105
|
+
proc = _git(repo_path, "rev-parse", "--verify", branch_name)
|
|
106
|
+
return proc is not None and proc.returncode == 0
|
|
83
107
|
|
|
84
108
|
|
|
85
109
|
def _has_recent_commits(branch_name: str, repo_path: Path, hours: int = 24) -> bool:
|
|
@@ -88,12 +112,8 @@ def _has_recent_commits(branch_name: str, repo_path: Path, hours: int = 24) -> b
|
|
|
88
112
|
if not branch_exists(branch_name, repo_path):
|
|
89
113
|
return False
|
|
90
114
|
since = (datetime.now() - timedelta(hours=hours)).strftime("%Y-%m-%dT%H:%M:%S")
|
|
91
|
-
proc =
|
|
92
|
-
|
|
93
|
-
f"--since={since}", "--pretty=format:%H"],
|
|
94
|
-
capture_output=True, text=True,
|
|
95
|
-
)
|
|
96
|
-
return proc.returncode == 0 and bool(proc.stdout.strip())
|
|
115
|
+
proc = _git(repo_path, "log", branch_name, f"--since={since}", "--pretty=format:%H")
|
|
116
|
+
return proc is not None and proc.returncode == 0 and bool(proc.stdout.strip())
|
|
97
117
|
|
|
98
118
|
|
|
99
119
|
def branch_in_progress(branch_name: str, repo_path: Path) -> bool:
|
|
@@ -117,11 +137,8 @@ def last_commit_date(branch_name: str, repo_path: Path) -> Optional[datetime]:
|
|
|
117
137
|
return None
|
|
118
138
|
if not branch_exists(branch_name, repo_path):
|
|
119
139
|
return None
|
|
120
|
-
proc =
|
|
121
|
-
|
|
122
|
-
capture_output=True, text=True,
|
|
123
|
-
)
|
|
124
|
-
if proc.returncode != 0 or not proc.stdout.strip():
|
|
140
|
+
proc = _git(repo_path, "log", "-1", branch_name, "--pretty=format:%cI")
|
|
141
|
+
if proc is None or proc.returncode != 0 or not proc.stdout.strip():
|
|
125
142
|
return None
|
|
126
143
|
try:
|
|
127
144
|
s = proc.stdout.strip().split("+")[0].split("Z")[0]
|
|
@@ -134,11 +151,8 @@ def path_last_commit_date(rel_path: str, repo_path: Path) -> Optional[datetime]:
|
|
|
134
151
|
"""Timestamp of the most recent commit touching `rel_path` (naive datetime)."""
|
|
135
152
|
if not repo_path or not Path(repo_path).exists():
|
|
136
153
|
return None
|
|
137
|
-
proc =
|
|
138
|
-
|
|
139
|
-
capture_output=True, text=True,
|
|
140
|
-
)
|
|
141
|
-
if proc.returncode != 0 or not proc.stdout.strip():
|
|
154
|
+
proc = _git(repo_path, "log", "-1", "--pretty=format:%cI", "--", rel_path)
|
|
155
|
+
if proc is None or proc.returncode != 0 or not proc.stdout.strip():
|
|
142
156
|
return None
|
|
143
157
|
try:
|
|
144
158
|
s = proc.stdout.strip().split("+")[0].split("Z")[0]
|
|
@@ -159,12 +173,10 @@ def path_committed_since(rel_path: str, since: date, repo_path: Path) -> bool:
|
|
|
159
173
|
if not repo_path or not Path(repo_path).exists():
|
|
160
174
|
return False
|
|
161
175
|
window_start = since - timedelta(days=1)
|
|
162
|
-
proc =
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
)
|
|
167
|
-
return proc.returncode == 0 and bool(proc.stdout.strip())
|
|
176
|
+
proc = _git(repo_path, "log",
|
|
177
|
+
f"--since={window_start.isoformat()}", "--pretty=format:%H",
|
|
178
|
+
"--", rel_path)
|
|
179
|
+
return proc is not None and proc.returncode == 0 and bool(proc.stdout.strip())
|
|
168
180
|
|
|
169
181
|
|
|
170
182
|
def git_mv(src_rel: str, dst_rel: str, repo_path: Path) -> bool:
|
|
@@ -173,8 +185,5 @@ def git_mv(src_rel: str, dst_rel: str, repo_path: Path) -> bool:
|
|
|
173
185
|
if not repo_path or not Path(repo_path).exists():
|
|
174
186
|
return False
|
|
175
187
|
(Path(repo_path) / dst_rel).parent.mkdir(parents=True, exist_ok=True)
|
|
176
|
-
proc =
|
|
177
|
-
|
|
178
|
-
capture_output=True, text=True,
|
|
179
|
-
)
|
|
180
|
-
return proc.returncode == 0
|
|
188
|
+
proc = _git(repo_path, "mv", src_rel, dst_rel)
|
|
189
|
+
return proc is not None and proc.returncode == 0
|
|
@@ -15,16 +15,30 @@ _GH_ISSUE_FIELDS = "number,state,labels,title,milestone,url,closedAt,body,update
|
|
|
15
15
|
_REPO_RE = re.compile(r"^[\w.-]+/[\w.-]+$")
|
|
16
16
|
GQL_CHUNK = 100 # issues per GraphQL query; GitHub GraphQL complexity budget ~5000 pts/query, 100 issueOrPullRequest nodes is well within it
|
|
17
17
|
|
|
18
|
+
# Bound every `gh` subprocess so a network stall can't hang the CLI (or the
|
|
19
|
+
# VS Code extension that spawns it) indefinitely (#196). The concurrent fetch
|
|
20
|
+
# paths compound an unbounded stall across a thread pool, so this matters.
|
|
21
|
+
GH_TIMEOUT = 30
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _valid_repo(repo: str) -> bool:
|
|
25
|
+
"""True when `repo` looks like `owner/name`. Callers that pass it to `gh`
|
|
26
|
+
should gate on this so a malformed config slug fails fast rather than
|
|
27
|
+
reaching the network (and never lands in argv as something flag-like)."""
|
|
28
|
+
return bool(repo) and _REPO_RE.match(repo) is not None
|
|
29
|
+
|
|
18
30
|
|
|
19
31
|
def fetch_issue(repo: str, number: int) -> Optional[dict]:
|
|
20
32
|
"""Fetch a single issue via gh. Returns parsed dict on success, None on failure.
|
|
21
|
-
Never raises — a missing `gh` binary or
|
|
33
|
+
Never raises — a missing `gh` binary, a timeout, or a bad repo yields None."""
|
|
34
|
+
if not _valid_repo(repo):
|
|
35
|
+
return None
|
|
22
36
|
try:
|
|
23
37
|
proc = subprocess.run(
|
|
24
38
|
["gh", "issue", "view", str(number),
|
|
25
39
|
"--repo", repo,
|
|
26
40
|
"--json", _GH_ISSUE_FIELDS],
|
|
27
|
-
capture_output=True, text=True,
|
|
41
|
+
capture_output=True, text=True, timeout=GH_TIMEOUT,
|
|
28
42
|
)
|
|
29
43
|
except Exception:
|
|
30
44
|
return None
|
|
@@ -158,7 +172,7 @@ def fetch_repo_issues_graphql(repo: str, numbers, chunk: int = GQL_CHUNK,
|
|
|
158
172
|
try:
|
|
159
173
|
proc = subprocess.run(
|
|
160
174
|
["gh", "api", "graphql", "-f", "query=" + _gql_query(owner, name, batch, fields=fields)],
|
|
161
|
-
capture_output=True, text=True,
|
|
175
|
+
capture_output=True, text=True, timeout=GH_TIMEOUT,
|
|
162
176
|
)
|
|
163
177
|
except Exception:
|
|
164
178
|
return {}
|
|
@@ -224,7 +238,7 @@ def fetch_open_issues(repo: str, limit: int = 1000) -> list[dict]:
|
|
|
224
238
|
"--state", "open",
|
|
225
239
|
"--json", "number,title,state,assignees,milestone",
|
|
226
240
|
"--limit", str(limit)],
|
|
227
|
-
capture_output=True, text=True,
|
|
241
|
+
capture_output=True, text=True, timeout=GH_TIMEOUT,
|
|
228
242
|
)
|
|
229
243
|
except Exception:
|
|
230
244
|
return []
|
|
@@ -238,6 +252,8 @@ def fetch_open_issues(repo: str, limit: int = 1000) -> list[dict]:
|
|
|
238
252
|
|
|
239
253
|
def fetch_recent_issues(repo: str, since_iso: str, extra_labels: list[str] = None) -> list[dict]:
|
|
240
254
|
"""Fetch issues created since `since_iso` (date YYYY-MM-DD)."""
|
|
255
|
+
if not _valid_repo(repo):
|
|
256
|
+
return []
|
|
241
257
|
search = f"created:>={since_iso}"
|
|
242
258
|
cmd = ["gh", "issue", "list", "--repo", repo,
|
|
243
259
|
"--state", "all",
|
|
@@ -247,7 +263,10 @@ def fetch_recent_issues(repo: str, since_iso: str, extra_labels: list[str] = Non
|
|
|
247
263
|
if extra_labels:
|
|
248
264
|
for lab in extra_labels:
|
|
249
265
|
cmd.extend(["--label", lab])
|
|
250
|
-
|
|
266
|
+
try:
|
|
267
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=GH_TIMEOUT)
|
|
268
|
+
except Exception:
|
|
269
|
+
return []
|
|
251
270
|
if proc.returncode != 0:
|
|
252
271
|
return []
|
|
253
272
|
return json.loads(proc.stdout) if proc.stdout.strip() else []
|
|
@@ -263,10 +282,19 @@ def repo_visibility(repo: str) -> Optional[str]:
|
|
|
263
282
|
return None
|
|
264
283
|
if repo in _VIS_CACHE:
|
|
265
284
|
return _VIS_CACHE[repo]
|
|
266
|
-
|
|
267
|
-
[
|
|
268
|
-
|
|
269
|
-
|
|
285
|
+
if not _valid_repo(repo):
|
|
286
|
+
_VIS_CACHE[repo] = None
|
|
287
|
+
return None
|
|
288
|
+
try:
|
|
289
|
+
proc = subprocess.run(
|
|
290
|
+
["gh", "repo", "view", repo, "--json", "visibility"],
|
|
291
|
+
capture_output=True, text=True, timeout=GH_TIMEOUT,
|
|
292
|
+
)
|
|
293
|
+
except Exception:
|
|
294
|
+
# Timeout / spawn failure → unknown visibility. needs_confirm() fails
|
|
295
|
+
# CLOSED on None (it still prompts), so this never weakens the gate.
|
|
296
|
+
_VIS_CACHE[repo] = None
|
|
297
|
+
return None
|
|
270
298
|
vis = None
|
|
271
299
|
if proc.returncode == 0 and proc.stdout.strip():
|
|
272
300
|
try:
|
|
@@ -328,10 +356,15 @@ def state_to_status_label(state: str) -> str:
|
|
|
328
356
|
def create_issue(repo: str, title: str, body: str) -> Optional[str]:
|
|
329
357
|
"""Open a GitHub issue via `gh issue create`. Returns the issue URL, or None
|
|
330
358
|
on failure. Reuses the user's `gh` auth; never touches tokens."""
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
359
|
+
if not _valid_repo(repo):
|
|
360
|
+
return None
|
|
361
|
+
try:
|
|
362
|
+
proc = subprocess.run(
|
|
363
|
+
["gh", "issue", "create", "--repo", repo, "--title", title, "--body", body],
|
|
364
|
+
capture_output=True, text=True, timeout=GH_TIMEOUT,
|
|
365
|
+
)
|
|
366
|
+
except Exception:
|
|
367
|
+
return None
|
|
335
368
|
if proc.returncode != 0:
|
|
336
369
|
return None
|
|
337
370
|
return proc.stdout.strip() or None
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Opt-in local version control for the private notes_root tier (#103).
|
|
2
|
+
|
|
3
|
+
The shared tier (`<repo>/.work-plan/`) is version-controlled by the repo it
|
|
4
|
+
lives in. The private tier (`notes_root`, e.g. `Project Notes/`) is not — so a
|
|
5
|
+
track edit (slot/group/handoff/close/set) has no history or undo. This module
|
|
6
|
+
adds an opt-in, *personal, never-pushed* git repo at notes_root: every
|
|
7
|
+
track-mutating command becomes an undoable commit + diff.
|
|
8
|
+
|
|
9
|
+
Every helper here NEVER raises — git absence, a stuck lock, a slow filesystem,
|
|
10
|
+
or a non-repo all resolve to "do nothing". A VCS failure must never change a
|
|
11
|
+
command's exit code (the dispatcher relies on this).
|
|
12
|
+
|
|
13
|
+
Safety rule: we only ever operate when notes_root is the git TOPLEVEL. If
|
|
14
|
+
notes_root sits inside someone else's repo (the workspace, a clone), we refuse
|
|
15
|
+
to `git add -A` there — that would sweep unrelated files into a foreign repo.
|
|
16
|
+
`notes-vcs init` makes notes_root its own root, satisfying this.
|
|
17
|
+
"""
|
|
18
|
+
import subprocess
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
# Bound every git subprocess so a stuck lock or slow FS can't stall the CLI
|
|
23
|
+
# (mirrors git_state.GIT_TIMEOUT; kept local to avoid a cross-module coupling).
|
|
24
|
+
GIT_TIMEOUT = 20
|
|
25
|
+
|
|
26
|
+
_GITIGNORE = ".DS_Store\nThumbs.db\n"
|
|
27
|
+
_INIT_COMMIT_MSG = "work-plan: initialize notes_root local history"
|
|
28
|
+
|
|
29
|
+
# Local git-config marker stamped on the repos work-plan creates for local
|
|
30
|
+
# history. We only ever auto-commit a repo carrying this marker, so we never
|
|
31
|
+
# adopt (and commit into) an arbitrary pre-existing repo the user pointed
|
|
32
|
+
# notes_root at.
|
|
33
|
+
_OWNED_KEY = "workplan.localhistory"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _git(notes_root, *args, timeout: int = GIT_TIMEOUT):
|
|
37
|
+
"""Run `git -C <notes_root> <args>`; return CompletedProcess or None.
|
|
38
|
+
|
|
39
|
+
None means git is missing, timed out, or the spawn failed — callers treat
|
|
40
|
+
it as "no data / could not act", preserving the never-raise contract.
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
return subprocess.run(
|
|
44
|
+
["git", "-C", str(notes_root), *args],
|
|
45
|
+
capture_output=True, text=True, timeout=timeout,
|
|
46
|
+
)
|
|
47
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_git_root(notes_root: Path) -> bool:
|
|
52
|
+
"""True only if notes_root is itself the toplevel of a git work tree.
|
|
53
|
+
|
|
54
|
+
A bare `.git` existence check would also pass for a subdirectory of a larger
|
|
55
|
+
repo, where `git add -A` would stage unrelated files. We compare the
|
|
56
|
+
resolved toplevel to the resolved notes_root so auto-commit only ever fires
|
|
57
|
+
on a repo we own.
|
|
58
|
+
"""
|
|
59
|
+
if not notes_root:
|
|
60
|
+
return False
|
|
61
|
+
root = Path(notes_root).expanduser()
|
|
62
|
+
if not root.is_dir():
|
|
63
|
+
return False
|
|
64
|
+
proc = _git(root, "rev-parse", "--show-toplevel")
|
|
65
|
+
if proc is None or proc.returncode != 0:
|
|
66
|
+
return False
|
|
67
|
+
top = proc.stdout.strip()
|
|
68
|
+
if not top:
|
|
69
|
+
return False
|
|
70
|
+
try:
|
|
71
|
+
return Path(top).resolve() == root.resolve()
|
|
72
|
+
except OSError:
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_under_git(notes_root: Path) -> bool:
|
|
77
|
+
"""True if notes_root is inside ANY git work tree (root or subdir).
|
|
78
|
+
|
|
79
|
+
Used by status/nudge messaging to distinguish "not a repo at all" from
|
|
80
|
+
"inside a repo but not its root" (the latter we deliberately won't touch).
|
|
81
|
+
"""
|
|
82
|
+
if not notes_root:
|
|
83
|
+
return False
|
|
84
|
+
root = Path(notes_root).expanduser()
|
|
85
|
+
if not root.is_dir():
|
|
86
|
+
return False
|
|
87
|
+
proc = _git(root, "rev-parse", "--is-inside-work-tree")
|
|
88
|
+
return proc is not None and proc.returncode == 0 and proc.stdout.strip() == "true"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def has_changes(notes_root: Path) -> bool:
|
|
92
|
+
"""True if the notes_root work tree has staged or unstaged changes."""
|
|
93
|
+
proc = _git(notes_root, "status", "--short")
|
|
94
|
+
return proc is not None and proc.returncode == 0 and bool(proc.stdout.strip())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def has_remotes(notes_root: Path) -> bool:
|
|
98
|
+
"""True if the repo has ANY configured remote.
|
|
99
|
+
|
|
100
|
+
A personal local-history repo must have none — otherwise private notes
|
|
101
|
+
could be pushed off the machine. Used to refuse enabling/committing history
|
|
102
|
+
on a remote-backed repo.
|
|
103
|
+
"""
|
|
104
|
+
proc = _git(notes_root, "remote")
|
|
105
|
+
return proc is not None and proc.returncode == 0 and bool(proc.stdout.strip())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def is_owned(notes_root: Path) -> bool:
|
|
109
|
+
"""True only if work-plan created this repo for local history.
|
|
110
|
+
|
|
111
|
+
`init_repo` stamps a local git-config marker; auto-commit requires it, so we
|
|
112
|
+
never commit into a repo we don't control (e.g. an existing clone the user
|
|
113
|
+
pointed notes_root at).
|
|
114
|
+
"""
|
|
115
|
+
proc = _git(notes_root, "config", "--local", "--get", _OWNED_KEY)
|
|
116
|
+
return proc is not None and proc.returncode == 0 and proc.stdout.strip() == "true"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def mark_owned(notes_root: Path) -> bool:
|
|
120
|
+
"""Stamp the ownership marker into the repo's local config. Returns success."""
|
|
121
|
+
proc = _git(notes_root, "config", "--local", _OWNED_KEY, "true")
|
|
122
|
+
return proc is not None and proc.returncode == 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def head_parent_sha(notes_root: Path) -> Optional[str]:
|
|
126
|
+
"""Short sha of HEAD's first parent, or None (root commit / no commits / not
|
|
127
|
+
a repo). The viewer compares this to the previously-seen HEAD to confirm a
|
|
128
|
+
post-write commit sits directly on top before offering Undo (#224 safety)."""
|
|
129
|
+
proc = _git(notes_root, "rev-parse", "--short", "--verify", "HEAD^")
|
|
130
|
+
if proc is None or proc.returncode != 0 or not proc.stdout.strip():
|
|
131
|
+
return None
|
|
132
|
+
return proc.stdout.strip()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def dirty_paths(notes_root: Path) -> set:
|
|
136
|
+
"""Set of work-tree paths with staged/unstaged changes (raw, quotepath off).
|
|
137
|
+
|
|
138
|
+
Empty set on any failure. Renames collapse to the destination path. Used by
|
|
139
|
+
the dispatcher to commit ONLY what a command changed, leaving pre-existing
|
|
140
|
+
dirty files untouched.
|
|
141
|
+
"""
|
|
142
|
+
proc = _git(notes_root, "-c", "core.quotepath=false", "status", "--porcelain")
|
|
143
|
+
if proc is None or proc.returncode != 0:
|
|
144
|
+
return set()
|
|
145
|
+
paths = set()
|
|
146
|
+
for line in proc.stdout.splitlines():
|
|
147
|
+
if len(line) < 4:
|
|
148
|
+
continue
|
|
149
|
+
path = line[3:]
|
|
150
|
+
if " -> " in path: # rename / copy → the destination path
|
|
151
|
+
path = path.split(" -> ", 1)[1]
|
|
152
|
+
if path:
|
|
153
|
+
paths.add(path)
|
|
154
|
+
return paths
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def last_commit_summary(notes_root: Path) -> Optional[str]:
|
|
158
|
+
"""'<short-sha> <subject>' of HEAD, or None (no commits / not a repo)."""
|
|
159
|
+
proc = _git(notes_root, "log", "-1", "--pretty=format:%h %s")
|
|
160
|
+
if proc is None or proc.returncode != 0 or not proc.stdout.strip():
|
|
161
|
+
return None
|
|
162
|
+
return proc.stdout.strip()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def last_commit_sha(notes_root: Path) -> Optional[str]:
|
|
166
|
+
"""Short sha of HEAD, or None (no commits / not a repo). The undo handle the
|
|
167
|
+
VS Code viewer diffs across a write to decide whether to offer Undo (#224)."""
|
|
168
|
+
proc = _git(notes_root, "log", "-1", "--pretty=format:%h")
|
|
169
|
+
if proc is None or proc.returncode != 0 or not proc.stdout.strip():
|
|
170
|
+
return None
|
|
171
|
+
return proc.stdout.strip()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def revert(notes_root: Path, sha: Optional[str] = None) -> Optional[str]:
|
|
175
|
+
"""Revert `sha` (default HEAD) in notes_root; return the new commit's sha.
|
|
176
|
+
|
|
177
|
+
Keeps git inside the engine so callers (the CLI `undo` verb, the viewer's
|
|
178
|
+
Undo button) never shell out to git themselves. No-op (None) when notes_root
|
|
179
|
+
isn't a git root we OWN with no remote (same boundary as auto_commit — we
|
|
180
|
+
must never rewrite an unrelated project clone's history), when there's no
|
|
181
|
+
commit to revert, or when `sha` is unsafe (empty / dash-led — git would read
|
|
182
|
+
a dash-led value as an option). Never raises. Uses --no-edit (non-interactive).
|
|
183
|
+
"""
|
|
184
|
+
root = Path(notes_root).expanduser()
|
|
185
|
+
if not is_git_root(root) or not is_owned(root) or has_remotes(root):
|
|
186
|
+
return None
|
|
187
|
+
target = sha if sha is not None else "HEAD"
|
|
188
|
+
# A dash-led ref would be parsed by git as an option, not a revision.
|
|
189
|
+
if not target or target.startswith("-"):
|
|
190
|
+
return None
|
|
191
|
+
proc = _git(root, "revert", "--no-edit", target)
|
|
192
|
+
if proc is None or proc.returncode != 0:
|
|
193
|
+
return None
|
|
194
|
+
return last_commit_sha(root)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def init_repo(notes_root: Path) -> bool:
|
|
198
|
+
"""git-init notes_root as a personal repo and make an initial commit.
|
|
199
|
+
|
|
200
|
+
Writes a small `.gitignore` (OS cruft only), stages everything, and commits
|
|
201
|
+
existing tracks so there's a baseline to diff against. Deliberately adds NO
|
|
202
|
+
remote — the private tier is never pushed. Returns True on a clean init +
|
|
203
|
+
initial commit, False on any failure (never raises).
|
|
204
|
+
|
|
205
|
+
Idempotent-ish: re-running on a repo WE own re-commits only if there's
|
|
206
|
+
something new. Refuses (returns False) to adopt a repo we did not create or
|
|
207
|
+
one that has a remote — private notes must never be pushable, and we won't
|
|
208
|
+
sweep an unrelated existing repo's files into history.
|
|
209
|
+
"""
|
|
210
|
+
root = Path(notes_root).expanduser()
|
|
211
|
+
if not root.is_dir():
|
|
212
|
+
return False
|
|
213
|
+
if is_git_root(root):
|
|
214
|
+
# An existing repo: only proceed if it's one we own AND has no remote.
|
|
215
|
+
if has_remotes(root) or not is_owned(root):
|
|
216
|
+
return False
|
|
217
|
+
else:
|
|
218
|
+
if _git(root, "init") is None:
|
|
219
|
+
return False
|
|
220
|
+
# Stamp ownership immediately, before any commit, so this repo can never
|
|
221
|
+
# later be mistaken for a foreign one (and a remote added after init is
|
|
222
|
+
# still caught by auto_commit's per-commit no-remote check).
|
|
223
|
+
if not mark_owned(root):
|
|
224
|
+
return False
|
|
225
|
+
gitignore = root / ".gitignore"
|
|
226
|
+
if not gitignore.exists():
|
|
227
|
+
try:
|
|
228
|
+
gitignore.write_text(_GITIGNORE, encoding="utf-8")
|
|
229
|
+
except OSError:
|
|
230
|
+
return False
|
|
231
|
+
if _git(root, "add", "-A") is None:
|
|
232
|
+
return False
|
|
233
|
+
# Nothing to commit (e.g. re-init of an unchanged repo) is success, not error.
|
|
234
|
+
if not has_changes(root) and last_commit_summary(root) is not None:
|
|
235
|
+
return True
|
|
236
|
+
proc = _git(root, "commit", "-m", _INIT_COMMIT_MSG)
|
|
237
|
+
return proc is not None and proc.returncode == 0
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def auto_commit(notes_root: Path, message: str,
|
|
241
|
+
paths: Optional[list] = None) -> Optional[str]:
|
|
242
|
+
"""Commit notes_root changes with `message`; return the new short SHA.
|
|
243
|
+
|
|
244
|
+
Safety gates (all must hold, else no-op None):
|
|
245
|
+
- notes_root is the git toplevel,
|
|
246
|
+
- it carries the ownership marker (work-plan created it), and
|
|
247
|
+
- it has NO remote (a personal, never-pushed history).
|
|
248
|
+
|
|
249
|
+
When `paths` is given, stages ONLY those paths so unrelated pre-existing
|
|
250
|
+
dirty files stay out of the commit; otherwise stages everything. Commits
|
|
251
|
+
only if something is actually staged. Never raises — a git failure here must
|
|
252
|
+
not change the calling command's exit code.
|
|
253
|
+
"""
|
|
254
|
+
root = Path(notes_root).expanduser()
|
|
255
|
+
if not is_git_root(root) or not is_owned(root) or has_remotes(root):
|
|
256
|
+
return None
|
|
257
|
+
if paths is None:
|
|
258
|
+
if _git(root, "add", "-A") is None:
|
|
259
|
+
return None
|
|
260
|
+
else:
|
|
261
|
+
if not paths:
|
|
262
|
+
return None
|
|
263
|
+
if _git(root, "add", "--", *paths) is None:
|
|
264
|
+
return None
|
|
265
|
+
# Commit only what's staged — scoped `add` above keeps unrelated dirty files
|
|
266
|
+
# unstaged, so they're preserved rather than folded into this commit.
|
|
267
|
+
staged = _git(root, "diff", "--cached", "--quiet")
|
|
268
|
+
if staged is None or staged.returncode == 0:
|
|
269
|
+
return None
|
|
270
|
+
proc = _git(root, "commit", "-m", message)
|
|
271
|
+
if proc is None or proc.returncode != 0:
|
|
272
|
+
return None
|
|
273
|
+
head = _git(root, "rev-parse", "--short", "HEAD")
|
|
274
|
+
if head is None or head.returncode != 0:
|
|
275
|
+
return None
|
|
276
|
+
return head.stdout.strip() or None
|
|
@@ -80,14 +80,25 @@ def parse_flags(args: list[str], known: set[str]) -> tuple[dict, list[str]]:
|
|
|
80
80
|
For `--key=value` flags, key.split("=", 1)[0] is matched against `known`.
|
|
81
81
|
|
|
82
82
|
Returns: (flags_dict, positional_list).
|
|
83
|
-
- flags_dict: {"--all": True, "--repo": "
|
|
83
|
+
- flags_dict: {"--all": True, "--repo": "myproject", ...} for flags found.
|
|
84
84
|
- positional_list: args that aren't flags.
|
|
85
85
|
|
|
86
86
|
Unknown flags are passed through as positional args (caller decides what to do).
|
|
87
87
|
"""
|
|
88
88
|
flags = {}
|
|
89
89
|
positional = []
|
|
90
|
+
end_of_opts = False
|
|
90
91
|
for arg in args:
|
|
92
|
+
# A bare `--` ends option parsing: everything after it is positional,
|
|
93
|
+
# even if it begins with `--`. Lets callers (e.g. the VS Code extension)
|
|
94
|
+
# pass a GitHub-derived value like a `--repo`-named track as a plain
|
|
95
|
+
# positional instead of having it misparsed as a flag (#194).
|
|
96
|
+
if end_of_opts:
|
|
97
|
+
positional.append(arg)
|
|
98
|
+
continue
|
|
99
|
+
if arg == "--":
|
|
100
|
+
end_of_opts = True
|
|
101
|
+
continue
|
|
91
102
|
if not arg.startswith("--"):
|
|
92
103
|
positional.append(arg)
|
|
93
104
|
continue
|