fix(source-control): gate push -u on absent-upstream + resolved-remote match (#442) - #783
Conversation
…e match (#442) A post-merge review of PR #763 empirically reproduced a residual silent clobber in the /pull-request create flow. The §2.4.1 conditional `-u` gate keyed on the LITERAL branch.<name>.remote config: in the triangular shape where remote.pushDefault names a fork globally but branch.<name>.remote is unset (fetch falls back to origin), the gate read "unset", took the `-u` bootstrap path, and `git push -u <fork>` repointed the fetch remote to the fork — the next rebase silently targeted the wrong base. Extract the gate into a co-located scripts/push-branch.sh (create.md §2.4.1 delegates to it) so the resolve-fetch -> resolve-push -> conditional-push sequence is executable and testable, not markdown-only. The gate now fires `-u` only when the branch has NO existing upstream AND its fetch and push remotes resolve to the same name; otherwise it pushes plain and writes no branch config. Requiring the upstream to be absent (not just comparing resolved names) also fixes a second corruption an independent Codex review surfaced: `git push -u` rewrites the branch's WHOLE upstream — branch.<name>.remote AND branch.<name>.merge — so an already-tracked branch, or one on a deliberate local-only `.` upstream, had its merge ref overwritten under a resolved-name- only comparison. This refines the review's literal prescription (pure resolved equality) for that reason. It also improves #763's `.` handling: publishing a branch for a PR no longer mutates a deliberate local-only upstream. Add push-branch.test.sh: an integration suite driving real bare-remote pushes across pushRemote-triangular, pushDefault-only triangular, non-triangular (asserting the merge ref survives), fresh-branch bootstrap, local-only `.`, and fetch-ambiguous shapes. It fails against both the old literal-probe gate and the pure-resolved gate, proving it pins the refined invariant rather than only the original bug — the integration coverage whose absence let this escape resolve-remote.test.sh's resolver-only cases. Refs #442 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 4m 9s —— View job Security Review
Scope: FindingsNo exploitable security vulnerabilities found. Detailed notes per surface below.
|
|
Claude finished @kyle-sexton's task in 3m 12s —— View job PR #783 Review
SummaryThis is a well-constructed fix-forward on a residual silent-clobber bug that survived PR #763. The logic, tests, and documentation are all sound. I have a few minor observations but no blocking concerns. push-branch.shLogic is correct. The two-condition gate —
Intentional Minor: Minor: push-branch.test.shTest coverage is excellent. 18 assertions, 6 real-bare-remote scenarios. The suite pins the refined invariant in both directions — it would fail against an always-plain-push implementation (case 1 expects
Case 5 exit-code check is well-structured. Capturing One cosmetic note: The create.mdThe 18-line inline bash block is cleanly replaced by a single delegation call: bash "${CLAUDE_PLUGIN_ROOT}/skills/pull-request/scripts/push-branch.sh" || exit 1This is the right call — the gate logic belonged in a testable script, not a reference doc. The inline comment concisely names the two corruptions being closed without duplicating the full rationale (which lives correctly in the script header). ✓ plugin.json / CHANGELOG.md
VerdictApprove. The |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 873c7aec05
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Claude finished @kyle-sexton's task in 1m 53s —— View job Security Review — PR #783
Scope: FindingsNo exploitable security vulnerabilities found. Detail per surface below.
|
|
Claude finished @kyle-sexton's task in 2m 25s —— View job PR #783 Review
SummaryThe core fix and its test harness are well-constructed. The 🔴 Important — Merge-only tracking config not guarded:
|
| Surface | Verdict |
|---|---|
Quoting / injection in push-branch.sh |
Clean — all external data passes through double-quoted variables, [[…]] comparison context, no eval |
set -uo pipefail without -e |
Intentional and correct — failed fetch resolution degrades to empty, not abort |
PUSH_REMOTE resolution with || exit 1, FETCH_REMOTE without |
Correct — push must be determinate; ambiguous fetch is a valid degraded case |
| Test cases 1–6, 18 assertions | Cover the 4 stated shapes plus bootstrap and fetch-ambiguous; non-vacuity proven in both directions as claimed |
create.md delegation to push-branch.sh |
Clean replacement; inline comment accurately names the two corruptions being closed |
plugin.json version bump 0.15.4 → 0.15.5 |
Correct patch increment |
Shell quoting in push-branch.test.sh |
All path vars double-quoted at use; git -C "$dir" scoping throughout; mktemp + trap cleanup correct |
… bootstrap (#442) Round-2 Codex review (PR #783) found a fourth clobber shape the prior gate missed: when branch.<name>.merge is configured but branch.<name>.remote is unset — a valid tracking config, since Git defaults the remote to origin so the branch tracks origin/<merge-ref> — push-branch.sh's "no existing upstream" probe read only branch.<name>.remote, saw it unset, and fired `git push -u`, which replaced the configured merge ref with refs/heads/<name>. The upstream is two keys (branch.<name>.remote AND branch.<name>.merge) and `git push -u` rewrites both. Bootstrap must therefore require BOTH absent, not just the remote. The gate now checks branch.<name>.merge alongside branch.<name>.remote; any configured tracking (full, local-only `.`, or merge-only) is preserved by a plain push. Add the merge-only test case (merge set, remote unset -> plain push, merge ref survives). It fails against the previous remote-only probe and passes against the fixed gate, so it pins the new invariant. Full suite 21/21; resolver 15/15; shellcheck, shfmt, markdownlint, changelog parity all green. Refs #442 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Claude finished @kyle-sexton's task in 1m 56s —— View job Security Review — PR #783
Scope: Finding: None — no exploitable security vulnerabilities foundThe HEAD commit (
|
|
Claude finished @kyle-sexton's task in 2m 25s —— View job PR #783 Review
Status of prior findingsCodex P2 / prior Claude review ( EXISTING_REMOTE=$(git config "branch.${BRANCH}.remote" 2>/dev/null)
EXISTING_MERGE=$(git config "branch.${BRANCH}.merge" 2>/dev/null)
if [[ -z "$EXISTING_REMOTE" && -z "$EXISTING_MERGE" && "$FETCH_REMOTE" == "$PUSH_REMOTE" ]]; thenThis is the correct fix. A branch with
|
| Shape | EXISTING_REMOTE |
EXISTING_MERGE |
Fetch == Push? | Gate fires -u? |
|---|---|---|---|---|
| Fresh branch bootstrap | ∅ | ∅ | yes (both origin) | ✓ correct |
| Already-tracked branch | origin | refs/heads/main | yes | no — preserves merge ref ✓ |
pushRemote-triangular |
upstream | (set) | no | no ✓ |
pushDefault-only triangular (#442) |
∅ | ∅ | no (origin≠fork) | no ✓ |
| Fetch-ambiguous | ∅ | ∅ | no (∅ ≠ fork) | no ✓ |
Local-only . |
. |
refs/heads/main | (moot) | no — "." is non-empty ✓ |
| Merge-only tracking (new) | ∅ | refs/heads/main | yes | no — EXISTING_MERGE non-empty ✓ |
Raw key reads are correct. The header explains the design clearly: upstream-absent probes read both keys raw (no .-normalization, no \r-strip). Any non-empty value means "has an upstream" → plain push. That normalization stays once, in resolve-remote.sh. This is the right architecture — no duplication.
Quoting / injection. No unquoted expansions, no eval. PUSH_REMOTE and BRANCH come from git (ref-name-constrained) or from the caller but always pass through "$VAR". git config "branch.${BRANCH}.remote" embeds BRANCH in a double-quoted string (single git argument), so no word-splitting occurs. [[ "$FETCH_REMOTE" == "$PUSH_REMOTE" ]] uses [[...]] context — no glob expansion on the right side. The prior security reviews confirmed this; the HEAD commit doesn't add any new surfaces.
set -uo pipefail without -e is intentional and correct. Explicit || exit 1 on push resolution, silent degradation for fetch resolution. -u catches unset-variable bugs at development time.
Detached HEAD / empty BRANCH. If git branch --show-current returns empty and no argument was passed, BRANCH stays empty. git config "branch..remote" is an invalid key → non-zero exit, suppressed, EXISTING_REMOTE="". Same for EXISTING_MERGE. Then bash "$RESOLVER" --push "" will almost certainly fail (the resolver also calls git branch --show-current) → || exit 1 aborts safely. Not a corruption risk; a safe error. Out of scope for the PR's stated test matrix.
Minor nit: Both push-branch.sh and push-branch.test.sh invoke the resolver via bash "$RESOLVER" rather than directly (both scripts are 100755 with a shebang). This is harmless — bash invocation bypasses the shebang and is equivalent — but slightly inconsistent with direct invocation. Noted by the prior reviewer as well; truly cosmetic.
push-branch.test.sh — coverage and correctness
Test case 7 (merge-only tracking) is well-constructed. It sets branch.merge=refs/heads/main without branch.remote, runs push-branch.sh, and asserts both that branch.remote stays UNSET and the merge ref survives. This is exactly the Codex P2 scenario, now pinned in the test matrix. Three assertions for the new case, consistent with the other cases.
Non-vacuity is proven in three directions:
- Case 1 (bootstrap) fails against an always-plain-push implementation.
- Cases 2, 6 fail against an always-
-uor resolved-name-only gate. - Case 7 (new) fails against a remote-only "no upstream" probe that misses merge-only tracking.
Test infrastructure is correct throughout. mktemp -d + trap 'rm -rf "$WORKDIR"' EXIT, git -C "$dir" scoping, double-quoted paths at point of use, test@example.com/Test local-only config writes. has_branch() consistently appends .git and callers pass bare-repo base paths without .git — the convention is uniform.
Case 5 exit-code capture (push_exit=$? after the subshell, then check "..." "$push_exit" "0") correctly validates that a failed fetch resolution exits 0 (degraded to plain push) rather than aborting. ✓
Count: 21 assertions across 7 real-bare-remote scenarios (up from 18/6 in the prior reviews, adding 3 assertions for case 7). The PR description says 18/18 — the count is now 21/21 with the merge-only case.
create.md
Old 18-line inline bash block cleanly replaced by a one-line delegation:
bash "${CLAUDE_PLUGIN_ROOT}/skills/pull-request/scripts/push-branch.sh" || exit 1The inline comment concisely names both corruptions being closed and points to the script header for full rationale. ${CLAUDE_PLUGIN_ROOT} is the standard plugin path — correct trust level, no ../ reach-out. || exit 1 propagates failures. ✓
plugin.json / CHANGELOG.md
Version bump 0.15.4 → 0.15.5 is the correct patch increment for a bug fix. The [0.15.5] changelog entry is comprehensive and accurate, covering the pushDefault-only clobber, the merge-ref overwrite family (already-tracked, ., merge-only), and the integration-test addition. ✓
Verdict
Approve. The HEAD commit resolves the one confirmed defect flagged by two independent prior reviewers. The unset AND resolved-equal gate — now requiring both branch.<name>.remote and branch.<name>.merge to be absent before bootstrapping — is the correct implementation. All seven shapes are exercised with real bare-remote pushes; non-vacuity is proven in three directions. No remaining correctness, security, or alignment issues.
No linked issue — fix-forward of merged #763; the original issue #442 is already closed, and this residual was caught by post-merge review rather than filed.
Summary
Fix-forward on just-merged #763 (source-control
/pull-requestcreate flow, issue #442). An independent post-merge review empirically reproduced a residual silent clobber that #763 left behind.The residual finding. create.md §2.4.1's conditional
-ugate keyed on the literalbranch.<name>.remoteconfig being set. In theremote.pushDefault-only triangular shape —pushDefault=forkglobally,branch.<name>.remoteunset, fetch falling back toorigin— the gate read "unset", took the-ubootstrap path, andgit push -u <fork>rewrotebranch.<name>.remoteto the fork. The next fetch/rebase then silently targeted the fork instead oforigin.Reproduced before and after (two bare remotes,
remote.pushDefault=fork,branch.<name>.remoteunset):branch.<name>.remoteafter pushfork(clobbered)origin— correctRoot-cause fix. The
-uconditional moved out of the markdown prose into a new co-locatedscripts/push-branch.sh(§2.4.1 now delegates to it), so the resolve-fetch → resolve-push → conditional-push sequence is executable and testable rather than living only in a reference doc. The gate fires-uonly when the branch has no existing upstream and its fetch and push remotes resolve to the same name; otherwise it pushes plain and writes no branch config. The normalized.-as-unset /\r-strip handling stays solely inresolve-remote.sh— the literal-unset probe reads the key raw (.is non-empty → "has an upstream").Divergence from the review's literal prescription (disclosed). The review prescribed gating purely on resolved-remote equality (
-uiff fetch == push). An independent Codex review of this change found thatgit push -urewrites the branch's whole upstream — bothbranch.<name>.remoteandbranch.<name>.merge— so the pure-resolved gate still clobbered the merge ref of an already-tracked branch, and of a deliberate local-only.upstream (git branch --track . <ref>), whenever the resolved names happened to match. Requiring the upstream to be absent before bootstrapping (unset AND resolved-equal) closes that second corruption while still fixing the reportedpushDefault-only case. This also improves #763's.handling: publishing a branch for a PR no longer mutates a deliberate local-only upstream. (Per the DEFER-FORBIDDEN carve-out, both Codex findings were folded in inline, not deferred.)Test plan
push-branch.test.shdrives real bare-remote pushes across: pushRemote-triangular,pushDefault-only triangular, non-triangular (asserting the merge ref survives), fresh-branch bootstrap (asserting-ufired), local-only., and fetch-ambiguous/push-determinate. 18/18 pass.resolve-remote.test.sh's resolver-only cases could not catch, which is why the clobber escaped fix(source-control): resolve remote dynamically in pull-request create flow (#442) #763.)resolve-remote.test.sh15/15 (unchanged resolver), changelog parity (--check+--check-bump), markdownlint, shellcheck, shfmt — all green.Merge posture
Fix-forward proceeding under operator momentum: the change is verified and ready, but the agent will not merge it. The merge decision is delegated to the operator. A veto window is open — object here before merge if the
unset AND resolved-equalrefinement (vs the review's pure-resolved prescription) warrants discussion; otherwise it may be merged.Related
originhardcoded in pull-request create flow (low severity) #442 — original low-severity finding (closed by fix(source-control): resolve remote dynamically in pull-request create flow (#442) #763); this fix-forward addresses the residual clobber that survived it.-ugate whose literal-probe form this replaces.🤖 Generated with Claude Code