From 4fa73a154500109690b91cab98a0ff78c096b744 Mon Sep 17 00:00:00 2001 From: Dieter Baier Date: Fri, 4 Sep 2026 12:12:36 +0200 Subject: [PATCH 1/4] issue_89: Sweep every branch, not just the current The four commands that establish the state at clock-in describe the current branch plus whatever carries an open pull request. A branch with commits and no pull request is invisible to all four, and that is the ordinary shape of work in progress. It cost a real day: on 3 September 2026 a consuming private journal recorded a topic as untouched while four commits for it sat on a side branch of the very repository being swept. The sweep was not wrong, it was incomplete, and the result was a durable record a later session would have believed. Clock-in gains the enumeration and clock-out defers to it rather than carrying a second copy. The new wiring scenario guards both halves: no other skill carries the command, and clock-out still reaches the skill that owns it. Both halves were verified to fail on a real regression. Closes part of #89. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011krf2MRrinYMyArXUuvPpj --- features/skill-wiring.feature | 9 ++++++++ skills/clock-in/SKILL.md | 27 +++++++++++++++++++--- skills/clock-out/SKILL.md | 6 +++++ test/skill-wiring.test.mjs | 42 +++++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/features/skill-wiring.feature b/features/skill-wiring.feature index fe5b9f5..3539bb3 100644 --- a/features/skill-wiring.feature +++ b/features/skill-wiring.feature @@ -33,6 +33,15 @@ Feature: Skill wiring # Naming a state is allowed and often clearer; defining it elsewhere is the # copy that drifts. All four are checked, not just one. + Scenario: The repository branch sweep is defined once and deferred to + Given clock-in defines the repository-wide branch enumeration + When every other skill is searched for that command + Then none carries it and clock-out references skills/clock-in/SKILL.md + # clock-out needs the same whole-repository view at the end of a session as + # clock-in needs at the start. Copying the command into it is the drift this + # feature exists to catch; the command is read from clock-in so that editing + # it there moves the guard with it. + Scenario: No caller carries the Convergence Check question structure Given the seven question titles read from the Convergence Check itself When every other skill is searched for those titles as headings diff --git a/skills/clock-in/SKILL.md b/skills/clock-in/SKILL.md index b1e69c3..4d06169 100644 --- a/skills/clock-in/SKILL.md +++ b/skills/clock-in/SKILL.md @@ -81,6 +81,27 @@ Note anything that contradicts what the topic files will claim. Uncommitted work, a branch that is not the base branch, an open pull request — these are facts about where the last session stopped, and they outrank any file. +Every one of those four commands describes the *current* branch, plus whatever +branches happen to carry an open pull request. A branch with commits and no pull +request is invisible to all four, and that is the ordinary shape of work in +progress. So establish the state of the whole repository as well: + +```sh +git for-each-ref --sort=-committerdate \ + --format='%(committerdate:short) %(refname:short) %(contents:subject)' \ + refs/remotes/origin refs/heads +``` + +Read it against the base branch: a ref that is ahead of it carries work this +session has not seen yet. Without a checkout, the same question is +`gh api "repos///branches?per_page=100"` followed by +`gh api "repos///compare/..."`. + +This is one repository's branches, not other repositories — the multi-project +view still belongs to the private layer. When the enumeration fails, that is a +recorded gap and not an empty result; a session that could not list the branches +has not established that there were none. + **2. Delegate to the project's own clock-in skill when it has one.** Look for `skills/clock-in/SKILL.md` in the repository being worked in, and apply @@ -119,9 +140,9 @@ here. is often why the plan says what it says. - The issues the plan references, if the plan's next step names any. -**7. Report the delta, then start.** Three things, briefly: where the topic -stands, what the plan's next step is, and what has changed on the base branch -since the file was last touched. If the two disagree, say so and fix the file — +**7. Report the delta, then start.** Four things, briefly: where the topic +stands, what the plan's next step is, what has changed on the base branch since +the file was last touched, and which other refs are ahead of it. If the two disagree, say so and fix the file — it describes now, so a stale statement in it is corrected on sight, not preserved. diff --git a/skills/clock-out/SKILL.md b/skills/clock-out/SKILL.md index 768d859..d5e512b 100644 --- a/skills/clock-out/SKILL.md +++ b/skills/clock-out/SKILL.md @@ -58,6 +58,12 @@ gh pr list --state open gh pr checks ``` +These see the current branch and whatever carries an open pull request. Establish +the state of the whole repository too, with the branch enumeration under step 1 +of `../clock-in/SKILL.md` — that file owns the command, and a second copy here +would get its own chance to drift from it. A branch that moved today and has no +pull request is exactly what a closing report must not miss. + A session does not end tidily just because it stopped. **2. Delegate to the project's own clock-out skill when it has one.** Look for diff --git a/test/skill-wiring.test.mjs b/test/skill-wiring.test.mjs index ad394b8..bfbad02 100644 --- a/test/skill-wiring.test.mjs +++ b/test/skill-wiring.test.mjs @@ -138,6 +138,48 @@ test("The Convergence Check result states live in exactly one skill", () => { assert.deepEqual(offenders, []); }); +// The branch enumeration clock-out needs is defined in clock-in. Read it from +// there rather than restating it, for the same reason as above: rename or reword +// the command and the guard follows it. +function canonicalBranchEnumeration() { + const text = read(path.join(skillsDir, "clock-in", "SKILL.md")); + const blocks = [...text.matchAll(/```sh\n([\s\S]*?)```/g)].map((m) => m[1]); + const sweeps = blocks.filter((block) => block.includes("for-each-ref")); + assert.equal( + sweeps.length, + 1, + `expected one branch enumeration in clock-in, found ${sweeps.length}`, + ); + return sweeps[0] + .split("\n") + .find((line) => line.includes("for-each-ref")) + .trim(); +} + +test("The repository branch sweep is defined once and deferred to", () => { + // Given clock-in defines the repository-wide branch enumeration + const command = canonicalBranchEnumeration(); + + // When every other skill is searched for that command + const copies = skillFiles() + .filter((file) => rel(file) !== path.join("skills", "clock-in", "SKILL.md")) + .filter((file) => read(file).includes(command)) + .map(rel); + + // Then none carries it and clock-out references skills/clock-in/SKILL.md + // + // Both halves matter. Without the first, the command gets pasted into + // clock-out and the two drift; without the second, it gets dropped from + // clock-out entirely and the closing report silently loses the branches + // again — which is the regression this scenario was written for. + assert.deepEqual(copies, []); + const clockOut = path.join(skillsDir, "clock-out", "SKILL.md"); + assert.ok( + skillReferences(clockOut).includes("../clock-in/SKILL.md"), + "clock-out no longer reaches the skill that owns the branch enumeration", + ); +}); + test("The Convergence Check questions are not copied into a caller", () => { // Given the canonical skills under skills/, and the seven question titles read // from the Convergence Check itself From 4181b62892f26deca398a07f2a5440772b48c782 Mon Sep 17 00:00:00 2001 From: Dieter Baier Date: Fri, 4 Sep 2026 12:12:53 +0200 Subject: [PATCH 2/4] issue_89: Record what a session actually read Both skills claim to start from the repository rather than from memory, and neither leaves a trace of having done it. A session that read the issues, the diary and the topic files and one that reconstructed them from the conversation produce artifacts that look identical, so the failure is silent by construction. The session now records which artifacts it fetched, with references a later session can re-fetch, and the handover record gains a field for it. That field is deliberately separate from evidence: evidence is what the day produced, read is what the session fetched to find out, and collapsing them loses the only statement that says the day was reconstructed from the repository. How far this reaches is stated in the skill instead of implied. The branch enumeration is enforced by a command; this record is enforced by review, because the diary form belongs to the project layer and there is no single path a toolkit-level check could look at. A consuming repository with a fixed diary shape can count it, and one does. Closes part of #89. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011krf2MRrinYMyArXUuvPpj --- skills/clock-in/SKILL.md | 28 ++++++++++++++++++++++++++++ skills/clock-out/SKILL.md | 11 +++++++++++ 2 files changed, 39 insertions(+) diff --git a/skills/clock-in/SKILL.md b/skills/clock-in/SKILL.md index 4d06169..1781dd3 100644 --- a/skills/clock-in/SKILL.md +++ b/skills/clock-in/SKILL.md @@ -150,6 +150,34 @@ preserved. `templates/progress.adoc` and fill the plan **with** the owner, not for them. A plan invented on their behalf is a guess wearing a checklist. +## Say what was read + +This skill claims to start from the repository rather than from memory. A session +that read the issues, the diary and the topic files and a session that +reconstructed them from the conversation produce artifacts that look identical, +so the claim needs a trace or it is only an intention. + +Record, in the layer that outlives the session, which repository artifacts this +session actually fetched — the branch enumeration, the issues read by number, the +topic files, the diary entry, the delegated skills — each with a reference a later +session can re-fetch. `clock-out/SKILL.md` says where that record lands. + +Two rules keep it worth having: + +- **An artifact this session did not fetch does not go in the list.** A plausible + entry written from memory is worse than a short list, because it is the one + thing a later session will trust without checking. +- **A failed fetch is an entry too**, naming what could not be read and why. + +Be clear about how much this is worth. The branch enumeration above is enforced +by a command: run it or do not, the output is either there or it is not. This +record is enforced by review — nothing in the toolkit can tell a truthful list +from an invented one, because the diary form belongs to the project layer and +there is no single path to check. A consuming repository whose diary has a fixed +shape can count the section instead, and one does. Saying that plainly is part of +the rule: a guard that claims more enforcement than it has is the same defect one +level up. + ## The private journal binding A private journal is per user, not per project. Its location must therefore never diff --git a/skills/clock-out/SKILL.md b/skills/clock-out/SKILL.md index d5e512b..f09a07c 100644 --- a/skills/clock-out/SKILL.md +++ b/skills/clock-out/SKILL.md @@ -92,6 +92,12 @@ file, apply steps 3 to 5 against the default layout in the pull requests — not from the session. At clock-out the day is fresh, which is exactly when memory feels reliable enough to skip the check. +The entry carries what this session actually fetched, under the rules in "Say +what was read" in `../clock-in/SKILL.md`: references a later session can +re-fetch, nothing that was not fetched, and every failed fetch named. That list +is what makes the sentence above checkable by a reader instead of merely +asserted. + **5. Say what is open — in the files, not only in the conversation.** The chat is gone tomorrow. A thread that outlives its topic goes to the project's long-lived thread list; one that dies with the topic stays in the progress file. @@ -118,10 +124,15 @@ private layer cannot reconstruct: | `status` | one line, verified against the tools | | `findings` | what was learned that is **not** specific to this project; may be empty | | `evidence` | pull requests, commits, issues — links, not prose | +| `read` | what this session fetched to reach the above, as re-fetchable references | | `threads` | open threads that outlive this project | Rules that make it safe to run more than once: +- **`evidence` and `read` are different sets.** `evidence` is what the day + produced; `read` is what the session fetched to find out. They overlap and are + routinely not the same, and collapsing them loses the only statement that says + the day was reconstructed from the repository. - **Keyed by project and day.** A second clock-out on the same day replaces that project's entry; it never appends a duplicate. - **The private layer owns the day.** It decides which day file the record lands From f6695a2d0c0c5cf35417e99ac1445776724a3ec7 Mon Sep 17 00:00:00 2001 From: Dieter Baier Date: Fri, 4 Sep 2026 13:06:15 +0200 Subject: [PATCH 3/4] issue_89: Report divergence, not a list of refs The enumeration printed ref, date and subject and then asked the reader to compare them against the base branch. That put the missing step back into interpretation, which is what acceptance criterion 1 forbids: a list of names is not divergence, and the claim that the sweep is enforced by a command was not yet true. It now resolves the base branch instead of assuming it, prints ahead and behind per ref plus the newest commit date, and ends in one verdict. Local and remote refs are deduplicated to one entry per branch name, and the local ref wins because a checkout's branch can be ahead of what was pushed. The API form is a full command rather than two endpoints. Both forms are fail-closed, and the same review found the reason next door: a gh call as the left member of a pipeline swallows its own failure, and a while read behind a pipe discards the gaps it collected in its subshell. The wiring test guarded where the command lives, not that it still does anything. A second scenario guards the promise: base resolved rather than assumed, commits counted in both directions. It fails against the previous version while the wiring test stays green, which is exactly the hole it fills. Closes part of #89. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011krf2MRrinYMyArXUuvPpj --- features/skill-wiring.feature | 9 ++++ skills/clock-in/SKILL.md | 89 +++++++++++++++++++++++++++++++---- test/skill-wiring.test.mjs | 33 ++++++++++++- 3 files changed, 119 insertions(+), 12 deletions(-) diff --git a/features/skill-wiring.feature b/features/skill-wiring.feature index 3539bb3..1250cea 100644 --- a/features/skill-wiring.feature +++ b/features/skill-wiring.feature @@ -42,6 +42,15 @@ Feature: Skill wiring # feature exists to catch; the command is read from clock-in so that editing # it there moves the guard with it. + Scenario: The branch enumeration reports divergence from the base branch + Given the branch enumeration clock-in defines + When it is inspected for what it produces + Then it resolves the base branch and counts commits in both directions + # The wiring scenario above keeps the command in one place. It does not keep + # it useful: reduced to a list of ref names, it would still pass while the + # divergence the skill promises quietly disappeared. This is the guard for + # the promise rather than for the location. + Scenario: No caller carries the Convergence Check question structure Given the seven question titles read from the Convergence Check itself When every other skill is searched for those titles as headings diff --git a/skills/clock-in/SKILL.md b/skills/clock-in/SKILL.md index 1781dd3..83945ab 100644 --- a/skills/clock-in/SKILL.md +++ b/skills/clock-in/SKILL.md @@ -86,21 +86,90 @@ branches happen to carry an open pull request. A branch with commits and no pull request is invisible to all four, and that is the ordinary shape of work in progress. So establish the state of the whole repository as well: +The enumeration names the base branch rather than assuming it, prints how far +each ref is ahead of and behind it, and ends in one verdict. Listing refs is not +the point — divergence from the base is, and a list of names still leaves the +comparison to whoever reads it. + ```sh -git for-each-ref --sort=-committerdate \ - --format='%(committerdate:short) %(refname:short) %(contents:subject)' \ - refs/remotes/origin refs/heads +gap="" + +base=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD) || + { echo "SWEEP: BLOCKED — UNAVAILABLE:base (run git remote set-head origin --auto once)"; exit 1; } +git fetch --quiet --prune origin || + { echo "SWEEP: BLOCKED — UNAVAILABLE:fetch"; exit 1; } +refs=$(git for-each-ref --format='%(refname:short)' refs/heads refs/remotes/origin) || + { echo "SWEEP: BLOCKED — UNAVAILABLE:refs"; exit 1; } +names=$(printf '%s\n' "$refs" | sed 's|^origin/||' | sort -u) || + { echo "SWEEP: BLOCKED — UNAVAILABLE:dedup"; exit 1; } + +for name in $names; do + case "$name" in "${base#origin/}" | origin | HEAD) continue ;; esac + if git show-ref --verify --quiet "refs/heads/$name"; then ref="$name"; else ref="origin/$name"; fi + if ! counts=$(git rev-list --left-right --count "$base...$ref" 2>/dev/null); then + gap="$gap UNAVAILABLE:divergence($ref)"; continue + fi + if ! newest=$(git log -1 --format=%cs "$ref" 2>/dev/null); then + gap="$gap UNAVAILABLE:date($ref)"; continue + fi + echo "$ref: $(echo "$counts" | awk '{print $2}') ahead," \ + "$(echo "$counts" | awk '{print $1}') behind $base, newest $newest" +done + +if [ -n "$gap" ]; then + echo "SWEEP: BLOCKED —$gap" + exit 1 +fi +echo "SWEEP: CLEAR (base $base)" ``` -Read it against the base branch: a ref that is ahead of it carries work this -session has not seen yet. Without a checkout, the same question is -`gh api "repos///branches?per_page=100"` followed by -`gh api "repos///compare/..."`. +**One entry per branch name, and the local ref wins.** A checkout's local branch +can be ahead of what was pushed, so reporting the remote copy of it would +understate the work in front of you. Where only the remote ref exists, that one +is used. Reporting both is the alternative and it is worse: two lines for one +branch read as two branches. + +Without a checkout, the same question and the same verdict: + +```sh +repo=/ +gap="" + +base=$(gh api "repos/$repo" --jq .default_branch 2>/dev/null) || + { echo "SWEEP: BLOCKED — UNAVAILABLE:default-branch"; exit 1; } +branches=$(gh api "repos/$repo/branches?per_page=100" --jq '.[].name' 2>/dev/null) || + { echo "SWEEP: BLOCKED — UNAVAILABLE:branch-list"; exit 1; } + +for b in $branches; do + [ "$b" = "$base" ] && continue + if line=$(gh api "repos/$repo/compare/$base...$b" --jq \ + '"\(.ahead_by) ahead, \(.behind_by) behind" + + (if (.commits | length) > 0 + then ", newest \((.commits | last).commit.committer.date[0:10])" + else "" end)' 2>/dev/null); then + echo "$b: $line" + else + gap="$gap UNAVAILABLE:compare($b)" + fi +done + +if [ -n "$gap" ]; then + echo "SWEEP: BLOCKED —$gap" + exit 1 +fi +echo "SWEEP: CLEAR (base $base)" +``` This is one repository's branches, not other repositories — the multi-project -view still belongs to the private layer. When the enumeration fails, that is a -recorded gap and not an empty result; a session that could not list the branches -has not established that there were none. +view still belongs to the private layer. + +**Both forms are fail-closed, and that is not decoration.** A query that did not +run says nothing about the branches, so every fetch is tested on its own and a +failed comparison is collected rather than skipped. Two shapes make this go +wrong quietly and both are avoided above: a `gh api` or `git` command as the left +member of a pipeline, whose failure the pipeline swallows, and a `while read` fed +by a pipe, which runs in a subshell and discards the gaps it collected. A session +that could not list the branches has not established that there were none. **2. Delegate to the project's own clock-in skill when it has one.** diff --git a/test/skill-wiring.test.mjs b/test/skill-wiring.test.mjs index bfbad02..51c4650 100644 --- a/test/skill-wiring.test.mjs +++ b/test/skill-wiring.test.mjs @@ -141,7 +141,7 @@ test("The Convergence Check result states live in exactly one skill", () => { // The branch enumeration clock-out needs is defined in clock-in. Read it from // there rather than restating it, for the same reason as above: rename or reword // the command and the guard follows it. -function canonicalBranchEnumeration() { +function canonicalBranchEnumerationBlock() { const text = read(path.join(skillsDir, "clock-in", "SKILL.md")); const blocks = [...text.matchAll(/```sh\n([\s\S]*?)```/g)].map((m) => m[1]); const sweeps = blocks.filter((block) => block.includes("for-each-ref")); @@ -150,7 +150,11 @@ function canonicalBranchEnumeration() { 1, `expected one branch enumeration in clock-in, found ${sweeps.length}`, ); - return sweeps[0] + return sweeps[0]; +} + +function canonicalBranchEnumeration() { + return canonicalBranchEnumerationBlock() .split("\n") .find((line) => line.includes("for-each-ref")) .trim(); @@ -180,6 +184,31 @@ test("The repository branch sweep is defined once and deferred to", () => { ); }); +test("The branch enumeration reports divergence from the base branch", () => { + // Given the branch enumeration clock-in defines + const block = canonicalBranchEnumerationBlock(); + + // When it is inspected for what it produces + // + // The wiring test above guards where the command lives. It would pass just as + // happily against `git for-each-ref` alone — a list of ref names, with the + // comparison left to whoever reads it, which is the version this scenario + // exists to reject. So the promise is checked, not the location: a base branch + // that is resolved rather than assumed, and a count in both directions. + const promises = [ + ["resolves the base branch", /symbolic-ref/], + ["counts commits in both directions", /rev-list --left-right --count/], + ["labels the ahead direction", /ahead/], + ["labels the behind direction", /behind/], + ]; + const missing = promises + .filter(([, pattern]) => !pattern.test(block)) + .map(([label]) => label); + + // Then it resolves the base branch and counts commits in both directions + assert.deepEqual(missing, []); +}); + test("The Convergence Check questions are not copied into a caller", () => { // Given the canonical skills under skills/, and the seven question titles read // from the Convergence Check itself From 3df3a29cc9fa851abbc33398556bf92c397bf1fb Mon Sep 17 00:00:00 2001 From: Dieter Baier Date: Fri, 4 Sep 2026 13:22:25 +0200 Subject: [PATCH 4/4] issue_89: Page the list, and never hide a ref pair Two ways the sweep could still report a whole repository while having seen part of it. The API form read one page. Past a hundred branches it printed SWEEP: CLEAR having looked at the first hundred - silent incompleteness arriving through the door marked success, which is the outcome this step exists to remove. It paginates now; the checkout form never had the limit. The deduplication was the worse one. "The local ref wins" holds only while origin/ is an ancestor of the local ref. A local branch left behind its remote, or diverged from it, would have had the remote state discarded - the commits the sweep is supposed to find, dropped by the step that claims to find them. Local wins only when the ancestry says so; otherwise both refs are listed and the pair is named. That is a repository state, not a failed query, so it reports rather than blocks. Verified against a repository built for it, with all four cases: local ahead, local behind, diverged, remote only. The semantic guard now covers both forms, because either regression would otherwise pass it while quietly narrowing what the sweep sees. Closes part of #89. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011krf2MRrinYMyArXUuvPpj --- features/skill-wiring.feature | 16 +++++----- skills/clock-in/SKILL.md | 58 ++++++++++++++++++++++++++--------- test/skill-wiring.test.mjs | 45 ++++++++++++++++++--------- 3 files changed, 84 insertions(+), 35 deletions(-) diff --git a/features/skill-wiring.feature b/features/skill-wiring.feature index 1250cea..1c5a0bb 100644 --- a/features/skill-wiring.feature +++ b/features/skill-wiring.feature @@ -42,14 +42,16 @@ Feature: Skill wiring # feature exists to catch; the command is read from clock-in so that editing # it there moves the guard with it. - Scenario: The branch enumeration reports divergence from the base branch - Given the branch enumeration clock-in defines - When it is inspected for what it produces - Then it resolves the base branch and counts commits in both directions + Scenario: The branch enumeration covers every branch and reports divergence + Given the two forms of the branch enumeration clock-in defines + When they are inspected for what they produce + Then the checkout form resolves the base branch and counts commits in both directions + And the API form pages through the whole branch list # The wiring scenario above keeps the command in one place. It does not keep - # it useful: reduced to a list of ref names, it would still pass while the - # divergence the skill promises quietly disappeared. This is the guard for - # the promise rather than for the location. + # it useful: reduced to a list of ref names, or left on the first API page, + # it would still pass while the skill's promise quietly disappeared. Both + # regressions report success while looking at part of the repository, which + # is the one outcome this step exists to prevent. Scenario: No caller carries the Convergence Check question structure Given the seven question titles read from the Convergence Check itself diff --git a/skills/clock-in/SKILL.md b/skills/clock-in/SKILL.md index 83945ab..a8a8f77 100644 --- a/skills/clock-in/SKILL.md +++ b/skills/clock-in/SKILL.md @@ -103,17 +103,36 @@ refs=$(git for-each-ref --format='%(refname:short)' refs/heads refs/remotes/orig names=$(printf '%s\n' "$refs" | sed 's|^origin/||' | sort -u) || { echo "SWEEP: BLOCKED — UNAVAILABLE:dedup"; exit 1; } -for name in $names; do - case "$name" in "${base#origin/}" | origin | HEAD) continue ;; esac - if git show-ref --verify --quiet "refs/heads/$name"; then ref="$name"; else ref="origin/$name"; fi - if ! counts=$(git rev-list --left-right --count "$base...$ref" 2>/dev/null); then - gap="$gap UNAVAILABLE:divergence($ref)"; continue +report_ref() { + if ! counts=$(git rev-list --left-right --count "$base...$1" 2>/dev/null); then + gap="$gap UNAVAILABLE:divergence($1)"; return fi - if ! newest=$(git log -1 --format=%cs "$ref" 2>/dev/null); then - gap="$gap UNAVAILABLE:date($ref)"; continue + if ! newest=$(git log -1 --format=%cs "$1" 2>/dev/null); then + gap="$gap UNAVAILABLE:date($1)"; return fi - echo "$ref: $(echo "$counts" | awk '{print $2}') ahead," \ + echo "$1: $(echo "$counts" | awk '{print $2}') ahead," \ "$(echo "$counts" | awk '{print $1}') behind $base, newest $newest" +} + +for name in $names; do + case "$name" in "${base#origin/}" | origin | HEAD) continue ;; esac + git show-ref --verify --quiet "refs/heads/$name" && has_local=yes || has_local=no + git show-ref --verify --quiet "refs/remotes/origin/$name" && has_remote=yes || has_remote=no + + if [ "$has_local" = yes ] && [ "$has_remote" = yes ]; then + git merge-base --is-ancestor "origin/$name" "$name" 2>/dev/null + case $? in + 0) report_ref "$name" ;; + 1) echo "$name: origin/$name is not contained in local — both listed" + report_ref "$name" + report_ref "origin/$name" ;; + *) gap="$gap UNAVAILABLE:ancestry($name)" ;; + esac + elif [ "$has_local" = yes ]; then + report_ref "$name" + else + report_ref "origin/$name" + fi done if [ -n "$gap" ]; then @@ -123,11 +142,16 @@ fi echo "SWEEP: CLEAR (base $base)" ``` -**One entry per branch name, and the local ref wins.** A checkout's local branch -can be ahead of what was pushed, so reporting the remote copy of it would -understate the work in front of you. Where only the remote ref exists, that one -is used. Reporting both is the alternative and it is worse: two lines for one -branch read as two branches. +**One entry per branch name, except where that would hide something.** A local +branch and its remote counterpart are the same work in two places only while +`origin/` is an ancestor of the local ref. Then the local one is ahead of +it or equal to it, and reporting that one alone loses nothing. + +When it is not an ancestor — the local branch is behind what was pushed, or the +two have diverged — collapsing them discards exactly the commits this sweep +exists to find. Both refs are listed then, and the pair is named as what it is. +That is a state of the repository rather than a failed query, so it is reported +and does not block. Where only one of the two exists, that one is used. Without a checkout, the same question and the same verdict: @@ -137,7 +161,7 @@ gap="" base=$(gh api "repos/$repo" --jq .default_branch 2>/dev/null) || { echo "SWEEP: BLOCKED — UNAVAILABLE:default-branch"; exit 1; } -branches=$(gh api "repos/$repo/branches?per_page=100" --jq '.[].name' 2>/dev/null) || +branches=$(gh api --paginate "repos/$repo/branches?per_page=100" --jq '.[].name' 2>/dev/null) || { echo "SWEEP: BLOCKED — UNAVAILABLE:branch-list"; exit 1; } for b in $branches; do @@ -160,6 +184,12 @@ fi echo "SWEEP: CLEAR (base $base)" ``` +`--paginate` is not decoration. Without it the listing stops after the first +page, and a repository with more than a hundred branches reports `SWEEP: CLEAR` +having looked at part of itself — the same silent incompleteness this section +exists to remove, arriving through the door marked "success". The checkout form +has no such limit; `for-each-ref` enumerates everything. + This is one repository's branches, not other repositories — the multi-project view still belongs to the private layer. diff --git a/test/skill-wiring.test.mjs b/test/skill-wiring.test.mjs index 51c4650..f354c29 100644 --- a/test/skill-wiring.test.mjs +++ b/test/skill-wiring.test.mjs @@ -153,6 +153,20 @@ function canonicalBranchEnumerationBlock() { return sweeps[0]; } +// The same skill defines a second form for sessions without a checkout. It is +// found by what only it contains, so neither block has to be located by index. +function canonicalApiEnumerationBlock() { + const text = read(path.join(skillsDir, "clock-in", "SKILL.md")); + const blocks = [...text.matchAll(/```sh\n([\s\S]*?)```/g)].map((m) => m[1]); + const forms = blocks.filter((block) => block.includes("/compare/")); + assert.equal( + forms.length, + 1, + `expected one API enumeration in clock-in, found ${forms.length}`, + ); + return forms[0]; +} + function canonicalBranchEnumeration() { return canonicalBranchEnumerationBlock() .split("\n") @@ -184,28 +198,31 @@ test("The repository branch sweep is defined once and deferred to", () => { ); }); -test("The branch enumeration reports divergence from the base branch", () => { - // Given the branch enumeration clock-in defines - const block = canonicalBranchEnumerationBlock(); +test("The branch enumeration covers every branch and reports divergence", () => { + // Given the two forms of the branch enumeration clock-in defines + const checkout = canonicalBranchEnumerationBlock(); + const api = canonicalApiEnumerationBlock(); - // When it is inspected for what it produces + // When they are inspected for what they produce // // The wiring test above guards where the command lives. It would pass just as - // happily against `git for-each-ref` alone — a list of ref names, with the - // comparison left to whoever reads it, which is the version this scenario - // exists to reject. So the promise is checked, not the location: a base branch - // that is resolved rather than assumed, and a count in both directions. + // happily against `git for-each-ref` alone, or against an API listing that + // stops after the first page — both of which report success while having + // looked at part of the repository. So the promise is checked, not the + // location. const promises = [ - ["resolves the base branch", /symbolic-ref/], - ["counts commits in both directions", /rev-list --left-right --count/], - ["labels the ahead direction", /ahead/], - ["labels the behind direction", /behind/], + ["checkout form resolves the base branch", checkout, /symbolic-ref/], + ["checkout form counts both directions", checkout, /rev-list --left-right --count/], + ["checkout form labels ahead", checkout, /ahead/], + ["checkout form labels behind", checkout, /behind/], + ["API form pages the whole list", api, /gh api --paginate/], ]; const missing = promises - .filter(([, pattern]) => !pattern.test(block)) + .filter(([, block, pattern]) => !pattern.test(block)) .map(([label]) => label); - // Then it resolves the base branch and counts commits in both directions + // Then the checkout form resolves the base branch and counts commits in both + // directions, and the API form pages through the whole branch list assert.deepEqual(missing, []); });