diff --git a/plugins/toolchain/.claude-plugin/plugin.json b/plugins/toolchain/.claude-plugin/plugin.json index b8c4f3c87..ad2f5deae 100644 --- a/plugins/toolchain/.claude-plugin/plugin.json +++ b/plugins/toolchain/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "toolchain", - "version": "0.4.1", + "version": "0.4.2", "description": "Repo-agnostic polyglot verification toolchain: build + test + lint for changed files across .NET, Python, TypeScript, Bash, PowerShell, Markdown, YAML, and cross-cutting surfaces (`/toolchain:check`, `/toolchain:lint`), plus a re-runnable `/toolchain:setup` with check (report the configured ecosystems and their command surface) and apply (interview, infer, and write the tracked per-ecosystem command config those skills resolve first).", "author": { "name": "Melodic Software", diff --git a/plugins/toolchain/CHANGELOG.md b/plugins/toolchain/CHANGELOG.md index e05f7da99..465a85a1e 100644 --- a/plugins/toolchain/CHANGELOG.md +++ b/plugins/toolchain/CHANGELOG.md @@ -3,6 +3,36 @@ All notable changes to the `toolchain` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.4.2] + +### Fixed + +- **Remote selected from those present, not assumed `origin`.** The clean-working-tree branch-diff + fallback in `/toolchain:check` and `/toolchain:lint` resolved the remote from the current branch's + `branch..remote`, but when that was unset (an unpushed feature branch) it forced `REMOTE=origin` + unconditionally. In a clone made with a differently named remote (`git clone -o vendor`) that has no + `origin` and no pushed upstream, `origin` does not exist, so `git symbolic-ref refs/remotes/origin/HEAD` + and every subsequent probe failed and the branch diff was skipped ("branch diff unavailable") — the + `origin` fallback the 0.4.1 note claimed "still resolves" a `git clone -o vendor` did not hold for the + not-yet-pushed case. Both call sites now probe candidate remotes in priority order — the branch's + tracking remote, then `origin` if present, then the rest — and select the first whose default branch + resolves to a locally available `refs/remotes//` tracking ref. This also skips a remote + that was added but never fetched (whose `git ls-remote` default-branch query succeeds over the network + but leaves no local ref for `git merge-base`) in favor of a later remote that has one, rather than + committing to the alphabetically first remote and bailing. The common tracking-remote case still + short-circuits on the first candidate with no extra network calls, and detection still degrades + gracefully (skips the branch-diff path) when no candidate yields a local default branch. Candidate + remote names are option-parse-safe: the tracking-ref checks use the fully-qualified + `refs/remotes//` form and the `git ls-remote` probe passes `--end-of-options`, so a + Git-legal remote whose name begins with a dash (`git clone --origin=-x`) is not misparsed as a command + option and skipped. A cross-plugin shared default-branch helper remains the broader fix tracked by + #436/#442. +- **Remote-prefix strip no longer breaks on `#` in a remote name.** The default-branch resolution + stripped the `$REMOTE/` prefix with `sed "s#^$REMOTE/##"`, whose `#` delimiter collides with a + `#` in the remote name (a Git-legal character), corrupting `DEFAULT_BRANCH` and silently skipping + the branch diff. Both call sites now strip the prefix with the `${DEFAULT_BRANCH#"$REMOTE/"}` + parameter expansion, which treats the remote name literally regardless of its characters. + ## [0.4.1] ### Fixed diff --git a/plugins/toolchain/skills/check/SKILL.md b/plugins/toolchain/skills/check/SKILL.md index 231cbcdf3..14aee536f 100644 --- a/plugins/toolchain/skills/check/SKILL.md +++ b/plugins/toolchain/skills/check/SKILL.md @@ -57,18 +57,27 @@ If `$ARGUMENTS` specifies an ecosystem, use it. If `all`, run every covered ecos If the working tree is clean, fall back to the branch diff so checkpoint-committed work still gets classified (the common pre-PR case: every green block was already committed). Resolve the default branch by **detection, not assumption** — never a hardcoded `main`/`master` — and assign it before use: ```bash -REMOTE=$(git config "branch.$(git branch --show-current | tr -d '\r').remote" 2>/dev/null | tr -d '\r') -[[ -z "$REMOTE" || "$REMOTE" == "." ]] && REMOTE=origin -DEFAULT_BRANCH=$(git symbolic-ref --short "refs/remotes/$REMOTE/HEAD" 2>/dev/null | sed "s#^$REMOTE/##") -DEFAULT_BRANCH=${DEFAULT_BRANCH:-$(git ls-remote --symref "$REMOTE" HEAD 2>/dev/null | awk '/^ref:/{sub(/refs\/heads\//,"",$2); print $2; exit}')} -if [[ -n "$DEFAULT_BRANCH" ]] && git rev-parse --verify --quiet "$REMOTE/$DEFAULT_BRANCH" >/dev/null; then - git diff --name-only "$(git merge-base "$REMOTE/$DEFAULT_BRANCH" HEAD)..HEAD" +REMOTE="" DEFAULT_BRANCH="" +TRACKED=$(git config "branch.$(git branch --show-current | tr -d '\r').remote" 2>/dev/null | tr -d '\r') +[[ "$TRACKED" == "." ]] && TRACKED="" +CANDIDATES=$( { [[ -n "$TRACKED" ]] && echo "$TRACKED"; git remote | grep -qx origin && echo origin; git remote; } | awk 'NF && !seen[$0]++' ) +while IFS= read -r CANDIDATE; do + BRANCH=$(git symbolic-ref --short "refs/remotes/$CANDIDATE/HEAD" 2>/dev/null) + BRANCH=${BRANCH#"$CANDIDATE/"} + BRANCH=${BRANCH:-$(git ls-remote --symref --end-of-options "$CANDIDATE" HEAD 2>/dev/null | awk '/^ref:/{sub(/refs\/heads\//,"",$2); print $2; exit}')} + if [[ -n "$BRANCH" ]] && git rev-parse --verify --quiet "refs/remotes/$CANDIDATE/$BRANCH" >/dev/null; then + REMOTE=$CANDIDATE DEFAULT_BRANCH=$BRANCH + break + fi +done <<< "$CANDIDATES" +if [[ -n "$REMOTE" ]]; then + git diff --name-only "$(git merge-base "refs/remotes/$REMOTE/$DEFAULT_BRANCH" HEAD)..HEAD" else echo "branch diff unavailable (could not detect default branch)" fi ``` -`$REMOTE` is the remote the current branch tracks (`branch..remote`), falling back to `origin` — never a hardcoded remote name, so a repo cloned with a different remote name (e.g. `git clone -o vendor`) still resolves. The fallback queries that remote's own `HEAD` (not the current branch's upstream, which on a pushed feature branch points at the feature branch itself and would make `merge-base` equal `HEAD`, yielding an empty diff). `merge-base` is taken against the remote-tracking ref `$REMOTE/$DEFAULT_BRANCH`, which resolves without a local branch of that name. If detection yields no default branch (no `$REMOTE/HEAD`, and the remote query fails or its ref is absent locally), skip the branch-diff path rather than guessing. A caller passing an explicit changed-file list (e.g. `/verification:confirm`) overrides both detection paths. +The loop probes candidate remotes in priority order — the remote the current branch tracks (`branch..remote`) first, then `origin` if present, then the rest — and selects the first one whose default branch resolves to a locally available tracking ref, never a hardcoded remote name. This handles an unpushed feature branch (no tracking remote) in a repo cloned with a different remote name (e.g. `git clone -o vendor`), and skips a remote that was added but never fetched (its default branch has no local `refs/remotes//` to diff against) in favor of a later remote that does — the candidate is accepted only when `git rev-parse` confirms the tracking ref exists locally. Each candidate's default branch comes from that remote's own `HEAD` (not the current branch's upstream, which on a pushed feature branch points at the feature branch itself and would make `merge-base` equal `HEAD`, yielding an empty diff), falling back to a `git ls-remote --symref` query when the local `HEAD` symref is absent. `merge-base` is taken against the fully-qualified remote-tracking ref `refs/remotes/$REMOTE/$DEFAULT_BRANCH`, which resolves without a local branch of that name and — like the `rev-parse` verify — cannot be misparsed as an option when the remote name begins with a dash (`git clone --origin=-x`); the `git ls-remote` probe terminates option parsing with `--end-of-options` for the same reason. If no candidate yields a locally available default branch, skip the branch-diff path rather than guessing. A caller passing an explicit changed-file list (e.g. `/verification:confirm`) overrides both detection paths. If neither path yields changes and no `$ARGUMENTS`: report "No changes found (working tree clean, no branch diff vs the default branch). Use `/toolchain:check all` to verify the full repo, or `/toolchain:check ` for a specific ecosystem." and exit. diff --git a/plugins/toolchain/skills/lint/SKILL.md b/plugins/toolchain/skills/lint/SKILL.md index c9eeffe49..fbdcf85d0 100644 --- a/plugins/toolchain/skills/lint/SKILL.md +++ b/plugins/toolchain/skills/lint/SKILL.md @@ -59,18 +59,27 @@ Parse `$ARGUMENTS` for: If an ecosystem filter was provided, use it. If `all`, run every applicable ecosystem from the config. Otherwise, classify changed files from `git status --porcelain` against each ecosystem's `globs` list; when the working tree is clean, fall back to the branch diff so checkpoint-committed work still gets classified. Resolve the default branch by **detection, not assumption** — never a hardcoded `main`/`master` — and assign it before use: ```bash -REMOTE=$(git config "branch.$(git branch --show-current | tr -d '\r').remote" 2>/dev/null | tr -d '\r') -[[ -z "$REMOTE" || "$REMOTE" == "." ]] && REMOTE=origin -DEFAULT_BRANCH=$(git symbolic-ref --short "refs/remotes/$REMOTE/HEAD" 2>/dev/null | sed "s#^$REMOTE/##") -DEFAULT_BRANCH=${DEFAULT_BRANCH:-$(git ls-remote --symref "$REMOTE" HEAD 2>/dev/null | awk '/^ref:/{sub(/refs\/heads\//,"",$2); print $2; exit}')} -if [[ -n "$DEFAULT_BRANCH" ]] && git rev-parse --verify --quiet "$REMOTE/$DEFAULT_BRANCH" >/dev/null; then - git diff --name-only "$(git merge-base "$REMOTE/$DEFAULT_BRANCH" HEAD)..HEAD" +REMOTE="" DEFAULT_BRANCH="" +TRACKED=$(git config "branch.$(git branch --show-current | tr -d '\r').remote" 2>/dev/null | tr -d '\r') +[[ "$TRACKED" == "." ]] && TRACKED="" +CANDIDATES=$( { [[ -n "$TRACKED" ]] && echo "$TRACKED"; git remote | grep -qx origin && echo origin; git remote; } | awk 'NF && !seen[$0]++' ) +while IFS= read -r CANDIDATE; do + BRANCH=$(git symbolic-ref --short "refs/remotes/$CANDIDATE/HEAD" 2>/dev/null) + BRANCH=${BRANCH#"$CANDIDATE/"} + BRANCH=${BRANCH:-$(git ls-remote --symref --end-of-options "$CANDIDATE" HEAD 2>/dev/null | awk '/^ref:/{sub(/refs\/heads\//,"",$2); print $2; exit}')} + if [[ -n "$BRANCH" ]] && git rev-parse --verify --quiet "refs/remotes/$CANDIDATE/$BRANCH" >/dev/null; then + REMOTE=$CANDIDATE DEFAULT_BRANCH=$BRANCH + break + fi +done <<< "$CANDIDATES" +if [[ -n "$REMOTE" ]]; then + git diff --name-only "$(git merge-base "refs/remotes/$REMOTE/$DEFAULT_BRANCH" HEAD)..HEAD" else echo "branch diff unavailable (could not detect default branch)" fi ``` -`$REMOTE` is the remote the current branch tracks (`branch..remote`), falling back to `origin` — never a hardcoded remote name, so a repo cloned with a different remote name (e.g. `git clone -o vendor`) still resolves. The fallback queries that remote's own `HEAD` (not the current branch's upstream, which on a pushed feature branch points at the feature branch itself and would make `merge-base` equal `HEAD`, yielding an empty diff). `merge-base` is taken against the remote-tracking ref `$REMOTE/$DEFAULT_BRANCH`, which resolves without a local branch of that name. If detection yields no default branch (no `$REMOTE/HEAD`, and the remote query fails or its ref is absent locally), skip the branch-diff path rather than guessing. A caller passing an explicit changed-file list (e.g. `/verification:confirm`) overrides both detection paths. Cross-cutting runs alongside detected ecosystems when ANY text file changed AND the repo opts into its tools. +The loop probes candidate remotes in priority order — the remote the current branch tracks (`branch..remote`) first, then `origin` if present, then the rest — and selects the first one whose default branch resolves to a locally available tracking ref, never a hardcoded remote name. This handles an unpushed feature branch (no tracking remote) in a repo cloned with a different remote name (e.g. `git clone -o vendor`), and skips a remote that was added but never fetched (its default branch has no local `refs/remotes//` to diff against) in favor of a later remote that does — the candidate is accepted only when `git rev-parse` confirms the tracking ref exists locally. Each candidate's default branch comes from that remote's own `HEAD` (not the current branch's upstream, which on a pushed feature branch points at the feature branch itself and would make `merge-base` equal `HEAD`, yielding an empty diff), falling back to a `git ls-remote --symref` query when the local `HEAD` symref is absent. `merge-base` is taken against the fully-qualified remote-tracking ref `refs/remotes/$REMOTE/$DEFAULT_BRANCH`, which resolves without a local branch of that name and — like the `rev-parse` verify — cannot be misparsed as an option when the remote name begins with a dash (`git clone --origin=-x`); the `git ls-remote` probe terminates option parsing with `--end-of-options` for the same reason. If no candidate yields a locally available default branch, skip the branch-diff path rather than guessing. A caller passing an explicit changed-file list (e.g. `/verification:confirm`) overrides both detection paths. Cross-cutting runs alongside detected ecosystems when ANY text file changed AND the repo opts into its tools. Auto-detection algorithm: