@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.
Files changed (42) hide show
  1. package/README.md +13 -7
  2. package/VERSION +1 -1
  3. package/package.json +1 -1
  4. package/skills/work-plan/SKILL.md +6 -4
  5. package/skills/work-plan/commands/canonicalize.py +7 -92
  6. package/skills/work-plan/commands/handoff.py +15 -6
  7. package/skills/work-plan/commands/init.py +13 -3
  8. package/skills/work-plan/commands/init_repo.py +8 -2
  9. package/skills/work-plan/commands/new_track.py +7 -0
  10. package/skills/work-plan/commands/notes_vcs.py +172 -0
  11. package/skills/work-plan/commands/refresh_md.py +106 -37
  12. package/skills/work-plan/commands/rename_track.py +243 -0
  13. package/skills/work-plan/commands/set_notes_root.py +8 -4
  14. package/skills/work-plan/commands/suggest_priorities.py +12 -2
  15. package/skills/work-plan/lib/config.py +11 -0
  16. package/skills/work-plan/lib/frontmatter.py +12 -3
  17. package/skills/work-plan/lib/git_state.py +61 -52
  18. package/skills/work-plan/lib/github_state.py +46 -13
  19. package/skills/work-plan/lib/notes_vcs.py +276 -0
  20. package/skills/work-plan/lib/prompts.py +12 -1
  21. package/skills/work-plan/lib/status_table.py +95 -5
  22. package/skills/work-plan/lib/tracks.py +9 -4
  23. package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/archive/shipped/old.md +1 -1
  24. package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/example.md +1 -1
  25. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +1 -1
  26. package/skills/work-plan/tests/test_config.py +12 -12
  27. package/skills/work-plan/tests/test_github_state.py +3 -3
  28. package/skills/work-plan/tests/test_init_repo.py +12 -7
  29. package/skills/work-plan/tests/test_new_track.py +7 -7
  30. package/skills/work-plan/tests/test_notes_vcs.py +426 -0
  31. package/skills/work-plan/tests/test_notes_vcs_command.py +312 -0
  32. package/skills/work-plan/tests/test_plan_status_issues.py +2 -2
  33. package/skills/work-plan/tests/test_refresh_md.py +159 -61
  34. package/skills/work-plan/tests/test_rename_track.py +351 -0
  35. package/skills/work-plan/tests/test_repo_filter.py +6 -6
  36. package/skills/work-plan/tests/test_security_cli_hardening.py +142 -0
  37. package/skills/work-plan/tests/test_set_notes_root.py +6 -2
  38. package/skills/work-plan/tests/test_status_table.py +61 -0
  39. package/skills/work-plan/tests/test_track_resolution.py +2 -2
  40. package/skills/work-plan/tests/test_tracks.py +4 -4
  41. package/skills/work-plan/work_plan.py +97 -17
  42. /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 = subprocess.run(
30
- ["git", "-C", str(repo_path), "branch", "--show-current"],
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 = subprocess.run(
42
- ["git", "-C", str(repo_path), "status", "--short"],
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 = subprocess.run(
52
- ["git", "-C", str(repo_path), "status", "--short"],
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
- proc = subprocess.run(
64
- ["git", "-C", str(repo_path), "rev-list", "--count", f"{base}..{branch_name}"],
65
- capture_output=True, text=True,
66
- )
67
- if proc.returncode != 0:
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
- proc = subprocess.run(
79
- ["git", "-C", str(repo_path), "rev-parse", "--verify", branch_name],
80
- capture_output=True, text=True,
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 = subprocess.run(
92
- ["git", "-C", str(repo_path), "log", branch_name,
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 = subprocess.run(
121
- ["git", "-C", str(repo_path), "log", "-1", branch_name, "--pretty=format:%cI"],
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 = subprocess.run(
138
- ["git", "-C", str(repo_path), "log", "-1", "--pretty=format:%cI", "--", rel_path],
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 = subprocess.run(
163
- ["git", "-C", str(repo_path), "log",
164
- f"--since={window_start.isoformat()}", "--pretty=format:%H", "--", rel_path],
165
- capture_output=True, text=True,
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 = subprocess.run(
177
- ["git", "-C", str(repo_path), "mv", src_rel, dst_rel],
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 any subprocess error yields None."""
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
- proc = subprocess.run(cmd, capture_output=True, text=True)
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
- proc = subprocess.run(
267
- ["gh", "repo", "view", repo, "--json", "visibility"],
268
- capture_output=True, text=True,
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
- proc = subprocess.run(
332
- ["gh", "issue", "create", "--repo", repo, "--title", title, "--body", body],
333
- capture_output=True, text=True,
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": "critforge", ...} for flags found.
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