snapshot.test.ts 36 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180
  1. import { test, expect } from "bun:test"
  2. import { $ } from "bun"
  3. import fs from "fs/promises"
  4. import path from "path"
  5. import { Snapshot } from "../../src/snapshot"
  6. import { Instance } from "../../src/project/instance"
  7. import { Filesystem } from "../../src/util/filesystem"
  8. import { tmpdir } from "../fixture/fixture"
  9. // Git always outputs /-separated paths internally. Snapshot.patch() joins them
  10. // with path.join (which produces \ on Windows) then normalizes back to /.
  11. // This helper does the same for expected values so assertions match cross-platform.
  12. const fwd = (...parts: string[]) => path.join(...parts).replaceAll("\\", "/")
  13. async function bootstrap() {
  14. return tmpdir({
  15. git: true,
  16. init: async (dir) => {
  17. const unique = Math.random().toString(36).slice(2)
  18. const aContent = `A${unique}`
  19. const bContent = `B${unique}`
  20. await Filesystem.write(`${dir}/a.txt`, aContent)
  21. await Filesystem.write(`${dir}/b.txt`, bContent)
  22. await $`git add .`.cwd(dir).quiet()
  23. await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet()
  24. return {
  25. aContent,
  26. bContent,
  27. }
  28. },
  29. })
  30. }
  31. test("tracks deleted files correctly", async () => {
  32. await using tmp = await bootstrap()
  33. await Instance.provide({
  34. directory: tmp.path,
  35. fn: async () => {
  36. const before = await Snapshot.track()
  37. expect(before).toBeTruthy()
  38. await $`rm ${tmp.path}/a.txt`.quiet()
  39. expect((await Snapshot.patch(before!)).files).toContain(fwd(tmp.path, "a.txt"))
  40. },
  41. })
  42. })
  43. test("revert should remove new files", async () => {
  44. await using tmp = await bootstrap()
  45. await Instance.provide({
  46. directory: tmp.path,
  47. fn: async () => {
  48. const before = await Snapshot.track()
  49. expect(before).toBeTruthy()
  50. await Filesystem.write(`${tmp.path}/new.txt`, "NEW")
  51. await Snapshot.revert([await Snapshot.patch(before!)])
  52. expect(
  53. await fs
  54. .access(`${tmp.path}/new.txt`)
  55. .then(() => true)
  56. .catch(() => false),
  57. ).toBe(false)
  58. },
  59. })
  60. })
  61. test("revert in subdirectory", async () => {
  62. await using tmp = await bootstrap()
  63. await Instance.provide({
  64. directory: tmp.path,
  65. fn: async () => {
  66. const before = await Snapshot.track()
  67. expect(before).toBeTruthy()
  68. await $`mkdir -p ${tmp.path}/sub`.quiet()
  69. await Filesystem.write(`${tmp.path}/sub/file.txt`, "SUB")
  70. await Snapshot.revert([await Snapshot.patch(before!)])
  71. expect(
  72. await fs
  73. .access(`${tmp.path}/sub/file.txt`)
  74. .then(() => true)
  75. .catch(() => false),
  76. ).toBe(false)
  77. // Note: revert currently only removes files, not directories
  78. // The empty subdirectory will remain
  79. },
  80. })
  81. })
  82. test("multiple file operations", async () => {
  83. await using tmp = await bootstrap()
  84. await Instance.provide({
  85. directory: tmp.path,
  86. fn: async () => {
  87. const before = await Snapshot.track()
  88. expect(before).toBeTruthy()
  89. await $`rm ${tmp.path}/a.txt`.quiet()
  90. await Filesystem.write(`${tmp.path}/c.txt`, "C")
  91. await $`mkdir -p ${tmp.path}/dir`.quiet()
  92. await Filesystem.write(`${tmp.path}/dir/d.txt`, "D")
  93. await Filesystem.write(`${tmp.path}/b.txt`, "MODIFIED")
  94. await Snapshot.revert([await Snapshot.patch(before!)])
  95. expect(await fs.readFile(`${tmp.path}/a.txt`, "utf-8")).toBe(tmp.extra.aContent)
  96. expect(
  97. await fs
  98. .access(`${tmp.path}/c.txt`)
  99. .then(() => true)
  100. .catch(() => false),
  101. ).toBe(false)
  102. // Note: revert currently only removes files, not directories
  103. // The empty directory will remain
  104. expect(await fs.readFile(`${tmp.path}/b.txt`, "utf-8")).toBe(tmp.extra.bContent)
  105. },
  106. })
  107. })
  108. test("empty directory handling", async () => {
  109. await using tmp = await bootstrap()
  110. await Instance.provide({
  111. directory: tmp.path,
  112. fn: async () => {
  113. const before = await Snapshot.track()
  114. expect(before).toBeTruthy()
  115. await $`mkdir ${tmp.path}/empty`.quiet()
  116. expect((await Snapshot.patch(before!)).files.length).toBe(0)
  117. },
  118. })
  119. })
  120. test("binary file handling", async () => {
  121. await using tmp = await bootstrap()
  122. await Instance.provide({
  123. directory: tmp.path,
  124. fn: async () => {
  125. const before = await Snapshot.track()
  126. expect(before).toBeTruthy()
  127. await Filesystem.write(`${tmp.path}/image.png`, new Uint8Array([0x89, 0x50, 0x4e, 0x47]))
  128. const patch = await Snapshot.patch(before!)
  129. expect(patch.files).toContain(fwd(tmp.path, "image.png"))
  130. await Snapshot.revert([patch])
  131. expect(
  132. await fs
  133. .access(`${tmp.path}/image.png`)
  134. .then(() => true)
  135. .catch(() => false),
  136. ).toBe(false)
  137. },
  138. })
  139. })
  140. test("symlink handling", async () => {
  141. await using tmp = await bootstrap()
  142. await Instance.provide({
  143. directory: tmp.path,
  144. fn: async () => {
  145. const before = await Snapshot.track()
  146. expect(before).toBeTruthy()
  147. await fs.symlink(`${tmp.path}/a.txt`, `${tmp.path}/link.txt`, "file")
  148. expect((await Snapshot.patch(before!)).files).toContain(fwd(tmp.path, "link.txt"))
  149. },
  150. })
  151. })
  152. test("large file handling", async () => {
  153. await using tmp = await bootstrap()
  154. await Instance.provide({
  155. directory: tmp.path,
  156. fn: async () => {
  157. const before = await Snapshot.track()
  158. expect(before).toBeTruthy()
  159. await Filesystem.write(`${tmp.path}/large.txt`, "x".repeat(1024 * 1024))
  160. expect((await Snapshot.patch(before!)).files).toContain(fwd(tmp.path, "large.txt"))
  161. },
  162. })
  163. })
  164. test("nested directory revert", async () => {
  165. await using tmp = await bootstrap()
  166. await Instance.provide({
  167. directory: tmp.path,
  168. fn: async () => {
  169. const before = await Snapshot.track()
  170. expect(before).toBeTruthy()
  171. await $`mkdir -p ${tmp.path}/level1/level2/level3`.quiet()
  172. await Filesystem.write(`${tmp.path}/level1/level2/level3/deep.txt`, "DEEP")
  173. await Snapshot.revert([await Snapshot.patch(before!)])
  174. expect(
  175. await fs
  176. .access(`${tmp.path}/level1/level2/level3/deep.txt`)
  177. .then(() => true)
  178. .catch(() => false),
  179. ).toBe(false)
  180. },
  181. })
  182. })
  183. test("special characters in filenames", async () => {
  184. await using tmp = await bootstrap()
  185. await Instance.provide({
  186. directory: tmp.path,
  187. fn: async () => {
  188. const before = await Snapshot.track()
  189. expect(before).toBeTruthy()
  190. await Filesystem.write(`${tmp.path}/file with spaces.txt`, "SPACES")
  191. await Filesystem.write(`${tmp.path}/file-with-dashes.txt`, "DASHES")
  192. await Filesystem.write(`${tmp.path}/file_with_underscores.txt`, "UNDERSCORES")
  193. const files = (await Snapshot.patch(before!)).files
  194. expect(files).toContain(fwd(tmp.path, "file with spaces.txt"))
  195. expect(files).toContain(fwd(tmp.path, "file-with-dashes.txt"))
  196. expect(files).toContain(fwd(tmp.path, "file_with_underscores.txt"))
  197. },
  198. })
  199. })
  200. test("revert with empty patches", async () => {
  201. await using tmp = await bootstrap()
  202. await Instance.provide({
  203. directory: tmp.path,
  204. fn: async () => {
  205. // Should not crash with empty patches
  206. expect(Snapshot.revert([])).resolves.toBeUndefined()
  207. // Should not crash with patches that have empty file lists
  208. expect(Snapshot.revert([{ hash: "dummy", files: [] }])).resolves.toBeUndefined()
  209. },
  210. })
  211. })
  212. test("patch with invalid hash", async () => {
  213. await using tmp = await bootstrap()
  214. await Instance.provide({
  215. directory: tmp.path,
  216. fn: async () => {
  217. const before = await Snapshot.track()
  218. expect(before).toBeTruthy()
  219. // Create a change
  220. await Filesystem.write(`${tmp.path}/test.txt`, "TEST")
  221. // Try to patch with invalid hash - should handle gracefully
  222. const patch = await Snapshot.patch("invalid-hash-12345")
  223. expect(patch.files).toEqual([])
  224. expect(patch.hash).toBe("invalid-hash-12345")
  225. },
  226. })
  227. })
  228. test("revert non-existent file", async () => {
  229. await using tmp = await bootstrap()
  230. await Instance.provide({
  231. directory: tmp.path,
  232. fn: async () => {
  233. const before = await Snapshot.track()
  234. expect(before).toBeTruthy()
  235. // Try to revert a file that doesn't exist in the snapshot
  236. // This should not crash
  237. expect(
  238. Snapshot.revert([
  239. {
  240. hash: before!,
  241. files: [`${tmp.path}/nonexistent.txt`],
  242. },
  243. ]),
  244. ).resolves.toBeUndefined()
  245. },
  246. })
  247. })
  248. test("unicode filenames", async () => {
  249. await using tmp = await bootstrap()
  250. await Instance.provide({
  251. directory: tmp.path,
  252. fn: async () => {
  253. const before = await Snapshot.track()
  254. expect(before).toBeTruthy()
  255. const unicodeFiles = [
  256. { path: fwd(tmp.path, "文件.txt"), content: "chinese content" },
  257. { path: fwd(tmp.path, "🚀rocket.txt"), content: "emoji content" },
  258. { path: fwd(tmp.path, "café.txt"), content: "accented content" },
  259. { path: fwd(tmp.path, "файл.txt"), content: "cyrillic content" },
  260. ]
  261. for (const file of unicodeFiles) {
  262. await Filesystem.write(file.path, file.content)
  263. }
  264. const patch = await Snapshot.patch(before!)
  265. expect(patch.files.length).toBe(4)
  266. for (const file of unicodeFiles) {
  267. expect(patch.files).toContain(file.path)
  268. }
  269. await Snapshot.revert([patch])
  270. for (const file of unicodeFiles) {
  271. expect(
  272. await fs
  273. .access(file.path)
  274. .then(() => true)
  275. .catch(() => false),
  276. ).toBe(false)
  277. }
  278. },
  279. })
  280. })
  281. test.skip("unicode filenames modification and restore", async () => {
  282. await using tmp = await bootstrap()
  283. await Instance.provide({
  284. directory: tmp.path,
  285. fn: async () => {
  286. const chineseFile = fwd(tmp.path, "文件.txt")
  287. const cyrillicFile = fwd(tmp.path, "файл.txt")
  288. await Filesystem.write(chineseFile, "original chinese")
  289. await Filesystem.write(cyrillicFile, "original cyrillic")
  290. const before = await Snapshot.track()
  291. expect(before).toBeTruthy()
  292. await Filesystem.write(chineseFile, "modified chinese")
  293. await Filesystem.write(cyrillicFile, "modified cyrillic")
  294. const patch = await Snapshot.patch(before!)
  295. expect(patch.files).toContain(chineseFile)
  296. expect(patch.files).toContain(cyrillicFile)
  297. await Snapshot.revert([patch])
  298. expect(await fs.readFile(chineseFile, "utf-8")).toBe("original chinese")
  299. expect(await fs.readFile(cyrillicFile, "utf-8")).toBe("original cyrillic")
  300. },
  301. })
  302. })
  303. test("unicode filenames in subdirectories", async () => {
  304. await using tmp = await bootstrap()
  305. await Instance.provide({
  306. directory: tmp.path,
  307. fn: async () => {
  308. const before = await Snapshot.track()
  309. expect(before).toBeTruthy()
  310. await $`mkdir -p "${tmp.path}/目录/подкаталог"`.quiet()
  311. const deepFile = fwd(tmp.path, "目录", "подкаталог", "文件.txt")
  312. await Filesystem.write(deepFile, "deep unicode content")
  313. const patch = await Snapshot.patch(before!)
  314. expect(patch.files).toContain(deepFile)
  315. await Snapshot.revert([patch])
  316. expect(
  317. await fs
  318. .access(deepFile)
  319. .then(() => true)
  320. .catch(() => false),
  321. ).toBe(false)
  322. },
  323. })
  324. })
  325. test("very long filenames", async () => {
  326. await using tmp = await bootstrap()
  327. await Instance.provide({
  328. directory: tmp.path,
  329. fn: async () => {
  330. const before = await Snapshot.track()
  331. expect(before).toBeTruthy()
  332. const longName = "a".repeat(200) + ".txt"
  333. const longFile = fwd(tmp.path, longName)
  334. await Filesystem.write(longFile, "long filename content")
  335. const patch = await Snapshot.patch(before!)
  336. expect(patch.files).toContain(longFile)
  337. await Snapshot.revert([patch])
  338. expect(
  339. await fs
  340. .access(longFile)
  341. .then(() => true)
  342. .catch(() => false),
  343. ).toBe(false)
  344. },
  345. })
  346. })
  347. test("hidden files", async () => {
  348. await using tmp = await bootstrap()
  349. await Instance.provide({
  350. directory: tmp.path,
  351. fn: async () => {
  352. const before = await Snapshot.track()
  353. expect(before).toBeTruthy()
  354. await Filesystem.write(`${tmp.path}/.hidden`, "hidden content")
  355. await Filesystem.write(`${tmp.path}/.gitignore`, "*.log")
  356. await Filesystem.write(`${tmp.path}/.config`, "config content")
  357. const patch = await Snapshot.patch(before!)
  358. expect(patch.files).toContain(fwd(tmp.path, ".hidden"))
  359. expect(patch.files).toContain(fwd(tmp.path, ".gitignore"))
  360. expect(patch.files).toContain(fwd(tmp.path, ".config"))
  361. },
  362. })
  363. })
  364. test("nested symlinks", async () => {
  365. await using tmp = await bootstrap()
  366. await Instance.provide({
  367. directory: tmp.path,
  368. fn: async () => {
  369. const before = await Snapshot.track()
  370. expect(before).toBeTruthy()
  371. await $`mkdir -p ${tmp.path}/sub/dir`.quiet()
  372. await Filesystem.write(`${tmp.path}/sub/dir/target.txt`, "target content")
  373. await fs.symlink(`${tmp.path}/sub/dir/target.txt`, `${tmp.path}/sub/dir/link.txt`, "file")
  374. await fs.symlink(`${tmp.path}/sub`, `${tmp.path}/sub-link`, "dir")
  375. const patch = await Snapshot.patch(before!)
  376. expect(patch.files).toContain(fwd(tmp.path, "sub", "dir", "link.txt"))
  377. expect(patch.files).toContain(fwd(tmp.path, "sub-link"))
  378. },
  379. })
  380. })
  381. test("file permissions and ownership changes", async () => {
  382. await using tmp = await bootstrap()
  383. await Instance.provide({
  384. directory: tmp.path,
  385. fn: async () => {
  386. const before = await Snapshot.track()
  387. expect(before).toBeTruthy()
  388. // Change permissions multiple times
  389. await $`chmod 600 ${tmp.path}/a.txt`.quiet()
  390. await $`chmod 755 ${tmp.path}/a.txt`.quiet()
  391. await $`chmod 644 ${tmp.path}/a.txt`.quiet()
  392. const patch = await Snapshot.patch(before!)
  393. // Note: git doesn't track permission changes on existing files by default
  394. // Only tracks executable bit when files are first added
  395. expect(patch.files.length).toBe(0)
  396. },
  397. })
  398. })
  399. test("circular symlinks", async () => {
  400. await using tmp = await bootstrap()
  401. await Instance.provide({
  402. directory: tmp.path,
  403. fn: async () => {
  404. const before = await Snapshot.track()
  405. expect(before).toBeTruthy()
  406. // Create circular symlink
  407. await fs.symlink(`${tmp.path}/circular`, `${tmp.path}/circular`, "dir").catch(() => {})
  408. const patch = await Snapshot.patch(before!)
  409. expect(patch.files.length).toBeGreaterThanOrEqual(0) // Should not crash
  410. },
  411. })
  412. })
  413. test("gitignore changes", async () => {
  414. await using tmp = await bootstrap()
  415. await Instance.provide({
  416. directory: tmp.path,
  417. fn: async () => {
  418. const before = await Snapshot.track()
  419. expect(before).toBeTruthy()
  420. await Filesystem.write(`${tmp.path}/.gitignore`, "*.ignored")
  421. await Filesystem.write(`${tmp.path}/test.ignored`, "ignored content")
  422. await Filesystem.write(`${tmp.path}/normal.txt`, "normal content")
  423. const patch = await Snapshot.patch(before!)
  424. // Should track gitignore itself
  425. expect(patch.files).toContain(fwd(tmp.path, ".gitignore"))
  426. // Should track normal files
  427. expect(patch.files).toContain(fwd(tmp.path, "normal.txt"))
  428. // Should not track ignored files (git won't see them)
  429. expect(patch.files).not.toContain(fwd(tmp.path, "test.ignored"))
  430. },
  431. })
  432. })
  433. test("git info exclude changes", async () => {
  434. await using tmp = await bootstrap()
  435. await Instance.provide({
  436. directory: tmp.path,
  437. fn: async () => {
  438. const before = await Snapshot.track()
  439. expect(before).toBeTruthy()
  440. const file = `${tmp.path}/.git/info/exclude`
  441. const text = await Bun.file(file).text()
  442. await Bun.write(file, `${text.trimEnd()}\nignored.txt\n`)
  443. await Bun.write(`${tmp.path}/ignored.txt`, "ignored content")
  444. await Bun.write(`${tmp.path}/normal.txt`, "normal content")
  445. const patch = await Snapshot.patch(before!)
  446. expect(patch.files).toContain(fwd(tmp.path, "normal.txt"))
  447. expect(patch.files).not.toContain(fwd(tmp.path, "ignored.txt"))
  448. const after = await Snapshot.track()
  449. const diffs = await Snapshot.diffFull(before!, after!)
  450. expect(diffs.some((x) => x.file === "normal.txt")).toBe(true)
  451. expect(diffs.some((x) => x.file === "ignored.txt")).toBe(false)
  452. },
  453. })
  454. })
  455. test("git info exclude keeps global excludes", async () => {
  456. await using tmp = await bootstrap()
  457. await Instance.provide({
  458. directory: tmp.path,
  459. fn: async () => {
  460. const global = `${tmp.path}/global.ignore`
  461. const config = `${tmp.path}/global.gitconfig`
  462. await Bun.write(global, "global.tmp\n")
  463. await Bun.write(config, `[core]\n\texcludesFile = ${global.replaceAll("\\", "/")}\n`)
  464. const prev = process.env.GIT_CONFIG_GLOBAL
  465. process.env.GIT_CONFIG_GLOBAL = config
  466. try {
  467. const before = await Snapshot.track()
  468. expect(before).toBeTruthy()
  469. const file = `${tmp.path}/.git/info/exclude`
  470. const text = await Bun.file(file).text()
  471. await Bun.write(file, `${text.trimEnd()}\ninfo.tmp\n`)
  472. await Bun.write(`${tmp.path}/global.tmp`, "global content")
  473. await Bun.write(`${tmp.path}/info.tmp`, "info content")
  474. await Bun.write(`${tmp.path}/normal.txt`, "normal content")
  475. const patch = await Snapshot.patch(before!)
  476. expect(patch.files).toContain(fwd(tmp.path, "normal.txt"))
  477. expect(patch.files).not.toContain(fwd(tmp.path, "global.tmp"))
  478. expect(patch.files).not.toContain(fwd(tmp.path, "info.tmp"))
  479. } finally {
  480. if (prev) process.env.GIT_CONFIG_GLOBAL = prev
  481. else delete process.env.GIT_CONFIG_GLOBAL
  482. }
  483. },
  484. })
  485. })
  486. test("concurrent file operations during patch", async () => {
  487. await using tmp = await bootstrap()
  488. await Instance.provide({
  489. directory: tmp.path,
  490. fn: async () => {
  491. const before = await Snapshot.track()
  492. expect(before).toBeTruthy()
  493. // Start creating files
  494. const createPromise = (async () => {
  495. for (let i = 0; i < 10; i++) {
  496. await Filesystem.write(`${tmp.path}/concurrent${i}.txt`, `concurrent${i}`)
  497. // Small delay to simulate concurrent operations
  498. await new Promise((resolve) => setTimeout(resolve, 1))
  499. }
  500. })()
  501. // Get patch while files are being created
  502. const patchPromise = Snapshot.patch(before!)
  503. await createPromise
  504. const patch = await patchPromise
  505. // Should capture some or all of the concurrent files
  506. expect(patch.files.length).toBeGreaterThanOrEqual(0)
  507. },
  508. })
  509. })
  510. test("snapshot state isolation between projects", async () => {
  511. // Test that different projects don't interfere with each other
  512. await using tmp1 = await bootstrap()
  513. await using tmp2 = await bootstrap()
  514. await Instance.provide({
  515. directory: tmp1.path,
  516. fn: async () => {
  517. const before1 = await Snapshot.track()
  518. await Filesystem.write(`${tmp1.path}/project1.txt`, "project1 content")
  519. const patch1 = await Snapshot.patch(before1!)
  520. expect(patch1.files).toContain(fwd(tmp1.path, "project1.txt"))
  521. },
  522. })
  523. await Instance.provide({
  524. directory: tmp2.path,
  525. fn: async () => {
  526. const before2 = await Snapshot.track()
  527. await Filesystem.write(`${tmp2.path}/project2.txt`, "project2 content")
  528. const patch2 = await Snapshot.patch(before2!)
  529. expect(patch2.files).toContain(fwd(tmp2.path, "project2.txt"))
  530. // Ensure project1 files don't appear in project2
  531. expect(patch2.files).not.toContain(fwd(tmp1?.path ?? "", "project1.txt"))
  532. },
  533. })
  534. })
  535. test("patch detects changes in secondary worktree", async () => {
  536. await using tmp = await bootstrap()
  537. const worktreePath = `${tmp.path}-worktree`
  538. await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet()
  539. try {
  540. await Instance.provide({
  541. directory: tmp.path,
  542. fn: async () => {
  543. expect(await Snapshot.track()).toBeTruthy()
  544. },
  545. })
  546. await Instance.provide({
  547. directory: worktreePath,
  548. fn: async () => {
  549. const before = await Snapshot.track()
  550. expect(before).toBeTruthy()
  551. const worktreeFile = fwd(worktreePath, "worktree.txt")
  552. await Filesystem.write(worktreeFile, "worktree content")
  553. const patch = await Snapshot.patch(before!)
  554. expect(patch.files).toContain(worktreeFile)
  555. },
  556. })
  557. } finally {
  558. await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow()
  559. await $`rm -rf ${worktreePath}`.quiet()
  560. }
  561. })
  562. test("revert only removes files in invoking worktree", async () => {
  563. await using tmp = await bootstrap()
  564. const worktreePath = `${tmp.path}-worktree`
  565. await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet()
  566. try {
  567. await Instance.provide({
  568. directory: tmp.path,
  569. fn: async () => {
  570. expect(await Snapshot.track()).toBeTruthy()
  571. },
  572. })
  573. const primaryFile = `${tmp.path}/worktree.txt`
  574. await Filesystem.write(primaryFile, "primary content")
  575. await Instance.provide({
  576. directory: worktreePath,
  577. fn: async () => {
  578. const before = await Snapshot.track()
  579. expect(before).toBeTruthy()
  580. const worktreeFile = fwd(worktreePath, "worktree.txt")
  581. await Filesystem.write(worktreeFile, "worktree content")
  582. const patch = await Snapshot.patch(before!)
  583. await Snapshot.revert([patch])
  584. expect(
  585. await fs
  586. .access(worktreeFile)
  587. .then(() => true)
  588. .catch(() => false),
  589. ).toBe(false)
  590. },
  591. })
  592. expect(await fs.readFile(primaryFile, "utf-8")).toBe("primary content")
  593. } finally {
  594. await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow()
  595. await $`rm -rf ${worktreePath}`.quiet()
  596. await $`rm -f ${tmp.path}/worktree.txt`.quiet()
  597. }
  598. })
  599. test("diff reports worktree-only/shared edits and ignores primary-only", async () => {
  600. await using tmp = await bootstrap()
  601. const worktreePath = `${tmp.path}-worktree`
  602. await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet()
  603. try {
  604. await Instance.provide({
  605. directory: tmp.path,
  606. fn: async () => {
  607. expect(await Snapshot.track()).toBeTruthy()
  608. },
  609. })
  610. await Instance.provide({
  611. directory: worktreePath,
  612. fn: async () => {
  613. const before = await Snapshot.track()
  614. expect(before).toBeTruthy()
  615. await Filesystem.write(`${worktreePath}/worktree-only.txt`, "worktree diff content")
  616. await Filesystem.write(`${worktreePath}/shared.txt`, "worktree edit")
  617. await Filesystem.write(`${tmp.path}/shared.txt`, "primary edit")
  618. await Filesystem.write(`${tmp.path}/primary-only.txt`, "primary change")
  619. const diff = await Snapshot.diff(before!)
  620. expect(diff).toContain("worktree-only.txt")
  621. expect(diff).toContain("shared.txt")
  622. expect(diff).not.toContain("primary-only.txt")
  623. },
  624. })
  625. } finally {
  626. await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow()
  627. await $`rm -rf ${worktreePath}`.quiet()
  628. await $`rm -f ${tmp.path}/shared.txt`.quiet()
  629. await $`rm -f ${tmp.path}/primary-only.txt`.quiet()
  630. }
  631. })
  632. test("track with no changes returns same hash", async () => {
  633. await using tmp = await bootstrap()
  634. await Instance.provide({
  635. directory: tmp.path,
  636. fn: async () => {
  637. const hash1 = await Snapshot.track()
  638. expect(hash1).toBeTruthy()
  639. // Track again with no changes
  640. const hash2 = await Snapshot.track()
  641. expect(hash2).toBe(hash1!)
  642. // Track again
  643. const hash3 = await Snapshot.track()
  644. expect(hash3).toBe(hash1!)
  645. },
  646. })
  647. })
  648. test("diff function with various changes", async () => {
  649. await using tmp = await bootstrap()
  650. await Instance.provide({
  651. directory: tmp.path,
  652. fn: async () => {
  653. const before = await Snapshot.track()
  654. expect(before).toBeTruthy()
  655. // Make various changes
  656. await $`rm ${tmp.path}/a.txt`.quiet()
  657. await Filesystem.write(`${tmp.path}/new.txt`, "new content")
  658. await Filesystem.write(`${tmp.path}/b.txt`, "modified content")
  659. const diff = await Snapshot.diff(before!)
  660. expect(diff).toContain("a.txt")
  661. expect(diff).toContain("b.txt")
  662. expect(diff).toContain("new.txt")
  663. },
  664. })
  665. })
  666. test("restore function", async () => {
  667. await using tmp = await bootstrap()
  668. await Instance.provide({
  669. directory: tmp.path,
  670. fn: async () => {
  671. const before = await Snapshot.track()
  672. expect(before).toBeTruthy()
  673. // Make changes
  674. await $`rm ${tmp.path}/a.txt`.quiet()
  675. await Filesystem.write(`${tmp.path}/new.txt`, "new content")
  676. await Filesystem.write(`${tmp.path}/b.txt`, "modified")
  677. // Restore to original state
  678. await Snapshot.restore(before!)
  679. expect(
  680. await fs
  681. .access(`${tmp.path}/a.txt`)
  682. .then(() => true)
  683. .catch(() => false),
  684. ).toBe(true)
  685. expect(await fs.readFile(`${tmp.path}/a.txt`, "utf-8")).toBe(tmp.extra.aContent)
  686. expect(
  687. await fs
  688. .access(`${tmp.path}/new.txt`)
  689. .then(() => true)
  690. .catch(() => false),
  691. ).toBe(true) // New files should remain
  692. expect(await fs.readFile(`${tmp.path}/b.txt`, "utf-8")).toBe(tmp.extra.bContent)
  693. },
  694. })
  695. })
  696. test("revert should not delete files that existed but were deleted in snapshot", async () => {
  697. await using tmp = await bootstrap()
  698. await Instance.provide({
  699. directory: tmp.path,
  700. fn: async () => {
  701. const snapshot1 = await Snapshot.track()
  702. expect(snapshot1).toBeTruthy()
  703. await $`rm ${tmp.path}/a.txt`.quiet()
  704. const snapshot2 = await Snapshot.track()
  705. expect(snapshot2).toBeTruthy()
  706. await Filesystem.write(`${tmp.path}/a.txt`, "recreated content")
  707. const patch = await Snapshot.patch(snapshot2!)
  708. expect(patch.files).toContain(fwd(tmp.path, "a.txt"))
  709. await Snapshot.revert([patch])
  710. expect(
  711. await fs
  712. .access(`${tmp.path}/a.txt`)
  713. .then(() => true)
  714. .catch(() => false),
  715. ).toBe(false)
  716. },
  717. })
  718. })
  719. test("revert preserves file that existed in snapshot when deleted then recreated", async () => {
  720. await using tmp = await bootstrap()
  721. await Instance.provide({
  722. directory: tmp.path,
  723. fn: async () => {
  724. await Filesystem.write(`${tmp.path}/existing.txt`, "original content")
  725. const snapshot = await Snapshot.track()
  726. expect(snapshot).toBeTruthy()
  727. await $`rm ${tmp.path}/existing.txt`.quiet()
  728. await Filesystem.write(`${tmp.path}/existing.txt`, "recreated")
  729. await Filesystem.write(`${tmp.path}/newfile.txt`, "new")
  730. const patch = await Snapshot.patch(snapshot!)
  731. expect(patch.files).toContain(fwd(tmp.path, "existing.txt"))
  732. expect(patch.files).toContain(fwd(tmp.path, "newfile.txt"))
  733. await Snapshot.revert([patch])
  734. expect(
  735. await fs
  736. .access(`${tmp.path}/newfile.txt`)
  737. .then(() => true)
  738. .catch(() => false),
  739. ).toBe(false)
  740. expect(
  741. await fs
  742. .access(`${tmp.path}/existing.txt`)
  743. .then(() => true)
  744. .catch(() => false),
  745. ).toBe(true)
  746. expect(await fs.readFile(`${tmp.path}/existing.txt`, "utf-8")).toBe("original content")
  747. },
  748. })
  749. })
  750. test("diffFull sets status based on git change type", async () => {
  751. await using tmp = await bootstrap()
  752. await Instance.provide({
  753. directory: tmp.path,
  754. fn: async () => {
  755. await Filesystem.write(`${tmp.path}/grow.txt`, "one\n")
  756. await Filesystem.write(`${tmp.path}/trim.txt`, "line1\nline2\n")
  757. await Filesystem.write(`${tmp.path}/delete.txt`, "gone")
  758. const before = await Snapshot.track()
  759. expect(before).toBeTruthy()
  760. await Filesystem.write(`${tmp.path}/grow.txt`, "one\ntwo\n")
  761. await Filesystem.write(`${tmp.path}/trim.txt`, "line1\n")
  762. await $`rm ${tmp.path}/delete.txt`.quiet()
  763. await Filesystem.write(`${tmp.path}/added.txt`, "new")
  764. const after = await Snapshot.track()
  765. expect(after).toBeTruthy()
  766. const diffs = await Snapshot.diffFull(before!, after!)
  767. expect(diffs.length).toBe(4)
  768. const added = diffs.find((d) => d.file === "added.txt")
  769. expect(added).toBeDefined()
  770. expect(added!.status).toBe("added")
  771. const deleted = diffs.find((d) => d.file === "delete.txt")
  772. expect(deleted).toBeDefined()
  773. expect(deleted!.status).toBe("deleted")
  774. const grow = diffs.find((d) => d.file === "grow.txt")
  775. expect(grow).toBeDefined()
  776. expect(grow!.status).toBe("modified")
  777. expect(grow!.additions).toBeGreaterThan(0)
  778. expect(grow!.deletions).toBe(0)
  779. const trim = diffs.find((d) => d.file === "trim.txt")
  780. expect(trim).toBeDefined()
  781. expect(trim!.status).toBe("modified")
  782. expect(trim!.additions).toBe(0)
  783. expect(trim!.deletions).toBeGreaterThan(0)
  784. },
  785. })
  786. })
  787. test("diffFull with new file additions", async () => {
  788. await using tmp = await bootstrap()
  789. await Instance.provide({
  790. directory: tmp.path,
  791. fn: async () => {
  792. const before = await Snapshot.track()
  793. expect(before).toBeTruthy()
  794. await Filesystem.write(`${tmp.path}/new.txt`, "new content")
  795. const after = await Snapshot.track()
  796. expect(after).toBeTruthy()
  797. const diffs = await Snapshot.diffFull(before!, after!)
  798. expect(diffs.length).toBe(1)
  799. const newFileDiff = diffs[0]
  800. expect(newFileDiff.file).toBe("new.txt")
  801. expect(newFileDiff.before).toBe("")
  802. expect(newFileDiff.after).toBe("new content")
  803. expect(newFileDiff.additions).toBe(1)
  804. expect(newFileDiff.deletions).toBe(0)
  805. },
  806. })
  807. })
  808. test("diffFull with file modifications", async () => {
  809. await using tmp = await bootstrap()
  810. await Instance.provide({
  811. directory: tmp.path,
  812. fn: async () => {
  813. const before = await Snapshot.track()
  814. expect(before).toBeTruthy()
  815. await Filesystem.write(`${tmp.path}/b.txt`, "modified content")
  816. const after = await Snapshot.track()
  817. expect(after).toBeTruthy()
  818. const diffs = await Snapshot.diffFull(before!, after!)
  819. expect(diffs.length).toBe(1)
  820. const modifiedFileDiff = diffs[0]
  821. expect(modifiedFileDiff.file).toBe("b.txt")
  822. expect(modifiedFileDiff.before).toBe(tmp.extra.bContent)
  823. expect(modifiedFileDiff.after).toBe("modified content")
  824. expect(modifiedFileDiff.additions).toBeGreaterThan(0)
  825. expect(modifiedFileDiff.deletions).toBeGreaterThan(0)
  826. },
  827. })
  828. })
  829. test("diffFull with file deletions", async () => {
  830. await using tmp = await bootstrap()
  831. await Instance.provide({
  832. directory: tmp.path,
  833. fn: async () => {
  834. const before = await Snapshot.track()
  835. expect(before).toBeTruthy()
  836. await $`rm ${tmp.path}/a.txt`.quiet()
  837. const after = await Snapshot.track()
  838. expect(after).toBeTruthy()
  839. const diffs = await Snapshot.diffFull(before!, after!)
  840. expect(diffs.length).toBe(1)
  841. const removedFileDiff = diffs[0]
  842. expect(removedFileDiff.file).toBe("a.txt")
  843. expect(removedFileDiff.before).toBe(tmp.extra.aContent)
  844. expect(removedFileDiff.after).toBe("")
  845. expect(removedFileDiff.additions).toBe(0)
  846. expect(removedFileDiff.deletions).toBe(1)
  847. },
  848. })
  849. })
  850. test("diffFull with multiple line additions", async () => {
  851. await using tmp = await bootstrap()
  852. await Instance.provide({
  853. directory: tmp.path,
  854. fn: async () => {
  855. const before = await Snapshot.track()
  856. expect(before).toBeTruthy()
  857. await Filesystem.write(`${tmp.path}/multi.txt`, "line1\nline2\nline3")
  858. const after = await Snapshot.track()
  859. expect(after).toBeTruthy()
  860. const diffs = await Snapshot.diffFull(before!, after!)
  861. expect(diffs.length).toBe(1)
  862. const multiDiff = diffs[0]
  863. expect(multiDiff.file).toBe("multi.txt")
  864. expect(multiDiff.before).toBe("")
  865. expect(multiDiff.after).toBe("line1\nline2\nline3")
  866. expect(multiDiff.additions).toBe(3)
  867. expect(multiDiff.deletions).toBe(0)
  868. },
  869. })
  870. })
  871. test("diffFull with addition and deletion", async () => {
  872. await using tmp = await bootstrap()
  873. await Instance.provide({
  874. directory: tmp.path,
  875. fn: async () => {
  876. const before = await Snapshot.track()
  877. expect(before).toBeTruthy()
  878. await Filesystem.write(`${tmp.path}/added.txt`, "added content")
  879. await $`rm ${tmp.path}/a.txt`.quiet()
  880. const after = await Snapshot.track()
  881. expect(after).toBeTruthy()
  882. const diffs = await Snapshot.diffFull(before!, after!)
  883. expect(diffs.length).toBe(2)
  884. const addedFileDiff = diffs.find((d) => d.file === "added.txt")
  885. expect(addedFileDiff).toBeDefined()
  886. expect(addedFileDiff!.before).toBe("")
  887. expect(addedFileDiff!.after).toBe("added content")
  888. expect(addedFileDiff!.additions).toBe(1)
  889. expect(addedFileDiff!.deletions).toBe(0)
  890. const removedFileDiff = diffs.find((d) => d.file === "a.txt")
  891. expect(removedFileDiff).toBeDefined()
  892. expect(removedFileDiff!.before).toBe(tmp.extra.aContent)
  893. expect(removedFileDiff!.after).toBe("")
  894. expect(removedFileDiff!.additions).toBe(0)
  895. expect(removedFileDiff!.deletions).toBe(1)
  896. },
  897. })
  898. })
  899. test("diffFull with multiple additions and deletions", async () => {
  900. await using tmp = await bootstrap()
  901. await Instance.provide({
  902. directory: tmp.path,
  903. fn: async () => {
  904. const before = await Snapshot.track()
  905. expect(before).toBeTruthy()
  906. await Filesystem.write(`${tmp.path}/multi1.txt`, "line1\nline2\nline3")
  907. await Filesystem.write(`${tmp.path}/multi2.txt`, "single line")
  908. await $`rm ${tmp.path}/a.txt`.quiet()
  909. await $`rm ${tmp.path}/b.txt`.quiet()
  910. const after = await Snapshot.track()
  911. expect(after).toBeTruthy()
  912. const diffs = await Snapshot.diffFull(before!, after!)
  913. expect(diffs.length).toBe(4)
  914. const multi1Diff = diffs.find((d) => d.file === "multi1.txt")
  915. expect(multi1Diff).toBeDefined()
  916. expect(multi1Diff!.additions).toBe(3)
  917. expect(multi1Diff!.deletions).toBe(0)
  918. const multi2Diff = diffs.find((d) => d.file === "multi2.txt")
  919. expect(multi2Diff).toBeDefined()
  920. expect(multi2Diff!.additions).toBe(1)
  921. expect(multi2Diff!.deletions).toBe(0)
  922. const removedADiff = diffs.find((d) => d.file === "a.txt")
  923. expect(removedADiff).toBeDefined()
  924. expect(removedADiff!.additions).toBe(0)
  925. expect(removedADiff!.deletions).toBe(1)
  926. const removedBDiff = diffs.find((d) => d.file === "b.txt")
  927. expect(removedBDiff).toBeDefined()
  928. expect(removedBDiff!.additions).toBe(0)
  929. expect(removedBDiff!.deletions).toBe(1)
  930. },
  931. })
  932. })
  933. test("diffFull with no changes", async () => {
  934. await using tmp = await bootstrap()
  935. await Instance.provide({
  936. directory: tmp.path,
  937. fn: async () => {
  938. const before = await Snapshot.track()
  939. expect(before).toBeTruthy()
  940. const after = await Snapshot.track()
  941. expect(after).toBeTruthy()
  942. const diffs = await Snapshot.diffFull(before!, after!)
  943. expect(diffs.length).toBe(0)
  944. },
  945. })
  946. })
  947. test("diffFull with binary file changes", async () => {
  948. await using tmp = await bootstrap()
  949. await Instance.provide({
  950. directory: tmp.path,
  951. fn: async () => {
  952. const before = await Snapshot.track()
  953. expect(before).toBeTruthy()
  954. await Filesystem.write(`${tmp.path}/binary.bin`, new Uint8Array([0x00, 0x01, 0x02, 0x03]))
  955. const after = await Snapshot.track()
  956. expect(after).toBeTruthy()
  957. const diffs = await Snapshot.diffFull(before!, after!)
  958. expect(diffs.length).toBe(1)
  959. const binaryDiff = diffs[0]
  960. expect(binaryDiff.file).toBe("binary.bin")
  961. expect(binaryDiff.before).toBe("")
  962. },
  963. })
  964. })
  965. test("diffFull with whitespace changes", async () => {
  966. await using tmp = await bootstrap()
  967. await Instance.provide({
  968. directory: tmp.path,
  969. fn: async () => {
  970. await Filesystem.write(`${tmp.path}/whitespace.txt`, "line1\nline2")
  971. const before = await Snapshot.track()
  972. expect(before).toBeTruthy()
  973. await Filesystem.write(`${tmp.path}/whitespace.txt`, "line1\n\nline2\n")
  974. const after = await Snapshot.track()
  975. expect(after).toBeTruthy()
  976. const diffs = await Snapshot.diffFull(before!, after!)
  977. expect(diffs.length).toBe(1)
  978. const whitespaceDiff = diffs[0]
  979. expect(whitespaceDiff.file).toBe("whitespace.txt")
  980. expect(whitespaceDiff.additions).toBeGreaterThan(0)
  981. },
  982. })
  983. })