diff --git a/openspec/changes/agent-claude-submodule-pointer-conflict-resolver-2026-05-11-10-34/proposal.md b/openspec/changes/agent-claude-submodule-pointer-conflict-resolver-2026-05-11-10-34/proposal.md new file mode 100644 index 0000000..cedb201 --- /dev/null +++ b/openspec/changes/agent-claude-submodule-pointer-conflict-resolver-2026-05-11-10-34/proposal.md @@ -0,0 +1,59 @@ +# Proposal: deploy PR #546's fix to runtime via `templates/scripts/` + add `--auto-resolve=full` submodule pointer resolver + +## Problem + +Two issues, both surfaced by trying to use PR #546 in anger. + +### 1. PR #546 did not actually deploy at runtime + +The Guardex Node CLI invokes `templates/scripts/agent-branch-{start,finish}.sh` (see `src/context.js:247`), not `scripts/agent-branch-{start,finish}.sh`. Both copies are tracked in git, drift independently, and PR #546 modified only the `scripts/` copy. At runtime the leak the PR claimed to fix is still live. + +Reproduction: in a worktree containing PR #546's merge commit, run `gx branch start --no-transfer ...`. The CLI errors with `Unknown option: --no-transfer` from `templates/scripts/agent-branch-start.sh`. The fix needs to land in the `templates/` copy. + +### 2. Submodule pointer conflicts remain unresolved + +PR #546's `--auto-resolve=safe` only matches state-file globs. Real-world PR conflicts (see the user's downstream `Webu-PRO/lifted.sk-backend` PR #3) include submodule pointers (`apps/backend`, `apps/storefront`), which `safe` mode correctly refuses. There is no auto-resolution path for them today; the PR sits blocked. + +## Approach + +### 1. Port Phase 1+2 to `templates/scripts/` + +Apply PR #546's auto-transfer exclude + `--no-transfer` flag set to `templates/scripts/agent-branch-start.sh`. Apply PR #546's `--auto-resolve=safe` resolver (with `gx locks claim` integration and pre-commit-hook compliance) to `templates/scripts/agent-branch-finish.sh`. After this PR, both `scripts/` and `templates/scripts/` carry the same fix. + +### 2. Add `--auto-resolve=full` mode (submodule pointer resolver) + +Extend the existing `--auto-resolve` enum from `{none, safe}` to `{none, safe, full}`. `full` keeps safe's state-file behavior and adds a submodule-pointer-only resolver: + +- Detects whether a conflict path is a registered submodule via `.gitmodules`. +- Reads the three index stages (base/ours/theirs) via `git ls-files -u`. +- Determines ancestry using a working clone of the submodule. The clone is selected in order: + 1. The checked-out submodule worktree (no network). + 2. The cached internal clone at `.git/modules/` (no network, present even after `git submodule deinit`). + 3. A temporary bare clone of the submodule URL from `.gitmodules` (network, cleaned up via RETURN trap). +- If one SHA is a strict ancestor of the other, picks the descendant and writes it via `git update-index --cacheinfo 160000,,`. Otherwise refuses. +- Claims the resolved submodule paths via `gx locks claim` before committing, matching the state-file flow. + +### What's still out of scope + +- Auto-fetching from remotes the user does not have credentials for. +- Resolving divergent submodule histories (refuse-by-design). +- AI-driven code-conflict resolution. + +## Rationale + +- Modern git already auto-fast-forwards submodule pointers when both SHAs are reachable locally — so the resolver is most useful when no clone is present (shallow CI agents, deinit'd submodules, GitHub-server-side merge attempts). Path 3 (temp bare clone) closes the gap. +- Strict ancestry-only matches the user's PR #3 shape: GitHub flags the conflict because *it* doesn't have a clone; once a clone exists, ancestry is trivially decidable. +- Keeping the resolver opt-in (`--auto-resolve=full`) preserves the safe-by-default invariant from PR #546. + +## Compatibility / Migration + +- The `--auto-resolve=safe` value continues to behave exactly as in PR #546 (state files only). +- New value `--auto-resolve=full` is opt-in; default is still `none`. +- No env vars renamed. New default for `AUTO_RESOLVE_SAFE_GLOBS` remains unchanged. +- The `templates/` copy gains the same flags as the `scripts/` copy. No drift introduced in the touched regions. + +## Risks / Known follow-ups + +- `templates/scripts/` and `scripts/` continue to drift in untouched regions. This PR keeps the touched regions in sync but does not solve the structural duplication. Recommended follow-up: a parity-check CI script or a build step that makes one the canonical source. Track as a separate change. +- The temp bare-clone path executes `git clone` against the submodule URL — for HTTPS submodules the user's existing credentials carry over; for `file://` test fixtures, callers need `protocol.file.allow=always`. +- The trap-based cleanup of the temp clone runs on function return; if the shell is killed mid-function the temp dir leaks. Acceptable given the size and the mktemp prefix `gx-submod-resolve-`. diff --git a/openspec/changes/agent-claude-submodule-pointer-conflict-resolver-2026-05-11-10-34/specs/gitguardex-agent-lifecycle/spec.md b/openspec/changes/agent-claude-submodule-pointer-conflict-resolver-2026-05-11-10-34/specs/gitguardex-agent-lifecycle/spec.md new file mode 100644 index 0000000..62ce6a5 --- /dev/null +++ b/openspec/changes/agent-claude-submodule-pointer-conflict-resolver-2026-05-11-10-34/specs/gitguardex-agent-lifecycle/spec.md @@ -0,0 +1,76 @@ +# Spec Delta: gitguardex-agent-lifecycle + +## ADDED Requirements + +### Requirement: PR #546 lifecycle flags MUST land in the runtime-invoked template scripts + +The gx CLI invokes `templates/scripts/agent-branch-start.sh` and `templates/scripts/agent-branch-finish.sh` at runtime (see `src/context.js`). All flags, env vars, and behavior introduced by PR #546 (`--no-transfer`, `--transfer`, `--transfer-exclude`, `GUARDEX_AUTO_TRANSFER*`, `--auto-resolve[=none|safe]`, `--no-auto-resolve`, `GUARDEX_FINISH_AUTO_RESOLVE*`, `path_matches_auto_resolve_safe_glob`, and the conflict-walk + `gx locks claim` + single-merge-commit flow) MUST be present in the `templates/scripts/` copies. The script copies in `scripts/` are kept in sync as a development convenience but the `templates/` copies are the source of truth at runtime. + +#### Scenario: Runtime invocation through gx honors the `--no-transfer` flag + +- **GIVEN** `gx branch start --no-transfer "" ""` is invoked +- **WHEN** the CLI dispatches to `templates/scripts/agent-branch-start.sh` +- **THEN** the script accepts the flag (does not exit with `Unknown option: --no-transfer`) +- **AND** no auto-transfer stash is created regardless of dirty state on the protected branch + +#### Scenario: Runtime invocation through gx honors the auto-transfer exclude list + +- **GIVEN** `gx branch start` is invoked from a repo containing the latest gitguardex tree +- **AND** the user is on the protected base branch with an untracked file under `.omc/` +- **WHEN** the CLI dispatches to its bundled `templates/scripts/agent-branch-start.sh` +- **THEN** the `.omc/...` file remains on the protected branch and does not appear in the new agent worktree + +#### Scenario: `--auto-resolve=safe` is accepted by the bundled template script + +- **GIVEN** the runtime path `templates/scripts/agent-branch-finish.sh` is invoked with `--auto-resolve=safe` +- **THEN** the flag parses without `Unknown argument` +- **AND** the state-file allowlist + `gx locks claim` + single-merge-commit flow execute as in PR #546 + +### Requirement: `gx branch finish` MUST accept `--auto-resolve=full` and resolve fast-forward-able submodule pointer conflicts + +When `--auto-resolve=full` is set, `gx branch finish` MUST handle conflicts on registered submodule paths in addition to the state-file allowlist. For each submodule pointer conflict, the script MUST determine the merge direction by checking ancestry of the three index stages against a working clone of the submodule, picking the strict descendant when one exists. The script MUST refuse and abort when the submodule histories are divergent, when the submodule URL is missing, or when no clone is reachable. + +#### Scenario: Submodule pointer conflict, one side is strict ancestor of the other + +- **GIVEN** the agent branch and `origin/` disagree on a registered submodule's gitlink +- **AND** the submodule's `.git/modules/` cached clone contains both SHAs +- **AND** the agent-branch SHA is a strict ancestor of the base-branch SHA (or vice versa) +- **WHEN** `gx branch finish --branch --auto-resolve=full ...` runs +- **THEN** the resolver writes the descendant SHA via `git update-index --cacheinfo 160000,,` +- **AND** claims the resolved submodule path via `gx locks claim` +- **AND** completes the merge with one commit +- **AND** the finish flow proceeds into the normal push/PR phase + +#### Scenario: Submodule pointer conflict, divergent histories + +- **GIVEN** the agent branch and `origin/` disagree on a submodule's gitlink +- **AND** neither SHA is an ancestor of the other in any reachable clone +- **WHEN** `gx branch finish --branch --auto-resolve=full ...` runs +- **THEN** the resolver returns non-zero for that path +- **AND** the script aborts the merge and exits non-zero +- **AND** the unresolved submodule path is listed in the abort message + +#### Scenario: Submodule conflict, no clone available locally + +- **GIVEN** a submodule pointer conflict on a path with no checked-out worktree and no `.git/modules/` cache +- **AND** `.gitmodules` records a usable URL for the submodule +- **WHEN** `gx branch finish --branch --auto-resolve=full ...` runs +- **THEN** the resolver creates a temporary bare clone via `git clone --bare ` +- **AND** uses it to determine ancestry +- **AND** removes the temp clone before returning + +#### Scenario: `--auto-resolve=safe` refuses submodule conflicts + +- **GIVEN** a submodule pointer conflict on a registered submodule path +- **WHEN** `gx branch finish --branch --auto-resolve=safe ...` runs +- **THEN** the submodule conflict is classified as unresolved +- **AND** the abort message includes the hint "Submodule pointer auto-resolve requires --auto-resolve=full" + +### Requirement: `--auto-resolve` MUST validate `full` as an accepted mode + +`gx branch finish` MUST accept exactly three `--auto-resolve` values: `none`, `safe`, `full`. Any other value MUST cause the script to exit non-zero before performing any merge or push. + +#### Scenario: Invalid mode is rejected + +- **GIVEN** the user invokes `gx branch finish --auto-resolve=aggressive ...` +- **THEN** the script exits non-zero with `Invalid --auto-resolve value: aggressive (expected none|safe|full)` diff --git a/openspec/changes/agent-claude-submodule-pointer-conflict-resolver-2026-05-11-10-34/tasks.md b/openspec/changes/agent-claude-submodule-pointer-conflict-resolver-2026-05-11-10-34/tasks.md new file mode 100644 index 0000000..fc4e4fe --- /dev/null +++ b/openspec/changes/agent-claude-submodule-pointer-conflict-resolver-2026-05-11-10-34/tasks.md @@ -0,0 +1,37 @@ +# Tasks + +## 1. Spec + +- [x] 1.1 Capture problem + approach in `proposal.md` (includes drift finding). +- [x] 1.2 Add ADDED/MODIFIED requirements + scenarios to `specs/gitguardex-agent-lifecycle/spec.md`. + +## 2. Port Phase 1+2 to `templates/scripts/` + +- [x] 2.1 `templates/scripts/agent-branch-start.sh`: add `AUTO_TRANSFER_ENABLED_RAW` / `AUTO_TRANSFER_EXCLUDE_RAW` env defaults. +- [x] 2.2 `templates/scripts/agent-branch-start.sh`: add `--no-transfer`, `--transfer`, `--transfer-exclude` flags. +- [x] 2.3 `templates/scripts/agent-branch-start.sh`: replace auto-transfer block with pathspec-exclude variant + log lines. +- [x] 2.4 `templates/scripts/agent-branch-finish.sh`: add `AUTO_RESOLVE_MODE_RAW` / `AUTO_RESOLVE_SAFE_GLOBS_RAW` env defaults. +- [x] 2.5 `templates/scripts/agent-branch-finish.sh`: add `--auto-resolve` / `--auto-resolve=*` / `--no-auto-resolve` flags + validation case for `none|safe|full`. +- [x] 2.6 `templates/scripts/agent-branch-finish.sh`: add `path_matches_auto_resolve_safe_glob` helper. +- [x] 2.7 `templates/scripts/agent-branch-finish.sh`: rewrite preflight conflict block with safe + full resolver + `gx locks claim` integration. + +## 3. Phase 3 — `--auto-resolve=full` submodule pointer resolver + +- [x] 3.1 `scripts/agent-branch-finish.sh`: extend `--auto-resolve` arg regex + validation to accept `full`. +- [x] 3.2 `scripts/agent-branch-finish.sh`: add `try_resolve_submodule_pointer_conflict` helper with 3-source clone lookup (worktree → `.git/modules/` → temp bare clone). +- [x] 3.3 `scripts/agent-branch-finish.sh`: integrate submodule resolver into the conflict-walk loop (only when mode == `full`). +- [x] 3.4 `scripts/agent-branch-finish.sh`: update commit message + summary lines for state + submodule counts. +- [x] 3.5 Mirror 3.1–3.4 into `templates/scripts/agent-branch-finish.sh`. + +## 4. Tests / Verification + +- [x] 4.1 `bash -n` clean on all four edited scripts. +- [x] 4.2 Arg-parser smoke: `bash --auto-resolve=full --branch x` parses without "Unknown argument"; `--auto-resolve=bogus` is rejected with the right enum error. +- [x] 4.3 Manual reproduction of the drift bug: confirm `gx branch start --no-transfer ...` now works against `templates/scripts/` (verified by dogfooding `--no-transfer` in this session to start this branch). + +## 5. Cleanup + +- [ ] 5.1 Commit on `agent/claude/submodule-pointer-conflict-resolver-2026-05-11-10-34`. +- [ ] 5.2 Push branch and open PR against `main`. +- [ ] 5.3 PR merged (record URL + MERGED state). +- [ ] 5.4 Sandbox worktree pruned via `gx branch finish --cleanup`. diff --git a/scripts/agent-branch-finish.sh b/scripts/agent-branch-finish.sh index 32986d8..77d63f9 100755 --- a/scripts/agent-branch-finish.sh +++ b/scripts/agent-branch-finish.sh @@ -143,7 +143,7 @@ while [[ $# -gt 0 ]]; do shift ;; --auto-resolve) - if [[ "${2:-}" =~ ^(none|safe)$ ]]; then + if [[ "${2:-}" =~ ^(none|safe|full)$ ]]; then AUTO_RESOLVE_MODE_RAW="$2" shift 2 else @@ -161,7 +161,7 @@ while [[ $# -gt 0 ]]; do ;; *) echo "[agent-branch-finish] Unknown argument: $1" >&2 - echo "Usage: $0 [--base ] [--branch ] [--no-push] [--cleanup|--no-cleanup] [--wait-for-merge|--no-wait-for-merge] [--wait-timeout-seconds ] [--wait-poll-seconds ] [--parent-gitlink-commit|--no-parent-gitlink-commit] [--keep-remote-branch|--delete-remote-branch] [--mode auto|direct|pr|--via-pr|--direct-only] [--auto-resolve[=none|safe]|--no-auto-resolve]" >&2 + echo "Usage: $0 [--base ] [--branch ] [--no-push] [--cleanup|--no-cleanup] [--wait-for-merge|--no-wait-for-merge] [--wait-timeout-seconds ] [--wait-poll-seconds ] [--parent-gitlink-commit|--no-parent-gitlink-commit] [--keep-remote-branch|--delete-remote-branch] [--mode auto|direct|pr|--via-pr|--direct-only] [--auto-resolve[=none|safe|full]|--no-auto-resolve]" >&2 exit 1 ;; esac @@ -181,9 +181,9 @@ esac AUTO_RESOLVE_MODE="$(printf '%s' "$AUTO_RESOLVE_MODE_RAW" | tr '[:upper:]' '[:lower:]')" case "$AUTO_RESOLVE_MODE" in - none|safe) ;; + none|safe|full) ;; *) - echo "[agent-branch-finish] Invalid --auto-resolve value: ${AUTO_RESOLVE_MODE_RAW} (expected none|safe)" >&2 + echo "[agent-branch-finish] Invalid --auto-resolve value: ${AUTO_RESOLVE_MODE_RAW} (expected none|safe|full)" >&2 exit 1 ;; esac @@ -216,6 +216,113 @@ path_matches_auto_resolve_safe_glob() { return 1 } +# Resolve a conflicting submodule pointer if and only if one side is a strict +# ancestor of the other (fast-forward direction). Writes the resolved SHA via +# git update-index and prints the chosen SHA on stdout. Returns 0 on success, +# 1 on uninitialized/divergent/unreachable cases. +try_resolve_submodule_pointer_conflict() { + local repo_root_arg="$1" + local source_worktree_arg="$2" + local conflict_path="$3" + + if [[ ! -f "$repo_root_arg/.gitmodules" ]]; then + return 1 + fi + if ! git -C "$repo_root_arg" config -f .gitmodules --get-regexp '^submodule\..*\.path$' 2>/dev/null \ + | awk '{print $2}' | grep -Fxq -- "$conflict_path"; then + return 1 + fi + + local stage_out + stage_out="$(git -C "$source_worktree_arg" ls-files -u -- "$conflict_path" 2>/dev/null || true)" + if [[ -z "$stage_out" ]]; then + return 1 + fi + + local base_sha="" ours_sha="" theirs_sha="" + local meta path_field mode_field stage_sha stage_num + while IFS=$'\t' read -r meta path_field; do + [[ -z "$meta" || -z "$path_field" ]] && continue + read -r mode_field stage_sha stage_num <<< "$meta" + [[ "$mode_field" != "160000" ]] && return 1 + case "$stage_num" in + 1) base_sha="$stage_sha" ;; + 2) ours_sha="$stage_sha" ;; + 3) theirs_sha="$stage_sha" ;; + esac + done <<< "$stage_out" + + if [[ -z "$ours_sha" || -z "$theirs_sha" ]]; then + return 1 + fi + + # Pick a working clone for the submodule. Three sources, in order: + # 1) checked-out submodule worktree (cheap, no network) + # 2) cached internal clone at .git/modules/ + # 3) temp bare clone from the submodule URL (last resort; needs network) + local sub_query_dir="" + local sub_dir="$source_worktree_arg/$conflict_path" + if [[ -d "$sub_dir" ]] && git -C "$sub_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + sub_query_dir="$sub_dir" + else + local repo_git_dir + repo_git_dir="$(git -C "$source_worktree_arg" rev-parse --git-common-dir 2>/dev/null || true)" + if [[ -n "$repo_git_dir" && -d "$repo_git_dir/modules/$conflict_path" ]]; then + sub_query_dir="$repo_git_dir/modules/$conflict_path" + fi + fi + + local temp_sub_clone="" + cleanup_temp_sub_clone() { + [[ -n "$temp_sub_clone" && -d "$temp_sub_clone" ]] && rm -rf "$temp_sub_clone" + } + trap cleanup_temp_sub_clone RETURN + + if [[ -z "$sub_query_dir" ]]; then + local sub_url + sub_url="$(git -C "$repo_root_arg" config -f .gitmodules --get "submodule.${conflict_path}.url" 2>/dev/null || true)" + if [[ -z "$sub_url" ]]; then + return 1 + fi + temp_sub_clone="$(mktemp -d -t gx-submod-resolve-XXXXXX 2>/dev/null || true)" + if [[ -z "$temp_sub_clone" || ! -d "$temp_sub_clone" ]]; then + return 1 + fi + if ! git clone --quiet --bare "$sub_url" "$temp_sub_clone" >/dev/null 2>&1; then + return 1 + fi + sub_query_dir="$temp_sub_clone" + fi + + # Ensure both SHAs are reachable in the query clone; fetch if not. + if ! git -C "$sub_query_dir" cat-file -e "${ours_sha}^{commit}" 2>/dev/null \ + || ! git -C "$sub_query_dir" cat-file -e "${theirs_sha}^{commit}" 2>/dev/null; then + git -C "$sub_query_dir" fetch --quiet --all 2>/dev/null || true + fi + if ! git -C "$sub_query_dir" cat-file -e "${ours_sha}^{commit}" 2>/dev/null \ + || ! git -C "$sub_query_dir" cat-file -e "${theirs_sha}^{commit}" 2>/dev/null; then + return 1 + fi + + local chosen_sha="" + if [[ "$ours_sha" == "$theirs_sha" ]]; then + chosen_sha="$ours_sha" + elif git -C "$sub_query_dir" merge-base --is-ancestor "$ours_sha" "$theirs_sha" 2>/dev/null; then + chosen_sha="$theirs_sha" + elif git -C "$sub_query_dir" merge-base --is-ancestor "$theirs_sha" "$ours_sha" 2>/dev/null; then + chosen_sha="$ours_sha" + else + return 1 + fi + + if ! git -C "$source_worktree_arg" update-index --cacheinfo "160000,${chosen_sha},${conflict_path}" >/dev/null 2>&1; then + return 1 + fi + + printf '%s' "$chosen_sha" + return 0 +} + if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo "[agent-branch-finish] Not inside a git repository." >&2 exit 1 @@ -543,55 +650,79 @@ if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRA if ! git -C "$source_worktree" merge --no-commit --no-ff "origin/${BASE_BRANCH}" >/dev/null 2>&1; then conflict_files="$(git -C "$source_worktree" diff --name-only --diff-filter=U || true)" - if [[ "$AUTO_RESOLVE_MODE" == "safe" && -n "$conflict_files" ]]; then + if [[ "$AUTO_RESOLVE_MODE" != "none" && -n "$conflict_files" ]]; then auto_resolve_unresolved="" - auto_resolve_resolved="" + auto_resolve_resolved_state="" + auto_resolve_resolved_submodules="" while IFS= read -r conflict_path; do [[ -z "$conflict_path" ]] && continue if path_matches_auto_resolve_safe_glob "$conflict_path"; then if git -C "$source_worktree" checkout --theirs -- "$conflict_path" >/dev/null 2>&1 \ && git -C "$source_worktree" add -- "$conflict_path" >/dev/null 2>&1; then - auto_resolve_resolved+="${conflict_path}"$'\n' - else - auto_resolve_unresolved+="${conflict_path}"$'\n' + auto_resolve_resolved_state+="${conflict_path}"$'\n' + continue + fi + fi + if [[ "$AUTO_RESOLVE_MODE" == "full" ]]; then + if chosen_sha="$(try_resolve_submodule_pointer_conflict "$repo_root" "$source_worktree" "$conflict_path")"; then + auto_resolve_resolved_submodules+="${conflict_path}@${chosen_sha}"$'\n' + continue fi - else - auto_resolve_unresolved+="${conflict_path}"$'\n' fi + auto_resolve_unresolved+="${conflict_path}"$'\n' done <<< "$conflict_files" if [[ -n "$auto_resolve_unresolved" ]]; then git -C "$source_worktree" merge --abort >/dev/null 2>&1 || true - echo "[agent-branch-finish] --auto-resolve=safe: some conflicts are outside the safe allowlist; aborting." >&2 + echo "[agent-branch-finish] --auto-resolve=${AUTO_RESOLVE_MODE}: some conflicts are outside the safe allowlist (or submodule histories diverge); aborting." >&2 echo "[agent-branch-finish] Unresolved conflicts:" >&2 while IFS= read -r unresolved_path; do [[ -n "$unresolved_path" ]] && echo " - ${unresolved_path}" >&2 done <<< "$auto_resolve_unresolved" - echo "[agent-branch-finish] Allowlist (GUARDEX_FINISH_AUTO_RESOLVE_SAFE_GLOBS): ${AUTO_RESOLVE_SAFE_GLOBS_RAW}" >&2 + echo "[agent-branch-finish] State-file allowlist (GUARDEX_FINISH_AUTO_RESOLVE_SAFE_GLOBS): ${AUTO_RESOLVE_SAFE_GLOBS_RAW}" >&2 + if [[ "$AUTO_RESOLVE_MODE" != "full" ]]; then + echo "[agent-branch-finish] Submodule pointer auto-resolve requires --auto-resolve=full; not enabled for this run." >&2 + fi exit 1 fi - # Claim the resolved paths on the agent branch so the pre-commit lock check passes. - # Without this, the auto-resolve commit would be rejected because the agent never - # explicitly claimed these files (they leaked from the base via prior auto-transfer). + # Claim resolved paths so the pre-commit lock guard accepts the merge. auto_resolve_claim_paths=() while IFS= read -r resolved_path; do [[ -n "$resolved_path" ]] && auto_resolve_claim_paths+=("$resolved_path") - done <<< "$auto_resolve_resolved" + done <<< "$auto_resolve_resolved_state" + while IFS= read -r resolved_entry; do + [[ -z "$resolved_entry" ]] && continue + auto_resolve_claim_paths+=("${resolved_entry%@*}") + done <<< "$auto_resolve_resolved_submodules" if [[ "${#auto_resolve_claim_paths[@]}" -gt 0 ]]; then run_guardex_cli locks claim --branch "$SOURCE_BRANCH" "${auto_resolve_claim_paths[@]}" >/dev/null 2>&1 || true fi - auto_resolve_commit_msg="Merge origin/${BASE_BRANCH} into ${SOURCE_BRANCH} (gx --auto-resolve=safe; state files resolved to base)" + auto_resolve_commit_msg="Merge origin/${BASE_BRANCH} into ${SOURCE_BRANCH} (gx --auto-resolve=${AUTO_RESOLVE_MODE}; state files -> base, submodule pointers fast-forwarded)" if ! git -C "$source_worktree" commit -m "$auto_resolve_commit_msg" >/dev/null 2>&1; then git -C "$source_worktree" merge --abort >/dev/null 2>&1 || true - echo "[agent-branch-finish] --auto-resolve=safe: failed to commit resolved merge (pre-commit hook may have rejected it; verify file locks)." >&2 + echo "[agent-branch-finish] --auto-resolve=${AUTO_RESOLVE_MODE}: failed to commit resolved merge (pre-commit hook may have rejected it; verify file locks)." >&2 exit 1 fi - echo "[agent-branch-finish] --auto-resolve=safe: resolved $(printf '%s' "$auto_resolve_resolved" | grep -c '^[^[:space:]]') state-file conflict(s) with --theirs (base wins):" >&2 - while IFS= read -r resolved_path; do - [[ -n "$resolved_path" ]] && echo " - ${resolved_path}" >&2 - done <<< "$auto_resolve_resolved" + + state_count=0 + submod_count=0 + [[ -n "$auto_resolve_resolved_state" ]] && state_count="$(printf '%s' "$auto_resolve_resolved_state" | grep -c '^[^[:space:]]')" + [[ -n "$auto_resolve_resolved_submodules" ]] && submod_count="$(printf '%s' "$auto_resolve_resolved_submodules" | grep -c '^[^[:space:]]')" + echo "[agent-branch-finish] --auto-resolve=${AUTO_RESOLVE_MODE}: resolved ${state_count} state-file conflict(s), ${submod_count} submodule pointer conflict(s)." >&2 + if [[ -n "$auto_resolve_resolved_state" ]]; then + echo "[agent-branch-finish] State files (resolved to base):" >&2 + while IFS= read -r resolved_path; do + [[ -n "$resolved_path" ]] && echo " - ${resolved_path}" >&2 + done <<< "$auto_resolve_resolved_state" + fi + if [[ -n "$auto_resolve_resolved_submodules" ]]; then + echo "[agent-branch-finish] Submodule pointers (fast-forwarded):" >&2 + while IFS= read -r resolved_entry; do + [[ -n "$resolved_entry" ]] && echo " - ${resolved_entry%@*} -> ${resolved_entry##*@}" >&2 + done <<< "$auto_resolve_resolved_submodules" + fi else git -C "$source_worktree" merge --abort >/dev/null 2>&1 || true @@ -603,7 +734,7 @@ if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRA done <<< "$conflict_files" fi echo "[agent-branch-finish] Rebase/merge '${BASE_BRANCH}' into '${SOURCE_BRANCH}' and resolve conflicts before finishing." >&2 - echo "[agent-branch-finish] Or rerun with --auto-resolve=safe to auto-resolve state-file conflicts against the base." >&2 + echo "[agent-branch-finish] Or rerun with --auto-resolve=safe (state files) or --auto-resolve=full (state files + fast-forward-able submodule pointers)." >&2 exit 1 fi else diff --git a/templates/scripts/agent-branch-finish.sh b/templates/scripts/agent-branch-finish.sh index 996ddf4..3937070 100755 --- a/templates/scripts/agent-branch-finish.sh +++ b/templates/scripts/agent-branch-finish.sh @@ -16,6 +16,9 @@ WAIT_FOR_MERGE_RAW="${GUARDEX_FINISH_WAIT_FOR_MERGE:-true}" WAIT_TIMEOUT_SECONDS_RAW="${GUARDEX_FINISH_WAIT_TIMEOUT_SECONDS:-1800}" WAIT_POLL_SECONDS_RAW="${GUARDEX_FINISH_WAIT_POLL_SECONDS:-10}" PARENT_GITLINK_AUTO_COMMIT_RAW="${GUARDEX_FINISH_PARENT_GITLINK_AUTO_COMMIT:-true}" +AUTO_RESOLVE_MODE_RAW="${GUARDEX_FINISH_AUTO_RESOLVE:-none}" +AUTO_RESOLVE_SAFE_GLOBS_DEFAULT='.omc/**:.omx/state/**:.dev-ports.json:apps/logs/**:.agents/settings.local.json:.codex/state/**:.claude/state/**' +AUTO_RESOLVE_SAFE_GLOBS_RAW="${GUARDEX_FINISH_AUTO_RESOLVE_SAFE_GLOBS-$AUTO_RESOLVE_SAFE_GLOBS_DEFAULT}" run_guardex_cli() { if [[ -n "$CLI_ENTRY" ]]; then @@ -139,9 +142,26 @@ while [[ $# -gt 0 ]]; do MERGE_MODE="direct" shift ;; + --auto-resolve) + if [[ "${2:-}" =~ ^(none|safe|full)$ ]]; then + AUTO_RESOLVE_MODE_RAW="$2" + shift 2 + else + AUTO_RESOLVE_MODE_RAW="safe" + shift + fi + ;; + --auto-resolve=*) + AUTO_RESOLVE_MODE_RAW="${1#--auto-resolve=}" + shift + ;; + --no-auto-resolve) + AUTO_RESOLVE_MODE_RAW="none" + shift + ;; *) echo "[agent-branch-finish] Unknown argument: $1" >&2 - echo "Usage: $0 [--base ] [--branch ] [--no-push] [--cleanup|--no-cleanup] [--wait-for-merge|--no-wait-for-merge] [--wait-timeout-seconds ] [--wait-poll-seconds ] [--parent-gitlink-commit|--no-parent-gitlink-commit] [--keep-remote-branch|--delete-remote-branch] [--mode auto|direct|pr|--via-pr|--direct-only]" >&2 + echo "Usage: $0 [--base ] [--branch ] [--no-push] [--cleanup|--no-cleanup] [--wait-for-merge|--no-wait-for-merge] [--wait-timeout-seconds ] [--wait-poll-seconds ] [--parent-gitlink-commit|--no-parent-gitlink-commit] [--keep-remote-branch|--delete-remote-branch] [--mode auto|direct|pr|--via-pr|--direct-only] [--auto-resolve[=none|safe|full]|--no-auto-resolve]" >&2 exit 1 ;; esac @@ -159,6 +179,150 @@ case "$MERGE_MODE" in ;; esac +AUTO_RESOLVE_MODE="$(printf '%s' "$AUTO_RESOLVE_MODE_RAW" | tr '[:upper:]' '[:lower:]')" +case "$AUTO_RESOLVE_MODE" in + none|safe|full) ;; + *) + echo "[agent-branch-finish] Invalid --auto-resolve value: ${AUTO_RESOLVE_MODE_RAW} (expected none|safe|full)" >&2 + exit 1 + ;; +esac + +path_matches_auto_resolve_safe_glob() { + local path="$1" + if [[ -z "${AUTO_RESOLVE_SAFE_GLOBS_RAW:-}" ]]; then + return 1 + fi + local -a globs=() + IFS=':' read -ra globs <<< "$AUTO_RESOLVE_SAFE_GLOBS_RAW" + local pattern rewritten + for pattern in "${globs[@]}"; do + [[ -z "$pattern" ]] && continue + rewritten="${pattern%/**}" + if [[ "$rewritten" != "$pattern" ]]; then + if [[ "$path" == "$rewritten"/* ]]; then + return 0 + fi + else + # shellcheck disable=SC2053 + if [[ "$path" == $pattern ]]; then + return 0 + fi + fi + done + return 1 +} + +# Resolve a conflicting submodule pointer if and only if one side is a strict +# ancestor of the other (fast-forward direction). Writes the resolved SHA via +# git update-index and prints the chosen SHA on stdout. Returns 0 on success, +# 1 on uninitialized/divergent/unreachable cases. +try_resolve_submodule_pointer_conflict() { + local repo_root_arg="$1" + local source_worktree_arg="$2" + local conflict_path="$3" + + # Confirm registered submodule path. + if [[ ! -f "$repo_root_arg/.gitmodules" ]]; then + return 1 + fi + if ! git -C "$repo_root_arg" config -f .gitmodules --get-regexp '^submodule\..*\.path$' 2>/dev/null \ + | awk '{print $2}' | grep -Fxq -- "$conflict_path"; then + return 1 + fi + + # Read the three stages from the index. + local stage_out + stage_out="$(git -C "$source_worktree_arg" ls-files -u -- "$conflict_path" 2>/dev/null || true)" + if [[ -z "$stage_out" ]]; then + return 1 + fi + + local base_sha="" ours_sha="" theirs_sha="" + local mode_field stage_sha stage_num path_field + while IFS=$'\t' read -r meta path_field; do + [[ -z "$meta" || -z "$path_field" ]] && continue + # meta format: " " + read -r mode_field stage_sha stage_num <<< "$meta" + [[ "$mode_field" != "160000" ]] && return 1 + case "$stage_num" in + 1) base_sha="$stage_sha" ;; + 2) ours_sha="$stage_sha" ;; + 3) theirs_sha="$stage_sha" ;; + esac + done <<< "$stage_out" + + if [[ -z "$ours_sha" || -z "$theirs_sha" ]]; then + return 1 + fi + + # Pick a working clone for the submodule. Three sources, in order: + # 1) checked-out submodule worktree (cheap, no network) + # 2) cached internal clone at .git/modules/ + # 3) temp bare clone from the submodule URL (last resort; needs network) + local sub_query_dir="" + local sub_dir="$source_worktree_arg/$conflict_path" + if [[ -d "$sub_dir" ]] && git -C "$sub_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + sub_query_dir="$sub_dir" + else + local repo_git_dir + repo_git_dir="$(git -C "$source_worktree_arg" rev-parse --git-common-dir 2>/dev/null || true)" + if [[ -n "$repo_git_dir" && -d "$repo_git_dir/modules/$conflict_path" ]]; then + sub_query_dir="$repo_git_dir/modules/$conflict_path" + fi + fi + + local temp_sub_clone="" + cleanup_temp_sub_clone() { + [[ -n "$temp_sub_clone" && -d "$temp_sub_clone" ]] && rm -rf "$temp_sub_clone" + } + trap cleanup_temp_sub_clone RETURN + + if [[ -z "$sub_query_dir" ]]; then + local sub_url + sub_url="$(git -C "$repo_root_arg" config -f .gitmodules --get "submodule.${conflict_path}.url" 2>/dev/null || true)" + if [[ -z "$sub_url" ]]; then + return 1 + fi + temp_sub_clone="$(mktemp -d -t gx-submod-resolve-XXXXXX 2>/dev/null || true)" + if [[ -z "$temp_sub_clone" || ! -d "$temp_sub_clone" ]]; then + return 1 + fi + if ! git clone --quiet --bare "$sub_url" "$temp_sub_clone" >/dev/null 2>&1; then + return 1 + fi + sub_query_dir="$temp_sub_clone" + fi + + if ! git -C "$sub_query_dir" cat-file -e "${ours_sha}^{commit}" 2>/dev/null \ + || ! git -C "$sub_query_dir" cat-file -e "${theirs_sha}^{commit}" 2>/dev/null; then + git -C "$sub_query_dir" fetch --quiet --all 2>/dev/null || true + fi + if ! git -C "$sub_query_dir" cat-file -e "${ours_sha}^{commit}" 2>/dev/null \ + || ! git -C "$sub_query_dir" cat-file -e "${theirs_sha}^{commit}" 2>/dev/null; then + return 1 + fi + + local chosen_sha="" + if [[ "$ours_sha" == "$theirs_sha" ]]; then + chosen_sha="$ours_sha" + elif git -C "$sub_query_dir" merge-base --is-ancestor "$ours_sha" "$theirs_sha" 2>/dev/null; then + chosen_sha="$theirs_sha" + elif git -C "$sub_query_dir" merge-base --is-ancestor "$theirs_sha" "$ours_sha" 2>/dev/null; then + chosen_sha="$ours_sha" + else + # Divergent histories; refuse. + return 1 + fi + + if ! git -C "$source_worktree_arg" update-index --cacheinfo "160000,${chosen_sha},${conflict_path}" >/dev/null 2>&1; then + return 1 + fi + + printf '%s' "$chosen_sha" + return 0 +} + if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo "[agent-branch-finish] Not inside a git repository." >&2 exit 1 @@ -485,20 +649,97 @@ if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRA if ! git -C "$source_worktree" merge --no-commit --no-ff "origin/${BASE_BRANCH}" >/dev/null 2>&1; then conflict_files="$(git -C "$source_worktree" diff --name-only --diff-filter=U || true)" - git -C "$source_worktree" merge --abort >/dev/null 2>&1 || true - echo "[agent-branch-finish] Preflight conflict detected between '${SOURCE_BRANCH}' and latest origin/${BASE_BRANCH}." >&2 - if [[ -n "$conflict_files" ]]; then - echo "[agent-branch-finish] Conflicting files:" >&2 - while IFS= read -r file; do - [[ -n "$file" ]] && echo " - ${file}" >&2 + if [[ "$AUTO_RESOLVE_MODE" != "none" && -n "$conflict_files" ]]; then + auto_resolve_unresolved="" + auto_resolve_resolved_state="" + auto_resolve_resolved_submodules="" + while IFS= read -r conflict_path; do + [[ -z "$conflict_path" ]] && continue + if path_matches_auto_resolve_safe_glob "$conflict_path"; then + if git -C "$source_worktree" checkout --theirs -- "$conflict_path" >/dev/null 2>&1 \ + && git -C "$source_worktree" add -- "$conflict_path" >/dev/null 2>&1; then + auto_resolve_resolved_state+="${conflict_path}"$'\n' + continue + fi + fi + if [[ "$AUTO_RESOLVE_MODE" == "full" ]]; then + if chosen_sha="$(try_resolve_submodule_pointer_conflict "$repo_root" "$source_worktree" "$conflict_path")"; then + auto_resolve_resolved_submodules+="${conflict_path}@${chosen_sha}"$'\n' + continue + fi + fi + auto_resolve_unresolved+="${conflict_path}"$'\n' done <<< "$conflict_files" + + if [[ -n "$auto_resolve_unresolved" ]]; then + git -C "$source_worktree" merge --abort >/dev/null 2>&1 || true + echo "[agent-branch-finish] --auto-resolve=${AUTO_RESOLVE_MODE}: some conflicts are outside the safe allowlist (or submodule histories diverge); aborting." >&2 + echo "[agent-branch-finish] Unresolved conflicts:" >&2 + while IFS= read -r unresolved_path; do + [[ -n "$unresolved_path" ]] && echo " - ${unresolved_path}" >&2 + done <<< "$auto_resolve_unresolved" + echo "[agent-branch-finish] State-file allowlist (GUARDEX_FINISH_AUTO_RESOLVE_SAFE_GLOBS): ${AUTO_RESOLVE_SAFE_GLOBS_RAW}" >&2 + if [[ "$AUTO_RESOLVE_MODE" != "full" ]]; then + echo "[agent-branch-finish] Submodule pointer auto-resolve requires --auto-resolve=full; not enabled for this run." >&2 + fi + exit 1 + fi + + # Claim resolved paths so the pre-commit lock guard accepts the merge. + auto_resolve_claim_paths=() + while IFS= read -r resolved_path; do + [[ -n "$resolved_path" ]] && auto_resolve_claim_paths+=("$resolved_path") + done <<< "$auto_resolve_resolved_state" + while IFS= read -r resolved_entry; do + [[ -z "$resolved_entry" ]] && continue + auto_resolve_claim_paths+=("${resolved_entry%@*}") + done <<< "$auto_resolve_resolved_submodules" + if [[ "${#auto_resolve_claim_paths[@]}" -gt 0 ]]; then + run_guardex_cli locks claim --branch "$SOURCE_BRANCH" "${auto_resolve_claim_paths[@]}" >/dev/null 2>&1 || true + fi + + auto_resolve_commit_msg="Merge origin/${BASE_BRANCH} into ${SOURCE_BRANCH} (gx --auto-resolve=${AUTO_RESOLVE_MODE}; state files -> base, submodule pointers fast-forwarded)" + if ! git -C "$source_worktree" commit -m "$auto_resolve_commit_msg" >/dev/null 2>&1; then + git -C "$source_worktree" merge --abort >/dev/null 2>&1 || true + echo "[agent-branch-finish] --auto-resolve=${AUTO_RESOLVE_MODE}: failed to commit resolved merge (pre-commit hook may have rejected it; verify file locks)." >&2 + exit 1 + fi + + state_count=0 + submod_count=0 + [[ -n "$auto_resolve_resolved_state" ]] && state_count="$(printf '%s' "$auto_resolve_resolved_state" | grep -c '^[^[:space:]]')" + [[ -n "$auto_resolve_resolved_submodules" ]] && submod_count="$(printf '%s' "$auto_resolve_resolved_submodules" | grep -c '^[^[:space:]]')" + echo "[agent-branch-finish] --auto-resolve=${AUTO_RESOLVE_MODE}: resolved ${state_count} state-file conflict(s), ${submod_count} submodule pointer conflict(s)." >&2 + if [[ -n "$auto_resolve_resolved_state" ]]; then + echo "[agent-branch-finish] State files (resolved to base):" >&2 + while IFS= read -r resolved_path; do + [[ -n "$resolved_path" ]] && echo " - ${resolved_path}" >&2 + done <<< "$auto_resolve_resolved_state" + fi + if [[ -n "$auto_resolve_resolved_submodules" ]]; then + echo "[agent-branch-finish] Submodule pointers (fast-forwarded):" >&2 + while IFS= read -r resolved_entry; do + [[ -n "$resolved_entry" ]] && echo " - ${resolved_entry%@*} -> ${resolved_entry##*@}" >&2 + done <<< "$auto_resolve_resolved_submodules" + fi + else + git -C "$source_worktree" merge --abort >/dev/null 2>&1 || true + + echo "[agent-branch-finish] Preflight conflict detected between '${SOURCE_BRANCH}' and latest origin/${BASE_BRANCH}." >&2 + if [[ -n "$conflict_files" ]]; then + echo "[agent-branch-finish] Conflicting files:" >&2 + while IFS= read -r file; do + [[ -n "$file" ]] && echo " - ${file}" >&2 + done <<< "$conflict_files" + fi + echo "[agent-branch-finish] Rebase/merge '${BASE_BRANCH}' into '${SOURCE_BRANCH}' and resolve conflicts before finishing." >&2 + echo "[agent-branch-finish] Or rerun with --auto-resolve=safe (state files) or --auto-resolve=full (state files + fast-forward-able submodule pointers)." >&2 + exit 1 fi - echo "[agent-branch-finish] Rebase/merge '${BASE_BRANCH}' into '${SOURCE_BRANCH}' and resolve conflicts before finishing." >&2 - exit 1 + else + git -C "$source_worktree" merge --abort >/dev/null 2>&1 || true fi - - git -C "$source_worktree" merge --abort >/dev/null 2>&1 || true fi should_create_integration_helper=1 diff --git a/templates/scripts/agent-branch-start.sh b/templates/scripts/agent-branch-start.sh index 5b961a7..8fd4bd8 100755 --- a/templates/scripts/agent-branch-start.sh +++ b/templates/scripts/agent-branch-start.sh @@ -16,6 +16,9 @@ OPENSPEC_CAPABILITY_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_CAPABILITY_SLUG:-}" OPENSPEC_MASTERPLAN_LABEL_RAW="${GUARDEX_OPENSPEC_MASTERPLAN_LABEL-masterplan}" OPENSPEC_TIER_RAW="${GUARDEX_OPENSPEC_TIER:-T3}" REUSE_EXISTING_RAW="${GUARDEX_BRANCH_START_REUSE_EXISTING:-true}" +AUTO_TRANSFER_ENABLED_RAW="${GUARDEX_AUTO_TRANSFER:-true}" +AUTO_TRANSFER_EXCLUDE_DEFAULT='.omc/**:.omx/state/**:.dev-ports.json:apps/logs/**:.agents/settings.local.json:.codex/state/**:.claude/state/**' +AUTO_TRANSFER_EXCLUDE_RAW="${GUARDEX_AUTO_TRANSFER_EXCLUDE-$AUTO_TRANSFER_EXCLUDE_DEFAULT}" PRINT_NAME_ONLY=0 POSITIONAL_ARGS=() @@ -67,6 +70,18 @@ while [[ $# -gt 0 ]]; do REUSE_EXISTING_RAW="false" shift ;; + --no-transfer) + AUTO_TRANSFER_ENABLED_RAW="false" + shift + ;; + --transfer) + AUTO_TRANSFER_ENABLED_RAW="true" + shift + ;; + --transfer-exclude) + AUTO_TRANSFER_EXCLUDE_RAW="${2:-}" + shift 2 + ;; --in-place|--allow-in-place) echo "[agent-branch-start] In-place branch mode is disabled." >&2 echo "[agent-branch-start] This command always creates an isolated worktree to keep your active checkout unchanged." >&2 @@ -790,18 +805,54 @@ restore_auto_transfer_stash_on_failure() { trap 'restore_auto_transfer_stash_on_failure "$?"' EXIT +auto_transfer_enabled_lc="$(printf '%s' "$AUTO_TRANSFER_ENABLED_RAW" | tr '[:upper:]' '[:lower:]')" +case "$auto_transfer_enabled_lc" in + 0|false|no|off) AUTO_TRANSFER_ENABLED=0 ;; + *) AUTO_TRANSFER_ENABLED=1 ;; +esac + +build_auto_transfer_stash_argv() { + # Emit NUL-separated argv: --include-untracked --message [-- :/ :(exclude,glob)PAT ...] + local msg="$1" + printf '%s\0' --include-untracked --message "$msg" + if [[ -z "${AUTO_TRANSFER_EXCLUDE_RAW:-}" ]]; then + return 0 + fi + printf '%s\0' '--' ':/' + local -a patterns=() + IFS=':' read -ra patterns <<< "$AUTO_TRANSFER_EXCLUDE_RAW" + local pattern + for pattern in "${patterns[@]}"; do + [[ -z "$pattern" ]] && continue + printf '%s\0' ":(exclude,glob)${pattern}" + done +} + current_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" protected_branches_raw="$(resolve_protected_branches "$repo_root")" if [[ -n "$current_branch" && "$current_branch" != "HEAD" ]] && is_protected_branch_name "$current_branch" "$protected_branches_raw"; then if has_local_changes "$repo_root"; then - auto_transfer_message="guardex-auto-transfer-${timestamp}-${agent_slug}-${task_slug}" - if git -C "$repo_root" stash push --include-untracked --message "$auto_transfer_message" >/dev/null 2>&1; then - auto_transfer_stash_ref="$(resolve_stash_ref_by_message "$repo_root" "$auto_transfer_message")" - if [[ -n "$auto_transfer_stash_ref" ]]; then - auto_transfer_source_branch="$current_branch" - echo "[agent-branch-start] Detected local changes on protected branch '${current_branch}'. Moving them to '${branch_name}'..." - if ! maybe_fail_after_auto_transfer_stash; then - exit 1 + if [[ "$AUTO_TRANSFER_ENABLED" -eq 0 ]]; then + echo "[agent-branch-start] Detected local changes on protected branch '${current_branch}'; --no-transfer set, leaving them in place." >&2 + echo "[agent-branch-start] If you intended those changes for '${branch_name}', stash them manually and apply inside the worktree." >&2 + else + auto_transfer_message="guardex-auto-transfer-${timestamp}-${agent_slug}-${task_slug}" + stash_argv=() + while IFS= read -r -d '' arg; do + stash_argv+=("$arg") + done < <(build_auto_transfer_stash_argv "$auto_transfer_message") + if git -C "$repo_root" stash push "${stash_argv[@]}" >/dev/null 2>&1; then + auto_transfer_stash_ref="$(resolve_stash_ref_by_message "$repo_root" "$auto_transfer_message")" + if [[ -n "$auto_transfer_stash_ref" ]]; then + auto_transfer_source_branch="$current_branch" + echo "[agent-branch-start] Detected local changes on protected branch '${current_branch}'. Moving them to '${branch_name}' (state-file globs excluded)..." + if ! maybe_fail_after_auto_transfer_stash; then + exit 1 + fi + else + if has_local_changes "$repo_root"; then + echo "[agent-branch-start] Local changes on '${current_branch}' all match the auto-transfer exclude list; leaving them in place on '${current_branch}'." >&2 + fi fi fi fi