Skip to content

Commit 64e6d0d

Browse files
committed
fix: harden merge-back recovery states
Treat in-progress non-conflicted merges as recoverable during merge-back preparation and make the recovery path less brittle after manual intervention. This preserves the existing merge-back flow while letting resumed cleanup-phase runs progress instead of failing on already-prepared git states.
1 parent 54e045d commit 64e6d0d

3 files changed

Lines changed: 30 additions & 12 deletions

File tree

packages/core/src/cli/post-run-merge-back-support.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,17 @@ export async function prepareMergeBackMerge(args: {
142142
};
143143
}
144144

145+
if (!mergeInProgress && statusBefore.stdout.trim().length > 0) {
146+
return {
147+
alreadyPrepared: false,
148+
earlyResult: {
149+
status: "aborted",
150+
message:
151+
"Merge-back aborted: main repo has uncommitted changes. Commit/stash first and rerun resume.",
152+
},
153+
};
154+
}
155+
145156
const branchCheck = await ensureBaseBranchCheckedOut({
146157
exec: args.exec,
147158
prompt: args.prompt,
@@ -180,17 +191,6 @@ export async function prepareMergeBackMerge(args: {
180191
return { alreadyPrepared: true, earlyResult: null };
181192
}
182193

183-
if (statusBefore.stdout.trim().length > 0) {
184-
return {
185-
alreadyPrepared: false,
186-
earlyResult: {
187-
status: "aborted",
188-
message:
189-
"Merge-back aborted: main repo has uncommitted changes. Commit/stash first and rerun resume.",
190-
},
191-
};
192-
}
193-
194194
const alreadyMerged = await runGit(args.exec, args.repoPath, [
195195
"merge-base",
196196
"--is-ancestor",

packages/core/src/cli/post-run-merge-back.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ function summarizeFirstFailure(
7878
return detail ? `${failed.name}: ${detail}` : failed.name;
7979
}
8080

81+
function isNoOpCommitMessage(message: string): boolean {
82+
return /nothing to commit, working tree clean/i.test(message);
83+
}
84+
8185
function makeExecWithDefaults(state: OttoStateV1): OttoExec {
8286
const rawExec = createNodeExec();
8387
const defaultEnv = buildRunDefaultEnv(state);
@@ -353,8 +357,17 @@ export async function maybeRunPostCleanupMergeBack(args: {
353357
`Merge ${worktreeBranch} after Otto run ${state.runId}`,
354358
], 60_000);
355359
if (commit.exitCode !== 0 || commit.timedOut) {
356-
await abortPendingMerge(exec, repoPath);
357360
const commitMessage = (commit.stderr || commit.stdout || "git commit failed").trim();
361+
if (isNoOpCommitMessage(commitMessage)) {
362+
const mergeHead = await runGit(exec, repoPath, ["rev-parse", "--verify", "--quiet", "MERGE_HEAD"], 30_000);
363+
if (mergeHead.exitCode !== 0 || mergeHead.timedOut) {
364+
return {
365+
status: "skipped",
366+
message: `Skipped merge-back: ${worktreeBranch} is already merged into ${baseBranch}.`,
367+
};
368+
}
369+
}
370+
await abortPendingMerge(exec, repoPath);
358371
return {
359372
status: "failed",
360373
message: `Merge-back failed while committing merge: ${commitMessage}`,

packages/core/test/post-run-merge-back.test.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ test("merge-back aborts when main repo has uncommitted changes", async () => {
114114
match:
115115
"git rev-parse --verify --quiet workflow-2026-03-22-expo-graphql-resolvers",
116116
},
117+
{ match: "git rev-parse --verify --quiet MERGE_HEAD", result: { exitCode: 1 } },
117118
{ match: "git status --porcelain=v1", result: { stdout: " M package.json\n" } },
118119
]);
119120

@@ -136,6 +137,7 @@ test("merge-back runs checks and commits on success", async () => {
136137
match:
137138
"git rev-parse --verify --quiet workflow-2026-03-22-expo-graphql-resolvers",
138139
},
140+
{ match: "git rev-parse --verify --quiet MERGE_HEAD", result: { exitCode: 1 } },
139141
{ match: "git status --porcelain=v1" },
140142
{ match: "git rev-parse --abbrev-ref HEAD", result: { stdout: "main\n" } },
141143
{
@@ -194,6 +196,7 @@ test("merge-back aborts merge when post-merge checks fail", async () => {
194196
match:
195197
"git rev-parse --verify --quiet workflow-2026-03-22-expo-graphql-resolvers",
196198
},
199+
{ match: "git rev-parse --verify --quiet MERGE_HEAD", result: { exitCode: 1 } },
197200
{ match: "git status --porcelain=v1" },
198201
{ match: "git rev-parse --abbrev-ref HEAD", result: { stdout: "main\n" } },
199202
{
@@ -241,6 +244,7 @@ test("merge-back resolves merge conflicts with task runner", async () => {
241244
match:
242245
"git rev-parse --verify --quiet workflow-2026-03-22-expo-graphql-resolvers",
243246
},
247+
{ match: "git rev-parse --verify --quiet MERGE_HEAD", result: { exitCode: 1 } },
244248
{ match: "git status --porcelain=v1" },
245249
{ match: "git rev-parse --abbrev-ref HEAD", result: { stdout: "main\n" } },
246250
{
@@ -289,6 +293,7 @@ test("merge-back aborts when automatic conflict resolution is declined", async (
289293
match:
290294
"git rev-parse --verify --quiet workflow-2026-03-22-expo-graphql-resolvers",
291295
},
296+
{ match: "git rev-parse --verify --quiet MERGE_HEAD", result: { exitCode: 1 } },
292297
{ match: "git status --porcelain=v1" },
293298
{ match: "git rev-parse --abbrev-ref HEAD", result: { stdout: "main\n" } },
294299
{

0 commit comments

Comments
 (0)