diff --git a/script/upstream/merge.ts b/script/upstream/merge.ts index f32d2a2675..ce5f3e8796 100644 --- a/script/upstream/merge.ts +++ b/script/upstream/merge.ts @@ -141,6 +141,22 @@ async function main() { const currentBranch = await git.getCurrentBranch() logger.info(`Current branch: ${currentBranch}`) + // Enable git rerere so conflict resolutions are recorded and reused across merges + if (!options.dryRun) { + await git.ensureRerere() + logger.info("git rerere enabled (resolutions will be recorded and reused automatically)") + + // Train rerere from past upstream merge commits so the cache is populated + // even on a fresh clone. This replays past merges to learn their resolutions. + logger.info("Training rerere cache from past merge history...") + const learned = await git.trainRerere("merge: upstream\\|Resolve merge conflict") + if (learned > 0) { + logger.success(`Learned ${learned} conflict resolution(s) from history`) + } else { + logger.info("No new resolutions to learn from history (cache already up to date)") + } + } + // Step 2: Fetch upstream logger.step(2, 8, "Fetching upstream...") @@ -385,6 +401,14 @@ async function main() { logger.info("Conflicted files:") logger.list(mergeResult.conflicts) + // Check if git rerere already auto-resolved any conflicts from recorded history. + // rerere.autoupdate stages them automatically; we just log how many were handled. + const rerereResolved = await git.getRerereResolved() + if (rerereResolved.length > 0) { + logger.success(`git rerere auto-resolved ${rerereResolved.length} conflict(s) from recorded history:`) + logger.list(rerereResolved) + } + // Since we applied all branding transforms pre-merge, remaining conflicts should be minimal. // These are likely files with kilocode_change markers or actual logic differences. diff --git a/script/upstream/utils/git.ts b/script/upstream/utils/git.ts index 4b456310a9..cf4fef55ee 100644 --- a/script/upstream/utils/git.ts +++ b/script/upstream/utils/git.ts @@ -236,3 +236,105 @@ export async function oursHasKilocodeChanges(file: string): Promise { if (result.exitCode !== 0) return false return result.stdout.toString().includes("kilocode_change") } + +/** + * Enable git rerere (REuse REcorded REsolution) in the local repo config. + * Also enables autoupdate so resolved files are automatically staged. + */ +export async function ensureRerere(): Promise { + await $`git config rerere.enabled true`.quiet() + await $`git config rerere.autoupdate true`.quiet() +} + +/** + * Train the rerere cache from past merge commits in the repo history. + * Implements the same logic as git's contrib/rerere-train.sh: + * For each merge commit in the range, replay the merge to let rerere + * record the pre-image, then check out the resolved tree so rerere + * records the post-image (the resolution). + * + * Returns the number of resolutions learned. + */ +export async function trainRerere(grep: string): Promise { + // Save the current HEAD so we can restore it afterwards + const headResult = await $`git symbolic-ref -q HEAD`.quiet().nothrow() + const branch = headResult.exitCode === 0 ? headResult.stdout.toString().trim() : null + const originalHead = branch ?? (await $`git rev-parse --verify HEAD`.text()).trim() + + let learned = 0 + + try { + // Find all merge commits matching the grep pattern (merges have multiple parents) + const revList = await $`git rev-list --parents --all --grep=${grep}`.quiet().nothrow() + if (revList.exitCode !== 0 || !revList.stdout.toString().trim()) return 0 + + const lines = revList.stdout + .toString() + .trim() + .split("\n") + .filter((l) => l.trim()) + + for (const line of lines) { + const parts = line.trim().split(/\s+/) + if (parts.length < 3) continue // skip non-merges (need commit + at least 2 parents) + + const [commit, parent1, ...otherParents] = parts + + // Checkout the first parent + const coResult = await $`git checkout -q ${parent1}`.quiet().nothrow() + if (coResult.exitCode !== 0) continue + + // Attempt the merge - we expect it to fail with conflicts + const mergeResult = await $`git merge --no-gpg-sign ${otherParents}`.quiet().nothrow() + if (mergeResult.exitCode === 0) { + // Cleanly merged — no conflicts to learn from, reset and skip + await $`git reset -q --hard`.quiet().nothrow() + continue + } + + // Check if rerere recorded a pre-image (MERGE_RR exists and is non-empty) + const mergeRR = Bun.file(`${process.env.GIT_DIR || ".git"}/MERGE_RR`) + const hasMergeRR = await mergeRR.exists().catch(() => false) + if (!hasMergeRR) { + await $`git reset -q --hard`.quiet().nothrow() + continue + } + + // Record the conflict pre-image + await $`git rerere`.quiet().nothrow() + + // Apply the actual resolution by checking out the merge commit's tree + await $`git checkout -q ${commit} -- .`.quiet().nothrow() + + // Record the resolution post-image + await $`git rerere`.quiet().nothrow() + + learned++ + await $`git reset -q --hard`.quiet().nothrow() + } + } finally { + // Always restore original branch + if (branch) { + await $`git checkout ${branch.replace("refs/heads/", "")}`.quiet().nothrow() + } else { + await $`git checkout ${originalHead}`.quiet().nothrow() + } + } + + return learned +} + +/** + * Return files that git rerere has already auto-resolved. + * These files no longer have conflict markers but haven't been staged yet + * (unless rerere.autoupdate is true, in which case they're already staged). + */ +export async function getRerereResolved(): Promise { + const result = await $`git rerere status`.quiet().nothrow() + if (result.exitCode !== 0 || !result.stdout.toString().trim()) return [] + return result.stdout + .toString() + .trim() + .split("\n") + .filter((f) => f.length > 0) +}