
Swain Sync
- 88 installs
- 2 repo stars
- Updated July 24, 2026
- cristoslc/swain
Fetch upstream, merge or rebase, stage changes, enforce gitignore hygiene, run ADR compliance and index rebuilds, generate a commit message from the diff, then commit and push.
About
Automates the full commit-and-push flow: fetch, merge or rebase, stage, gitignore hygiene, ADR compliance checks, index rebuilds, diff-derived commit message, then commit and push to upstream. A developer uses it to sync, commit, and push work, with conflict handling that prefers local for config and upstream for scaffolding.
- Generates commit messages from the diff and enforces gitignore hygiene
- Runs ADR compliance and index rebuilds before committing
Swain Sync by the numbers
- 88 all-time installs (skills.sh)
- Ranked #245 of 733 Git & Pull Requests skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cristoslc/swain --skill swain-syncAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 88 |
|---|---|
| repo stars | ★ 2 |
| Last updated | July 24, 2026 |
| Repository | cristoslc/swain ↗ |
What it does
Fetch upstream, merge or rebase, stage changes, enforce gitignore hygiene, run ADR compliance and index rebuilds, generate a commit message from the diff, then commit and push.
Files
<!-- swain-model-hint: sonnet, effort: low -->
<!-- session-check: SPEC-121 --> Before proceeding with any state-changing operation, check for an active session:
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
bash "$REPO_ROOT/.agents/bin/swain-session-check.sh" 2>/dev/nullIf the JSON output has "status" other than "active", inform the operator: "No active session — start one with /swain-init?" Proceed if they dismiss.
Run through the following steps in order without pausing for confirmation unless a decision point is explicitly marked as requiring one.
Delegate this to a sub-agent so the main conversation thread stays clean. Include the full text of these instructions in the agent prompt, since sub-agents cannot read skill files directly.
Step 1 — Detect worktree context and fetch/rebase upstream
First, detect whether you are running in a git linked worktree:
GIT_COMMON=$(git rev-parse --git-common-dir)
GIT_DIR=$(git rev-parse --git-dir)
IN_WORKTREE=$( [ "$GIT_COMMON" != "$GIT_DIR" ] && echo "yes" || echo "no" )
REPO_ROOT=$(git rev-parse --show-toplevel)
TRUNK=$(bash "$REPO_ROOT/.agents/bin/swain-trunk.sh")IN_WORKTREE=yes means the current directory is inside a linked worktree (e.g., .worktrees/agent-abc123). Use this flag in Steps 3, 6, and the session bookmark step.
Next, check whether the current branch has an upstream tracking branch:
git --no-pager rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/nullIf there is an upstream, fetch and rebase to incorporate upstream changes BEFORE staging or committing:
git fetch originIf there are local changes (dirty working tree), stash them first. Use a targeted pop to avoid interfering with stashes from other worktrees (SPEC-252: shared stash is a collision vector):
BRANCH=$(git rev-parse --abbrev-ref HEAD)
STASH_MSG="swain-sync: auto-stash [$BRANCH]"
git stash push -m "$STASH_MSG"
STASH_REF=$(git stash list --format='%gd %s' | grep -F "$STASH_MSG" | head -1 | cut -d' ' -f1)
git --no-pager rebase origin/$BRANCH
git stash pop "$STASH_REF"If the rebase has conflicts after stash pop, abort and report:
git rebase --abort # if rebase itself conflicts
git stash pop "$STASH_REF" # recover stashed changes (targeted, not bare pop)Show the user the conflicting files and stop. Do not force-push or drop changes.
If there is no upstream (@{u} returns an error) and IN_WORKTREE=yes, the worktree branch has no remote tracking counterpart. Merge the trunk branch to combine the agent's changes with whatever landed since the branch was created:
git fetch origin
git merge "origin/$TRUNK" --no-editIf the merge has conflicts, report them and stop. Do not attempt to auto-resolve.
If origin cannot be fetched, skip fetch/rebase and proceed to Step 2.
If there is no upstream and IN_WORKTREE=no (main worktree, new branch), skip this step entirely.
Step 2 — Survey the working tree
git --no-pager status
git --no-pager diff # unstaged changes
git --no-pager diff --cached # already-staged changesIf the working tree is completely clean and there is nothing to push, report that and stop.
Step 3 — Stage changes
Identify files that look like secrets (.env, *.pem, *_rsa, credentials.*, secrets.*). If any are present, warn the user and exclude them from staging.
If there are 10 or fewer changed files (excluding secrets), stage them individually:
git add file1 file2 ...If there are more than 10 changed files, stage everything and then unstage secrets:
git add -A
git reset HEAD -- <secret-file-1> <secret-file-2> ...Step 3.5 — Gitignore check
Before committing, verify .gitignore hygiene. This step is blocking — if relevant patterns are missing, stop and require the user to fix .gitignore before proceeding.
1. Check existence
If no .gitignore file exists in the repo root:
STOP: No .gitignore file found. Create one before committing — without it, secrets, build artifacts, and OS files can enter git history.Minimal starting point: curl -sL https://www.toptal.com/developers/gitignore/api/macos,linux,node,python > .gitignoreStop execution. Do not commit.
2. Detect relevant patterns
Check which patterns are relevant to this repo, based on what actually exists on disk:
| Pattern | Relevant if |
|---|---|
.env | .env.example exists, OR any untracked/tracked .env or .env.* file is present (excluding .env.example), OR dotenv appears in package.json or requirements.txt |
node_modules/ | package.json exists in the repo root or any subdirectory |
__pycache__/ | any *.py file exists in the repo |
*.pyc | same as __pycache__/ |
.DS_Store | repo is on macOS (uname returns Darwin) |
For each relevant pattern, check if .gitignore contains it (exact match or substring). Collect missing ones.
3. Decide whether to block
- If no relevant patterns are missing: this step is silent. Continue to Step 3.7.
- If any relevant patterns are missing: stop and report:
STOP: .gitignore is missing patterns relevant to this repo:-.env—.env.examplefound; without this, a local.envfile could be committed
-node_modules/—package.jsonfound
>
Add the missing patterns before committing:
echo ".env" >> .gitignore
echo "node_modules/" >> .gitignore
>
To permanently suppress a specific pattern check (intentional omission), add a comment to .gitignore:# swain-sync: allow .env
Stop execution. Do not commit until the user resolves this.
4. Skip logic
If .gitignore contains # swain-sync: allow <pattern> for a given pattern, treat that pattern as intentionally omitted and do not flag it.
Step 3.7 — ADR compliance check
If modified files include any swain artifacts (docs/spec/, docs/epic/, docs/vision/, docs/research/, docs/journey/, docs/persona/, docs/runbook/, docs/design/, docs/train/), run an ADR compliance check against each modified artifact:
bash "$REPO_ROOT/.agents/bin/adr-check.sh" <artifact-path>For each artifact with findings (exit code 1 = advisory RELEVANT findings, exit code 2 = actionable DEAD_REF or STALE findings), collect the output and present a single consolidated warning after all checks complete:
ADR compliance: N artifact(s) have findings that may need attention.
<condensed findings summary>
This step is advisory — it warns but never blocks the commit. Continue to Step 4 regardless.
If the adr-check.sh script is not found or fails with exit code 3, skip silently — the check is only available in repos with swain-design installed.
Step 3.8 — Design drift check
Run design-check.sh with no arguments (scan all active DESIGNs) to detect design-to-code drift:
bash "$REPO_ROOT/.agents/bin/design-check.sh" 2>/dev/nullFor each DESIGN with findings (STALE or BROKEN sourcecode-refs), collect the output and present a single consolidated warning after the check completes:
Design drift: N DESIGN(s) have stale or broken sourcecode-refs.
<condensed findings summary>
This step is advisory — it warns but never blocks the commit. Continue to Step 4 regardless.
If the design-check.sh script is not found or fails with exit code 2, skip silently — the check is only available in repos with swain-design installed.
Step 3.85 — README reconciliation (ADR-023)
If README.md exists and the staged changes include artifacts or code that could affect README claims, run a lightweight drift check:
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
if [ -f "$REPO_ROOT/README.md" ]; then
STAGED_ARTIFACTS=$(git diff --cached --name-only | grep -E "^docs/(spec|epic|vision|design)/" || true)
STAGED_CODE=$(git diff --cached --name-only | grep -vE "^docs/" | grep -E "\.(py|js|ts|sh|go|rs)$" || true)
if [ -n "$STAGED_ARTIFACTS" ] || [ -n "$STAGED_CODE" ]; then
echo "README_CHECK: staged changes touch artifacts or code — verify README alignment"
fi
fiThis step is advisory — it reminds the agent to check README accuracy when commits touch code or artifacts that could affect README claims. It does not block the commit. The full README gate lives in swain-release (Step 5.7).
Step 3.9 — Artifact number collision check
Run detect-duplicate-numbers.sh to find duplicate artifact numbers introduced by merges or concurrent worktree work:
bash "$REPO_ROOT/.agents/bin/detect-duplicate-numbers.sh" 2>/dev/nullIf collisions are found (exit code 1), this step is blocking — do not commit until resolved:
Artifact number collision detected:
<collision output>
>
Auto-fix available: run fix-collisions.sh to renumber the newer artifact(s).Or run fix-collisions.sh --dry-run to preview changes first.Offer to run fix-collisions.sh automatically. If the operator accepts, run it, stage the changes, and continue to Step 4. If the operator declines, stop execution — do not commit with duplicate numbers.
If the script is not found, skip silently — the check is only available in repos with swain-design installed.
Step 4 — Generate a commit message
Read the staged diff (git --no-pager diff --cached) and write a commit message that:
- Opens with a conventional-commit prefix matching the dominant change type:
feat— new feature or capabilityfix— bug fixdocs— documentation onlychore— tooling, deps, config with no behavior changerefactor— restructuring without behavior changetest— test additions or fixes- Includes a concise imperative-mood subject line (≤ 72 chars).
- Adds a short body (2–5 lines) summarising why, not just what, when the diff is non-trivial.
- Appends one
Co-Authored-Bytrailer per model participant. Resolve the model name from the system prompt (e.g.,Claude Opus 4.6,Gemini 2.5 Pro,GLM-5.1). If the model identity is unavailable, useAI Assistantas a fallback. When the session involved subagents running under a different model, include one trailer per model — the supervisor first, then each subagent model on its own line. Do not hardcode model names.
Example shape (single model):
feat(terraform): add Cloudflare DNS module for hub provisioning
Operators can now point DNS at Cloudflare without migrating their zone.
Module is activated by dns_provider=cloudflare and requires only
CLOUDFLARE_API_TOKEN — no other provider credentials are validated.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>Example shape (supervisor + subagent):
feat(terraform): add Cloudflare DNS module for hub provisioning
Operators can now point DNS at Cloudflare without migrating their zone.
Module is activated by dns_provider=cloudflare and requires only
CLOUDFLARE_API_TOKEN — no other provider credentials are validated.
Co-Authored-By: GLM-5.1 <noreply@unknown>
Co-Authored-By: Kimi-K2.5 <noreply@unknown>Step 4.5 — Pre-commit hook check
Check if pre-commit hooks are configured:
test -f .pre-commit-config.yaml && command -v pre-commit >/dev/null 2>&1 && echo "hooks-configured" || echo "no-hooks"If no-hooks, emit a one-time warning (do not repeat if the same session already warned):
WARN: No pre-commit hooks configured. Run /swain-init to set up security scanning.Continue to Step 5 regardless — hooks are recommended but not required.
Step 5 — Commit
git --no-pager commit -m "$(cat <<'EOF'
<generated message here>
EOF
)"Use a heredoc so multi-line messages survive the shell without escaping issues.
IMPORTANT: Never use --no-verify. If pre-commit hooks are installed, they MUST run. There is no bypass.
If the commit fails because a pre-commit hook rejected it:
1. Parse the output to identify which hook(s) failed and what was found 2. Present findings clearly:
Pre-commit hook failed:
gitleaks: 2 findings (describe what was flagged)
>
Fix the findings and run /swain-sync again.Suppress false positives: add to .gitleaksignore3. Stop execution — do not push. Do not retry automatically.
Step 6 — Push
If `IN_WORKTREE=yes`: fetch and integrate upstream changes, then push the worktree's commits directly to trunk (the development branch):
# Always fetch and merge trunk before the first push attempt.
# This prevents avoidable rejections when trunk moved since the worktree was created.
git fetch origin
git merge "origin/$TRUNK" --no-edit || {
echo "Merge conflict with upstream trunk. Resolve before pushing."
git merge --abort
exit 1
}
# Link safety — scan changed files for worktree-specific path links (SPEC-216/217/218)
# Runs after merge, before push, so any auto-fixes land in the same push.
DETECT_SCRIPT="$(git rev-parse --show-toplevel)/.agents/bin/detect-worktree-links.sh"
RESOLVE_SCRIPT="$(git rev-parse --show-toplevel)/.agents/bin/resolve-worktree-links.sh"
if [ -x "$DETECT_SCRIPT" ]; then
MERGE_BASE=$(git merge-base HEAD "origin/$TRUNK" 2>/dev/null || true)
CHANGED_FILES=$([ -n "$MERGE_BASE" ] && git diff --name-only "$MERGE_BASE" HEAD 2>/dev/null || true)
if [ -n "$CHANGED_FILES" ]; then
if ! echo "$CHANGED_FILES" | xargs "$DETECT_SCRIPT" --repo-root "$(git rev-parse --show-toplevel)" > /dev/null 2>&1; then
echo "[link-safety] Found suspicious links. Resolving automatically..."
if ! echo "$CHANGED_FILES" | xargs "$RESOLVE_SCRIPT" --repo-root "$(git rev-parse --show-toplevel)"; then
echo "[link-safety] UNRESOLVABLE links remain — merge aborted. Fix before pushing."
exit 1
fi
git add -u
ORIG_MSG=$(git log -1 --pretty=%B)
git commit --amend -m "$ORIG_MSG
[link-safety: auto-resolved worktree path links]"
fi
fi
fi
MAX_RETRIES=3
ATTEMPT=0
while [ $ATTEMPT -lt $MAX_RETRIES ]; do
git push origin "HEAD:$TRUNK" && break
ATTEMPT=$((ATTEMPT + 1))
if [ $ATTEMPT -lt $MAX_RETRIES ]; then
echo "Push rejected (attempt $ATTEMPT/$MAX_RETRIES). Fetching and re-merging..."
git fetch origin
git merge "origin/$TRUNK" --no-edit || {
echo "Merge conflict during retry. Reporting to operator."
git merge --abort
break
}
# Run tests on the merged result before retrying push
# (project-specific test command — detect from project structure)
fi
done
if [ $ATTEMPT -ge $MAX_RETRIES ]; then
echo "Push failed after $MAX_RETRIES attempts. Reporting to operator."
fiIf the push is rejected due to branch protection rules or required reviews (check the rejection message), fall back to opening a PR instead:
BRANCH=$(git rev-parse --abbrev-ref HEAD)
SUBJECT=$(git log -1 --pretty=format:'%s')
BODY=$(git log -1 --pretty=format:'%b')
gh pr create --base "$TRUNK" --head "$BRANCH" --title "$SUBJECT" --body "$BODY"Report the PR URL. Do not retry the push. Proceed to worktree pruning below.
After a successful push or PR creation, mark the lockfile as ready for cleanup (SPEC-249, EPIC-056). swain-sync does NOT remove the worktree itself — cleanup is deferred to bin/swain, which runs after the runtime exits and can verify the commit hash before pruning.
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
BRANCH=$(git rev-parse --abbrev-ref HEAD)
# Mark lockfile ready_for_cleanup if lockfile library is available
LOCKFILE_SCRIPT="$REPO_ROOT/.agents/bin/swain-lockfile.sh"
if [ -f "$LOCKFILE_SCRIPT" ]; then
bash "$LOCKFILE_SCRIPT" mark-ready "$BRANCH" 2>/dev/null || true
echo "Worktree marked ready_for_cleanup — bin/swain will prune after session ends."
else
echo "Note: lockfile library not found — worktree cleanup must be done manually."
fiDo NOT call git worktree remove from swain-sync. The runtime cannot leave its own worktree directory (most runtimes lack CWD persistence), and removing the worktree from under a running runtime causes ENOENT failures.
**If `IN_WORKTREE=no`** (main worktree, normal case):
git push # or: git push -u origin HEAD (if no upstream)
If push fails due to divergent history (shouldn't happen after Step 1 rebase, but as a safety net):
git --no-pager pull --rebase git push
## Step 7 — Verify
Run `git --no-pager status` and `git --no-pager log --oneline -3` to verify the push landed and show the user the final state. Do not prompt for confirmation — just report the result.
## Index rebuild (SPEC-047)
Before committing (after staging, before Step 5), check whether any artifact index files (`list-*.md`) are stale. If the rebuild script exists, run it for each artifact type that had changes staged:
REBUILD_SCRIPT="$REPO_ROOT/.agents/bin/rebuild-index.sh" if [[ -x "$REBUILD_SCRIPT" ]]; then
Detect which types had staged changes
for type in spec epic initiative spike adr persona runbook design vision journey train; do if git diff --cached --name-only | grep -q "^docs/$type/"; then bash "$REBUILD_SCRIPT" "$type" git add "docs/$type/list-${type}.md" 2>/dev/null || true fi done fi
This ensures the index is current when the session's commits land.
## Session bookmark
After a successful push, update the bookmark. Use `$REPO_ROOT` (set in Step 1) as the search root so this works from both main and linked worktrees:
bash "$REPO_ROOT/.agents/bin/swain-bookmark.sh" "Pushed {n} commits to {branch}"
#!/usr/bin/env bash
# swain-trunk.sh — Auto-detect the trunk (development) branch from git state.
# EPIC-029: No configuration required for the common case.
#
# Usage:
# TRUNK=$(bash scripts/swain-trunk.sh)
# git merge "origin/$TRUNK" --no-edit
#
# Detection logic:
# 1. If swain.settings.json has git.trunk, use that (explicit override).
# 2. If NOT in a worktree (GIT_COMMON_DIR == GIT_DIR), current branch IS trunk.
# 3. If IN a worktree (GIT_COMMON_DIR != GIT_DIR), read the main worktree's branch.
# 4. Fallback: "trunk" if detection fails (detached HEAD, unusual state).
set -euo pipefail
_swain_trunk_detect() {
local repo_root
repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || { echo "trunk"; return; }
# 1. Settings override
local settings_file="$repo_root/swain.settings.json"
if [ -f "$settings_file" ]; then
local override
override=$(python3 -c "
import json, sys
try:
d = json.load(open(sys.argv[1]))
v = d.get('git', {}).get('trunk', '')
if v: print(v)
except Exception:
pass
" "$settings_file" 2>/dev/null) || true
if [ -n "${override:-}" ]; then
echo "$override"
return
fi
fi
# 2/3. Auto-detect from git worktree state
local git_common git_dir
git_common="$(git rev-parse --git-common-dir 2>/dev/null)" || { echo "trunk"; return; }
git_dir="$(git rev-parse --git-dir 2>/dev/null)" || { echo "trunk"; return; }
# Normalize paths for comparison (resolve symlinks, trailing slashes)
git_common="$(cd "$git_common" 2>/dev/null && pwd -P)"
git_dir="$(cd "$git_dir" 2>/dev/null && pwd -P)"
if [ "$git_common" = "$git_dir" ]; then
# Not in a worktree — current branch IS trunk
local branch
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null)" || { echo "trunk"; return; }
if [ "$branch" = "HEAD" ]; then
# Detached HEAD — fallback
echo "trunk"
else
echo "$branch"
fi
else
# In a worktree — trunk is the main worktree's branch
local main_head="$git_common/HEAD"
if [ -f "$main_head" ]; then
local ref
ref="$(cat "$main_head")"
if [[ "$ref" == ref:\ refs/heads/* ]]; then
echo "${ref#ref: refs/heads/}"
else
# Detached HEAD in main worktree — fallback
echo "trunk"
fi
else
echo "trunk"
fi
fi
}
_swain_trunk_detect
#!/usr/bin/env bash
# Tests for swain-trunk.sh
# Run: bash scripts/test-swain-trunk.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SWAIN_TRUNK="$SCRIPT_DIR/swain-trunk.sh"
PASS=0
FAIL=0
TMPDIR_BASE=""
cleanup() {
if [ -n "$TMPDIR_BASE" ] && [ -d "$TMPDIR_BASE" ]; then
rm -rf "$TMPDIR_BASE"
fi
}
trap cleanup EXIT
assert_eq() {
local desc="$1" expected="$2" actual="$3"
if [ "$expected" = "$actual" ]; then
echo " PASS: $desc"
PASS=$((PASS + 1))
else
echo " FAIL: $desc (expected '$expected', got '$actual')"
FAIL=$((FAIL + 1))
fi
}
TMPDIR_BASE="$(mktemp -d)"
# ─── T1: Normal repo (not worktree) returns current branch ───
echo "T1: Auto-detection on current branch"
T1_DIR="$TMPDIR_BASE/t1"
mkdir -p "$T1_DIR"
(
cd "$T1_DIR"
git init -b develop . >/dev/null 2>&1
git commit --allow-empty -m "init" >/dev/null 2>&1
result=$(bash "$SWAIN_TRUNK")
assert_eq "returns 'develop' when on develop" "develop" "$result"
)
# ─── T2: Settings override ───
echo "T2: Settings override"
T2_DIR="$TMPDIR_BASE/t2"
mkdir -p "$T2_DIR"
(
cd "$T2_DIR"
git init -b main . >/dev/null 2>&1
git commit --allow-empty -m "init" >/dev/null 2>&1
echo '{"git": {"trunk": "custom-trunk"}}' > swain.settings.json
result=$(bash "$SWAIN_TRUNK")
assert_eq "returns 'custom-trunk' from settings override" "custom-trunk" "$result"
)
# ─── T3: Settings override empty string is ignored ───
echo "T3: Empty settings override falls through to auto-detect"
T3_DIR="$TMPDIR_BASE/t3"
mkdir -p "$T3_DIR"
(
cd "$T3_DIR"
git init -b main . >/dev/null 2>&1
git commit --allow-empty -m "init" >/dev/null 2>&1
echo '{"git": {"trunk": ""}}' > swain.settings.json
result=$(bash "$SWAIN_TRUNK")
assert_eq "returns 'main' when override is empty" "main" "$result"
)
# ─── T4: No settings file falls through to auto-detect ───
echo "T4: No settings file"
T4_DIR="$TMPDIR_BASE/t4"
mkdir -p "$T4_DIR"
(
cd "$T4_DIR"
git init -b trunk . >/dev/null 2>&1
git commit --allow-empty -m "init" >/dev/null 2>&1
result=$(bash "$SWAIN_TRUNK")
assert_eq "returns 'trunk' when no settings file" "trunk" "$result"
)
# ─── T5: From inside a worktree, returns main worktree's branch ───
echo "T5: Detection from inside a worktree"
T5_DIR="$TMPDIR_BASE/t5"
mkdir -p "$T5_DIR"
(
cd "$T5_DIR"
git init -b develop . >/dev/null 2>&1
git commit --allow-empty -m "init" >/dev/null 2>&1
git worktree add "$T5_DIR/wt" -b feature-x >/dev/null 2>&1
cd "$T5_DIR/wt"
result=$(bash "$SWAIN_TRUNK")
assert_eq "returns 'develop' from worktree (not 'feature-x')" "develop" "$result"
cd "$T5_DIR"
git worktree remove "$T5_DIR/wt" 2>/dev/null || true
)
# ─── T6: Settings override from worktree ───
echo "T6: Settings override from worktree"
T6_DIR="$TMPDIR_BASE/t6"
mkdir -p "$T6_DIR"
(
cd "$T6_DIR"
git init -b main . >/dev/null 2>&1
git commit --allow-empty -m "init" >/dev/null 2>&1
echo '{"git": {"trunk": "release"}}' > swain.settings.json
git add . && git commit -m "settings" >/dev/null 2>&1
git worktree add "$T6_DIR/wt" -b feature-y >/dev/null 2>&1
cd "$T6_DIR/wt"
result=$(bash "$SWAIN_TRUNK")
assert_eq "returns 'release' override from worktree" "release" "$result"
cd "$T6_DIR"
git worktree remove "$T6_DIR/wt" 2>/dev/null || true
)
# ─── Summary ───
echo ""
echo "Results: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] && exit 0 || exit 1