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.55.62",
"version": "0.55.63",
"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 \u2014 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 \u2014 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 \u2014 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 \u2014 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
57 changes: 57 additions & 0 deletions plugins/source-control/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,63 @@
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.55.63]

### Changed

- **The three worktree gates spawn fewer processes per hook payload, with the payload still fed
the way `lib/hook-utils.sh` prescribes.** `worktree-add-containment-gate.sh`,
`worktree-add-claim-gate.sh` and `worktree-create-gate.sh` each read a payload field through
`$(printf '%s' "$INPUT" | jq … 2>/dev/null | tr -d '\r')`. The `printf '%s' "$INPUT" | jq`
feed stays: a here-string is a pipe bash fills itself, and a payload at or above the pipe
capacity (65536 bytes, traced on Git Bash in #1587) blocks the shell before `jq` is exec'd,
which on a hook is the timeout and on a containment gate a fail-open as well. What changes is
everything behind the feed. The `| tr -d '\r'` stage is a parameter expansion, one process
fewer per field. The create gate's two-level `json_field` wrapper became a `_to` form that runs
each rung's pipeline directly, keeping its jq program text byte-for-byte so a non-string
`.name` still falls through to the string-shaped fallback rung and is still refused.
`git_unlocated`'s `2>/dev/null` moved onto its subshell, so bash execs `git` there instead of
forking for it. The block message's `git config --get-all | tail -n 1 | tr -d '\r'` root
lookup collapsed to one `git`, with a sentinel captured inside the substitution so a
blank last `melodic.worktreeroot` value stays empty (command substitution would
otherwise strip it and recommend the preceding path while `worktree-create.sh`'s
`tail -n 1` falls through). The claim gate's write-only stderr temp file (a `mktemp` and an
`rm` whose contents were never read) became `2>/dev/null`, which also removes the
`|| continue` that silently skipped a claim whenever `TMPDIR` was unwritable.

Counted with `strace -f -e trace=clone,clone3,fork,vfork,execve`, process creations then
`execve`, before → after:

| Path | creations | `execve` |
| --- | --- | --- |
| containment, a `worktree` command that is not an `add` | 8 → 7 | 3 → 2 |
| containment, an `add` outside every repository | 22 → 18 | 7 → 5 |
| containment, an `add` into a working tree (blocks) | 21 → 18 | 7 → 5 |
| containment, an `add` into a `.git` directory (blocks) | 27 → 22 | 9 → 7 |
| containment, `git -C <repo> worktree add` (blocks, names the configured root) | 26 → 20 | 10 → 6 |
| claim, a `worktree` command that is not an `add` | 8 → 7 | 3 → 2 |
| claim, a parsed `add` target | 28 → 22 | 11 → 6 |
| create, a payload carrying no `.name` | 19 → 13 | 6 → 4 |

The `execve` drops are named removals, not removed work: `tr -d '\r'` (a parameter expansion
now), `tail -n 1` (a sentinel plus a last-line expansion, so an empty last record
stays empty), `head -n 1` (a `${v%%$'\n'*}` expansion), and
the claim gate's `mktemp`/`rm` pair. Every remaining `jq`, `git` and `sed` call is the same
call with the same arguments. Four of the seven creations left on the hot path belong to
`lib/hook-utils.sh` (the `hook::buffer_stdin` substitution and `hook::json_complete`'s
`printf | jq -e .`), which is synced across 17 plugin copies and out of scope here; the gate's
own share is the one `printf | jq` field read, 3 creations and 1 `execve`.

### Added

- **`hooks/worktree-gates-spawn-budget.test.sh`** holds those counts as ceilings, measured with
`strace` because part of the cost is a fork that never execs and neither `set -x` nor a `PATH`
shim can see one. Upper bounds, not equalities, so a later library change that removes more
work does not fail it. Proven non-vacuous against seven mutants, one per change. It also fails
when any of the three gates feeds `$INPUT` or `$payload` to a reader by here-string, the one
regression the ceilings cannot see because it lowers the count. It skips as a suite where
`strace` is absent or cannot ptrace, rather than asserting on empty trace output.

## [0.55.62]

### Changed
Expand Down
28 changes: 28 additions & 0 deletions plugins/source-control/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,34 @@ two hook processes. The same best-effort caveat applies: a command containing `$
backtick or `$VAR` spawns both processes whatever its text, since the filter cannot see
what the substitution expands to.

#### Measured cost

The two Bash-matcher worktree gates and the `WorktreeCreate` gate, counted as kernel
process creations rather than wall time: the host that reported these hooks timing out
pays 0.3-0.9 s per spawn, so wall time there describes contention more than it describes
the hook. Counted with `strace -f -e trace=clone,clone3,fork,vfork,execve`:

| Path | clone-family | `execve` |
| --- | --- | --- |
| Either Bash gate, a `worktree` command that is not an `add` | 7 | 2 |
| `worktree-add-containment-gate`, an `add` it allows | 18 | 5 |
| `worktree-add-containment-gate`, an `add` it blocks | 18-22 | 5-7 |
| `worktree-add-claim-gate`, a parsed `add` target | 22 | 6 |
| `worktree-create-gate`, before it reaches the placement helper | 13 | 4 |

Four of the seven creations on the not-an-`add` path belong to
[`lib/hook-utils.sh`](../../lib/hook-utils.sh), not to these gates: the
`hook::buffer_stdin` substitution and `hook::json_complete`'s `printf | jq -e .`. The
gates' own share of that path is one `printf '%s' "$INPUT" | jq` field read, 3 creations
and 1 `execve`. That feed is the form the library prescribes and is kept on purpose: a
here-string would cost 1 creation, but bash fills a here-string's pipe itself and
deadlocks at the pipe capacity on Git Bash (#1587), which on a hook is the timeout. The
block-path spread is the ancestor walk: a `.git`-directory target asks `git rev-parse` a
third time, and a block message that names a configured root reads the
`melodic.worktreeroot` key. `hooks/worktree-gates-spawn-budget.test.sh` holds these
numbers as ceilings, proves itself non-vacuous against seven mutants, one per change, and
fails when a gate feeds its payload to a reader by here-string.

## Works in any repo

- **Self-contained.** Everything runs on `git`, `gh` (authenticated), `jq`,
Expand Down
37 changes: 31 additions & 6 deletions plugins/source-control/hooks/worktree-add-claim-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,32 @@ hook::buffer_stdin_to INPUT || exit 0

hook::require_jq "PostToolUse" "source-control-worktree-add-claim-gate" "$INPUT"

COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null | tr -d '\r')
# ONE `jq` for the field and no `tr` behind it. The payload is fed through
# `printf '%s' "$INPUT" | jq`, the form lib/hook-utils.sh prescribes for a hook
# payload (hook::jq_field, hook::json_complete) and NEVER a here-string: bash
# fills a here-string's pipe itself, so a payload at or above the pipe capacity
# (65536 bytes, traced on Git Bash in #1587) blocks the shell before jq is ever
# exec'd, and that hang is this hook's timeout. The separate writer process is
# the correct trade: 3 creations for the read where a here-string costs 1. The
# CR strip is the same all-CRs-removed contract done with parameter expansion
# instead of a `| tr -d '\r'` stage. Same form as the containment sibling;
# strace counted 4 creations / 2 execve before, 3 / 1 after.
COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
COMMAND="${COMMAND//$'\r'/}"
[[ -n "$COMMAND" ]] || exit 0
[[ "$COMMAND" =~ (^|[^[:alnum:]_.-])[Gg][Ii][Tt][^[:alnum:]_-] ]] || exit 0
[[ "$COMMAND" == *worktree* ]] || exit 0
[[ "$COMMAND" == *add* ]] || exit 0

HOOK_CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null | tr -d '\r')
SESSION=$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null | tr -d '\r')
# Same `printf | jq` feed as the command read above. Two reads rather than one
# hook::jq_fields call for both: the batched reader's `// ""` and `tostring`
# semantics differ from `// empty` on a non-string field, and its NUL-flag
# verdict contract is one a blocking-adjacent caller would then have to honour.
# Keeping each field's own `// empty` read is what keeps this behaviour-preserving.
HOOK_CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null)
HOOK_CWD="${HOOK_CWD//$'\r'/}"
SESSION=$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null)
SESSION="${SESSION//$'\r'/}"

CLAIM="$HOOK_DIR/../scripts/worktree-claim.sh"
[[ -f "$CLAIM" ]] || exit 0
Expand Down Expand Up @@ -235,10 +253,17 @@ for target in "${CLAIM_TARGETS[@]}"; do
if [[ -n "$SESSION" ]]; then
args+=(--session-id "$SESSION")
fi
err_file="$(mktemp "${TMPDIR:-/tmp}/worktree-add-claim.XXXXXX")" || continue
claim_rc=0
claim_out="$(bash "$CLAIM" "${args[@]}" 2>"$err_file")" || claim_rc=$?
rm -f "$err_file"
# The helper's stderr is discarded, and `2>/dev/null` is the whole of what the
# temp file used to do: it was created, written by the redirection, and
# removed without ever being read. Dropping it removes two processes (mktemp,
# rm) and the `|| continue` fail-open that silently skipped the claim whenever
# TMPDIR was unwritable. The redirection sits on a group holding exactly ONE
# command, so it silences the helper's stderr and nothing else, and `$?` on
# the group is still the HELPER's status — `claim_rc=$?` stays outside it, per
# the reason spelled out in the create-gate sibling: read inside `if ! cmd`
# it would be a constant 0.
{ claim_out="$(bash "$CLAIM" "${args[@]}")"; } 2>/dev/null || claim_rc=$?
if [[ "$claim_out" == *"worktree-claim.sh: lane active"* && "$claim_rc" -eq 0 ]]; then
claimed_any=1
fi
Expand Down
42 changes: 37 additions & 5 deletions plugins/source-control/hooks/worktree-add-containment-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,20 @@ hook::buffer_stdin_to INPUT || exit 0

hook::require_jq "PreToolUse" "source-control-worktree-add-containment-gate" "$INPUT"

COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null | tr -d '\r')
# ONE `jq` for the field and no `tr` behind it. The payload is fed through
# `printf '%s' "$INPUT" | jq`, the form lib/hook-utils.sh prescribes for a hook
# payload (hook::jq_field, hook::json_complete) and NEVER a here-string: bash
# fills a here-string's pipe itself, so a payload at or above the pipe capacity
# (65536 bytes, traced on Git Bash in #1587, the platform these gates time out
# on) blocks the shell before jq is ever exec'd. Here that hang IS the hook
# timeout, which on a containment gate is a stall and a fail-open at once, so the
# separate writer process is the correct trade: 3 creations for the read where a
# here-string costs 1. The CR strip is the same all-CRs-removed contract done
# with parameter expansion instead of a `| tr -d '\r'` stage (one process fewer).
# strace -f -e trace=clone,clone3,fork,vfork,execve: 4 creations / 2 execve
# before, 3 / 1 after.
COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
COMMAND="${COMMAND//$'\r'/}"
[[ -n "$COMMAND" ]] || exit 0
# Applicability pre-filter before any parsing: `git` as its own word (or a
# path's last segment) plus both subcommand words, so an unrelated Bash call
Expand All @@ -87,7 +100,9 @@ COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/nul
[[ "$COMMAND" == *worktree* ]] || exit 0
[[ "$COMMAND" == *add* ]] || exit 0

HOOK_CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null | tr -d '\r')
# Same `printf | jq` feed and parameter-expansion CR strip as the command read.
HOOK_CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null)
HOOK_CWD="${HOOK_CWD//$'\r'/}"

# Set once a segment changes the working directory; every later segment then
# resolves against a directory this hook cannot see (same rule and same
Expand Down Expand Up @@ -175,13 +190,19 @@ resolve_against() {
# inherited GIT_DIR cannot skew a probe's answer (#972 discipline, same as
# hook::in_git_working_tree). The unset is confined to the subshell, so it never
# reaches the hook's own environment.
#
# The `2>/dev/null` sits on the SUBSHELL, not on the git command: with the
# redirection off the last command in the subshell, bash execs git in that
# subshell instead of forking a third process for it (3 creations per probe
# before, 2 after, measured with strace). `unset` writes nothing to stderr, so
# moving the redirection out one level silences the same stream and no other.
# shellcheck disable=SC2329 # reached via the hook::bash_parse_segments callback chain
git_unlocated() {
(
unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_CEILING_DIRECTORIES \
GIT_DISCOVERY_ACROSS_FILESYSTEM
git "$@" 2>/dev/null
)
git "$@"
) 2>/dev/null
}

# repo_enclosing <abs-target> — if the target's nearest existing ancestor sits
Expand Down Expand Up @@ -227,7 +248,18 @@ repo_enclosing() {
configured_root() {
local hint="$1" r=""
if [[ -n "$hint" ]] && git -C "$hint" rev-parse --git-dir >/dev/null 2>&1; then
r=$(git -C "$hint" config --get-all --type=path melodic.worktreeroot 2>/dev/null | tail -n 1 | tr -d '\r')
# Last-wins without `tail`: a sentinel is captured inside the substitution
# so command substitution cannot strip a blank final record (an empty last
# `melodic.worktreeroot` value must stay empty, matching
# `scripts/worktree-create.sh`'s `tail -n 1`, which then falls through to
# the plugin option). Strip the sentinel, then one trailing newline (the
# last record's terminator), then take the last line. CR strip stays a
# parameter expansion.
{ r=$(git -C "$hint" config --get-all --type=path melodic.worktreeroot; printf x); } 2>/dev/null
r="${r%x}"
r="${r%$'\n'}"
r="${r##*$'\n'}"
r="${r//$'\r'/}"
fi
if [[ -z "$r" ]]; then
r="${CLAUDE_PLUGIN_OPTION_WORKTREE_ROOT:-}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,20 @@ assert_contains "the key outranks the plugin option in the message" "$ERR" \
assert_not_contains "the outranked option root is not the named remedy" "$ERR" \
"$TEST_TMPDIR/option-root"

# An empty last value is still the last-wins record. Command substitution would
# strip it and leave the preceding path; the creation helper's `tail -n 1` keeps
# it empty and falls through. The block message must name the same fallback.
EMPTYLAST="$(mkrepo)"
git -C "$EMPTYLAST" config --add melodic.worktreeroot "$TEST_TMPDIR/first-root"
git -C "$EMPTYLAST" config --add melodic.worktreeroot ""
run "$EMPTYLAST" "git worktree add $EMPTYLAST/wt-empty-last" \
CLAUDE_PLUGIN_OPTION_WORKTREE_ROOT="$TEST_TMPDIR/option-root"
assert_exit "an empty last worktreeroot value still blocks the nested target" 2 "$RC"
assert_not_contains "an empty last worktreeroot value is not the preceding path" \
"$ERR" "$TEST_TMPDIR/first-root"
assert_contains "an empty last worktreeroot value falls through to the plugin option" \
"$ERR" "$TEST_TMPDIR/option-root"

# An unexpanded placeholder is an unset value, not a root to recommend.
# shellcheck disable=SC2016
run "$REPO" "git worktree add $REPO/wt-token" \
Expand Down
43 changes: 34 additions & 9 deletions plugins/source-control/hooks/worktree-create-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -146,24 +146,49 @@ fi
# jq is the primary reader; the fallback exists because failing the creation over
# an absent optional CLI would be a worse outcome than a bounded string extract of
# two flat, harness-generated string fields.
json_field() {
local field="$1" value=""
#
# json_field_to <var> <field> — writes the field into <var> in THIS shell rather
# than printing it, so the two substitutions that used to wrap each read (one
# around the whole helper, one around hook::jq_field) are gone; what remains per
# rung is the reader's own `printf | jq` (or `printf | sed`) pipeline. The `_to`
# shape is the same fork-avoidance convention hook-utils.sh documents on its own
# `_to` helpers.
#
# The payload is fed through `printf '%s' "$payload" |`, the form hook-utils.sh
# prescribes for a hook payload and NEVER a here-string: bash fills a
# here-string's pipe itself, so a payload at or above the pipe capacity (65536
# bytes, traced on Git Bash in #1587) blocks the shell before the reader is ever
# exec'd, and that hang is this hook's timeout. The separate writer process is
# the correct trade.
#
# The jq PROGRAM TEXT is byte-for-byte what hook::jq_field builds, gsub included.
# That is deliberate and load-bearing: `gsub` is string-only, so a payload whose
# field is a NUMBER or an OBJECT makes jq fail and the read falls through to the
# string-shaped sed rung, which matches only a quoted string and so yields
# nothing — a refusal. Dropping the gsub to save nothing would turn a non-string
# `.name` into an accepted worktree name. The CR strip stays jq-side for the same
# reason: moving it into bash would change which values reach the fallback.
json_field_to() {
local __dest="$1" field="$2" value=""
if command -v jq >/dev/null 2>&1; then
value="$(hook::jq_field "$payload" ".$field")" || value=""
value=$(printf '%s' "$payload" | jq -r "(.${field} // empty)"' | gsub("\r";"")' 2>/dev/null)
fi
if [[ -z "$value" ]]; then
value="$(printf '%s' "$payload" |
sed -n 's/.*"'"$field"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' |
head -n 1 | tr -d '\r')"
# No `2>/dev/null` is added on this rung: sed's stderr reached this hook's
# stderr before and still does. `head -n 1` and `tr -d '\r'` are parameter
# expansions now, which is where two of the pipeline's processes went.
value=$(printf '%s' "$payload" | sed -n 's/.*"'"$field"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
value="${value%%$'\n'*}"
value="${value//$'\r'/}"
# The harness emits JSON-escaped Windows paths, so a fallback read has to undo
# the one escape those paths actually carry.
value="${value//\\\\/\\}"
fi
printf '%s' "$value"
printf -v "$__dest" '%s' "$value"
}

name="$(json_field name)"
repo_dir="$(json_field cwd)"
json_field_to name name
json_field_to repo_dir cwd

if [[ -z "$name" ]]; then
gate::refuse \
Expand Down
Loading