Skip to content

Commit e8e7651

Browse files
NagyViktNagyVikt
andauthored
Keep submodule commits publishable during finish (#544)
Guardex finish already owns parent branch publication, but parent repos can update a submodule gitlink before the nested repo branch is available remotely. The finish path now detects changed gitlinks, pushes the checked-out submodule branch that contains the referenced commit, and stops before parent publication when the nested commit cannot be safely published. Constraint: Codex host approval policy cannot be bypassed; approval must stay on the gx finish command surface. Rejected: Tell agents to run standalone git push in submodules | repeats approval prompts and can strand parent branches with local-only gitlinks Confidence: high Scope-risk: moderate Directive: Keep scripts/agent-branch-finish.sh and templates/scripts/agent-branch-finish.sh in sync for finish-flow changes. Tested: bash -n scripts/agent-branch-finish.sh templates/scripts/agent-branch-finish.sh Tested: node --test --test-name-pattern 'pushes changed submodule branch' test/finish.test.js Tested: openspec validate agent-codex-codex-task-2026-05-11-09-45 --type change --strict Tested: openspec validate --specs Not-tested: Full test/finish.test.js remains blocked by existing expectation drift in unrelated subtests. Co-authored-by: NagyVikt <nagy.viktordp@gmail.com>
1 parent 246da12 commit e8e7651

7 files changed

Lines changed: 280 additions & 0 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-05-11
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
## Why
2+
3+
- `gx branch finish` can push a parent branch that updates a submodule gitlink before the submodule branch containing that commit is on its remote.
4+
- That strands the parent branch/PR with a gitlink that collaborators and CI cannot fetch, and it causes agents to ask for separate ad hoc `git push` approval outside the Guardex finish flow.
5+
6+
## What Changes
7+
8+
- During `gx branch finish`, changed submodule gitlinks are detected before the parent branch is pushed or merged.
9+
- For each changed checked-out submodule, Guardex pushes the local branch containing the gitlink commit to that submodule's configured remote.
10+
- If the submodule commit cannot be safely pushed, finish fails before pushing the parent branch.
11+
12+
## Impact
13+
14+
- Affects `scripts/agent-branch-finish.sh` PR and direct push flows.
15+
- Keeps network approval consolidated around the single `gx branch finish` invocation.
16+
- Does not bypass host approval policy; it only moves the required submodule push into the existing finish command.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Finish pushes changed submodule branches before parent branch publication
4+
When an agent branch updates a submodule gitlink, `gx branch finish` SHALL push the checked-out submodule branch that contains the gitlink commit before pushing or merging the parent repository branch.
5+
6+
#### Scenario: Changed submodule branch is local-only
7+
- **GIVEN** a parent agent branch points a submodule gitlink at a commit that exists only on a local submodule branch
8+
- **WHEN** `gx branch finish` runs with push enabled
9+
- **THEN** the submodule branch is pushed to the submodule remote before the parent branch is pushed or merged
10+
- **AND** the parent finish flow continues only after that submodule push succeeds.
11+
12+
#### Scenario: Changed submodule commit cannot be safely published
13+
- **GIVEN** a parent agent branch points a submodule gitlink at a commit without a checked-out submodule branch that contains it
14+
- **WHEN** `gx branch finish` runs with push enabled
15+
- **THEN** Guardex reports the unsafe submodule state
16+
- **AND** the parent branch is not pushed by the finish flow.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
## Definition of Done
2+
3+
This change is complete only when **all** of the following are true:
4+
5+
- Every checkbox below is checked.
6+
- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff.
7+
- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline.
8+
9+
## Handoff
10+
11+
- Handoff: change=`agent-codex-codex-task-2026-05-11-09-45`; branch=`agent/codex/codex-task-2026-05-11-09-45`; scope=`TODO`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`.
12+
- Copy prompt: Continue `agent-codex-codex-task-2026-05-11-09-45` on branch `agent/codex/codex-task-2026-05-11-09-45`. Work inside the existing sandbox, review `openspec/changes/agent-codex-codex-task-2026-05-11-09-45/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/codex-task-2026-05-11-09-45 --base main --via-pr --wait-for-merge --cleanup`.
13+
14+
## 1. Specification
15+
16+
- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-codex-task-2026-05-11-09-45`.
17+
- [x] 1.2 Define normative requirements in `specs/agent-codex-codex-task-2026-05-11-09-45/spec.md`.
18+
19+
## 2. Implementation
20+
21+
- [x] 2.1 Implement scoped behavior changes.
22+
- [x] 2.2 Add/update focused regression coverage.
23+
24+
## 3. Verification
25+
26+
- [x] 3.1 Run targeted project verification commands.
27+
- [x] 3.2 Run `openspec validate agent-codex-codex-task-2026-05-11-09-45 --type change --strict`.
28+
- [x] 3.3 Run `openspec validate --specs`.
29+
30+
## 4. Cleanup (mandatory; run before claiming completion)
31+
32+
- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/codex/codex-task-2026-05-11-09-45 --base main --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation.
33+
- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff.
34+
- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch).

scripts/agent-branch-finish.sh

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,7 @@ merge_completed=0
382382
merge_status="pr"
383383
direct_push_error=""
384384
pr_url=""
385+
changed_submodule_push_done=0
385386

386387
cleanup() {
387388
if [[ -n "$integration_worktree" && -d "$integration_worktree" ]]; then
@@ -718,6 +719,77 @@ maybe_auto_commit_parent_gitlink() {
718719
echo "[agent-branch-finish] Parent gitlink auto-committed '${subrepo_rel}' in ${super_root}."
719720
}
720721

722+
maybe_push_changed_submodule_branches() {
723+
local base_ref="${1:-}"
724+
local source_ref="${2:-}"
725+
local changed_path=""
726+
local gitlink_mode=""
727+
local gitlink_sha=""
728+
local submodule_dir=""
729+
local branch_name=""
730+
local candidate_branch=""
731+
local remote_name=""
732+
local push_output=""
733+
734+
if [[ "$PUSH_ENABLED" -ne 1 || "$changed_submodule_push_done" -eq 1 ]]; then
735+
return 0
736+
fi
737+
changed_submodule_push_done=1
738+
if [[ -z "$base_ref" || -z "$source_ref" ]]; then
739+
return 0
740+
fi
741+
742+
while IFS= read -r changed_path; do
743+
[[ -n "$changed_path" ]] || continue
744+
745+
gitlink_mode="$(git -C "$source_worktree" ls-tree "$source_ref" -- "$changed_path" | awk 'NR == 1 { print $1 }')"
746+
if [[ "$gitlink_mode" != "160000" ]]; then
747+
continue
748+
fi
749+
gitlink_sha="$(git -C "$source_worktree" ls-tree "$source_ref" -- "$changed_path" | awk 'NR == 1 { print $3 }')"
750+
if [[ -z "$gitlink_sha" ]]; then
751+
continue
752+
fi
753+
754+
submodule_dir="${source_worktree}/${changed_path}"
755+
if ! git -C "$submodule_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
756+
echo "[agent-branch-finish] Warning: changed gitlink '${changed_path}' has no checked-out submodule at ${submodule_dir}; cannot auto-push submodule commit ${gitlink_sha}." >&2
757+
return 1
758+
fi
759+
if ! git -C "$submodule_dir" cat-file -e "${gitlink_sha}^{commit}" >/dev/null 2>&1; then
760+
echo "[agent-branch-finish] Warning: changed gitlink '${changed_path}' points at ${gitlink_sha}, but that commit is not present in ${submodule_dir}; cannot auto-push it." >&2
761+
return 1
762+
fi
763+
764+
branch_name="$(git -C "$submodule_dir" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
765+
if [[ -z "$branch_name" || "$branch_name" == "HEAD" ]] || ! git -C "$submodule_dir" merge-base --is-ancestor "$gitlink_sha" "$branch_name" >/dev/null 2>&1; then
766+
candidate_branch="$(git -C "$submodule_dir" for-each-ref --contains "$gitlink_sha" --format='%(refname:short)' refs/heads | head -n 1)"
767+
branch_name="$candidate_branch"
768+
fi
769+
if [[ -z "$branch_name" || "$branch_name" == "HEAD" ]]; then
770+
echo "[agent-branch-finish] Warning: changed gitlink '${changed_path}' points at ${gitlink_sha}, but no local submodule branch contains it; cannot choose a safe remote branch to push." >&2
771+
return 1
772+
fi
773+
774+
remote_name="$(git -C "$submodule_dir" config --get "branch.${branch_name}.remote" || true)"
775+
if [[ -z "$remote_name" ]]; then
776+
remote_name="origin"
777+
fi
778+
if ! git -C "$submodule_dir" remote get-url "$remote_name" >/dev/null 2>&1; then
779+
echo "[agent-branch-finish] Warning: changed gitlink '${changed_path}' branch '${branch_name}' has no usable remote '${remote_name}'; cannot auto-push submodule commit ${gitlink_sha}." >&2
780+
return 1
781+
fi
782+
783+
if push_output="$(git -C "$submodule_dir" push -u "$remote_name" "${branch_name}:${branch_name}" 2>&1)"; then
784+
echo "[agent-branch-finish] Pushed changed submodule '${changed_path}' branch '${branch_name}' to '${remote_name}' before parent finish."
785+
else
786+
echo "[agent-branch-finish] Changed submodule '${changed_path}' must be pushed before the parent branch can be finished." >&2
787+
[[ -n "$push_output" ]] && echo "$push_output" >&2
788+
return 1
789+
fi
790+
done < <(git -C "$source_worktree" diff --name-only "$base_ref" "$source_ref" -- 2>/dev/null || true)
791+
}
792+
721793
wait_for_pr_merge() {
722794
local deadline
723795
deadline=$(( $(date +%s) + WAIT_TIMEOUT_SECONDS ))
@@ -788,6 +860,7 @@ run_pr_flow() {
788860
return 0
789861
fi
790862

863+
maybe_push_changed_submodule_branches "$start_ref" "$SOURCE_BRANCH"
791864
git -C "$source_worktree" push -u origin "$SOURCE_BRANCH"
792865

793866
pr_title="$(git -C "$repo_root" log -1 --pretty=%s "$SOURCE_BRANCH" 2>/dev/null || true)"
@@ -836,6 +909,7 @@ run_pr_flow() {
836909

837910
if [[ "$PUSH_ENABLED" -eq 1 ]]; then
838911
if [[ "$MERGE_MODE" != "pr" ]]; then
912+
maybe_push_changed_submodule_branches "$start_ref" "$SOURCE_BRANCH"
839913
if ! direct_push_output="$(git -C "$integration_worktree" push origin "HEAD:${BASE_BRANCH}" 2>&1)"; then
840914
direct_push_error="$direct_push_output"
841915
merge_completed=0

templates/scripts/agent-branch-finish.sh

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,7 @@ merge_completed=0
382382
merge_status="pr"
383383
direct_push_error=""
384384
pr_url=""
385+
changed_submodule_push_done=0
385386

386387
cleanup() {
387388
if [[ -n "$integration_worktree" && -d "$integration_worktree" ]]; then
@@ -718,6 +719,77 @@ maybe_auto_commit_parent_gitlink() {
718719
echo "[agent-branch-finish] Parent gitlink auto-committed '${subrepo_rel}' in ${super_root}."
719720
}
720721

722+
maybe_push_changed_submodule_branches() {
723+
local base_ref="${1:-}"
724+
local source_ref="${2:-}"
725+
local changed_path=""
726+
local gitlink_mode=""
727+
local gitlink_sha=""
728+
local submodule_dir=""
729+
local branch_name=""
730+
local candidate_branch=""
731+
local remote_name=""
732+
local push_output=""
733+
734+
if [[ "$PUSH_ENABLED" -ne 1 || "$changed_submodule_push_done" -eq 1 ]]; then
735+
return 0
736+
fi
737+
changed_submodule_push_done=1
738+
if [[ -z "$base_ref" || -z "$source_ref" ]]; then
739+
return 0
740+
fi
741+
742+
while IFS= read -r changed_path; do
743+
[[ -n "$changed_path" ]] || continue
744+
745+
gitlink_mode="$(git -C "$source_worktree" ls-tree "$source_ref" -- "$changed_path" | awk 'NR == 1 { print $1 }')"
746+
if [[ "$gitlink_mode" != "160000" ]]; then
747+
continue
748+
fi
749+
gitlink_sha="$(git -C "$source_worktree" ls-tree "$source_ref" -- "$changed_path" | awk 'NR == 1 { print $3 }')"
750+
if [[ -z "$gitlink_sha" ]]; then
751+
continue
752+
fi
753+
754+
submodule_dir="${source_worktree}/${changed_path}"
755+
if ! git -C "$submodule_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
756+
echo "[agent-branch-finish] Warning: changed gitlink '${changed_path}' has no checked-out submodule at ${submodule_dir}; cannot auto-push submodule commit ${gitlink_sha}." >&2
757+
return 1
758+
fi
759+
if ! git -C "$submodule_dir" cat-file -e "${gitlink_sha}^{commit}" >/dev/null 2>&1; then
760+
echo "[agent-branch-finish] Warning: changed gitlink '${changed_path}' points at ${gitlink_sha}, but that commit is not present in ${submodule_dir}; cannot auto-push it." >&2
761+
return 1
762+
fi
763+
764+
branch_name="$(git -C "$submodule_dir" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
765+
if [[ -z "$branch_name" || "$branch_name" == "HEAD" ]] || ! git -C "$submodule_dir" merge-base --is-ancestor "$gitlink_sha" "$branch_name" >/dev/null 2>&1; then
766+
candidate_branch="$(git -C "$submodule_dir" for-each-ref --contains "$gitlink_sha" --format='%(refname:short)' refs/heads | head -n 1)"
767+
branch_name="$candidate_branch"
768+
fi
769+
if [[ -z "$branch_name" || "$branch_name" == "HEAD" ]]; then
770+
echo "[agent-branch-finish] Warning: changed gitlink '${changed_path}' points at ${gitlink_sha}, but no local submodule branch contains it; cannot choose a safe remote branch to push." >&2
771+
return 1
772+
fi
773+
774+
remote_name="$(git -C "$submodule_dir" config --get "branch.${branch_name}.remote" || true)"
775+
if [[ -z "$remote_name" ]]; then
776+
remote_name="origin"
777+
fi
778+
if ! git -C "$submodule_dir" remote get-url "$remote_name" >/dev/null 2>&1; then
779+
echo "[agent-branch-finish] Warning: changed gitlink '${changed_path}' branch '${branch_name}' has no usable remote '${remote_name}'; cannot auto-push submodule commit ${gitlink_sha}." >&2
780+
return 1
781+
fi
782+
783+
if push_output="$(git -C "$submodule_dir" push -u "$remote_name" "${branch_name}:${branch_name}" 2>&1)"; then
784+
echo "[agent-branch-finish] Pushed changed submodule '${changed_path}' branch '${branch_name}' to '${remote_name}' before parent finish."
785+
else
786+
echo "[agent-branch-finish] Changed submodule '${changed_path}' must be pushed before the parent branch can be finished." >&2
787+
[[ -n "$push_output" ]] && echo "$push_output" >&2
788+
return 1
789+
fi
790+
done < <(git -C "$source_worktree" diff --name-only "$base_ref" "$source_ref" -- 2>/dev/null || true)
791+
}
792+
721793
wait_for_pr_merge() {
722794
local deadline
723795
deadline=$(( $(date +%s) + WAIT_TIMEOUT_SECONDS ))
@@ -788,6 +860,7 @@ run_pr_flow() {
788860
return 0
789861
fi
790862

863+
maybe_push_changed_submodule_branches "$start_ref" "$SOURCE_BRANCH"
791864
git -C "$source_worktree" push -u origin "$SOURCE_BRANCH"
792865

793866
pr_title="$(git -C "$repo_root" log -1 --pretty=%s "$SOURCE_BRANCH" 2>/dev/null || true)"
@@ -836,6 +909,7 @@ run_pr_flow() {
836909

837910
if [[ "$PUSH_ENABLED" -eq 1 ]]; then
838911
if [[ "$MERGE_MODE" != "pr" ]]; then
912+
maybe_push_changed_submodule_branches "$start_ref" "$SOURCE_BRANCH"
839913
if ! direct_push_output="$(git -C "$integration_worktree" push origin "HEAD:${BASE_BRANCH}" 2>&1)"; then
840914
direct_push_error="$direct_push_output"
841915
merge_completed=0

test/finish.test.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,70 @@ test('agent-branch-finish auto-commits parent gitlink after nested repo finish',
231231
assert.equal(parentStatus.stdout.trim(), '', 'parent gitlink should be committed cleanly');
232232
});
233233

234+
test('agent-branch-finish pushes changed submodule branch before parent finish', () => {
235+
const parentDir = initRepoOnBranch('main');
236+
seedCommit(parentDir);
237+
attachOriginRemoteForBranch(parentDir, 'main');
238+
239+
const childDir = path.join(parentDir, 'apps', 'storefront');
240+
fs.mkdirSync(childDir, { recursive: true });
241+
let result = runCmd('git', ['init', '-b', 'main'], childDir);
242+
assert.equal(result.status, 0, result.stderr || result.stdout);
243+
configureGitIdentity(childDir);
244+
fs.writeFileSync(path.join(childDir, 'app.css'), '@tailwind utilities;\n', 'utf8');
245+
seedCommit(childDir);
246+
const childOrigin = attachOriginRemoteForBranch(childDir, 'main');
247+
result = runCmd('git', ['push', 'origin', 'main'], childDir);
248+
assert.equal(result.status, 0, result.stderr || result.stdout);
249+
const childMainCommit = runCmd('git', ['rev-parse', 'HEAD'], childDir).stdout.trim();
250+
251+
result = runCmd('git', ['update-index', '--add', '--cacheinfo', '160000', childMainCommit, 'apps/storefront'], parentDir);
252+
assert.equal(result.status, 0, result.stderr || result.stdout);
253+
result = runCmd('git', ['commit', '-m', 'track storefront submodule'], parentDir, {
254+
ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1',
255+
});
256+
assert.equal(result.status, 0, result.stderr || result.stdout);
257+
result = runCmd('git', ['push', 'origin', 'main'], parentDir);
258+
assert.equal(result.status, 0, result.stderr || result.stdout);
259+
260+
const childBranch = 'agent/codex/fix-css-import-order-2026-05-11';
261+
result = runCmd('git', ['checkout', '-b', childBranch], childDir);
262+
assert.equal(result.status, 0, result.stderr || result.stdout);
263+
fs.writeFileSync(path.join(childDir, 'app.css'), '@import url("https://fonts.example/test.css");\n@tailwind utilities;\n', 'utf8');
264+
result = runCmd('git', ['add', 'app.css'], childDir);
265+
assert.equal(result.status, 0, result.stderr || result.stdout);
266+
result = runCmd('git', ['commit', '-m', 'Keep font imports ahead of Tailwind output'], childDir);
267+
assert.equal(result.status, 0, result.stderr || result.stdout);
268+
const childCommit = runCmd('git', ['rev-parse', 'HEAD'], childDir).stdout.trim();
269+
270+
const parentBranch = 'agent/codex/point-storefront-css-fix';
271+
result = runCmd('git', ['checkout', '-b', parentBranch], parentDir);
272+
assert.equal(result.status, 0, result.stderr || result.stdout);
273+
result = runCmd('git', ['update-index', '--cacheinfo', '160000', childCommit, 'apps/storefront'], parentDir);
274+
assert.equal(result.status, 0, result.stderr || result.stdout);
275+
result = runCmd('git', ['commit', '-m', 'Point storefront at CSS import-order fix'], parentDir);
276+
assert.equal(result.status, 0, result.stderr || result.stdout);
277+
278+
result = runBranchFinish([
279+
'--branch',
280+
parentBranch,
281+
'--base',
282+
'main',
283+
'--direct-only',
284+
'--no-cleanup',
285+
], parentDir);
286+
assert.equal(result.status, 0, result.stderr || result.stdout);
287+
assert.match(result.stdout, /Pushed changed submodule 'apps\/storefront' branch 'agent\/codex\/fix-css-import-order-2026-05-11' to 'origin' before parent finish/);
288+
289+
const remoteChildHead = runCmd('git', ['ls-remote', childOrigin, `refs/heads/${childBranch}`], parentDir);
290+
assert.equal(remoteChildHead.status, 0, remoteChildHead.stderr || remoteChildHead.stdout);
291+
assert.match(remoteChildHead.stdout.trim(), new RegExp(`^${childCommit}\\s+refs/heads/${escapeRegexLiteral(childBranch)}$`));
292+
293+
const parentMainGitlink = runCmd('git', ['ls-tree', 'origin/main', '--', 'apps/storefront'], parentDir);
294+
assert.equal(parentMainGitlink.status, 0, parentMainGitlink.stderr || parentMainGitlink.stdout);
295+
assert.match(parentMainGitlink.stdout, new RegExp(`160000 commit ${childCommit}\\s+apps/storefront`));
296+
});
297+
234298

235299
test('agent-branch-finish auto-syncs source branch when behind origin/dev', () => {
236300
const repoDir = initRepo();

0 commit comments

Comments
 (0)