Skip to content

Branch name sanitization disagrees across gstack: reviews for the same branch land in multiple files, dashboard misses entries #1127

@marcosmoova

Description

@marcosmoova

Summary

Branches with a / in the name (the conventional feat/*, fix/*, chore/*) are sanitized three different ways across the gstack codebase. Writers and the reader use different transforms, so the same branch's review history gets split across up to two files on disk and the review-readiness dashboard reads from only one of them.

Three transforms coexist

All three are run against a bare git rev-parse --abbrev-ref HEAD / git branch --show-current, to produce a $BRANCH that then gets used in $GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl:

  1. Strip form (tr -cd 'a-zA-Z0-9._-') — feat/foofeatfoo.
    bin/gstack-slug:44. This is what bin/gstack-review-log (writer) and bin/gstack-review-read (reader) both use, because they both eval "$(gstack-slug)".
  2. Dash form (tr '/' '-') — feat/foofeat-foo.
    ship/SKILL.md:1624 (Step 8 plan audit), and other skill steps that do their own git branch --show-current | tr '/' '-' inline.
  3. Bare slash (no transform) — feat/foo stays feat/foo.
    context-restore / ship / review / every skill's Context Recovery block sets _BRANCH=$(git branch --show-current ...) and then uses it in grep "\"branch\":\"${_BRANCH}\"" against jsonl files and as "$_PROJ/${_BRANCH}-reviews.jsonl" existence checks.

Evidence — same branch, multiple files on disk

~/.gstack/projects/invoice_approval/ on my machine, after a handful of ship cycles:

feat-ocr-monorepo-migration-reviews.jsonl      ← branch "feat/ocr-monorepo-migration"
featocr-monorepo-migration-reviews.jsonl       ← SAME branch, different sanitizer

feat-clerk-v7-webhooks-reviews.jsonl           ← branch "feat/clerk-v7-webhooks"
featclerk-v7-webhooks-reviews.jsonl            ← SAME branch, different sanitizer

feat-characterization-tolerance-reviews.jsonl  ← branch "feat/characterization-tolerance" (only dash form exists)

fixocr-conftest-aws-env-isolation-reviews.jsonl  ← branch "fix/ocr-conftest-aws-env-isolation" (only strip form)

Two branches have the review history split across two files. For the others, only one transform wrote any entries this time around, so only one file exists — but the historical record is incomplete either way.

Impact

  1. Review-readiness dashboard undercounts reviews. Step 1 of /ship calls bin/gstack-review-read, which reads via the strip form. Reviews written by any caller using the dash form (including parts of /ship itself via inline tr '/' '-') are invisible to the dashboard.
  2. Cross-review finding dedup is broken for slashed branches. ship/SKILL.md:2481 (Step 9.3) calls gstack-review-read (strip form) and compares against the current HEAD's findings. Entries written under the dash form never participate in "skipped findings shouldn't be re-flagged" dedup.
  3. context-restore's 'LAST_SESSION' and 'RECENT_PATTERN' checks use bare-slash $_BRANCH to grep timeline.jsonl. timeline.jsonl appears to use the bare form consistently (those greps do work), but any jsonl-writing skill that uses a different transform leaves entries that context-restore can't cross-reference.
  4. Silent by design. Every call site pipes to 2>/dev/null || true, so failed writes never surface. Users don't notice their review history is being split until they look at disk.

Suggested fix

Normalize branch in exactly one place and use it everywhere. Options:

  • Preferred: single sanitizer in bin/gstack-slug, switch the output to dash form (feat/foofeat-foo) since it's more readable on disk and reversible-ish. Update the tr command on bin/gstack-slug:44 and remove all ad-hoc tr '/' '-' and bare $BRANCH uses across the skill files; everyone does eval "$(gstack-slug)" and uses $BRANCH.
  • Add mkdir -p "$(dirname "$TARGET_FILE")" before every echo ... >> ...-reviews.jsonl as a backstop, so the bare-slash case at least creates the fix/ subdir instead of silently failing (this would catch users who write their own inline ship steps).
  • Ship a one-time migration in setup / gstack-upgrade: for each featfoo-*.jsonl + feat-foo-*.jsonl pair in ~/.gstack/projects/*/, merge to one canonical filename. Without migration, existing users' history stays split forever.

Repro

# On a fresh project, check out a branch with a slash
git checkout -b feat/demo-bug

# Run /review then /ship. Both complete successfully.
# Now look at disk:
ls -la ~/.gstack/projects/<your-project>/*reviews.jsonl
# Expect to see at least one "featdemo-bug-reviews.jsonl" (strip form)
# and possibly a "feat-demo-bug-reviews.jsonl" (dash form) depending on
# which skills got run.

Env

  • gstack version: whatever the current upstream was as of 2026-04-21
  • macOS 25.4.0, zsh
  • Git with SSH remote

Discovery context

Noticed while running /ship on fix/ocr-conftest-aws-env-isolation. Ran gstack-review-read at Step 1 — got NO_REVIEWS despite a prior gstack-review-log call from Step 9 that reported success. Disk inspection showed the review went into fixocr-conftest-aws-env-isolation-reviews.jsonl (strip form) while the read attempt was checking the wrong path.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions