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
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Change: Auto-Commit Parent Subrepo Gitlink Upgrades

## Why

When a nested repo finishes and updates its base branch, the containing repo can be left with a changed gitlink entry. In VS Code this shows up as staged or unstaged subrepo entries that need a second manual commit even though the nested finish flow already completed.

## What Changes

- Extend `agent-branch-finish` so, after a successful nested repo finish and base-worktree fast-forward, it detects a tracked superproject gitlink and commits only that subrepo pointer in the parent repo.
- Add `--parent-gitlink-commit` / `--no-parent-gitlink-commit` and `GUARDEX_FINISH_PARENT_GITLINK_AUTO_COMMIT` controls.
- Keep the commit path-scoped to the single gitlink so unrelated parent staged changes are not bundled.

## Risks

- Parent repo hooks may reject the auto-commit on protected branches. The finish flow should warn and continue rather than treating the nested repo finish as failed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
## ADDED Requirements

### Requirement: Parent Gitlink Auto-Commit

After a successful nested repository finish, Guardex SHALL detect when the finished repository's base worktree is tracked as a `160000` gitlink in a containing superproject and SHALL attempt to commit only that gitlink path in the parent repository.

#### Scenario: Nested repo finish advances parent gitlink

- **GIVEN** a nested repo is tracked as a gitlink by a parent repo
- **WHEN** `gx branch finish` merges the nested agent branch and fast-forwards the nested base worktree
- **THEN** Guardex commits the parent repo gitlink path with a message naming that subrepo pointer
- **AND** unrelated parent staged paths are not included in that commit

#### Scenario: Parent auto-commit is disabled

- **GIVEN** parent gitlink auto-commit is disabled by flag or environment
- **WHEN** a nested repo finish advances the nested base worktree
- **THEN** Guardex skips the parent gitlink commit attempt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Tasks

## 1. Spec

- [x] Define parent gitlink auto-commit behavior and controls.

## 2. Tests

- [x] Add regression coverage for nested repo finish creating a parent gitlink commit.

## 3. Implementation

- [x] Add parent gitlink detection and path-scoped parent commit to `agent-branch-finish`.
- [x] Pass parent gitlink controls through `gx finish`.

## 4. Cleanup

- [x] Run focused verification.
- [ ] Commit and finish via PR with merge + cleanup evidence.

Verification:
- `bash -n scripts/agent-branch-finish.sh`
- `bash -n templates/scripts/agent-branch-finish.sh`
- `node --test test/cli-args-dispatch.test.js test/finish.test.js`
- `openspec validate agent-codex-auto-commit-parent-subrepo-upgrades-2026-04-23-11-59 --strict`
- `git diff --check`
- `npm test`
85 changes: 84 additions & 1 deletion scripts/agent-branch-finish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ CLEANUP_AFTER_MERGE_RAW="${GUARDEX_FINISH_CLEANUP:-false}"
WAIT_FOR_MERGE_RAW="${GUARDEX_FINISH_WAIT_FOR_MERGE:-false}"
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}"

run_guardex_cli() {
if [[ -n "$CLI_ENTRY" ]]; then
Expand Down Expand Up @@ -67,6 +68,7 @@ CLEANUP_AFTER_MERGE="$(normalize_bool "$CLEANUP_AFTER_MERGE_RAW" "0")"
WAIT_FOR_MERGE="$(normalize_bool "$WAIT_FOR_MERGE_RAW" "0")"
WAIT_TIMEOUT_SECONDS="$(normalize_int "$WAIT_TIMEOUT_SECONDS_RAW" "1800" "30")"
WAIT_POLL_SECONDS="$(normalize_int "$WAIT_POLL_SECONDS_RAW" "10" "0")"
PARENT_GITLINK_AUTO_COMMIT="$(normalize_bool "$PARENT_GITLINK_AUTO_COMMIT_RAW" "1")"

while [[ $# -gt 0 ]]; do
case "$1" in
Expand Down Expand Up @@ -117,6 +119,14 @@ while [[ $# -gt 0 ]]; do
WAIT_POLL_SECONDS="$(normalize_int "${2:-}" "10" "0")"
shift 2
;;
--parent-gitlink-commit)
PARENT_GITLINK_AUTO_COMMIT=1
shift
;;
--no-parent-gitlink-commit)
PARENT_GITLINK_AUTO_COMMIT=0
shift
;;
--mode)
MERGE_MODE="${2:-auto}"
shift 2
Expand All @@ -131,7 +141,7 @@ while [[ $# -gt 0 ]]; do
;;
*)
echo "[agent-branch-finish] Unknown argument: $1" >&2
echo "Usage: $0 [--base <branch>] [--branch <branch>] [--no-push] [--cleanup|--no-cleanup] [--wait-for-merge|--no-wait-for-merge] [--wait-timeout-seconds <n>] [--wait-poll-seconds <n>] [--keep-remote-branch|--delete-remote-branch] [--mode auto|direct|pr|--via-pr|--direct-only]" >&2
echo "Usage: $0 [--base <branch>] [--branch <branch>] [--no-push] [--cleanup|--no-cleanup] [--wait-for-merge|--no-wait-for-merge] [--wait-timeout-seconds <n>] [--wait-poll-seconds <n>] [--parent-gitlink-commit|--no-parent-gitlink-commit] [--keep-remote-branch|--delete-remote-branch] [--mode auto|direct|pr|--via-pr|--direct-only]" >&2
exit 1
;;
esac
Expand Down Expand Up @@ -499,6 +509,78 @@ read_merged_pr_for_head() {
return 0
}

maybe_auto_commit_parent_gitlink() {
local base_wt="${1:-}"
local base_wt_real=""
local super_root_raw=""
local super_root=""
local subrepo_rel=""
local gitlink_mode=""
local add_output=""
local commit_output=""
local commit_message=""

if [[ "$PARENT_GITLINK_AUTO_COMMIT" -ne 1 || "$PUSH_ENABLED" -ne 1 ]]; then
return 0
fi
if [[ -z "$base_wt" ]]; then
return 0
fi
if ! base_wt_real="$(cd "$base_wt" && pwd -P 2>/dev/null)"; then
return 0
fi
if [[ "$base_wt_real" != "$repo_common_root" ]]; then
return 0
fi
if ! is_clean_worktree "$repo_common_root"; then
echo "[agent-branch-finish] Parent gitlink auto-commit skipped; nested base worktree is dirty: ${repo_common_root}" >&2
return 0
fi

super_root_raw="$(git -C "$repo_common_root" rev-parse --show-superproject-working-tree 2>/dev/null || true)"
if [[ -z "$super_root_raw" ]]; then
return 0
fi
if ! super_root="$(cd "$super_root_raw" && pwd -P 2>/dev/null)"; then
return 0
fi

case "$repo_common_root" in
"$super_root"/*) subrepo_rel="${repo_common_root#"$super_root"/}" ;;
*) return 0 ;;
esac
if [[ -z "$subrepo_rel" || "$subrepo_rel" == "$repo_common_root" ]]; then
return 0
fi

gitlink_mode="$(git -C "$super_root" ls-files -s -- "$subrepo_rel" | awk 'NR == 1 { print $1 }')"
if [[ "$gitlink_mode" != "160000" ]]; then
return 0
fi
if git -C "$super_root" diff --quiet -- "$subrepo_rel" \
&& git -C "$super_root" diff --cached --quiet -- "$subrepo_rel"; then
return 0
fi

if ! add_output="$(git -C "$super_root" add -- "$subrepo_rel" 2>&1)"; then
echo "[agent-branch-finish] Warning: parent gitlink staging failed for ${subrepo_rel} in ${super_root}." >&2
[[ -n "$add_output" ]] && echo "$add_output" >&2
return 0
fi
if git -C "$super_root" diff --cached --quiet -- "$subrepo_rel"; then
return 0
fi

commit_message="Update ${subrepo_rel} subrepo pointer"
if ! commit_output="$(git -C "$super_root" commit -m "$commit_message" -- "$subrepo_rel" 2>&1)"; then
echo "[agent-branch-finish] Warning: parent gitlink auto-commit failed in ${super_root}." >&2
[[ -n "$commit_output" ]] && echo "$commit_output" >&2
return 0
fi

echo "[agent-branch-finish] Parent gitlink auto-committed '${subrepo_rel}' in ${super_root}."
}

wait_for_pr_merge() {
local deadline
deadline=$(( $(date +%s) + WAIT_TIMEOUT_SECONDS ))
Expand Down Expand Up @@ -667,6 +749,7 @@ base_worktree="$(get_worktree_for_branch "$BASE_BRANCH")"
if [[ -n "$base_worktree" ]] && is_clean_worktree "$base_worktree" && [[ "$PUSH_ENABLED" -eq 1 ]]; then
git -C "$base_worktree" pull --ff-only origin "$BASE_BRANCH" >/dev/null 2>&1 || true
fi
maybe_auto_commit_parent_gitlink "$base_worktree"

if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then
if [[ "$source_worktree" == "$repo_root" ]]; then
Expand Down
9 changes: 9 additions & 0 deletions src/cli/args.js
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,7 @@ function parseFinishArgs(rawArgs, defaults = {}) {
cleanup: defaults.cleanup ?? true,
keepRemote: false,
noAutoCommit: false,
parentGitlinkCommit: defaults.parentGitlinkCommit ?? true,
failFast: false,
commitMessage: '',
mergeMode: defaults.mergeMode || 'pr',
Expand Down Expand Up @@ -865,6 +866,14 @@ function parseFinishArgs(rawArgs, defaults = {}) {
options.noAutoCommit = true;
continue;
}
if (arg === '--parent-gitlink-commit') {
options.parentGitlinkCommit = true;
continue;
}
if (arg === '--no-parent-gitlink-commit') {
options.parentGitlinkCommit = false;
continue;
}
if (arg === '--fail-fast') {
options.failFast = true;
continue;
Expand Down
1 change: 1 addition & 0 deletions src/finish/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ function finish(rawArgs, defaults = {}) {
if (options.keepRemote) {
finishArgs.push('--keep-remote-branch');
}
finishArgs.push(options.parentGitlinkCommit ? '--parent-gitlink-commit' : '--no-parent-gitlink-commit');

if (options.dryRun) {
console.log(`[${TOOL_NAME}] [dry-run] Would run: gx branch finish ${finishArgs.join(' ')}`);
Expand Down
85 changes: 84 additions & 1 deletion templates/scripts/agent-branch-finish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ CLEANUP_AFTER_MERGE_RAW="${GUARDEX_FINISH_CLEANUP:-false}"
WAIT_FOR_MERGE_RAW="${GUARDEX_FINISH_WAIT_FOR_MERGE:-false}"
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}"

run_guardex_cli() {
if [[ -n "$CLI_ENTRY" ]]; then
Expand Down Expand Up @@ -67,6 +68,7 @@ CLEANUP_AFTER_MERGE="$(normalize_bool "$CLEANUP_AFTER_MERGE_RAW" "0")"
WAIT_FOR_MERGE="$(normalize_bool "$WAIT_FOR_MERGE_RAW" "0")"
WAIT_TIMEOUT_SECONDS="$(normalize_int "$WAIT_TIMEOUT_SECONDS_RAW" "1800" "30")"
WAIT_POLL_SECONDS="$(normalize_int "$WAIT_POLL_SECONDS_RAW" "10" "0")"
PARENT_GITLINK_AUTO_COMMIT="$(normalize_bool "$PARENT_GITLINK_AUTO_COMMIT_RAW" "1")"

while [[ $# -gt 0 ]]; do
case "$1" in
Expand Down Expand Up @@ -117,6 +119,14 @@ while [[ $# -gt 0 ]]; do
WAIT_POLL_SECONDS="$(normalize_int "${2:-}" "10" "0")"
shift 2
;;
--parent-gitlink-commit)
PARENT_GITLINK_AUTO_COMMIT=1
shift
;;
--no-parent-gitlink-commit)
PARENT_GITLINK_AUTO_COMMIT=0
shift
;;
--mode)
MERGE_MODE="${2:-auto}"
shift 2
Expand All @@ -131,7 +141,7 @@ while [[ $# -gt 0 ]]; do
;;
*)
echo "[agent-branch-finish] Unknown argument: $1" >&2
echo "Usage: $0 [--base <branch>] [--branch <branch>] [--no-push] [--cleanup|--no-cleanup] [--wait-for-merge|--no-wait-for-merge] [--wait-timeout-seconds <n>] [--wait-poll-seconds <n>] [--keep-remote-branch|--delete-remote-branch] [--mode auto|direct|pr|--via-pr|--direct-only]" >&2
echo "Usage: $0 [--base <branch>] [--branch <branch>] [--no-push] [--cleanup|--no-cleanup] [--wait-for-merge|--no-wait-for-merge] [--wait-timeout-seconds <n>] [--wait-poll-seconds <n>] [--parent-gitlink-commit|--no-parent-gitlink-commit] [--keep-remote-branch|--delete-remote-branch] [--mode auto|direct|pr|--via-pr|--direct-only]" >&2
exit 1
;;
esac
Expand Down Expand Up @@ -499,6 +509,78 @@ read_merged_pr_for_head() {
return 0
}

maybe_auto_commit_parent_gitlink() {
local base_wt="${1:-}"
local base_wt_real=""
local super_root_raw=""
local super_root=""
local subrepo_rel=""
local gitlink_mode=""
local add_output=""
local commit_output=""
local commit_message=""

if [[ "$PARENT_GITLINK_AUTO_COMMIT" -ne 1 || "$PUSH_ENABLED" -ne 1 ]]; then
return 0
fi
if [[ -z "$base_wt" ]]; then
return 0
fi
if ! base_wt_real="$(cd "$base_wt" && pwd -P 2>/dev/null)"; then
return 0
fi
if [[ "$base_wt_real" != "$repo_common_root" ]]; then
return 0
fi
if ! is_clean_worktree "$repo_common_root"; then
echo "[agent-branch-finish] Parent gitlink auto-commit skipped; nested base worktree is dirty: ${repo_common_root}" >&2
return 0
fi

super_root_raw="$(git -C "$repo_common_root" rev-parse --show-superproject-working-tree 2>/dev/null || true)"
if [[ -z "$super_root_raw" ]]; then
return 0
fi
if ! super_root="$(cd "$super_root_raw" && pwd -P 2>/dev/null)"; then
return 0
fi

case "$repo_common_root" in
"$super_root"/*) subrepo_rel="${repo_common_root#"$super_root"/}" ;;
*) return 0 ;;
esac
if [[ -z "$subrepo_rel" || "$subrepo_rel" == "$repo_common_root" ]]; then
return 0
fi

gitlink_mode="$(git -C "$super_root" ls-files -s -- "$subrepo_rel" | awk 'NR == 1 { print $1 }')"
if [[ "$gitlink_mode" != "160000" ]]; then
return 0
fi
if git -C "$super_root" diff --quiet -- "$subrepo_rel" \
&& git -C "$super_root" diff --cached --quiet -- "$subrepo_rel"; then
return 0
fi

if ! add_output="$(git -C "$super_root" add -- "$subrepo_rel" 2>&1)"; then
echo "[agent-branch-finish] Warning: parent gitlink staging failed for ${subrepo_rel} in ${super_root}." >&2
[[ -n "$add_output" ]] && echo "$add_output" >&2
return 0
fi
if git -C "$super_root" diff --cached --quiet -- "$subrepo_rel"; then
return 0
fi

commit_message="Update ${subrepo_rel} subrepo pointer"
if ! commit_output="$(git -C "$super_root" commit -m "$commit_message" -- "$subrepo_rel" 2>&1)"; then
echo "[agent-branch-finish] Warning: parent gitlink auto-commit failed in ${super_root}." >&2
[[ -n "$commit_output" ]] && echo "$commit_output" >&2
return 0
fi

echo "[agent-branch-finish] Parent gitlink auto-committed '${subrepo_rel}' in ${super_root}."
}

wait_for_pr_merge() {
local deadline
deadline=$(( $(date +%s) + WAIT_TIMEOUT_SECONDS ))
Expand Down Expand Up @@ -667,6 +749,7 @@ base_worktree="$(get_worktree_for_branch "$BASE_BRANCH")"
if [[ -n "$base_worktree" ]] && is_clean_worktree "$base_worktree" && [[ "$PUSH_ENABLED" -eq 1 ]]; then
git -C "$base_worktree" pull --ff-only origin "$BASE_BRANCH" >/dev/null 2>&1 || true
fi
maybe_auto_commit_parent_gitlink "$base_worktree"

if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then
if [[ "$source_worktree" == "$repo_root" ]]; then
Expand Down
2 changes: 2 additions & 0 deletions test/cli-args-dispatch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ test('parseFinishArgs rejects non-agent branches and preserves explicit override
'--direct-only',
'--keep-remote',
'--no-auto-commit',
'--no-parent-gitlink-commit',
'--fail-fast',
'--commit-message',
'Finish the active lane',
Expand All @@ -187,6 +188,7 @@ test('parseFinishArgs rejects non-agent branches and preserves explicit override
assert.equal(options.mergeMode, 'direct');
assert.equal(options.keepRemote, true);
assert.equal(options.noAutoCommit, true);
assert.equal(options.parentGitlinkCommit, false);
assert.equal(options.failFast, true);
assert.equal(options.commitMessage, 'Finish the active lane');
});
Expand Down
Loading