Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/source-control/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "source-control",
"version": "0.51.9",
"version": "0.51.10",
"description": "Git and GitHub delivery workflow: /commit (Conventional Commits + Co-Authored-By trailer via safe heredoc mechanics), /pull-request (prep, create, CI monitoring, review-comment triage, merge, CI-log fetch), /babysit-prs (self-pacing fleet loop — safe by default; opt-in worker/autopilot tiers add gate-checked merge and thread resolution behind a deterministic Python engine), /babysit-loop (the loop-lane merge lane: a standing or drain loop that invokes babysit-prs per cycle, configured through repo-scoped babysit_loop_* keys on the layered source-control.md seam, with merge authority human-only until the target repo's tracked config adopts the lane, a gate-proven C2-mechanical baseline once adopted, and standing merge-rung raises binding from the team-tracked layer only — with one named exception, where an invocation line explicitly typing both the autopilot tier keyword and the dedicated raise argument --merge c3-this-run widens that single invocation's merge authority up to C3 behind a fresh independent frontier-tier resolver, while C4-structural and C5-untrusted-provenance stay unconditionally human-merge), /worktree (create, status, cleanup, audit for parallel-session isolation), /setup (check the effective commit-subject / PR-title convention merged across its config layers and the babysit-prs config, or apply — interview the repo and write the convention config to a chosen layer), and /resolve-conflicts (intent-first merge/rebase conflict resolution with a semantic-conflict sweep — never --abort). The commit-subject / PR-title convention is configurable via a source-control.md config written by a re-runnable setup skill, layered across a ~/.claude user-global file, the tracked team file, and a gitignored .claude/source-control.local.md personal overlay merged per key; Conventional Commits is the default when no convention is declared.",
"author": {
"name": "Melodic Software",
Expand Down
20 changes: 20 additions & 0 deletions plugins/source-control/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,26 @@
All notable changes to the `source-control` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.51.10]

### Documentation

- **`exec-bit-check.sh`'s rename arm is `diff.renames`-dependent on the DEFAULT config, and that
trade is now recorded where the gate is (#2141).** `git mv` of a `100644` shebang file reads as
`D`+`A` under `diff.renames=false` and IS reported through the `A` branch; the same index and the
same HEAD read as `R100` under the default `diff.renames=true` and are NOT. Only the config
differs. **No behaviour change** — the `R*` arm keeps its `100755`-source gate. #2141 weighed
dropping the gate for renames and making the `A` branch skip a rename-as-add, and kept the gate:
the false positive it prevents is real and pinned by `repo19` in `exec-bit-check.test.sh` — a
deliberately non-executable sourced library or template must not be flipped to `100755` because
someone moved it. Dropping the gate would buy config-agreement by shipping that false positive to
every consumer; making the `A` branch match would buy it by reporting *less*, risking silence on
genuinely new files. What changes is the prose: content-determinism is stated as a property of the
`A` and `C` classes only — never of the whole tool — at the script header, at the candidate-set
gate, in `--help`, in `reference/exec-bit.md`, and next to `repo19`. New case group **19b** pins
both halves of the disagreement on one fixture repo, with HEAD and the index asserted identical
across the two runs, so the decision is executable rather than only written down.

## [0.51.9]

### Fixed
Expand Down
12 changes: 12 additions & 0 deletions plugins/source-control/skills/commit/reference/exec-bit.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ staged mode is `100644`.
file's own deliberate `100644` riding along through a move. Dropping the bit is what a
`core.filemode=false` platform produces on `mv` plus `git add`; a sourced library or template
with a shebang and no exec bit is not a defect and is never touched.

**The rename arm's accepted trade (#2141).** That gate is the one place the check's answer
still depends on `diff.renames`, and it splits on the *default* setting rather than an opt-in
one: `git mv` of a `100644` shebang file reads as `D`+`A` under `diff.renames=false` and **is**
reported through the `A` branch, while the same index and the same HEAD read as `R100` under
the default `diff.renames=true` and are **not**. Only the config differs. This is decided, not
overlooked — #2141 weighed keeping the gate, dropping it for renames too, and making the `A`
branch skip a rename-as-add, and kept the gate with no behaviour change. Dropping it would ship
the `repo19` false positive (a deliberately non-executable sourced library flipped to `100755`
because someone moved it) to every consumer; making the `A` branch match would buy agreement by
reporting *less*, at the risk of silencing genuinely new files. So **content-determinism is a
property of the `A` and `C` classes, not of the whole tool** — do not restate it unqualified.
- A **copy** destination is a path that did **not** previously exist, so it is newly added no
matter what the source's mode was — squarely inside the newly-added-only scope. It is a
candidate unconditionally, exactly as an `A` is. Gating it on the source mode is what made the
Expand Down
41 changes: 40 additions & 1 deletion plugins/source-control/skills/commit/scripts/exec-bit-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@
# and therefore dropped the bit. See the candidate-set comment further down for
# why `R` and `C` are judged differently.
#
# WHAT IT DOES NOT CLAIM: content-determinism is not a property of the whole
# tool. It holds for the `A` and `C` classes only. The rename arm's
# `100755`-source gate deliberately makes a `git mv` of a `100644` shebang file
# answer differently under `diff.renames=false` (reported, as `A`) and under the
# DEFAULT `diff.renames=true` (not reported, as `R`) — same index, same HEAD.
# That is a decided trade, kept in #2141; the candidate-set comment carries the
# reasoning and `repo19` in the test file carries the false positive it buys.
#
# Deliberate scope limits, each matching the skill's documented reasoning:
# - Newly-added paths only. An already-tracked file that was already
# executable needs no action, and a full-repo sweep is not this check's job.
Expand Down Expand Up @@ -91,6 +99,11 @@ Reports paths staged at a NEW index entry whose staged blob starts with '#!' but
whose staged mode is 100644. A new entry is not just status 'A': a copy
destination counts, as does a rename destination whose source was 100755.
Symlinks and already-executable entries are skipped.

Scope note: a rename off a 100644 source is deliberately NOT reported, so for
that one case the answer depends on your diff.renames setting (reported as an
add when off, skipped as a rename when on). Decided trade — see
reference/exec-bit.md.
EOF
}

Expand Down Expand Up @@ -205,7 +218,10 @@ fi
# it on -- `git diff -h` documents `-M`/`-C` as "detect renames"/"detect copies",
# rename detection is on by default (`diff.renames`), and copy detection turns on
# with `diff.renames=copies`. Keying on `A` would make the check fail open on the
# consumer's diff configuration rather than on the staged content.
# consumer's diff configuration rather than on the staged content -- for the `A`
# and `C` classes, which is exactly as far as that property goes. It is NOT a
# property of the whole tool: the rename arm below deliberately does not have it,
# and the `R` paragraph states why and on whose authority.
#
# `--raw` rather than `--name-status` because a RENAME needs BOTH modes to
# judge: its record is `:<srcmode> <dstmode> <srcsha> <dstsha> <status>`, and
Expand All @@ -219,6 +235,29 @@ fi
# flip a mode nobody changed -- squarely outside this check's
# newly-added-only scope. So the rename arm gates on a `100755` source.
#
# THE ACCEPTED TRADE (#2141) -- recorded at the gate, not only at the
# test that pins it. This gate makes the RENAME arm's answer a function
# of `diff.renames`, and it splits on the DEFAULT config rather than an
# opt-in one. `git mv` of a `100644` shebang file reads as `D`+`A` under
# `diff.renames=false` and IS reported through the `A` branch; the SAME
# index and the SAME HEAD read as `R100` under the default
# `diff.renames=true` and are NOT. Only the config differs.
#
# That residual is decided, not overlooked. #2141 weighed three policies
# -- keep the gate, drop it for renames too, or make the `A` branch skip
# a rename-as-add -- and settled on KEEPING it, with no behaviour change.
# The false positive the gate prevents is real and is pinned by `repo19`
# in exec-bit-check.test.sh: a deliberately non-executable sourced
# library or template must not be flipped to `100755` because someone
# moved it. Dropping the gate buys config-agreement by shipping that
# false positive on every consumer; making the `A` branch match buys it
# by reporting LESS, which risks silencing genuinely new files. Neither
# trade was judged worth the agreement.
#
# So: do NOT "repair" this arm into agreement with the `A` branch on the
# theory that the disagreement is the same defect class as #2118. It is
# the same SHAPE and a different verdict. Reopen #2141 first.
#
# C -- the destination is a path that did NOT previously exist. It is newly
# added no matter what the source's mode was, which puts it squarely
# INSIDE the newly-added-only scope, so the copy arm gates on nothing and
Expand Down
118 changes: 118 additions & 0 deletions plugins/source-control/skills/commit/scripts/exec-bit-check.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,25 @@ assert_eq "a rename that preserved the exec bit is NOT reported" \
# file is already tracked, and flipping it to 100755 would change a mode nobody
# touched. Only a 100755 source can have dropped the bit — a candidate set that
# ignores the source mode reports this one.
#
# THIS IS THE CASE THAT BUYS THE TRADE IN #2141, and the reasoning belongs here
# rather than only at the gate. The rename arm deliberately does NOT have the
# content-determinism property the copy arm has (case group 21b): the very same
# staged content this fixture builds is REPORTED under `diff.renames=false`,
# where it arrives as `D`+`A`, and is NOT reported under the default
# `diff.renames=true`, where it arrives as `R100`. Case group 19b directly below
# pins both halves of that disagreement, on one repo, with the index and HEAD
# asserted identical across the two runs.
#
# #2141 weighed three policies — keep the gate, drop it for renames too, or make
# the `A` branch skip a rename-as-add — and KEPT the gate with no behaviour
# change, because THIS fixture is a real false positive and not a hypothetical:
# a deliberately non-executable sourced library or template must not be flipped
# to `100755` because someone moved it. Dropping the gate would buy
# config-agreement by shipping that false positive to every consumer; making the
# `A` branch match would buy it by reporting less, risking silence on genuinely
# new files. If a future change makes this case report, it has adopted a policy
# #2141 rejected — reopen that issue rather than deleting the assertion.
repo19="$(mkrepo)"
(
cd "$repo19" || exit 1
Expand All @@ -537,6 +556,105 @@ assert_eq "the fixture's destination really is 100644 with a shebang" \
assert_eq "a rename whose SOURCE was never executable is NOT reported" \
"" "$(bash "$HELPER" --repo-dir "$repo19" --list 2>/dev/null)"

# --- Case group 19b: the two diff.renames configurations DISAGREE, deliberately
#
# The rename-arm counterpart of case group 21b, and the direct analogue of the
# table in #2141. Same shape as 21b — ONE fixture repo, run twice with nothing
# changing between the runs but the single `diff.renames` key — but the asserted
# outcome is INVERTED: for a rename off a `100644` shebang source the two
# configurations must NOT agree, because the `100755`-source gate is kept
# (repo19 above says why, and the candidate-set comment in exec-bit-check.sh
# says it again at the gate).
#
# This is a decision record in executable form, not a defect report. It is
# ALSO the discriminator against the two policies #2141 rejected:
# * drop the rename gate -> the `diff.renames=true` run starts reporting
# `dis-lib-moved.sh` and the `renames_on` assertion fails;
# * skip a rename-as-add on the `A` branch
# -> the `diff.renames=false` run stops reporting it
# and the `renames_off` assertion fails.
# Neither half can pass under a policy other than the one that shipped.
#
# WHAT MAKES THE COMPARISON HONEST: the index tree and HEAD tree are captured on
# both runs and asserted EQUAL to each other. That is what turns "two different
# answers" into "two different answers for identical staged content" — without
# it the case would prove only that two different repositories differ. The raw
# status letters are asserted on both runs as well, so a git that declines to
# pair the rename skips the case rather than silently passing it through the `A`
# branch twice and "agreeing".
#
# `zz-dis-extra.sh` is an unrelated newly-added shebang file sorting AFTER
# `dis-lib-moved.sh`, so its record follows the rename pair in the NUL stream.
# Asserting the EXACT output pins that the `R*` arm consumed BOTH of its path
# fields: an arm that reads short desynchronizes every record behind it, and a
# rename arm that skips its candidate is exactly the arm most likely to look
# correct while having read short.
#
# PATH NAMES ARE PREFIXED `dis-` ON PURPOSE. `mkrepo` increments REPO_SEQ inside
# a command substitution, so the increment never reaches the caller and every
# fixture in this suite is handed the SAME repository path. Each fixture's own
# unqualified `git commit` clears the previous one's staged set, which is why
# that has gone unnoticed — but file NAMES persist, and a fixture reusing an
# earlier one's name gets `fatal: destination exists` from `git mv`, silently,
# inside the `>/dev/null 2>&1` subshell. This group hit exactly that against
# `repo19`'s `lib.sh`: it passed standalone and skipped in the full suite. The
# diagnostic skip below is what surfaced it.
repo24="$(mkrepo)"
(
cd "$repo24" || exit 1
git config core.filemode false
printf '#!/usr/bin/env bash\necho sourced\n' >dis-lib.sh
git add dis-lib.sh
git commit -qm "seed the disagreement fixture's non-executable source"
git mv dis-lib.sh dis-lib-moved.sh
printf '#!/usr/bin/env bash\necho unrelated\n' >zz-dis-extra.sh
git add zz-dis-extra.sh
) >/dev/null 2>&1

# `false` and `true` are asserted explicitly rather than leaning on the ambient
# default, but `true` IS git's default — which is why #2141 called this the
# configuration most consumers actually run.
(cd "$repo24" && git config diff.renames false) >/dev/null 2>&1
dis_off_status="$(cd "$repo24" && git diff --cached --name-status | tr '\t' ' ' | tr '\n' ';')"
dis_off_head="$(cd "$repo24" && git rev-parse 'HEAD^{tree}' 2>/dev/null)"
dis_off_index="$(cd "$repo24" && git write-tree 2>/dev/null)"
dis_off="$(bash "$HELPER" --repo-dir "$repo24" --list 2>/dev/null | sort | tr '\n' ' ')"

(cd "$repo24" && git config diff.renames true) >/dev/null 2>&1
dis_on_status="$(cd "$repo24" && git diff --cached --name-status | tr '\t' ' ' | tr '\n' ';')"
dis_on_raw="$(cd "$repo24" && git diff --cached --raw | grep 'dis-lib-moved\.sh')"
dis_on_head="$(cd "$repo24" && git rev-parse 'HEAD^{tree}' 2>/dev/null)"
dis_on_index="$(cd "$repo24" && git write-tree 2>/dev/null)"
dis_on="$(bash "$HELPER" --repo-dir "$repo24" --list 2>/dev/null | sort | tr '\n' ' ')"

if [[ "$dis_off_status" == *"A dis-lib-moved.sh;"* ]] && [[ "$dis_off_status" == *"D dis-lib.sh;"* ]] &&
[[ "$dis_on_status" == *"R"*"dis-lib.sh dis-lib-moved.sh;"* ]]; then
# The premise first: nothing but the config key differs between the two runs.
assert_eq "HEAD is identical across the two diff.renames runs" \
"$dis_off_head" "$dis_on_head"
assert_eq "the INDEX is identical across the two diff.renames runs" \
"$dis_off_index" "$dis_on_index"
assert_contains "the diff.renames=true run really pairs it as a rename off a 100644 source" \
"$dis_on_raw" ":100644 100644"
# Both sides against the EXPECTED set, never merely against each other.
assert_eq "diff.renames=false REPORTS the moved 100644 shebang file (as an add)" \
"dis-lib-moved.sh zz-dis-extra.sh " "$dis_off"
assert_eq "diff.renames=true does NOT report it (the kept #2141 gate)" \
"zz-dis-extra.sh " "$dis_on"
# The shape itself, stated as a verdict so it reads as an intended property
# rather than as two assertions that happen to differ. (assert_eq over a
# derived verdict rather than a new assert_ne helper: one call site does not
# earn a second comparison primitive in the shared harness.)
dis_verdict="$([[ "$dis_off" == "$dis_on" ]] && printf 'AGREE' || printf 'DISAGREE')"
assert_eq "the two diff.renames configurations DISAGREE on identical staged content — deliberate, kept in #2141" \
"DISAGREE" "$dis_verdict"
else
# The skip reports WHAT IT SAW, not just that it gave up. A silent skip is
# indistinguishable from a fixture that never built, and this suite has been
# bitten by exactly that.
skip_case "this git did not produce both a D+A and an R pairing for the disagreement fixture (renames=false saw: ${dis_off_status:-<empty>} | renames=true saw: ${dis_on_status:-<empty>})"
fi

# The COPY arm is the OPPOSITE of repo19 above, and this case pins that
# asymmetry (#2118). A rename destination is the same tracked file at a new
# path, so a `100644` source means nothing dropped a bit. A copy destination is
Expand Down
Loading