@stylusnexus/work-plan 2026.6.21 → 2026.6.22
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 +3 -1
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/commands/plan_status.py +53 -6
- package/skills/work-plan/lib/blockers.py +40 -0
- package/skills/work-plan/lib/git_state.py +86 -0
- package/skills/work-plan/lib/next_up.py +5 -1
- package/skills/work-plan/lib/render.py +5 -1
- package/skills/work-plan/tests/test_blockers.py +47 -0
- package/skills/work-plan/tests/test_paths_last_commit_dates.py +144 -0
- package/skills/work-plan/tests/test_plan_status_archive.py +10 -2
package/README.md
CHANGED
|
@@ -115,6 +115,8 @@ flowchart TB
|
|
|
115
115
|
|
|
116
116
|
> **`brief` and `orient` annotate blocked issues with `⊘ blocked by #N`** — read-only, surfaced from GitHub's native dependency edges, nothing is written back. Cross-repo blockers show as `owner/repo#N`; same-repo duplicates of manually-declared blockers are deduplicated.
|
|
117
117
|
|
|
118
|
+
> **A manual `blockers:` entry can be a free-text note, not just an issue number.** Normally a blocker is an issue number (`- 5550` or `- "#5550"`), which the VS Code dependency graph draws as a `blocks` edge. But it can also be prose (e.g. `- "gated on the cost go/no-go verdict"`) for when the blocker isn't a single issue. Free-text blockers still mark the track blocked and render as prose in `brief`/`orient` and the detail panel — but they don't draw a graph edge, so prefer an issue number when there is one.
|
|
119
|
+
|
|
118
120
|
> **GitHub access is read-only by default, with three explicit, opt-in write actions.** Issue *data* always comes from read-only `gh` calls (`gh issue list`, `gh issue view`), and every routine write (frontmatter, status table, session log) goes to your local markdown files only. The three GitHub-*mutating* actions are all opt-in and gated: `plan-status --issues` **creates** a GitHub issue per partial plan (`gh issue create`, prompts before opening); `close-issue` (#305) **closes** an issue via `gh issue close` — for the common case where a PR merged to `dev` left its issue OPEN (GitHub auto-closes only from the default branch), with the VS Code viewer firing a mandatory "Close on GitHub? — cannot be undone" modal on every close; and `in-progress` (#271) **adds or removes** the `work-plan:in-progress` label on an issue, public-repo gated via the confirm-token flow. Nothing else touches GitHub state.
|
|
119
121
|
|
|
120
122
|
## Shared tracks
|
|
@@ -565,7 +567,7 @@ Every write verb the VS Code extension drives runs **without a TTY** — explici
|
|
|
565
567
|
|
|
566
568
|
**The Plans view can write to plan-doc frontmatter — and only frontmatter.** Right-click a plan in the viewer → **Confirm Verdict…** / **Clear Confirmation** (writes `verdict_override`), **Acknowledge & Save to Doc** / **Clear Saved Acknowledgment** (writes `acknowledged`), or **Stamp Baseline — Watch for Drift** / **Clear Baseline** (writes `verdict_baseline`) drives the matching CLI write behind a mandatory modal that names the exact file and states the write touches only the doc's YAML frontmatter — never its prose body, checkboxes, or declared-file manifest. These are the only viewer-initiated writes to a plan doc, each touches one frontmatter key and nothing else, and on a public repo each additionally passes through the public-repo confirm modal above. The default **Acknowledge (stop flagging)** remains per-machine and writes nothing to git; **Acknowledge & Save to Doc** is the opt-in durable, shared variant. Everything else the Plans view does (scan, local acknowledge) remains read-only on git.
|
|
567
569
|
|
|
568
|
-
Archived shipped plans (`plan-archive` / `plan-status --archive-shipped`) move into `archive/shipped/` and drop out of the live verdict buckets; the viewer surfaces them in a collapsed **"Archived (N)"** node per repo. Right-click a shipped plan → **Archive Plan…**, or a repo → **Archive shipped plans
|
|
570
|
+
Archived shipped plans (`plan-archive` / `plan-status --archive-shipped`) move into `archive/shipped/` and drop out of the live verdict buckets; the viewer surfaces them in a collapsed **"Archived (N)"** node per repo. Right-click a shipped plan → **Archive Plan…**, multi-select several (Cmd/Ctrl/Shift-click) → **Archive Plan…** to archive the batch behind one confirm, or a repo → **Archive shipped plans…** to sweep them all.
|
|
569
571
|
|
|
570
572
|
## Version
|
|
571
573
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026.06.
|
|
1
|
+
2026.06.22+f62c4cc
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stylusnexus/work-plan",
|
|
3
|
-
"version": "2026.6.
|
|
3
|
+
"version": "2026.6.22",
|
|
4
4
|
"description": "Track-aware daily work planning over GitHub issues. Shared tracks (git-synced .work-plan/ in each repo), AI clustering (group/auto-triage), VS Code viewer, Claude Code + Codex plugins. Pure Python stdlib.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"work-plan": "bin/work-plan"
|
|
@@ -6,7 +6,7 @@ Manifest-less (prose) docs are flagged 👻 for the Phase 1b LLM pass.
|
|
|
6
6
|
"""
|
|
7
7
|
import json
|
|
8
8
|
import sys
|
|
9
|
-
from datetime import date
|
|
9
|
+
from datetime import date, timedelta
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
12
|
from lib import config as config_mod
|
|
@@ -120,13 +120,41 @@ def _declared_paths_on_disk(decls, repo_root) -> list:
|
|
|
120
120
|
return out
|
|
121
121
|
|
|
122
122
|
|
|
123
|
-
def
|
|
123
|
+
def _committed_since_from_map(last_dates, plan_date, repo_root):
|
|
124
|
+
"""`committed_since(rel)` served from the batched date map (#391), mirroring
|
|
125
|
+
manifest.score_manifest's default: with no plan date, degrade to a filesystem
|
|
126
|
+
existence check; otherwise "committed on/after plan_date" is `last-commit-date
|
|
127
|
+
>= plan_date - 1 day` (the same 1-day window `path_committed_since` widens to,
|
|
128
|
+
so same-day Modify commits still count)."""
|
|
129
|
+
if plan_date is None:
|
|
130
|
+
return lambda rel: (Path(repo_root) / rel).exists()
|
|
131
|
+
cutoff = plan_date - timedelta(days=1)
|
|
132
|
+
|
|
133
|
+
def committed_since(rel):
|
|
134
|
+
dt = last_dates.get(rel)
|
|
135
|
+
return dt is not None and dt.date() >= cutoff
|
|
136
|
+
|
|
137
|
+
return committed_since
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _evaluate(doc, repo_root, today, dead_days, stall_days, last_dates=None) -> dict:
|
|
124
141
|
text = _read(doc.path)
|
|
125
142
|
decls = manifest.parse_declared_paths(text)
|
|
126
143
|
pdate = manifest.plan_date_from_filename(doc.path.name)
|
|
127
|
-
|
|
144
|
+
# `last_dates` is the batched {path: datetime} map precomputed in run() (#391)
|
|
145
|
+
# over every doc rel AND every declared path — one chunked git walk for the
|
|
146
|
+
# whole repo. When present, serve score_manifest's per-Modify-path
|
|
147
|
+
# `committed_since` (the real O(n) cost — 1434 git spawns on CritForge), the
|
|
148
|
+
# doc's own last-commit date, and the partial-staleness clock from it: zero
|
|
149
|
+
# git spawns in this loop. When absent (direct callers / tests), fall back to
|
|
150
|
+
# the per-path git calls so behavior is unchanged.
|
|
151
|
+
cs = _committed_since_from_map(last_dates, pdate, repo_root) if last_dates is not None else None
|
|
152
|
+
score = manifest.score_manifest(decls, repo_root, pdate, committed_since=cs)
|
|
128
153
|
done, total_chk = manifest.count_checkboxes(text)
|
|
129
|
-
|
|
154
|
+
if last_dates is not None:
|
|
155
|
+
last_dt = last_dates.get(doc.rel)
|
|
156
|
+
else:
|
|
157
|
+
last_dt = git_state.path_last_commit_date(doc.rel, repo_root)
|
|
130
158
|
last_d = last_dt.date() if last_dt else None
|
|
131
159
|
if decls and manifest.out_of_tree_ratio(decls, repo_root) >= verdict_mod.FOREIGN_RATIO:
|
|
132
160
|
v = verdict_mod.Verdict(
|
|
@@ -151,7 +179,11 @@ def _evaluate(doc, repo_root, today, dead_days, stall_days) -> dict:
|
|
|
151
179
|
if v.label == "partial":
|
|
152
180
|
on_disk = _declared_paths_on_disk(decls, repo_root)
|
|
153
181
|
if on_disk:
|
|
154
|
-
|
|
182
|
+
if last_dates is not None:
|
|
183
|
+
ds = [last_dates[p] for p in on_disk if p in last_dates]
|
|
184
|
+
manifest_dt = max(ds) if ds else None
|
|
185
|
+
else:
|
|
186
|
+
manifest_dt = git_state.paths_last_commit_date(on_disk, repo_root)
|
|
155
187
|
if manifest_dt is None:
|
|
156
188
|
stalled = True # present on disk but never committed
|
|
157
189
|
else:
|
|
@@ -462,7 +494,22 @@ def run(args: list) -> int:
|
|
|
462
494
|
docs = [d for d in docs if d.kind == type_filter]
|
|
463
495
|
|
|
464
496
|
stall_days = _resolve_stall_days(flags)
|
|
465
|
-
|
|
497
|
+
# Batch EVERY git date the scan needs — each doc's own rel plus every declared
|
|
498
|
+
# path (Modify paths drive score_manifest's committed_since, the real O(n)
|
|
499
|
+
# cost) — into one chunked git walk (#391), instead of a subprocess per path.
|
|
500
|
+
# This is what hung the Plans view on large repos (CritForge: 391 docs +
|
|
501
|
+
# 1434 declared paths = ~1800 git spawns → ~40s, now a handful of walks).
|
|
502
|
+
batch_paths = set()
|
|
503
|
+
for d in docs:
|
|
504
|
+
batch_paths.add(d.rel)
|
|
505
|
+
try:
|
|
506
|
+
for dp in manifest.parse_declared_paths(_read(d.path)):
|
|
507
|
+
batch_paths.add(dp.path)
|
|
508
|
+
except OSError:
|
|
509
|
+
pass
|
|
510
|
+
last_dates = git_state.paths_last_commit_dates(sorted(batch_paths), repo_root)
|
|
511
|
+
rows = [_evaluate(d, repo_root, today, dead_days, stall_days, last_dates)
|
|
512
|
+
for d in docs]
|
|
466
513
|
|
|
467
514
|
if flags.get("--llm"):
|
|
468
515
|
if flags.get("--apply"):
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Track blocker normalization — the Python sibling of the viewer's blockerIssue().
|
|
2
|
+
|
|
3
|
+
A `blockers:` frontmatter entry is usually an issue number, but it may also be a
|
|
4
|
+
free-text note (e.g. "gated on the cost go/no-go verdict, needs #5548 telemetry").
|
|
5
|
+
Consumers that render a blocker with a leading `#` must funnel it through
|
|
6
|
+
`blocker_display` first, or a prose blocker prints as `#<sentence>`; the next-up
|
|
7
|
+
gate must funnel it through `blocker_issue`, or a string-form ref ("5550") never
|
|
8
|
+
matches the integer issue number it should exclude.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
_ISSUE_REF = re.compile(r"^\s*#?(\d+)\s*$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def blocker_issue(value):
|
|
17
|
+
"""The issue number a blocker refers to, or None for free-text.
|
|
18
|
+
|
|
19
|
+
Mirrors the viewer's `blockerIssue`: a bare int, "5550", or "#5550" resolves
|
|
20
|
+
to the number; anything else (prose, even when it embeds a `#5550`) is
|
|
21
|
+
free-text. A leading-zero string like "007" is treated as free-text rather
|
|
22
|
+
than silently coerced to 7. `bool` is rejected (it is an int subclass).
|
|
23
|
+
"""
|
|
24
|
+
if isinstance(value, bool):
|
|
25
|
+
return None
|
|
26
|
+
if isinstance(value, int):
|
|
27
|
+
return value
|
|
28
|
+
if isinstance(value, str):
|
|
29
|
+
m = _ISSUE_REF.match(value)
|
|
30
|
+
if m is not None:
|
|
31
|
+
digits = m.group(1)
|
|
32
|
+
if str(int(digits)) == digits: # reject leading-zero "007"
|
|
33
|
+
return int(digits)
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def blocker_display(value):
|
|
38
|
+
"""How to show a blocker: `#N` for an issue ref, the prose itself otherwise."""
|
|
39
|
+
n = blocker_issue(value)
|
|
40
|
+
return f"#{n}" if n is not None else str(value)
|
|
@@ -254,6 +254,92 @@ def paths_last_commit_date(rel_paths, repo_path: Path) -> Optional[datetime]:
|
|
|
254
254
|
return None
|
|
255
255
|
|
|
256
256
|
|
|
257
|
+
# A `%cI` commit line ("2026-06-19T10:00:00-07:00") vs a path line, so the
|
|
258
|
+
# batched walk below can tell which is which without a sentinel (plan/spec doc
|
|
259
|
+
# paths never look like an ISO timestamp).
|
|
260
|
+
_ISO_DT_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _is_inrepo_rel(p: str) -> bool:
|
|
264
|
+
"""True if `p` is a repo-relative path that stays inside the repo — safe to
|
|
265
|
+
pass to `git log -- <p>`. Rejects empty, absolute (`/…`), home (`~…`),
|
|
266
|
+
backslash, and any `..` that escapes the root (a foreign plan's off-tree
|
|
267
|
+
declared path). git would exit 128 on those, poisoning the batch."""
|
|
268
|
+
if not p or p[0] in "/~" or "\\" in p:
|
|
269
|
+
return False
|
|
270
|
+
depth = 0
|
|
271
|
+
for part in p.split("/"):
|
|
272
|
+
if part in ("", "."):
|
|
273
|
+
continue
|
|
274
|
+
if part == "..":
|
|
275
|
+
depth -= 1
|
|
276
|
+
if depth < 0:
|
|
277
|
+
return False
|
|
278
|
+
else:
|
|
279
|
+
depth += 1
|
|
280
|
+
return True
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def paths_last_commit_dates(rel_paths, repo_path: Path) -> dict:
|
|
284
|
+
"""Most-recent commit datetime touching EACH of `rel_paths`, in a SINGLE
|
|
285
|
+
`git log` walk (#391) — vs one `path_last_commit_date` subprocess per path,
|
|
286
|
+
whose spawn overhead makes a many-doc scan O(docs) in process startups.
|
|
287
|
+
|
|
288
|
+
Returns {rel_path: datetime} for paths with at least one commit; a path
|
|
289
|
+
never committed is omitted. `git log` walks newest-first, so the first
|
|
290
|
+
commit that touches a path is its last touch. Never raises (None/bad-repo/
|
|
291
|
+
git-error all yield the partial-or-empty map).
|
|
292
|
+
"""
|
|
293
|
+
out: dict = {}
|
|
294
|
+
if not rel_paths or not repo_path or not Path(repo_path).exists():
|
|
295
|
+
return out
|
|
296
|
+
# Drop off-tree paths (absolute, ~, ..-escape) BEFORE the git call: an
|
|
297
|
+
# out-of-repo pathspec makes `git log -- <…>` exit 128, which would poison
|
|
298
|
+
# the WHOLE chunk and silently lose every other path in it. Such paths can't
|
|
299
|
+
# have a commit in this repo anyway, so omitting them is correct (#391).
|
|
300
|
+
paths = [p for p in dict.fromkeys(rel_paths) if _is_inrepo_rel(p)]
|
|
301
|
+
if not paths:
|
|
302
|
+
return out
|
|
303
|
+
# Chunk the pathspec so a many-doc repo (1000s of declared paths) can't blow
|
|
304
|
+
# past the OS arg-length limit; each chunk is one `git log` walk.
|
|
305
|
+
for i in range(0, len(paths), _BATCH_CHUNK):
|
|
306
|
+
_collect_last_dates(paths[i:i + _BATCH_CHUNK], repo_path, out)
|
|
307
|
+
return out
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
_BATCH_CHUNK = 400
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _collect_last_dates(paths, repo_path, out: dict) -> None:
|
|
314
|
+
"""One `git log --name-only` walk over `paths`; record each path's newest
|
|
315
|
+
commit datetime into `out`. core.quotePath=false → non-ASCII paths print raw
|
|
316
|
+
so they match the rel strings exactly."""
|
|
317
|
+
remaining = set(paths)
|
|
318
|
+
proc = _git(repo_path, "-c", "core.quotePath=false",
|
|
319
|
+
"log", "--format=%cI", "--name-only", "--", *paths)
|
|
320
|
+
if proc is None or proc.returncode != 0:
|
|
321
|
+
return
|
|
322
|
+
cur = None
|
|
323
|
+
for line in proc.stdout.splitlines():
|
|
324
|
+
if not line:
|
|
325
|
+
continue
|
|
326
|
+
if _ISO_DT_RE.match(line):
|
|
327
|
+
cur = line
|
|
328
|
+
continue
|
|
329
|
+
if cur and line in remaining:
|
|
330
|
+
try:
|
|
331
|
+
# %cI carries a numeric tz offset (often negative, e.g. -07:00).
|
|
332
|
+
# Parse it, then drop tzinfo for a consistent NAIVE datetime — so
|
|
333
|
+
# callers can compare/max() across paths (a +offset and a -offset
|
|
334
|
+
# mix would otherwise be aware-vs-naive). .date() is unchanged.
|
|
335
|
+
out[line] = datetime.fromisoformat(cur).replace(tzinfo=None)
|
|
336
|
+
except ValueError:
|
|
337
|
+
pass
|
|
338
|
+
remaining.discard(line)
|
|
339
|
+
if not remaining:
|
|
340
|
+
break
|
|
341
|
+
|
|
342
|
+
|
|
257
343
|
def path_committed_since(rel_path: str, since: date, repo_path: Path) -> bool:
|
|
258
344
|
"""True if `rel_path` has any commit on/around `since` or later (a datetime.date).
|
|
259
345
|
|
|
@@ -40,6 +40,7 @@ from __future__ import annotations
|
|
|
40
40
|
from datetime import datetime
|
|
41
41
|
from typing import Iterable, Optional
|
|
42
42
|
|
|
43
|
+
from lib.blockers import blocker_issue
|
|
43
44
|
from lib.github_state import extract_priority, short_milestone
|
|
44
45
|
|
|
45
46
|
PRIORITY_RANK = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
|
|
@@ -151,7 +152,10 @@ def suggest_next_up(
|
|
|
151
152
|
List of issue numbers, highest-ranked first. Empty if nothing
|
|
152
153
|
qualifies (e.g., everything closed or blocked).
|
|
153
154
|
"""
|
|
154
|
-
blockers
|
|
155
|
+
# Normalize blockers to issue numbers: a string-form ref ("5550"/"#5550")
|
|
156
|
+
# must still exclude issue 5550, and a free-text blocker resolves to None
|
|
157
|
+
# (dropped) so it harmlessly gates nothing.
|
|
158
|
+
blockers = {n for n in (blocker_issue(b) for b in (blocker_nums or [])) if n is not None}
|
|
155
159
|
in_progress = set(in_progress_nums or [])
|
|
156
160
|
# Resolve order: None → default preset; unknown names in list → skipped.
|
|
157
161
|
effective_order = order if order is not None else PRESETS[DEFAULT_PRESET]
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Compose terminal output strings."""
|
|
2
2
|
|
|
3
|
+
from lib.blockers import blocker_display
|
|
4
|
+
|
|
3
5
|
|
|
4
6
|
def time_aware_framing(gap_seconds: int, current_hour: int, handoff_today: bool = True) -> str:
|
|
5
7
|
"""Adapt framing to gap-since-last-activity + hour."""
|
|
@@ -66,7 +68,9 @@ def render_track_row(t: dict) -> str:
|
|
|
66
68
|
if t["blockers"]:
|
|
67
69
|
for b in t["blockers"]:
|
|
68
70
|
reason = b.get("reason", "manually flagged")
|
|
69
|
-
|
|
71
|
+
# blocker_display: `#N` for an issue ref, the prose verbatim for a
|
|
72
|
+
# free-text blocker (a raw `#{number}` would print `#<sentence>`).
|
|
73
|
+
lines.append(f" Blocker: {blocker_display(b['number'])} — {reason}")
|
|
70
74
|
else:
|
|
71
75
|
lines.append(" Blockers: none")
|
|
72
76
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Tests for lib.blockers — blocker_issue / blocker_display normalization."""
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
from lib.blockers import blocker_issue, blocker_display
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestBlockerIssue(unittest.TestCase):
|
|
9
|
+
def test_bare_int_is_its_own_ref(self):
|
|
10
|
+
self.assertEqual(blocker_issue(5550), 5550)
|
|
11
|
+
|
|
12
|
+
def test_pure_id_strings_resolve(self):
|
|
13
|
+
self.assertEqual(blocker_issue("5550"), 5550)
|
|
14
|
+
self.assertEqual(blocker_issue("#5550"), 5550)
|
|
15
|
+
self.assertEqual(blocker_issue(" #5550 "), 5550)
|
|
16
|
+
|
|
17
|
+
def test_prose_is_free_text_even_with_embedded_ref(self):
|
|
18
|
+
# Must NOT extract 5550 — it's an active next_up item being described.
|
|
19
|
+
self.assertIsNone(
|
|
20
|
+
blocker_issue("#5550 selective routing is gated on the verdict, needs #5548")
|
|
21
|
+
)
|
|
22
|
+
self.assertIsNone(blocker_issue("waiting on design review"))
|
|
23
|
+
|
|
24
|
+
def test_leading_zero_is_free_text(self):
|
|
25
|
+
# "007" must not silently become issue 7.
|
|
26
|
+
self.assertIsNone(blocker_issue("007"))
|
|
27
|
+
|
|
28
|
+
def test_bool_and_empty_are_free_text(self):
|
|
29
|
+
self.assertIsNone(blocker_issue(True))
|
|
30
|
+
self.assertIsNone(blocker_issue(""))
|
|
31
|
+
self.assertIsNone(blocker_issue("#"))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TestBlockerDisplay(unittest.TestCase):
|
|
35
|
+
def test_issue_ref_gets_hash_prefix(self):
|
|
36
|
+
self.assertEqual(blocker_display(5550), "#5550")
|
|
37
|
+
self.assertEqual(blocker_display("#5550"), "#5550")
|
|
38
|
+
self.assertEqual(blocker_display("5550"), "#5550")
|
|
39
|
+
|
|
40
|
+
def test_free_text_shown_verbatim_without_hash(self):
|
|
41
|
+
prose = "gated on the cost go/no-go verdict"
|
|
42
|
+
self.assertEqual(blocker_display(prose), prose)
|
|
43
|
+
self.assertNotIn("#", blocker_display(prose))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
unittest.main()
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Batched last-commit-date lookup (#391): one `git log` walk yields the latest
|
|
2
|
+
commit datetime per path, replacing N per-path subprocess spawns. Offline —
|
|
3
|
+
`_git` is mocked, no real git invoked."""
|
|
4
|
+
import io
|
|
5
|
+
import json as _json
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from contextlib import redirect_stdout
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from unittest import mock
|
|
12
|
+
|
|
13
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
14
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
15
|
+
|
|
16
|
+
from lib import git_state
|
|
17
|
+
from commands import plan_status
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _FakeProc:
|
|
21
|
+
def __init__(self, stdout, returncode=0):
|
|
22
|
+
self.stdout = stdout
|
|
23
|
+
self.returncode = returncode
|
|
24
|
+
self.stderr = ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# `git log --format=%cI --name-only` over three paths, newest-first. a.md last
|
|
28
|
+
# touched in the newest commit; b.md too; c.md only in the older commit.
|
|
29
|
+
LOG = (
|
|
30
|
+
"2026-06-19T10:00:00-07:00\n"
|
|
31
|
+
"\n"
|
|
32
|
+
"docs/plans/a.md\n"
|
|
33
|
+
"docs/plans/b.md\n"
|
|
34
|
+
"2026-06-10T09:00:00-07:00\n"
|
|
35
|
+
"\n"
|
|
36
|
+
"docs/plans/a.md\n"
|
|
37
|
+
"docs/plans/c.md\n"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PathsLastCommitDatesTest(unittest.TestCase):
|
|
42
|
+
def test_single_call_maps_each_path_to_its_latest(self):
|
|
43
|
+
with mock.patch("lib.git_state.Path.exists", return_value=True), \
|
|
44
|
+
mock.patch("lib.git_state._git", return_value=_FakeProc(LOG)) as g:
|
|
45
|
+
res = git_state.paths_last_commit_dates(
|
|
46
|
+
["docs/plans/a.md", "docs/plans/b.md", "docs/plans/c.md"],
|
|
47
|
+
Path("/repo"))
|
|
48
|
+
g.assert_called_once() # ONE git spawn, not three
|
|
49
|
+
self.assertEqual(res["docs/plans/a.md"].date().isoformat(), "2026-06-19")
|
|
50
|
+
self.assertEqual(res["docs/plans/b.md"].date().isoformat(), "2026-06-19")
|
|
51
|
+
self.assertEqual(res["docs/plans/c.md"].date().isoformat(), "2026-06-10")
|
|
52
|
+
|
|
53
|
+
def test_batched_call_uses_name_only_walk(self):
|
|
54
|
+
with mock.patch("lib.git_state.Path.exists", return_value=True), \
|
|
55
|
+
mock.patch("lib.git_state._git", return_value=_FakeProc(LOG)) as g:
|
|
56
|
+
git_state.paths_last_commit_dates(["docs/plans/a.md"], Path("/repo"))
|
|
57
|
+
args = g.call_args.args
|
|
58
|
+
self.assertIn("--name-only", args)
|
|
59
|
+
self.assertIn("log", args)
|
|
60
|
+
self.assertIn("docs/plans/a.md", args) # path passed through
|
|
61
|
+
|
|
62
|
+
def test_uncommitted_path_omitted(self):
|
|
63
|
+
with mock.patch("lib.git_state.Path.exists", return_value=True), \
|
|
64
|
+
mock.patch("lib.git_state._git", return_value=_FakeProc(LOG)):
|
|
65
|
+
res = git_state.paths_last_commit_dates(
|
|
66
|
+
["docs/plans/a.md", "docs/plans/never.md"], Path("/repo"))
|
|
67
|
+
self.assertIn("docs/plans/a.md", res)
|
|
68
|
+
self.assertNotIn("docs/plans/never.md", res) # no commit → omitted
|
|
69
|
+
|
|
70
|
+
def test_empty_input_no_git_call(self):
|
|
71
|
+
with mock.patch("lib.git_state._git") as g:
|
|
72
|
+
self.assertEqual(git_state.paths_last_commit_dates([], Path("/repo")), {})
|
|
73
|
+
g.assert_not_called()
|
|
74
|
+
|
|
75
|
+
def test_git_failure_returns_empty(self):
|
|
76
|
+
with mock.patch("lib.git_state.Path.exists", return_value=True), \
|
|
77
|
+
mock.patch("lib.git_state._git", return_value=_FakeProc("", returncode=128)):
|
|
78
|
+
self.assertEqual(
|
|
79
|
+
git_state.paths_last_commit_dates(["docs/plans/a.md"], Path("/repo")), {})
|
|
80
|
+
|
|
81
|
+
def test_offtree_paths_filtered_before_git(self):
|
|
82
|
+
# An off-tree pathspec (../, absolute, ~) makes `git log` exit 128 and
|
|
83
|
+
# poison the whole chunk; they must be dropped before the call (#391).
|
|
84
|
+
with mock.patch("lib.git_state.Path.exists", return_value=True), \
|
|
85
|
+
mock.patch("lib.git_state._git", return_value=_FakeProc(LOG)) as g:
|
|
86
|
+
res = git_state.paths_last_commit_dates(
|
|
87
|
+
["docs/plans/a.md", "../escape.md", "/abs/x.md", "~/h.md",
|
|
88
|
+
"a/../../b.md"], Path("/repo"))
|
|
89
|
+
passed = g.call_args.args
|
|
90
|
+
self.assertIn("docs/plans/a.md", passed)
|
|
91
|
+
for bad in ("../escape.md", "/abs/x.md", "~/h.md", "a/../../b.md"):
|
|
92
|
+
self.assertNotIn(bad, passed) # never reaches git
|
|
93
|
+
self.assertIn("docs/plans/a.md", res)
|
|
94
|
+
|
|
95
|
+
def test_all_offtree_no_git_call(self):
|
|
96
|
+
with mock.patch("lib.git_state.Path.exists", return_value=True), \
|
|
97
|
+
mock.patch("lib.git_state._git") as g:
|
|
98
|
+
self.assertEqual(
|
|
99
|
+
git_state.paths_last_commit_dates(["../x", "/y"], Path("/repo")), {})
|
|
100
|
+
g.assert_not_called()
|
|
101
|
+
|
|
102
|
+
def test_none_proc_returns_empty(self):
|
|
103
|
+
with mock.patch("lib.git_state.Path.exists", return_value=True), \
|
|
104
|
+
mock.patch("lib.git_state._git", return_value=None):
|
|
105
|
+
self.assertEqual(
|
|
106
|
+
git_state.paths_last_commit_dates(["docs/plans/a.md"], Path("/repo")), {})
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class PlanStatusBatchesTest(unittest.TestCase):
|
|
110
|
+
"""run() must use the ONE batched call, not a per-doc git spawn (#391)."""
|
|
111
|
+
|
|
112
|
+
def _repo(self, d):
|
|
113
|
+
root = Path(d)
|
|
114
|
+
(root / "docs/plans").mkdir(parents=True)
|
|
115
|
+
# A Modify path is what drove the per-path committed_since git spawns.
|
|
116
|
+
(root / "docs/plans/a.md").write_text(
|
|
117
|
+
"# A\n\n- Create: `src/a.ts`\n- Modify: `src/x.ts`\n")
|
|
118
|
+
(root / "docs/plans/b.md").write_text("# B\n\n- Create: `src/b.ts`\n")
|
|
119
|
+
return root
|
|
120
|
+
|
|
121
|
+
def test_run_batches_one_call_no_per_doc_spawn(self):
|
|
122
|
+
with tempfile.TemporaryDirectory() as d:
|
|
123
|
+
root = self._repo(d)
|
|
124
|
+
with mock.patch("commands.plan_status.Path.cwd", return_value=root), \
|
|
125
|
+
mock.patch("commands.plan_status.git_state.paths_last_commit_dates",
|
|
126
|
+
return_value={}) as batch, \
|
|
127
|
+
mock.patch("commands.plan_status.git_state.path_last_commit_date") as per, \
|
|
128
|
+
mock.patch("commands.plan_status.git_state.paths_last_commit_date") as per_multi:
|
|
129
|
+
buf = io.StringIO()
|
|
130
|
+
with redirect_stdout(buf):
|
|
131
|
+
rc = plan_status.run(["--json"])
|
|
132
|
+
self.assertEqual(rc, 0)
|
|
133
|
+
batch.assert_called_once() # one git walk for all docs + declared paths
|
|
134
|
+
per.assert_not_called() # no per-doc last-commit spawn
|
|
135
|
+
per_multi.assert_not_called() # no per-partial staleness spawn
|
|
136
|
+
# committed_since is served from the injected map (run() passes a
|
|
137
|
+
# closure to score_manifest), so its per-Modify-path git fallback
|
|
138
|
+
# never runs — that was the real O(n) cost.
|
|
139
|
+
docs = _json.loads(buf.getvalue())["docs"]
|
|
140
|
+
self.assertEqual(len(docs), 2)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__":
|
|
144
|
+
unittest.main()
|
|
@@ -25,9 +25,17 @@ class ArchiveTest(unittest.TestCase):
|
|
|
25
25
|
return root
|
|
26
26
|
|
|
27
27
|
def _run(self, root, args, mv_ok=True):
|
|
28
|
-
# stale last-commit (well beyond DEAD_DAYS) so the absent-file plan is dead
|
|
28
|
+
# stale last-commit (well beyond DEAD_DAYS) so the absent-file plan is dead.
|
|
29
|
+
# run() now reads the batched paths_last_commit_dates map (#391); mock its
|
|
30
|
+
# .get to return the stale date for every doc (path_last_commit_date is the
|
|
31
|
+
# fallback path, mocked too).
|
|
32
|
+
stale = datetime(2026, 1, 1)
|
|
33
|
+
batched = mock.MagicMock()
|
|
34
|
+
batched.get.return_value = stale
|
|
29
35
|
with mock.patch("commands.plan_status.git_state.path_last_commit_date",
|
|
30
|
-
return_value=
|
|
36
|
+
return_value=stale), \
|
|
37
|
+
mock.patch("commands.plan_status.git_state.paths_last_commit_dates",
|
|
38
|
+
return_value=batched), \
|
|
31
39
|
mock.patch("commands.plan_status.Path.cwd", return_value=root), \
|
|
32
40
|
mock.patch("commands.plan_status.git_state.git_mv",
|
|
33
41
|
return_value=mv_ok) as mv, \
|