Files
oh-my-pi/packages/coding-agent/src/task/worktree.ts
T
can1357 8cd23ebd15 merge #3844: 3-way dirty-context fallback for isolated branch merges
# Conflicts:
#	packages/coding-agent/src/task/worktree.ts
#	packages/coding-agent/test/task/worktree.test.ts
2026-06-30 03:03:43 +02:00

787 lines
27 KiB
TypeScript

import type { Dirent } from "node:fs";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import * as natives from "@oh-my-pi/pi-natives";
import { getWorktreeDir, logger, Snowflake } from "@oh-my-pi/pi-utils";
import * as git from "../utils/git";
import * as jj from "../utils/jj";
import { mapWithConcurrencyLimit } from "./parallel";
const { IsoBackendKind } = natives;
const TASK_ISOLATION_DIR_PREFIX = "t";
const TASK_ISOLATION_DIR_DIGEST_CHARS = 9;
const TASK_ISOLATION_MOUNT_DIR = "m";
type IsoBackendKind = natives.IsoBackendKind;
/** Baseline state for a single git repository. */
export interface RepoBaseline {
repoRoot: string;
headCommit: string;
staged: string;
unstaged: string;
untracked: string[];
untrackedPatch: string;
}
/** Baseline state for the project, including any nested git repos. */
export interface WorktreeBaseline {
root: RepoBaseline;
/** Nested git repos (path relative to root.repoRoot). */
nested: Array<{ relativePath: string; baseline: RepoBaseline }>;
}
export async function getRepoRoot(cwd: string): Promise<string> {
// Pure-jj check runs first so a jj workspace nested under an unrelated
// outer Git checkout is rejected at its own root rather than silently
// mutating the surrounding Git tree behind jj's back.
if (await jj.isPureJjRepo(cwd)) {
throw new Error(
"Isolated task execution requires a Git checkout, but this workspace is pure Jujutsu (`.jj/` without a colocated `.git/`). Run `jj git init --colocate` to add a Git checkout, or set `task.isolation.mode: none` to disable task isolation.",
);
}
const repoRoot = await git.repo.root(cwd);
if (repoRoot) return repoRoot;
throw new Error("Git repository not found for isolated task execution.");
}
const GIT_NO_INDEX_NULL_PATH = process.platform === "win32" ? "NUL" : "/dev/null";
export function getGitNoIndexNullPath(): string {
return GIT_NO_INDEX_NULL_PATH;
}
/** Find nested git repositories (non-submodule) under the given root. */
async function discoverNestedRepos(repoRoot: string): Promise<string[]> {
// Get submodule paths so we can exclude them
const submodulePaths = new Set(await git.ls.submodules(repoRoot));
// Find all .git dirs/files that aren't the root or known submodules
const result: string[] = [];
async function walk(dir: string): Promise<void> {
let entries: Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.name === "node_modules" || entry.name === ".git") continue;
if (!entry.isDirectory()) continue;
const full = path.join(dir, entry.name);
const rel = path.relative(repoRoot, full);
// Check if this directory is itself a git repo
const gitDir = path.join(full, ".git");
let hasGit = false;
try {
await fs.access(gitDir);
hasGit = true;
} catch {}
if (hasGit && !submodulePaths.has(rel)) {
result.push(rel);
// Don't recurse into nested repos — they manage their own tree
continue;
}
await walk(full);
}
}
await walk(repoRoot);
return result;
}
async function captureUntrackedPatch(repoRoot: string, untracked: readonly string[]): Promise<string> {
if (untracked.length === 0) return "";
const nullPath = getGitNoIndexNullPath();
// Bound concurrent git spawns; large untracked sets would otherwise fork one
// process per file at once.
const { results: untrackedDiffs } = await mapWithConcurrencyLimit([...untracked], 8, entry =>
git.diff(repoRoot, {
allowFailure: true,
binary: true,
noIndex: { left: nullPath, right: entry },
}),
);
return untrackedDiffs.filter((diff): diff is string => !!diff?.trim()).join("\n");
}
async function captureRepoBaseline(repoRoot: string): Promise<RepoBaseline> {
const headCommit = (await git.head.sha(repoRoot)) ?? "";
const staged = await git.diff(repoRoot, { binary: true, cached: true });
const unstaged = await git.diff(repoRoot, { binary: true });
const untracked = await git.ls.untracked(repoRoot);
const untrackedPatch = await captureUntrackedPatch(repoRoot, untracked);
return { repoRoot, headCommit, staged, unstaged, untracked, untrackedPatch };
}
async function writeSyntheticTree(repoDir: string, baseTreeish: string, patches: readonly string[]): Promise<string> {
const tempIndex = path.join(os.tmpdir(), `omp-task-index-${Snowflake.next()}`);
try {
await git.readTree(repoDir, baseTreeish, {
env: { GIT_INDEX_FILE: tempIndex },
});
for (const patch of patches) {
if (!patch.trim()) continue;
await git.patch.applyText(repoDir, patch, {
cached: true,
env: { GIT_INDEX_FILE: tempIndex },
});
}
return await git.writeTree(repoDir, {
env: { GIT_INDEX_FILE: tempIndex },
});
} finally {
await fs.rm(tempIndex, { force: true });
}
}
export async function captureBaseline(repoRoot: string): Promise<WorktreeBaseline> {
const [root, nestedPaths] = await Promise.all([captureRepoBaseline(repoRoot), discoverNestedRepos(repoRoot)]);
const nested = await Promise.all(
nestedPaths.map(async relativePath => ({
relativePath,
baseline: await captureRepoBaseline(path.join(repoRoot, relativePath)),
})),
);
return { root, nested };
}
async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline, objectRepoDir = repoDir): Promise<string> {
const currentHead = (await git.head.sha(repoDir)) ?? "";
const currentStaged = await git.diff(repoDir, { binary: true, cached: true });
const currentUnstaged = await git.diff(repoDir, { binary: true });
const currentUntracked = await git.ls.untracked(repoDir);
const currentUntrackedPatch = await captureUntrackedPatch(repoDir, currentUntracked);
const committedPatch =
currentHead && currentHead !== rb.headCommit
? await git.diff.tree(repoDir, rb.headCommit, currentHead, {
allowFailure: true,
binary: true,
})
: "";
const baselineTree = await writeSyntheticTree(objectRepoDir, rb.headCommit, [
rb.staged,
rb.unstaged,
rb.untrackedPatch,
]);
const currentTree = await writeSyntheticTree(objectRepoDir, rb.headCommit, [
committedPatch,
currentStaged,
currentUnstaged,
currentUntrackedPatch,
]);
return git.diff.tree(objectRepoDir, baselineTree, currentTree, {
allowFailure: true,
binary: true,
});
}
export interface NestedRepoPatch {
relativePath: string;
patch: string;
}
function unquoteGitDiffPath(rawPath: string): string {
let value = rawPath;
if (value.startsWith('"') && value.endsWith('"')) {
try {
value = JSON.parse(value) as string;
} catch {
value = value.slice(1, -1);
}
}
return value.replace(/^[ab]\//, "");
}
function parseDiffGitLinePaths(line: string): string[] {
if (!line.startsWith("diff --git ")) return [];
const rest = line.slice("diff --git ".length);
const quoted = rest.match(/^("(?:\\.|[^"])+"|\/dev\/null) ("(?:\\.|[^"])+"|\/dev\/null)$/);
const parts = quoted ? [quoted[1], quoted[2]] : rest.split(" ");
if (parts.length < 2) return [];
const paths = parts
.slice(0, 2)
.map(unquoteGitDiffPath)
.filter(file => file && file !== "/dev/null");
return [...new Set(paths)];
}
function patchTouchedFiles(patch: string): string[] {
const files = new Set<string>();
for (const line of patch.split("\n")) {
for (const file of parseDiffGitLinePaths(line)) files.add(file);
}
return [...files];
}
export interface DeltaPatchResult {
rootPatch: string;
nestedPatches: NestedRepoPatch[];
}
export async function captureDeltaPatch(isolationDir: string, baseline: WorktreeBaseline): Promise<DeltaPatchResult> {
const rootPatch = await captureRepoDeltaPatch(isolationDir, baseline.root, baseline.root.repoRoot);
const nestedPatches: NestedRepoPatch[] = [];
for (const { relativePath, baseline: nb } of baseline.nested) {
const nestedDir = path.join(isolationDir, relativePath);
try {
await fs.access(path.join(nestedDir, ".git"));
} catch {
continue;
}
const patch = await captureRepoDeltaPatch(nestedDir, nb, nb.repoRoot);
if (patch.trim()) nestedPatches.push({ relativePath, patch });
}
return { rootPatch, nestedPatches };
}
/**
* Apply nested repo patches directly to their working directories after parent merge.
*
* Pre-existing dirty state in a nested repo is stashed before the patch is
* applied and popped back (with `--index` so staged WIP stays staged) after
* the commit, so unrelated user edits never get folded into the agent's
* commit. A failing `git stash pop` (e.g. user edits collide with the patched
* lines) leaves the stash entry intact, emits a `logger.warn`, and is
* returned to the caller as a human-readable warning string — the agent
* commit already landed, so this is a partial success the workflow needs to
* see, not a thrown failure.
*
* Returns the collected stash-restore warnings (empty when every nested repo
* was restored cleanly). Throws when the patch apply itself fails.
*
* @param commitMessage Optional async function to generate a commit message from the combined diff.
* If omitted or returns null, falls back to a generic message.
*/
export async function applyNestedPatches(
repoRoot: string,
patches: NestedRepoPatch[],
commitMessage?: (diff: string) => Promise<string | null>,
): Promise<string[]> {
const warnings: string[] = [];
// Group patches by target repo to apply all at once and commit
const byRepo = new Map<string, NestedRepoPatch[]>();
for (const p of patches) {
if (!p.patch.trim()) continue;
const group = byRepo.get(p.relativePath) ?? [];
group.push(p);
byRepo.set(p.relativePath, group);
}
for (const [relativePath, repoPatches] of byRepo) {
const nestedDir = path.join(repoRoot, relativePath);
try {
await fs.access(path.join(nestedDir, ".git"));
} catch {
continue;
}
const combinedDiff = repoPatches.map(p => p.patch).join("\n");
const touchedFiles = [...new Set(repoPatches.flatMap(p => patchTouchedFiles(p.patch)))];
// Preserve any pre-existing dirty state (tracked + untracked) so we
// commit only the agent delta, not the user's in-flight work.
const stashed =
(await git.status(nestedDir)).trim().length > 0
? await git.stash.push(nestedDir, `omp-isolation-${Snowflake.next()}`)
: false;
try {
for (const { patch } of repoPatches) {
await git.patch.applyText(nestedDir, patch);
}
if ((await git.status(nestedDir)).trim().length > 0) {
if (touchedFiles.length === 0) {
throw new Error(`Nested repo patch for ${relativePath} did not include stageable file paths.`);
}
const msg = (await commitMessage?.(combinedDiff)) ?? "changes from isolated task(s)";
await git.stage.files(nestedDir, touchedFiles);
await git.commit(nestedDir, msg);
}
} finally {
if (stashed) {
try {
await git.stash.pop(nestedDir, { index: true });
} catch (popErr) {
const message = popErr instanceof Error ? popErr.message : String(popErr);
logger.warn("Pre-existing nested-repo dirty state could not be auto-restored", {
nestedDir,
error: message,
});
warnings.push(
`Pre-existing dirty state in nested repo \`${relativePath}\` could not be auto-restored after the agent commit; stash entry preserved (${message}).`,
);
}
}
}
}
return warnings;
}
// ═══════════════════════════════════════════════════════════════════════════
// Unified isolation lifecycle — picks the best backend via the PAL and
// returns the merged-view path together with the resolved kind.
// ═══════════════════════════════════════════════════════════════════════════
/**
* User-facing isolation mode names exposed by the `task.isolation.mode`
* setting. Mapped to a backend-kind hint via {@link parseIsolationMode};
* the PAL's `iso_resolve` then falls back through the kind order
* whenever the hint isn't available on the current host.
*/
export type TaskIsolationMode =
| "none"
| "auto"
| "apfs"
| "btrfs"
| "zfs"
| "reflink"
| "overlayfs"
| "projfs"
| "block-clone"
| "rcopy"
// Legacy values, accepted for back-compat with pre-PAL settings files.
| "worktree"
| "fuse-overlay"
| "fuse-projfs";
/**
* Translate a {@link TaskIsolationMode} string to an [`IsoBackendKind`]
* the PAL can act on. `"none"` returns `null` (caller skips isolation
* entirely); `"auto"` returns `undefined` (no hint — let the resolver
* pick). Anything else returns the matching kind.
*/
export function parseIsolationMode(mode: TaskIsolationMode): IsoBackendKind | undefined {
switch (mode) {
case "none":
case "auto":
return undefined;
case "apfs":
return IsoBackendKind.Apfs;
case "btrfs":
return IsoBackendKind.Btrfs;
case "zfs":
return IsoBackendKind.Zfs;
case "reflink":
return IsoBackendKind.LinuxReflink;
case "overlayfs":
case "fuse-overlay":
return IsoBackendKind.Overlayfs;
case "projfs":
case "fuse-projfs":
return IsoBackendKind.Projfs;
case "block-clone":
return IsoBackendKind.WindowsBlockClone;
case "rcopy":
case "worktree":
return IsoBackendKind.Rcopy;
}
}
export interface IsolationHandle {
/** Merged view materialised by the backend; pass this to the task. */
mergedDir: string;
/** Backend the PAL actually used. */
backend: IsoBackendKind;
/** True when the resolver downgraded from `preferred` to `backend`. */
fellBack: boolean;
/** Optional reason associated with `fellBack`. */
fallbackReason: string | null;
}
/**
* Materialise `merged` for a single task. `preferred` is a hint — when
* its prerequisites are missing the PAL silently falls back, and the
* caller learns about that through `IsolationHandle.fellBack` +
* `fallbackReason`.
*/
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function getTaskIsolationSegment(repoRoot: string, id: string): string {
const key = `${path.resolve(repoRoot)}\0${id}`;
const digest = Bun.hash(key).toString(16).padStart(16, "0").slice(-TASK_ISOLATION_DIR_DIGEST_CHARS);
return `${TASK_ISOLATION_DIR_PREFIX}${digest}`;
}
export async function ensureIsolation(
baseCwd: string,
id: string,
preferred?: IsoBackendKind,
): Promise<IsolationHandle> {
const repoRoot = await getRepoRoot(baseCwd);
const baseDir = getWorktreeDir(getTaskIsolationSegment(repoRoot, id));
const mergedDir = path.join(baseDir, TASK_ISOLATION_MOUNT_DIR);
const resolution = natives.isoResolve(preferred ?? null);
const candidates = resolution.candidates.length > 0 ? resolution.candidates : [resolution.kind];
let fallbackReason = resolution.reason ?? null;
for (const candidate of candidates) {
await fs.rm(baseDir, { recursive: true, force: true });
try {
await natives.isoStart(candidate, repoRoot, mergedDir);
return {
mergedDir,
backend: candidate,
fellBack: candidate !== resolution.kind || resolution.fellBack,
fallbackReason,
};
} catch (err) {
await fs.rm(baseDir, { recursive: true, force: true });
const message = errorMessage(err);
if (!natives.isoIsUnavailableError(message)) {
throw err;
}
fallbackReason ??= message;
}
}
throw new Error(fallbackReason ?? "No isolation backend is available.");
}
/** Tear down a handle returned by {@link ensureIsolation}. */
export async function cleanupIsolation(handle: IsolationHandle): Promise<void> {
try {
try {
await natives.isoStop(handle.backend, handle.mergedDir);
} catch (err) {
logger.warn("isolation backend stop failed during cleanup", {
backend: handle.backend,
mergedDir: handle.mergedDir,
error: err instanceof Error ? err.message : String(err),
});
}
} finally {
// baseDir is the parent of the merged directory
const baseDir = path.dirname(handle.mergedDir);
await fs.rm(baseDir, { recursive: true, force: true });
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Branch-mode isolation
// ═══════════════════════════════════════════════════════════════════════════
export interface CommitToBranchResult {
branchName?: string;
nestedPatches: NestedRepoPatch[];
/**
* SHA of the parent-repo commit the task branch was created on top of, so
* {@link mergeTaskBranches} can cherry-pick the range `baseSha..branchName`
* and preserve every agent commit's message and author.
*/
baseSha?: string;
}
function baselineHasRootWip(baseline: RepoBaseline): boolean {
return !!(baseline.staged.trim() || baseline.unstaged.trim() || baseline.untrackedPatch.trim());
}
async function commitPatchToBranchWorktree(
tmpDir: string,
taskId: string,
patchText: string,
message: string,
author?: git.CommitAuthor,
): Promise<void> {
try {
await git.patch.applyText(tmpDir, patchText);
} catch (err) {
if (!(err instanceof git.GitCommandError)) throw err;
// Plain apply rejects when the parent checkout carries unrelated dirty
// context near the task's edits; retry with --3way before giving up.
try {
await git.patch.applyText(tmpDir, patchText, { threeWay: true });
} catch (threeWayErr) {
if (threeWayErr instanceof git.GitCommandError) {
const stderr = threeWayErr.result.stderr.slice(0, 2000);
logger.error("commitToBranch: git apply --3way failed", {
taskId,
exitCode: threeWayErr.result.exitCode,
stderr,
initialStderr: err.result.stderr.slice(0, 2000),
patchSize: patchText.length,
patchHead: patchText.slice(0, 500),
});
throw new Error(`git apply --3way failed for task ${taskId}: ${stderr}`);
}
throw threeWayErr;
}
}
await git.stage.files(tmpDir);
await git.commit(tmpDir, message, author ? { author } : {});
}
interface FilteredAgentReplayOptions {
baseline: WorktreeBaseline;
branchName: string;
commitMessage?: (diff: string) => Promise<string | null>;
fallbackMessage: string;
isolationDir: string;
isolationHead: string;
repoRoot: string;
rootPatch: string;
taskId: string;
}
async function replayFilteredAgentCommits(opts: FilteredAgentReplayOptions): Promise<void> {
const baselineSha = opts.baseline.root.headCommit;
await git.branch.create(opts.repoRoot, opts.branchName, baselineSha);
const tmpDir = path.join(os.tmpdir(), `omp-branch-${Snowflake.next()}`);
try {
await git.worktree.add(opts.repoRoot, tmpDir, opts.branchName);
const agentCommits = await git.revList.range(opts.isolationDir, baselineSha, opts.isolationHead);
const dirtyBaselineTree = await writeSyntheticTree(opts.isolationDir, baselineSha, [
opts.baseline.root.staged,
opts.baseline.root.unstaged,
opts.baseline.root.untrackedPatch,
]);
let previousFilteredTree = baselineSha;
for (const commitSha of agentCommits) {
const taskStatePatch = await git.diff.tree(opts.isolationDir, dirtyBaselineTree, `${commitSha}^{tree}`, {
allowFailure: true,
binary: true,
});
const currentFilteredTree = await writeSyntheticTree(opts.repoRoot, baselineSha, [taskStatePatch]);
const commitPatch = await git.diff.tree(opts.repoRoot, previousFilteredTree, currentFilteredTree, {
allowFailure: true,
binary: true,
});
if (commitPatch.trim()) {
const details = await git.commitDetails(opts.isolationDir, commitSha);
await commitPatchToBranchWorktree(
tmpDir,
opts.taskId,
commitPatch,
details.message || commitSha,
details.author,
);
}
previousFilteredTree = currentFilteredTree;
}
const finalFilteredTree = await writeSyntheticTree(opts.repoRoot, baselineSha, [opts.rootPatch]);
const leftoverPatch = await git.diff.tree(opts.repoRoot, previousFilteredTree, finalFilteredTree, {
allowFailure: true,
binary: true,
});
if (leftoverPatch.trim()) {
const msg = (opts.commitMessage && (await opts.commitMessage(leftoverPatch))) || opts.fallbackMessage;
await commitPatchToBranchWorktree(tmpDir, opts.taskId, leftoverPatch, msg);
}
} finally {
await git.worktree.tryRemove(opts.repoRoot, tmpDir);
await fs.rm(tmpDir, { recursive: true, force: true });
}
}
/**
* Capture task-only changes from the isolation worktree onto a parent-repo
* branch named `omp/task/${taskId}`. Only root-repo changes go on the branch;
* nested-repo patches are returned separately because the parent git can't
* track files inside gitlinks.
*
* If the agent committed inside isolation (HEAD moved past
* `baseline.root.headCommit`), clean-baseline runs fetch the raw commit range
* into the parent repo and later cherry-pick `baseSha..branchName`, preserving
* every message and author verbatim. Dirty-baseline runs rewrite each agent
* commit against the captured baseline WIP before committing it to the task
* branch, so user staged/unstaged/untracked changes present at isolation
* start are not replayed into the parent commit history.
*
* If the agent did not commit, the captured delta is collapsed onto a single
* branch commit with an AI-generated (or fallback) message — the legacy
* behaviour.
*
* Returns `null` when no root or nested changes exist.
*/
export async function commitToBranch(
isolationDir: string,
baseline: WorktreeBaseline,
taskId: string,
description: string | undefined,
commitMessage?: (diff: string) => Promise<string | null>,
): Promise<CommitToBranchResult | null> {
const baselineSha = baseline.root.headCommit;
const isolationHead = (await git.head.sha(isolationDir)) ?? "";
const agentCommitted = isolationHead !== "" && isolationHead !== baselineSha;
const { rootPatch, nestedPatches } = await captureDeltaPatch(isolationDir, baseline);
if (!rootPatch.trim() && nestedPatches.length === 0) return null;
if (!rootPatch.trim()) return { nestedPatches };
const repoRoot = baseline.root.repoRoot;
const branchName = `omp/task/${taskId}`;
const fallbackMessage = description || taskId;
let branchCreated = false;
if (agentCommitted) {
if (baselineHasRootWip(baseline.root)) {
await replayFilteredAgentCommits({
baseline,
branchName,
commitMessage,
fallbackMessage,
isolationDir,
isolationHead,
repoRoot,
rootPatch,
taskId,
});
} else {
// Transfer the agent's commit objects (which live in isolation's `.git`,
// stranded once `cleanupIsolation` tears the overlay down) into the parent
// repo's object DB and create the branch at the agent's HEAD. `+HEAD:…`
// force-overwrites a stale branch from a prior run.
await git.fetch(repoRoot, isolationDir, "HEAD", `refs/heads/${branchName}`);
// Leftover = anything still uncommitted in isolation on top of the
// agent's last commit (staged, unstaged, untracked). The agent didn't
// commit it, so it goes in as one AI-summarized trailing commit.
const leftoverPatch = await captureRepoDeltaPatch(isolationDir, {
repoRoot: isolationDir,
headCommit: isolationHead,
staged: "",
unstaged: "",
untracked: [],
untrackedPatch: "",
});
if (leftoverPatch.trim()) {
const tmpDir = path.join(os.tmpdir(), `omp-branch-${Snowflake.next()}`);
try {
await git.worktree.add(repoRoot, tmpDir, branchName);
const msg = (commitMessage && (await commitMessage(leftoverPatch))) || fallbackMessage;
await commitPatchToBranchWorktree(tmpDir, taskId, leftoverPatch, msg);
} finally {
await git.worktree.tryRemove(repoRoot, tmpDir);
await fs.rm(tmpDir, { recursive: true, force: true });
}
}
}
branchCreated = true;
} else if (rootPatch.trim()) {
await git.branch.create(repoRoot, branchName, baselineSha);
branchCreated = true;
const tmpDir = path.join(os.tmpdir(), `omp-branch-${Snowflake.next()}`);
try {
await git.worktree.add(repoRoot, tmpDir, branchName);
const msg = (commitMessage && (await commitMessage(rootPatch))) || fallbackMessage;
await commitPatchToBranchWorktree(tmpDir, taskId, rootPatch, msg);
} finally {
await git.worktree.tryRemove(repoRoot, tmpDir);
await fs.rm(tmpDir, { recursive: true, force: true });
}
}
return {
branchName: branchCreated ? branchName : undefined,
baseSha: baselineSha,
nestedPatches,
};
}
export interface MergeBranchResult {
merged: string[];
failed: string[];
conflict?: string;
/** Set when cherry-picks landed on HEAD but restoring the stashed working tree failed. */
stashConflict?: string;
}
/**
* Cherry-pick task branch commits sequentially onto HEAD. When `baseSha` is
* provided the cherry-pick uses the inclusive range `baseSha..branchName`,
* replaying every commit individually and preserving each commit's message
* and author. When omitted, the branch is cherry-picked as a single commit
* (legacy callers).
*
* Stops on the first conflict and reports which branches succeeded.
*/
export async function mergeTaskBranches(
repoRoot: string,
branches: Array<{ branchName: string; taskId: string; description?: string; baseSha?: string }>,
): Promise<MergeBranchResult> {
// Serialize against other in-process git mutations on this repo: concurrent
// background merges interleaving stash push/pop + cherry-pick would corrupt
// the working tree (lost uncommitted changes, mixed-up stash entries).
return git.withRepoLock(repoRoot, async () => {
const merged: string[] = [];
const failed: string[] = [];
// Stash dirty working tree so cherry-pick can operate on a clean HEAD.
// Without this, cherry-pick refuses to run when uncommitted changes exist.
const didStash = await git.stash.push(repoRoot, "omp-task-merge");
let conflictResult: MergeBranchResult | undefined;
try {
for (const { branchName, baseSha } of branches) {
try {
const target = baseSha ? `${baseSha}..${branchName}` : branchName;
await git.cherryPick(repoRoot, target);
} catch (err) {
try {
await git.cherryPick.abort(repoRoot);
} catch {
/* no state to abort */
}
const stderr =
err instanceof git.GitCommandError
? err.result.stderr.trim()
: err instanceof Error
? err.message
: String(err);
failed.push(branchName);
conflictResult = {
merged,
failed: [...failed, ...branches.slice(merged.length + failed.length).map(b => b.branchName)],
conflict: `${branchName}: ${stderr}`,
};
break;
}
merged.push(branchName);
}
} finally {
if (didStash) {
try {
await git.stash.pop(repoRoot, { index: true });
} catch {
// Stash-pop conflicts mean the replayed changes clash with the user's
// uncommitted edits. The cherry-picked commits are already on HEAD, so
// the merged branches DID land — report them as merged and surface the
// stash conflict separately instead of claiming they are unmerged.
logger.warn("Failed to restore stashed changes after task merge; stash entry preserved");
const stashConflict =
"stash pop: cherry-picked changes conflict with uncommitted edits. The merged commits are on HEAD; run `git stash pop` and resolve manually.";
if (conflictResult) {
conflictResult.stashConflict = stashConflict;
} else {
conflictResult = { merged, failed: [], stashConflict };
}
}
}
}
return conflictResult ?? { merged, failed };
});
}
/** Clean up temporary task branches. */
export async function cleanupTaskBranches(repoRoot: string, branches: string[]): Promise<void> {
for (const branch of branches) {
await git.branch.tryDelete(repoRoot, branch);
}
}