worktree-diff.test.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. import { test, expect, describe } from "bun:test"
  2. import { $ } from "bun"
  3. import { tmpdir } from "../fixture/fixture"
  4. import path from "path"
  5. /**
  6. * Tests for the worktree diff logic used by GET /experimental/worktree/diff.
  7. * Reproduces the exact git commands from the endpoint to verify they work
  8. * for tracked, staged, and untracked files.
  9. */
  10. describe("worktree diff git commands", () => {
  11. async function setupRepo() {
  12. const tmp = await tmpdir({
  13. git: true,
  14. init: async (dir) => {
  15. // Create an initial file and commit it so we have a base
  16. await Bun.write(path.join(dir, "existing.txt"), "hello\n")
  17. await $`git add .`.cwd(dir).quiet()
  18. await $`git commit -m "add existing.txt"`.cwd(dir).quiet()
  19. },
  20. })
  21. return tmp
  22. }
  23. test("git diff sees committed changes but NOT untracked files", async () => {
  24. await using tmp = await setupRepo()
  25. const dir = tmp.path
  26. // Get the current HEAD as our "ancestor" (simulating merge-base)
  27. const headResult = await $`git rev-parse HEAD`.cwd(dir).quiet()
  28. const ancestor = headResult.stdout.toString().trim()
  29. // Create an untracked file (agent writes a file but doesn't stage it)
  30. await Bun.write(path.join(dir, "life.py"), 'print("hello world")\n')
  31. // Verify the file exists
  32. const exists = await Bun.file(path.join(dir, "life.py")).exists()
  33. expect(exists).toBe(true)
  34. // git diff --name-status does NOT see untracked files
  35. const nameStatus = await $`git -c core.quotepath=false diff --name-status --no-renames ${ancestor}`
  36. .cwd(dir)
  37. .quiet()
  38. .nothrow()
  39. const nameStatusOutput = nameStatus.stdout.toString().trim()
  40. console.log("git diff --name-status output:", JSON.stringify(nameStatusOutput))
  41. expect(nameStatusOutput).toBe("") // empty — life.py is untracked
  42. // git ls-files --others DOES see untracked files
  43. const untracked = await $`git ls-files --others --exclude-standard`.cwd(dir).quiet().nothrow()
  44. const untrackedOutput = untracked.stdout.toString().trim()
  45. console.log("git ls-files --others output:", JSON.stringify(untrackedOutput))
  46. expect(untrackedOutput).toContain("life.py")
  47. })
  48. test("git diff sees staged (added) files", async () => {
  49. await using tmp = await setupRepo()
  50. const dir = tmp.path
  51. const headResult = await $`git rev-parse HEAD`.cwd(dir).quiet()
  52. const ancestor = headResult.stdout.toString().trim()
  53. // Create and stage a new file
  54. await Bun.write(path.join(dir, "staged.py"), 'print("staged")\n')
  55. await $`git add staged.py`.cwd(dir).quiet()
  56. const nameStatus = await $`git -c core.quotepath=false diff --name-status --no-renames ${ancestor}`
  57. .cwd(dir)
  58. .quiet()
  59. .nothrow()
  60. const nameStatusOutput = nameStatus.stdout.toString().trim()
  61. console.log("git diff --name-status (staged):", JSON.stringify(nameStatusOutput))
  62. // git diff <ancestor> (no --cached) compares ancestor to working tree,
  63. // which includes staged changes
  64. expect(nameStatusOutput).toContain("staged.py")
  65. })
  66. test("git diff sees modifications to tracked files", async () => {
  67. await using tmp = await setupRepo()
  68. const dir = tmp.path
  69. const headResult = await $`git rev-parse HEAD`.cwd(dir).quiet()
  70. const ancestor = headResult.stdout.toString().trim()
  71. // Modify existing tracked file without staging
  72. await Bun.write(path.join(dir, "existing.txt"), "hello\nmodified\n")
  73. const nameStatus = await $`git -c core.quotepath=false diff --name-status --no-renames ${ancestor}`
  74. .cwd(dir)
  75. .quiet()
  76. .nothrow()
  77. const nameStatusOutput = nameStatus.stdout.toString().trim()
  78. console.log("git diff --name-status (modified):", JSON.stringify(nameStatusOutput))
  79. expect(nameStatusOutput).toContain("existing.txt")
  80. })
  81. test("full diff pipeline: tracked + untracked combined", async () => {
  82. await using tmp = await setupRepo()
  83. const dir = tmp.path
  84. const headResult = await $`git rev-parse HEAD`.cwd(dir).quiet()
  85. const ancestor = headResult.stdout.toString().trim()
  86. // Modify existing file (tracked change)
  87. await Bun.write(path.join(dir, "existing.txt"), "hello\nmodified\n")
  88. // Create untracked file
  89. await Bun.write(path.join(dir, "new-file.py"), 'print("new")\n')
  90. // Step 1: git diff for tracked changes
  91. const nameStatus = await $`git -c core.quotepath=false diff --name-status --no-renames ${ancestor}`
  92. .cwd(dir)
  93. .quiet()
  94. .nothrow()
  95. const tracked = new Set<string>()
  96. const trackedFiles: string[] = []
  97. for (const line of nameStatus.stdout.toString().trim().split("\n")) {
  98. if (!line) continue
  99. const parts = line.split("\t")
  100. const file = parts.slice(1).join("\t")
  101. if (file) {
  102. tracked.add(file)
  103. trackedFiles.push(file)
  104. }
  105. }
  106. // Step 2: git ls-files for untracked
  107. const untrackedResult = await $`git ls-files --others --exclude-standard`.cwd(dir).quiet().nothrow()
  108. const untrackedFiles: string[] = []
  109. for (const file of untrackedResult.stdout.toString().trim().split("\n")) {
  110. if (!file || tracked.has(file)) continue
  111. untrackedFiles.push(file)
  112. }
  113. console.log("tracked files:", trackedFiles)
  114. console.log("untracked files:", untrackedFiles)
  115. expect(trackedFiles).toContain("existing.txt")
  116. expect(trackedFiles).not.toContain("new-file.py")
  117. expect(untrackedFiles).toContain("new-file.py")
  118. expect(untrackedFiles).not.toContain("existing.txt")
  119. // Combined = both
  120. const allFiles = [...trackedFiles, ...untrackedFiles]
  121. expect(allFiles).toContain("existing.txt")
  122. expect(allFiles).toContain("new-file.py")
  123. })
  124. test("worktree scenario: branch with no new commits, only untracked files", async () => {
  125. // This is the exact scenario from the screenshot:
  126. // - Worktree created from main
  127. // - Agent writes life.py (never committed/staged)
  128. // - merge-base HEAD main = HEAD (no divergence)
  129. // - git diff shows nothing, git ls-files --others shows life.py
  130. await using tmp = await setupRepo()
  131. const dir = tmp.path
  132. // Simulate: worktree is on same commit as base (no new commits)
  133. // merge-base HEAD HEAD = HEAD
  134. const mergeBase = await $`git merge-base HEAD HEAD`.cwd(dir).quiet()
  135. const ancestor = mergeBase.stdout.toString().trim()
  136. console.log("ancestor (same as HEAD):", ancestor)
  137. // Agent writes a file
  138. await Bun.write(
  139. path.join(dir, "life.py"),
  140. `
  141. import random
  142. import time
  143. import os
  144. def create_board(rows, cols):
  145. return [[random.choice([0, 1]) for _ in range(cols)] for _ in range(rows)]
  146. print("Game of Life")
  147. `,
  148. )
  149. // git diff: nothing (HEAD == ancestor, no tracked changes)
  150. const nameStatus = await $`git -c core.quotepath=false diff --name-status --no-renames ${ancestor}`
  151. .cwd(dir)
  152. .quiet()
  153. .nothrow()
  154. const nameStatusRaw = nameStatus.stdout.toString().trim()
  155. console.log("nameStatus raw:", JSON.stringify(nameStatusRaw))
  156. // git ls-files --others: should find life.py
  157. const untrackedResult = await $`git ls-files --others --exclude-standard`.cwd(dir).quiet().nothrow()
  158. const untrackedRaw = untrackedResult.stdout.toString().trim()
  159. console.log("untracked raw:", JSON.stringify(untrackedRaw))
  160. expect(untrackedRaw).toContain("life.py")
  161. // Now simulate the full endpoint logic
  162. const seen = new Set<string>()
  163. const diffs: { file: string; status: string }[] = []
  164. // Process tracked changes
  165. for (const line of nameStatusRaw.split("\n")) {
  166. if (!line) continue
  167. const parts = line.split("\t")
  168. const file = parts.slice(1).join("\t")
  169. if (file) {
  170. seen.add(file)
  171. diffs.push({ file, status: "modified" })
  172. }
  173. }
  174. // Process untracked files
  175. if (untrackedResult.exitCode === 0) {
  176. for (const file of untrackedRaw.split("\n")) {
  177. if (!file || seen.has(file)) continue
  178. const f = Bun.file(path.join(dir, file))
  179. if (!(await f.exists())) continue
  180. diffs.push({ file, status: "added" })
  181. }
  182. }
  183. console.log("final diffs:", diffs)
  184. expect(diffs.length).toBe(1)
  185. expect(diffs[0]!.file).toBe("life.py")
  186. expect(diffs[0]!.status).toBe("added")
  187. })
  188. })