feat: enable git rerere

This commit is contained in:
Catriel Müller
2026-03-16 20:28:36 -03:00
parent c29dcdcc60
commit eb662661ca
2 changed files with 126 additions and 0 deletions
+24
View File
@@ -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.
+102
View File
@@ -236,3 +236,105 @@ export async function oursHasKilocodeChanges(file: string): Promise<boolean> {
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<void> {
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<number> {
// 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<string[]> {
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)
}