feat: enable git rerere

This commit is contained in:
Catriel Müller
2026-03-17 07:46:48 -03:00
parent c29dcdcc60
commit eb662661ca
2 changed files with 126 additions and 0 deletions
+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)
}