git.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. #!/usr/bin/env bun
  2. /**
  3. * Git utilities for upstream merge automation
  4. */
  5. import { $ } from "bun"
  6. export interface BranchInfo {
  7. current: string
  8. exists: boolean
  9. }
  10. export interface RemoteInfo {
  11. name: string
  12. url: string
  13. }
  14. export async function getCurrentBranch(): Promise<string> {
  15. const result = await $`git rev-parse --abbrev-ref HEAD`.text()
  16. return result.trim()
  17. }
  18. export async function branchExists(name: string): Promise<boolean> {
  19. const result = await $`git show-ref --verify --quiet refs/heads/${name}`.nothrow()
  20. return result.exitCode === 0
  21. }
  22. export async function remoteBranchExists(remote: string, branch: string): Promise<boolean> {
  23. const result = await $`git ls-remote --heads ${remote} ${branch}`.text()
  24. return result.trim().length > 0
  25. }
  26. export async function getRemotes(): Promise<RemoteInfo[]> {
  27. const result = await $`git remote -v`.text()
  28. const lines = result.trim().split("\n")
  29. const remotes: RemoteInfo[] = []
  30. const seen = new Set<string>()
  31. for (const line of lines) {
  32. const parts = line.split(/\s+/)
  33. const name = parts[0] ?? ""
  34. const url = parts[1] ?? ""
  35. if (name && !seen.has(name)) {
  36. seen.add(name)
  37. remotes.push({ name, url })
  38. }
  39. }
  40. return remotes
  41. }
  42. export async function hasUpstreamRemote(): Promise<boolean> {
  43. const remotes = await getRemotes()
  44. return remotes.some((r) => r.name === "upstream")
  45. }
  46. export async function fetchUpstream(): Promise<void> {
  47. const result = await $`git fetch upstream`.quiet().nothrow()
  48. if (result.exitCode !== 0) {
  49. throw new Error(`Failed to fetch upstream: ${result.stderr.toString()}`)
  50. }
  51. }
  52. export async function checkout(ref: string): Promise<void> {
  53. await $`git checkout ${ref}`
  54. }
  55. export async function createBranch(name: string, from?: string): Promise<void> {
  56. if (from) {
  57. await $`git checkout -b ${name} ${from}`
  58. } else {
  59. await $`git checkout -b ${name}`
  60. }
  61. }
  62. export async function deleteBranch(name: string, force = false): Promise<void> {
  63. if (force) {
  64. await $`git branch -D ${name}`
  65. } else {
  66. await $`git branch -d ${name}`
  67. }
  68. }
  69. export async function backupAndDeleteBranch(name: string): Promise<string | null> {
  70. if (!(await branchExists(name))) return null
  71. const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)
  72. const backupName = `backup/${name}-${timestamp}`
  73. const current = await getCurrentBranch()
  74. // Create backup from the existing branch
  75. await $`git branch ${backupName} ${name}`
  76. // Delete the old branch (must not be on it)
  77. if (current === name) {
  78. throw new Error(`Cannot backup and delete branch '${name}' while it is checked out`)
  79. }
  80. await deleteBranch(name, true)
  81. return backupName
  82. }
  83. export async function push(remote = "origin", branch?: string, setUpstream = false): Promise<void> {
  84. const currentBranch = branch || (await getCurrentBranch())
  85. if (setUpstream) {
  86. await $`git push -u ${remote} ${currentBranch}`
  87. } else {
  88. await $`git push ${remote} ${currentBranch}`
  89. }
  90. }
  91. export async function pull(remote = "origin", branch?: string): Promise<void> {
  92. if (branch) {
  93. await $`git pull ${remote} ${branch}`
  94. } else {
  95. await $`git pull ${remote}`
  96. }
  97. }
  98. export async function commit(message: string): Promise<void> {
  99. await $`git commit -am ${message}`
  100. }
  101. export async function merge(branch: string): Promise<{ success: boolean; conflicts: string[] }> {
  102. const result = await $`git merge ${branch}`.nothrow()
  103. if (result.exitCode === 0) {
  104. return { success: true, conflicts: [] }
  105. }
  106. // Get list of conflicted files
  107. const conflicts = await getConflictedFiles()
  108. return { success: false, conflicts }
  109. }
  110. export async function getConflictedFiles(): Promise<string[]> {
  111. const result = await $`git diff --name-only --diff-filter=U`.text()
  112. return result
  113. .trim()
  114. .split("\n")
  115. .filter((f) => f.length > 0)
  116. }
  117. export async function hasUncommittedChanges(): Promise<boolean> {
  118. const result = await $`git status --porcelain`.text()
  119. return result.trim().length > 0
  120. }
  121. export async function restoreDirectories(dirs: string[]): Promise<void> {
  122. for (const dir of dirs) {
  123. await $`git restore ${dir}`.quiet().nothrow()
  124. }
  125. }
  126. export async function stageAll(): Promise<void> {
  127. await $`git add -A`
  128. }
  129. export async function stageFiles(files: string[]): Promise<void> {
  130. for (const file of files) {
  131. await $`git add ${file}`
  132. }
  133. }
  134. export async function getCommitMessage(ref: string): Promise<string> {
  135. const result = await $`git log -1 --format=%s ${ref}`.text()
  136. return result.trim()
  137. }
  138. export async function getCommitHash(ref: string): Promise<string> {
  139. const result = await $`git rev-parse ${ref}`.text()
  140. return result.trim()
  141. }
  142. export async function getTagsForCommit(commit: string): Promise<string[]> {
  143. const result = await $`git tag --points-at ${commit}`.text()
  144. return result
  145. .trim()
  146. .split("\n")
  147. .filter((t) => t.length > 0)
  148. }
  149. export async function getAllTags(): Promise<string[]> {
  150. const result = await $`git tag -l`.text()
  151. return result
  152. .trim()
  153. .split("\n")
  154. .filter((t) => t.length > 0)
  155. }
  156. export async function getUpstreamTags(): Promise<string[]> {
  157. const result = await $`git ls-remote --tags upstream`.quiet().nothrow()
  158. if (result.exitCode !== 0) {
  159. throw new Error(`Failed to list upstream tags: ${result.stderr.toString()}`)
  160. }
  161. const output = result.stdout.toString()
  162. const tags: string[] = []
  163. for (const line of output.trim().split("\n")) {
  164. const match = line.match(/refs\/tags\/([^\^]+)$/)
  165. if (match && match[1]) tags.push(match[1])
  166. }
  167. return tags
  168. }
  169. export async function abortMerge(): Promise<void> {
  170. await $`git merge --abort`
  171. }
  172. export async function checkoutOurs(files: string[]): Promise<void> {
  173. for (const file of files) {
  174. await $`git checkout --ours ${file}`
  175. }
  176. }
  177. export async function checkoutTheirs(files: string[]): Promise<void> {
  178. for (const file of files) {
  179. await $`git checkout --theirs ${file}`
  180. }
  181. }
  182. /**
  183. * Remove untracked files and directories from specific directories.
  184. * Used to clean build artifacts from Kilo-specific directories after checking
  185. * out the upstream branch, where package-level .gitignore files don't exist.
  186. */
  187. export async function cleanDirectories(dirs: string[]): Promise<void> {
  188. for (const dir of dirs) {
  189. await $`git clean -fd ${dir}`.quiet().nothrow()
  190. }
  191. }
  192. /**
  193. * Check if the "ours" version of a conflicted file contains kilocode_change markers.
  194. * Uses git stage :2: which is the "ours" side during a merge conflict.
  195. * Returns false if the file doesn't exist in ours (new file from upstream).
  196. */
  197. export async function oursHasKilocodeChanges(file: string): Promise<boolean> {
  198. const result = await $`git show :2:${file}`.quiet().nothrow()
  199. if (result.exitCode !== 0) return false
  200. return result.stdout.toString().includes("kilocode_change")
  201. }
  202. /**
  203. * Enable git rerere (REuse REcorded REsolution) in the local repo config.
  204. * Also enables autoupdate so resolved files are automatically staged.
  205. */
  206. export async function ensureRerere(): Promise<void> {
  207. await $`git config rerere.enabled true`.quiet()
  208. await $`git config rerere.autoupdate true`.quiet()
  209. }
  210. /**
  211. * Train the rerere cache from past merge commits in the repo history.
  212. * Implements the same logic as git's contrib/rerere-train.sh:
  213. * For each merge commit in the range, replay the merge to let rerere
  214. * record the pre-image, then check out the resolved tree so rerere
  215. * records the post-image (the resolution).
  216. *
  217. * Returns the number of resolutions learned.
  218. */
  219. export async function trainRerere(grep: string): Promise<number> {
  220. // Save the current HEAD so we can restore it afterwards
  221. const headResult = await $`git symbolic-ref -q HEAD`.quiet().nothrow()
  222. const branch = headResult.exitCode === 0 ? headResult.stdout.toString().trim() : null
  223. const originalHead = branch ?? (await $`git rev-parse --verify HEAD`.text()).trim()
  224. let learned = 0
  225. try {
  226. // Find all merge commits matching the grep pattern (merges have multiple parents)
  227. const revList = await $`git rev-list --parents --all --grep=${grep}`.quiet().nothrow()
  228. if (revList.exitCode !== 0 || !revList.stdout.toString().trim()) return 0
  229. const lines = revList.stdout
  230. .toString()
  231. .trim()
  232. .split("\n")
  233. .filter((l) => l.trim())
  234. for (const line of lines) {
  235. const parts = line.trim().split(/\s+/)
  236. if (parts.length < 3) continue // skip non-merges (need commit + at least 2 parents)
  237. const [commit, parent1, ...otherParents] = parts
  238. // Checkout the first parent
  239. const coResult = await $`git checkout -q ${parent1}`.quiet().nothrow()
  240. if (coResult.exitCode !== 0) continue
  241. // Attempt the merge - we expect it to fail with conflicts
  242. const mergeResult = await $`git merge --no-gpg-sign ${otherParents}`.quiet().nothrow()
  243. if (mergeResult.exitCode === 0) {
  244. // Cleanly merged — no conflicts to learn from, reset and skip
  245. await $`git reset -q --hard`.quiet().nothrow()
  246. continue
  247. }
  248. // Check if rerere recorded a pre-image (MERGE_RR exists and is non-empty)
  249. const mergeRR = Bun.file(`${process.env.GIT_DIR || ".git"}/MERGE_RR`)
  250. const hasMergeRR = await mergeRR.exists().catch(() => false)
  251. if (!hasMergeRR) {
  252. await $`git reset -q --hard`.quiet().nothrow()
  253. continue
  254. }
  255. // Record the conflict pre-image
  256. await $`git rerere`.quiet().nothrow()
  257. // Apply the actual resolution by checking out the merge commit's tree
  258. await $`git checkout -q ${commit} -- .`.quiet().nothrow()
  259. // Record the resolution post-image
  260. await $`git rerere`.quiet().nothrow()
  261. learned++
  262. await $`git reset -q --hard`.quiet().nothrow()
  263. }
  264. } finally {
  265. // Always restore original branch
  266. if (branch) {
  267. await $`git checkout ${branch.replace("refs/heads/", "")}`.quiet().nothrow()
  268. } else {
  269. await $`git checkout ${originalHead}`.quiet().nothrow()
  270. }
  271. }
  272. return learned
  273. }
  274. /**
  275. * Return files that git rerere has already auto-resolved.
  276. * These files no longer have conflict markers but haven't been staged yet
  277. * (unless rerere.autoupdate is true, in which case they're already staged).
  278. */
  279. export async function getRerereResolved(): Promise<string[]> {
  280. const result = await $`git rerere status`.quiet().nothrow()
  281. if (result.exitCode !== 0 || !result.stdout.toString().trim()) return []
  282. return result.stdout
  283. .toString()
  284. .trim()
  285. .split("\n")
  286. .filter((f) => f.length > 0)
  287. }