Skip to content

fix(slug): canonicalize slash branches to dash form so review rows stop splitting (#1127) - #2465

Open
ShuratCode wants to merge 3 commits into
garrytan:mainfrom
ShuratCode:fix/1127-writer-side-branch-sanitize
Open

fix(slug): canonicalize slash branches to dash form so review rows stop splitting (#1127)#2465
ShuratCode wants to merge 3 commits into
garrytan:mainfrom
ShuratCode:fix/1127-writer-side-branch-sanitize

Conversation

@ShuratCode

Copy link
Copy Markdown

Fixes the writer half of #1127. Complements #1851, which fixes the read
half (Context Recovery lookup across 44 SKILL.md files). The two are
independent and land cleanly in either order — #1851 canonicalizes the reads,
this canonicalizes the writes. I have not touched the read path.

The problem

Three different sanitizations of the same branch name coexist in gstack:

# Transform feat/foo becomes Where
1 tr -cd 'a-zA-Z0-9._-'deletes the slash featfoo bin/gstack-slug:52gstack-review-log, gstack-review-read
2 tr '/' '-'replaces the slash feat-foo ship/sections/plan-completion.md:19 (Step 8)
3 none — raw feat/foo ship/SKILL.md:1356 (Step 20)

So one branch's review rows land in up to three places, and /retro sees a
fraction of its own history.

Live evidence, one project dir

20 branches are split across two files each, and the pattern is perfectly
consistent: the dash-form file holds only the ship row, the strip-form file
holds only the other skills' rows. Not one of the 20 is mixed.

Clearest single case, branch feat/cdk-app-scaffold-R-1053:

  • feat-cdk-app-scaffold-R-1053-reviews.jsonl (form 2) — the /ship row
  • featcdk-app-scaffold-R-1053-reviews.jsonl (form 1) — the /review row,
    written three hours earlier the same day

feat/partners-list-page-R-620 is the same shape with more on the strip side:
/ship in the dash file, /review and /plan-eng-review in the strip file.
/retro reading either filename sees half the branch's history.

Transform 3 has two failure modes, not one

This is the part worth stating plainly, because the two reports on #1127 look
like different bugs and are not:

  • No feat/ directory exists → the redirect dies with
    no such file or directory and the row is lost silently. Reported upstream on
    1.60.1.0 for feat/4-posting-service.
  • A feat/ directory already exists → the write succeeds into it, and the
    row is invisible to every flat glob. That is the machine this was found on:
    5 rows sit in projects/<slug>/feat/.

"Rows are lost" and "rows are fragmented" are the same bug seen from two
machines.

The fix

1. bin/gstack-slug — replace the slash instead of deleting it, so
transform 1 agrees with transform 2:

-BRANCH=$(printf '%s' "${RAW_BRANCH:-}" | tr -cd 'a-zA-Z0-9._-')
+BRANCH=$(printf '%s' "${RAW_BRANCH:-}" | tr '/' '-' | tr -cd 'a-zA-Z0-9._-')

The file's header promises a [a-zA-Z0-9._-] invariant. Still holds — the
tr -cd stays as the final filter. Verified across shapes:

feat/foo-R-1   -> feat-foo-R-1      main          -> main
fix/logout     -> fix-logout        feature/a/b/c -> feature-a-b-c
chore/x_y.z    -> chore-x_y.z

2. ship Step 20 — the old block interpolated $BRANCH into a redirect path
and carried a literal BRANCH token in the JSON, with a "substitute BRANCH =
current branch name" instruction below it. A model doing that substitution can
hit the path too, which is what produces the feat/ subdirectory.

It now routes through bin/gstack-review-log, which takes no path argument,
so there is nothing left to corrupt. The helper already resolves slug and branch,
creates the directory, validates the JSON, and enqueues for gbrain sync — the
step gets shorter, not longer. The BRANCH placeholder is gone entirely,
replaced by a real shell expansion.

Edited ship/SKILL.md.tmpl and regenerated via bun run gen:skill-docs; the
three ship golden fixtures are regenerated in the same commit.

Migration: deliberately deferred

Change 1 renames featfoo-reviews.jsonl to feat-foo-reviews.jsonl, so existing
files are orphaned rather than rewritten. Not adding a migration, for a measured
reason: on the machine this was found, 303 of 308 rows (98.4%) are already
top-level and reachable by a flat glob. The 5 stranded rows are all ship rows
carrying a coverage_pct but no plan-item counts (four 0, one -1). A
one-time merge folding featfoo-* and feat/* into the canonical
feat-foo-*, sorted by timestamp, is easy to add as a follow-up if a maintainer
wants it — say the word and I will.

@kkru-labs suggested a shared canonical normalizer that every caller imports.
Aligning gstack-slug is the minimal version of that idea (one authority instead
of three); extracting a real shared helper is a clean follow-up on top.

Verification

  • bun run test — green, except two failures proven pre-existing by running
    the same two files against origin/main in a clean worktree:
    test/skill-e2e-ios-swift-build.test.ts (Swift toolchain, local) and
    test/helpers/observability.test.ts (counts /* non-fatal */ comments in
    session-runner.ts). Neither touches anything in this diff.
  • test/gstack-slug-sanitize.test.ts passes unchanged — it asserts the charset
    invariant on SLUG and never pinned BRANCH slash-deletion.
  • test/gen-skill-docs.test.ts passes — the ship union still contains
    reviews.jsonl via the Context Recovery block.
  • The three ship golden-file regressions in test/host-config.test.ts are green
    against the regenerated fixtures.
  • slop-scan: no new findings in 6 changed files.
  • End-to-end from a slash-named branch in a scratch repo: all five branch shapes
    above normalize as expected, one canonical flat file written, zero nested
    directories created.

Conflict risk

Four open PRs also touch ship/SKILL.md.tmpl: #2343, #2334, #2303, #2301. None
of them touch Step 20, so this is textual-conflict risk on the generated
SKILL.md and goldens, not a scope overlap. Happy to rebase behind whichever
lands first.

No VERSION bump or CHANGELOG entry here — following #1851, the sibling PR on
the same issue, and keeping this out of the way of the four in-flight ship
template PRs. Happy to add both if that is the house preference.

Aligns gstack-slug with ship Step 8's tr '/' '-' so gstack-review-log and
gstack-review-read stop writing feat/foo to featfoo while other writers
use feat-foo. The [a-zA-Z0-9._-] invariant promised in the file header
still holds; tr -cd remains the final filter.

Refs garrytan#1127
Step 20 built ~/.gstack/projects/$SLUG/$BRANCH-reviews.jsonl by hand and
also carried a literal BRANCH token in the JSON payload. A slash branch
turned the redirect into a subdirectory write, or failed outright when
the directory did not exist.

gstack-review-log takes no path argument and already resolves the slug
and branch, creates the directory, validates the JSON, and enqueues for
gbrain sync. The BRANCH placeholder is replaced by a shell expansion.

Refs garrytan#1127
@trunk-io

trunk-io Bot commented Aug 6, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants