diff --git a/openspec/changes/agent-codex-auto-commit-parent-subrepo-upgrades-2026-04-23-11-59/proposal.md b/openspec/changes/agent-codex-auto-commit-parent-subrepo-upgrades-2026-04-23-11-59/proposal.md new file mode 100644 index 00000000..e4400b26 --- /dev/null +++ b/openspec/changes/agent-codex-auto-commit-parent-subrepo-upgrades-2026-04-23-11-59/proposal.md @@ -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. diff --git a/openspec/changes/agent-codex-auto-commit-parent-subrepo-upgrades-2026-04-23-11-59/specs/finish/spec.md b/openspec/changes/agent-codex-auto-commit-parent-subrepo-upgrades-2026-04-23-11-59/specs/finish/spec.md new file mode 100644 index 00000000..9226b679 --- /dev/null +++ b/openspec/changes/agent-codex-auto-commit-parent-subrepo-upgrades-2026-04-23-11-59/specs/finish/spec.md @@ -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 diff --git a/openspec/changes/agent-codex-auto-commit-parent-subrepo-upgrades-2026-04-23-11-59/tasks.md b/openspec/changes/agent-codex-auto-commit-parent-subrepo-upgrades-2026-04-23-11-59/tasks.md new file mode 100644 index 00000000..df23f8e1 --- /dev/null +++ b/openspec/changes/agent-codex-auto-commit-parent-subrepo-upgrades-2026-04-23-11-59/tasks.md @@ -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` diff --git a/scripts/agent-branch-finish.sh b/scripts/agent-branch-finish.sh index 400a08c1..ca7e40c1 100755 --- a/scripts/agent-branch-finish.sh +++ b/scripts/agent-branch-finish.sh @@ -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 @@ -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 @@ -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 @@ -131,7 +141,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 ] [--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]" >&2 exit 1 ;; esac @@ -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 )) @@ -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 diff --git a/src/cli/args.js b/src/cli/args.js index 599e623c..e0529801 100644 --- a/src/cli/args.js +++ b/src/cli/args.js @@ -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', @@ -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; diff --git a/src/finish/index.js b/src/finish/index.js index 2fcb4264..5b5d1ae0 100644 --- a/src/finish/index.js +++ b/src/finish/index.js @@ -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(' ')}`); diff --git a/templates/scripts/agent-branch-finish.sh b/templates/scripts/agent-branch-finish.sh index 400a08c1..ca7e40c1 100755 --- a/templates/scripts/agent-branch-finish.sh +++ b/templates/scripts/agent-branch-finish.sh @@ -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 @@ -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 @@ -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 @@ -131,7 +141,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 ] [--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]" >&2 exit 1 ;; esac @@ -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 )) @@ -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 diff --git a/test/cli-args-dispatch.test.js b/test/cli-args-dispatch.test.js index 9f1b08a2..f5c33207 100644 --- a/test/cli-args-dispatch.test.js +++ b/test/cli-args-dispatch.test.js @@ -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', @@ -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'); }); diff --git a/test/finish.test.js b/test/finish.test.js index d40b3e63..22b75c75 100644 --- a/test/finish.test.js +++ b/test/finish.test.js @@ -154,6 +154,80 @@ test('finish command auto-commits dirty agent worktree and runs PR finish flow f }); +test('agent-branch-finish auto-commits parent gitlink after nested repo finish', () => { + const parentDir = initRepoOnBranch('main'); + seedCommit(parentDir); + + const childDir = path.join(parentDir, 'packages', 'child'); + fs.mkdirSync(childDir, { recursive: true }); + let result = runCmd('git', ['init', '-b', 'main'], childDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + configureGitIdentity(childDir); + fs.writeFileSync( + path.join(childDir, 'package.json'), + JSON.stringify({ name: 'child', private: true, scripts: {} }, null, 2) + '\n', + 'utf8', + ); + seedCommit(childDir); + attachOriginRemoteForBranch(childDir, 'main'); + + result = runNode(['setup', '--target', childDir, '--no-global-install', '--no-recursive'], childDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['add', '.'], childDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['commit', '-m', 'apply gx setup'], childDir, { + ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1', + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['push', 'origin', 'main'], childDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + result = runCmd('git', ['add', 'packages/child'], parentDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['commit', '-m', 'track child subrepo'], parentDir, { + ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1', + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + fs.writeFileSync(path.join(parentDir, 'unrelated-parent.txt'), 'already staged\n', 'utf8'); + result = runCmd('git', ['add', 'unrelated-parent.txt'], parentDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + result = runBranchStart(['gitlink-finish', 'bot', 'main'], childDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + const agentBranch = extractCreatedBranch(result.stdout); + const agentWorktree = extractCreatedWorktree(result.stdout); + commitFile(agentWorktree, 'finished.txt', 'nested branch finished\n', 'finish nested repo'); + + result = runBranchFinish([ + '--branch', + agentBranch, + '--base', + 'main', + '--direct-only', + '--cleanup', + '--parent-gitlink-commit', + ], childDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /Parent gitlink auto-committed 'packages\/child'/); + + const parentSubject = runCmd('git', ['log', '-1', '--pretty=%s'], parentDir); + assert.equal(parentSubject.status, 0, parentSubject.stderr || parentSubject.stdout); + assert.equal(parentSubject.stdout.trim(), 'Update packages/child subrepo pointer'); + + const parentCommitFiles = runCmd('git', ['diff-tree', '--no-commit-id', '--name-only', '-r', 'HEAD'], parentDir); + assert.equal(parentCommitFiles.status, 0, parentCommitFiles.stderr || parentCommitFiles.stdout); + assert.deepEqual(parentCommitFiles.stdout.trim().split('\n'), ['packages/child']); + + const stagedParentFiles = runCmd('git', ['diff', '--cached', '--name-only'], parentDir); + assert.equal(stagedParentFiles.status, 0, stagedParentFiles.stderr || stagedParentFiles.stdout); + assert.deepEqual(stagedParentFiles.stdout.trim().split('\n'), ['unrelated-parent.txt']); + + const parentStatus = runCmd('git', ['status', '--short', '--', 'packages/child'], parentDir); + assert.equal(parentStatus.status, 0, parentStatus.stderr || parentStatus.stdout); + assert.equal(parentStatus.stdout.trim(), '', 'parent gitlink should be committed cleanly'); +}); + + test('agent-branch-finish auto-syncs source branch when behind origin/dev', () => { const repoDir = initRepo(); seedCommit(repoDir);