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
114 changes: 108 additions & 6 deletions packages/code/src/lib/conflict-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,61 @@ Instructions:
When you are done, every conflict must be resolved and the merge commit created.`;
}

/** How many times a failed pre-push hook may be handed to the agent for fixing. */
const MAX_HOOK_FIX_ATTEMPTS = 2;

/** Characters of raw hook error output included in the agent's fix prompt. */
const HOOK_ERROR_PROMPT_LENGTH = 1500;

/** Build the agent prompt for fixing a failed pre-push hook on the merge commit. */
export function buildHookFixPrompt(options: { branch: string; hookError: string }): string {
const rawError = options.hookError.trim();
const excerpt =
rawError.length > HOOK_ERROR_PROMPT_LENGTH
? `…${rawError.slice(-HOOK_ERROR_PROMPT_LENGTH)}`
: rawError;
return `# Fix Pre-Push Hook Failures

A \`git push\` of \`${options.branch}\` was rejected by a pre-push hook. The branch
carries a merge commit that has NOT been published yet.

Hook error output (may be truncated):
\`\`\`
${excerpt || "(no output captured — run the project's check scripts to reproduce)"}
\`\`\`

Instructions:
1. Reproduce the failing checks with the project's own scripts (lint, format,
typecheck, tests) and fix the underlying issues. Change as little as possible.
2. Stage everything with \`git add -A\`.
3. Fold the fixes into the existing commit with \`git commit --amend --no-edit\`
(only if there is anything to fold in — the commit is a merge commit and must
keep both parents).
4. Do NOT push. Do NOT rebase, reset, or create new commits beyond the amend.
5. Do NOT abort or undo the merge.

When you are done, the failing checks must pass locally and the working tree
must be clean.`;
}

/**
* Fold leftover working-tree changes from a hook-fix agent run into the merge
* commit (amend keeps both parents). A no-op when the tree is already clean.
*
* @returns `true` when changes were found and successfully amended in.
*/
async function amendHookFixIntoMergeCommit(workDir: string): Promise<boolean> {
const status = await Utils.executeGitCommand(["status", "--porcelain"], { cwd: workDir });
if (!status.success || status.output.trim() === "") {
return false;
}
await Utils.executeGitCommand(["add", "-A"], { cwd: workDir });
const amend = await Utils.executeGitCommand(["commit", "--amend", "--no-edit"], {
cwd: workDir,
});
return amend.success;
}

/**
* How many times the merge+push cycle may restart when a push is rejected
* because the branch moved underneath us (or was rejected transiently).
Expand Down Expand Up @@ -520,12 +575,59 @@ export async function resolveConflictsOnPr(

// The exact lease makes the head check above atomic with the push. The
// push helper also verifies that HEAD descends from the leased commit.
const push = await Utils.pushCurrentBranch({
cwd: workDir,
expectedBranch: branch,
expectedRemoteSha: leaseSha ?? remoteTip,
verbose,
});
const pushOnce = () =>
Utils.pushCurrentBranch({
cwd: workDir,
expectedBranch: branch,
expectedRemoteSha: leaseSha ?? remoteTip,
verbose,
});
let push = await pushOnce();

// The PR repo's pre-push hooks run inside this ephemeral worktree and
// can fail for fixable reasons (lint, formatting, types). Hand the
// failure to the agent and retry, mirroring the review flow's hook
// fixer; without this the resolved merge never lands on the PR. A
// branch race is not fixable by the agent, so the lease is re-checked
// first to keep the existing defer/retry handling authoritative.
let hookFixAttempt = 0;
while (!push.success && push.hookError && hookFixAttempt < MAX_HOOK_FIX_ATTEMPTS) {
if (leaseSha) {
const refreshed = await Utils.executeGitCommand(
["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`],
{ cwd: workDir, verbose },
);
const moved = refreshed.success
? await Utils.executeGitCommand(["rev-parse", `origin/${branch}`], { cwd: workDir })
: null;
const newTip = moved?.success ? moved.output.trim() : null;
if (newTip && newTip !== leaseSha) {
break; // the post-push handling below defers or retries on races
}
}
hookFixAttempt++;
console.log(
`⚠️ Pre-push hook failed (attempt ${hookFixAttempt}/${MAX_HOOK_FIX_ATTEMPTS}); handing to the agent`,
);
let fixResult: { success: boolean; output: string };
try {
fixResult = await agentRunner(
buildHookFixPrompt({ branch, hookError: push.hookError }),
workDir,
verbose,
);
} catch (error) {
fixResult = { success: false, output: (error as Error).message };
}
// Trust the tree, not the agent's word: fold any leftover changes into
// the merge commit so the retry pushes a complete tree.
const amended = await amendHookFixIntoMergeCommit(workDir);
if (!fixResult.success && !amended) {
console.warn("⚠️ Hook-fix agent run failed; retrying the push anyway");
}
push = await pushOnce();
}

if (!push.success) {
if (leaseSha) {
const refreshed = await Utils.executeGitCommand(
Expand Down
89 changes: 88 additions & 1 deletion packages/code/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { fetchWithRetry as sharedFetchWithRetry } from "@devintern/utils";
import { spawn } from "child_process";
import { existsSync, mkdirSync, readdirSync, rmSync } from "fs";
import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "fs";
import { basename, dirname, join } from "path";

/**
Expand Down Expand Up @@ -1788,6 +1788,10 @@ export class Utils {
if (verbose) {
console.log(`📦 Installing dependencies...`);
}
// Confine hook rewrites by dependency postinstalls (lefthook)
// to this worktree, before `bun install` gets a chance to
// touch the shared `.git/hooks`.
await Utils.isolateWorktreeHooks(worktreePath, { verbose });
const installResult = await Utils.installDependencies(worktreePath, { verbose });

if (!installResult.success) {
Expand Down Expand Up @@ -1969,6 +1973,10 @@ export class Utils {
if (verbose) {
console.log(`📦 Installing dependencies...`);
}
// Confine hook rewrites by dependency postinstalls (lefthook) to this
// worktree, before `bun install` gets a chance to touch the shared
// `.git/hooks`.
await Utils.isolateWorktreeHooks(worktreePath, { verbose });
const installResult = await Utils.installDependencies(worktreePath, {
verbose,
});
Expand Down Expand Up @@ -2094,6 +2102,85 @@ export class Utils {
await Utils.executeGitCommand(["worktree", "prune"], { verbose: false, cwd });
}

/**
* Point a linked review worktree's `core.hooksPath` at a private directory
* (via per-worktree git config) seeded with copies of the shared hooks.
*
* A linked worktree shares `.git/hooks` with the user's checkout. Dependency
* postinstalls that rewrite hooks (lefthook's `lefthook install`) would
* therefore clobber the user's real hooks with scripts hardcoding this
* ephemeral worktree's `node_modules` path — breaking every later push once
* the worktree is removed. Redirecting hooks first confines those rewrites
* to the private directory, where they stay valid for this run's pushes and
* vanish together with the worktree.
*
* The private directory lives in the worktree's git admin area
* (`<repo>/.git/worktrees/<name>/hooks`), which keeps it outside the working
* tree (invisible to `git status`, `git clean`, and `git add -A`) and lets
* `git worktree remove` clean it up automatically. The per-worktree config
* lives in the same admin area; `extensions.worktreeConfig` stays enabled in
* the shared config, which is harmless.
*/
static async isolateWorktreeHooks(
worktreePath: string,
options?: { verbose?: boolean },
): Promise<void> {
const verbose = options?.verbose ?? false;

const gitDir = await Utils.executeGitCommand(["rev-parse", "--absolute-git-dir"], {
cwd: worktreePath,
});
if (!gitDir.success || !gitDir.output.trim()) {
console.warn(`⚠️ Could not locate worktree git dir; hooks stay shared: ${gitDir.error}`);
return;
}
const hooksDir = join(gitDir.output.trim(), "hooks");

// Existing shared hooks (e.g. plain scripts installed outside a package's
// postinstall) keep working in the worktree: copy them into the isolated
// directory. This must run before `core.hooksPath` is set, because
// `git rev-parse --git-path hooks` resolves through it.
const sharedHooks = await Utils.executeGitCommand(["rev-parse", "--git-path", "hooks"], {
cwd: worktreePath,
});
if (sharedHooks.success && sharedHooks.output.trim() && existsSync(sharedHooks.output.trim())) {
const sharedDir = sharedHooks.output.trim();
try {
Utils.ensureDirectoryExists(hooksDir);
for (const entry of readdirSync(sharedDir)) {
if (entry.endsWith(".sample")) continue;
const source = join(sharedDir, entry);
if (!statSync(source).isFile()) continue;
copyFileSync(source, join(hooksDir, entry));
chmodSync(join(hooksDir, entry), 0o755);
}
} catch (error) {
console.warn(
`⚠️ Could not copy shared git hooks into the worktree: ${(error as Error).message}`,
);
}
}

const enable = await Utils.executeGitCommand(["config", "extensions.worktreeConfig", "true"], {
cwd: worktreePath,
});
if (!enable.success) {
console.warn(`⚠️ Could not enable per-worktree git config: ${enable.error}`);
return;
}
const setPath = await Utils.executeGitCommand(
["config", "--worktree", "core.hooksPath", hooksDir],
{ cwd: worktreePath },
);
if (!setPath.success) {
console.warn(`⚠️ Could not redirect worktree git hooks: ${setPath.error}`);
return;
}
if (verbose) {
console.log(` 🔒 Git hooks isolated to ${hooksDir}`);
}
}

/**
* Auto-detect package managers and install project dependencies in a worktree.
*
Expand Down
Loading
Loading