snapshot.test.ts 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218
  1. import { afterEach, expect } from "bun:test"
  2. import { $ } from "bun"
  3. import { CrossSpawnSpawner } from "@kirincode-ai/core/cross-spawn-spawner"
  4. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  5. import { FSUtil } from "@kirincode-ai/core/fs-util"
  6. import fs from "fs/promises"
  7. import path from "path"
  8. import { Effect, Fiber, Layer } from "effect"
  9. import { Snapshot } from "../../src/snapshot"
  10. import {
  11. disposeAllInstances,
  12. provideInstance,
  13. testInstanceStoreLayer,
  14. TestInstance,
  15. tmpdirScoped,
  16. } from "../fixture/fixture"
  17. import { testEffect } from "../lib/effect"
  18. const it = testEffect(
  19. Layer.mergeAll(LayerNode.compile(LayerNode.group([Snapshot.node, FSUtil.node])), testInstanceStoreLayer),
  20. )
  21. // Windows forbids both * and : in directory names.
  22. const nonWindowsIt = process.platform === "win32" ? it.live.skip : it.live
  23. // Git always outputs /-separated paths internally. Snapshot.patch() joins them
  24. // with path.join (which produces \ on Windows) then normalizes back to /.
  25. // This helper does the same for expected values so assertions match cross-platform.
  26. const fwd = (...parts: string[]) => path.join(...parts).replaceAll("\\", "/")
  27. const SNAPSHOT_BATCH_BOUNDARY = 100
  28. const OVER_BATCH_COUNT = SNAPSHOT_BATCH_BOUNDARY + 1
  29. const MIXED_BATCH_GROUP_COUNT = Math.ceil(OVER_BATCH_COUNT / 4)
  30. afterEach(async () => {
  31. await disposeAllInstances()
  32. })
  33. const exec = (cwd: string, command: string[]) =>
  34. Effect.promise(async () => {
  35. const proc = Bun.spawn(command, { cwd, stdout: "ignore", stderr: "pipe" })
  36. const code = await proc.exited
  37. if (code !== 0) throw new Error(`${command.join(" ")} failed: ${await new Response(proc.stderr).text()}`)
  38. })
  39. const write = (file: string, content: string | Uint8Array) =>
  40. FSUtil.Service.use((fs) => fs.writeWithDirs(file, content))
  41. const readText = (file: string) => FSUtil.Service.use((fs) => fs.readFileString(file))
  42. const exists = (file: string) => FSUtil.Service.use((fs) => fs.existsSafe(file))
  43. const mkdirp = (dir: string) => FSUtil.Service.use((fs) => fs.ensureDir(dir))
  44. const rm = (file: string) =>
  45. FSUtil.Service.use((fs) => fs.remove(file, { recursive: true, force: true }).pipe(Effect.ignore))
  46. const initialize = Effect.fn("SnapshotTest.initialize")(function* (dir: string) {
  47. const unique = Math.random().toString(36).slice(2)
  48. const aContent = `A${unique}`
  49. const bContent = `B${unique}`
  50. yield* write(`${dir}/a.txt`, aContent)
  51. yield* write(`${dir}/b.txt`, bContent)
  52. return { aContent, bContent }
  53. })
  54. type Bootstrapped = { path: string; extra: { aContent: string; bContent: string } }
  55. const bootstrap = Effect.fn("SnapshotTest.bootstrap")(function* () {
  56. const tmp = yield* TestInstance
  57. return { path: tmp.directory, extra: yield* initialize(tmp.directory) }
  58. })
  59. const withTrackedSnapshot = <A, E, R>(
  60. fn: (input: { tmp: Bootstrapped; snapshot: Snapshot.Interface; before: string }) => Effect.Effect<A, E, R>,
  61. ) =>
  62. Effect.gen(function* () {
  63. const tmp = yield* bootstrap()
  64. const snapshot = yield* Snapshot.Service
  65. const before = yield* snapshot.track()
  66. expect(before).toBeTruthy()
  67. return yield* fn({ tmp, snapshot, before: before! })
  68. })
  69. const bootstrapScoped = Effect.fn("SnapshotTest.bootstrapScoped")(function* () {
  70. const dir = yield* tmpdirScoped({ git: true }).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node)))
  71. return { path: dir, extra: yield* initialize(dir) }
  72. })
  73. const scopedGitTmpdir = () =>
  74. tmpdirScoped({ git: true }).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node)))
  75. const cleanupWorktree = (repo: string, worktree: string, files: string[] = []) =>
  76. Effect.promise(async () => {
  77. await $`git worktree remove --force ${worktree}`.cwd(repo).quiet().nothrow()
  78. await fs.rm(worktree, { recursive: true, force: true }).catch(() => undefined)
  79. await Promise.all(files.map((file) => fs.rm(file, { recursive: true, force: true }).catch(() => undefined)))
  80. })
  81. const withGitConfigGlobal = <A, E, R>(config: string, self: Effect.Effect<A, E, R>) =>
  82. Effect.acquireUseRelease(
  83. Effect.sync(() => {
  84. const previous = process.env.GIT_CONFIG_GLOBAL
  85. process.env.GIT_CONFIG_GLOBAL = config
  86. return previous
  87. }),
  88. () => self,
  89. (previous) =>
  90. Effect.sync(() => {
  91. if (previous) process.env.GIT_CONFIG_GLOBAL = previous
  92. else delete process.env.GIT_CONFIG_GLOBAL
  93. }),
  94. )
  95. it.instance(
  96. "tracks deleted files correctly",
  97. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  98. Effect.gen(function* () {
  99. yield* rm(`${tmp.path}/a.txt`)
  100. expect((yield* snapshot.patch(before)).files).toContain(fwd(tmp.path, "a.txt"))
  101. }),
  102. ),
  103. { git: true },
  104. )
  105. it.instance(
  106. "revert should remove new files",
  107. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  108. Effect.gen(function* () {
  109. yield* write(`${tmp.path}/new.txt`, "NEW")
  110. const patch = yield* snapshot.patch(before)
  111. yield* snapshot.revert([patch])
  112. expect(yield* exists(`${tmp.path}/new.txt`)).toBe(false)
  113. }),
  114. ),
  115. { git: true },
  116. )
  117. it.instance(
  118. "revert in subdirectory",
  119. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  120. Effect.gen(function* () {
  121. yield* mkdirp(`${tmp.path}/sub`)
  122. yield* write(`${tmp.path}/sub/file.txt`, "SUB")
  123. const patch = yield* snapshot.patch(before)
  124. yield* snapshot.revert([patch])
  125. expect(yield* exists(`${tmp.path}/sub/file.txt`)).toBe(false)
  126. }),
  127. ),
  128. { git: true },
  129. )
  130. it.instance(
  131. "multiple file operations",
  132. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  133. Effect.gen(function* () {
  134. yield* rm(`${tmp.path}/a.txt`)
  135. yield* write(`${tmp.path}/c.txt`, "C")
  136. yield* mkdirp(`${tmp.path}/dir`)
  137. yield* write(`${tmp.path}/dir/d.txt`, "D")
  138. yield* write(`${tmp.path}/b.txt`, "MODIFIED")
  139. const patch = yield* snapshot.patch(before)
  140. yield* snapshot.revert([patch])
  141. expect(yield* readText(`${tmp.path}/a.txt`)).toBe(tmp.extra.aContent)
  142. expect(yield* exists(`${tmp.path}/c.txt`)).toBe(false)
  143. expect(yield* readText(`${tmp.path}/b.txt`)).toBe(tmp.extra.bContent)
  144. }),
  145. ),
  146. { git: true },
  147. )
  148. it.instance(
  149. "empty directory handling",
  150. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  151. Effect.gen(function* () {
  152. yield* mkdirp(`${tmp.path}/empty`)
  153. expect((yield* snapshot.patch(before)).files.length).toBe(0)
  154. }),
  155. ),
  156. { git: true },
  157. )
  158. it.instance(
  159. "binary file handling",
  160. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  161. Effect.gen(function* () {
  162. yield* write(`${tmp.path}/image.png`, new Uint8Array([0x89, 0x50, 0x4e, 0x47]))
  163. const patch = yield* snapshot.patch(before)
  164. expect(patch.files).toContain(fwd(tmp.path, "image.png"))
  165. yield* snapshot.revert([patch])
  166. expect(yield* exists(`${tmp.path}/image.png`)).toBe(false)
  167. }),
  168. ),
  169. { git: true },
  170. )
  171. it.instance(
  172. "symlink handling",
  173. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  174. Effect.gen(function* () {
  175. yield* Effect.promise(() => fs.symlink(`${tmp.path}/a.txt`, `${tmp.path}/link.txt`, "file"))
  176. expect((yield* snapshot.patch(before)).files).toContain(fwd(tmp.path, "link.txt"))
  177. }),
  178. ),
  179. { git: true },
  180. )
  181. it.instance(
  182. "file under size limit handling",
  183. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  184. Effect.gen(function* () {
  185. yield* write(`${tmp.path}/large.txt`, "x".repeat(1024 * 1024))
  186. expect((yield* snapshot.patch(before)).files).toContain(fwd(tmp.path, "large.txt"))
  187. }),
  188. ),
  189. { git: true },
  190. )
  191. it.instance(
  192. "large added files are skipped",
  193. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  194. Effect.gen(function* () {
  195. yield* write(`${tmp.path}/huge.txt`, new Uint8Array(2 * 1024 * 1024 + 1))
  196. expect((yield* snapshot.patch(before)).files).toEqual([])
  197. expect(yield* snapshot.diff(before)).toBe("")
  198. expect(yield* snapshot.track()).toBe(before)
  199. }),
  200. ),
  201. { git: true },
  202. )
  203. it.instance(
  204. "nested directory revert",
  205. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  206. Effect.gen(function* () {
  207. yield* mkdirp(`${tmp.path}/level1/level2/level3`)
  208. yield* write(`${tmp.path}/level1/level2/level3/deep.txt`, "DEEP")
  209. const patch = yield* snapshot.patch(before)
  210. yield* snapshot.revert([patch])
  211. expect(yield* exists(`${tmp.path}/level1/level2/level3/deep.txt`)).toBe(false)
  212. }),
  213. ),
  214. { git: true },
  215. )
  216. it.instance(
  217. "special characters in filenames",
  218. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  219. Effect.gen(function* () {
  220. yield* write(`${tmp.path}/file with spaces.txt`, "SPACES")
  221. yield* write(`${tmp.path}/file-with-dashes.txt`, "DASHES")
  222. yield* write(`${tmp.path}/file_with_underscores.txt`, "UNDERSCORES")
  223. const files = (yield* snapshot.patch(before)).files
  224. expect(files).toContain(fwd(tmp.path, "file with spaces.txt"))
  225. expect(files).toContain(fwd(tmp.path, "file-with-dashes.txt"))
  226. expect(files).toContain(fwd(tmp.path, "file_with_underscores.txt"))
  227. }),
  228. ),
  229. { git: true },
  230. )
  231. it.instance(
  232. "revert with empty patches",
  233. Effect.gen(function* () {
  234. yield* bootstrap()
  235. const snapshot = yield* Snapshot.Service
  236. yield* snapshot.revert([])
  237. yield* snapshot.revert([{ hash: "dummy", files: [] }])
  238. }),
  239. { git: true },
  240. )
  241. it.instance(
  242. "patch with invalid hash",
  243. withTrackedSnapshot(({ tmp, snapshot }) =>
  244. Effect.gen(function* () {
  245. yield* write(`${tmp.path}/test.txt`, "TEST")
  246. const patch = yield* snapshot.patch("invalid-hash-12345")
  247. expect(patch.files).toEqual([])
  248. expect(patch.hash).toBe("invalid-hash-12345")
  249. }),
  250. ),
  251. { git: true },
  252. )
  253. it.instance(
  254. "revert non-existent file",
  255. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  256. Effect.gen(function* () {
  257. yield* snapshot.revert([{ hash: before, files: [`${tmp.path}/nonexistent.txt`] }])
  258. }),
  259. ),
  260. { git: true },
  261. )
  262. it.instance(
  263. "unicode filenames",
  264. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  265. Effect.gen(function* () {
  266. const unicodeFiles = [
  267. { path: fwd(tmp.path, "文件.txt"), content: "chinese content" },
  268. { path: fwd(tmp.path, "🚀rocket.txt"), content: "emoji content" },
  269. { path: fwd(tmp.path, "café.txt"), content: "accented content" },
  270. { path: fwd(tmp.path, "файл.txt"), content: "cyrillic content" },
  271. ]
  272. yield* Effect.all(
  273. unicodeFiles.map((file) => write(file.path, file.content)),
  274. { concurrency: "unbounded" },
  275. )
  276. const patch = yield* snapshot.patch(before)
  277. expect(patch.files.length).toBe(4)
  278. for (const file of unicodeFiles) expect(patch.files).toContain(file.path)
  279. yield* snapshot.revert([patch])
  280. for (const file of unicodeFiles) expect(yield* exists(file.path)).toBe(false)
  281. }),
  282. ),
  283. { git: true },
  284. )
  285. it.instance.skip(
  286. "unicode filenames modification and restore",
  287. Effect.gen(function* () {
  288. const tmp = yield* bootstrap()
  289. const snapshot = yield* Snapshot.Service
  290. const chineseFile = fwd(tmp.path, "文件.txt")
  291. const cyrillicFile = fwd(tmp.path, "файл.txt")
  292. yield* write(chineseFile, "original chinese")
  293. yield* write(cyrillicFile, "original cyrillic")
  294. const before = yield* snapshot.track()
  295. expect(before).toBeTruthy()
  296. yield* write(chineseFile, "modified chinese")
  297. yield* write(cyrillicFile, "modified cyrillic")
  298. const patch = yield* snapshot.patch(before!)
  299. expect(patch.files).toContain(chineseFile)
  300. expect(patch.files).toContain(cyrillicFile)
  301. yield* snapshot.revert([patch])
  302. expect(yield* readText(chineseFile)).toBe("original chinese")
  303. expect(yield* readText(cyrillicFile)).toBe("original cyrillic")
  304. }),
  305. { git: true },
  306. )
  307. it.instance(
  308. "unicode filenames in subdirectories",
  309. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  310. Effect.gen(function* () {
  311. yield* mkdirp(`${tmp.path}/目录/подкаталог`)
  312. const deepFile = fwd(tmp.path, "目录", "подкаталог", "文件.txt")
  313. yield* write(deepFile, "deep unicode content")
  314. const patch = yield* snapshot.patch(before)
  315. expect(patch.files).toContain(deepFile)
  316. yield* snapshot.revert([patch])
  317. expect(yield* exists(deepFile)).toBe(false)
  318. }),
  319. ),
  320. { git: true },
  321. )
  322. it.instance(
  323. "very long filenames",
  324. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  325. Effect.gen(function* () {
  326. const longFile = fwd(tmp.path, `${"a".repeat(200)}.txt`)
  327. yield* write(longFile, "long filename content")
  328. const patch = yield* snapshot.patch(before)
  329. expect(patch.files).toContain(longFile)
  330. yield* snapshot.revert([patch])
  331. expect(yield* exists(longFile)).toBe(false)
  332. }),
  333. ),
  334. { git: true },
  335. )
  336. it.instance(
  337. "hidden files",
  338. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  339. Effect.gen(function* () {
  340. yield* write(`${tmp.path}/.hidden`, "hidden content")
  341. yield* write(`${tmp.path}/.gitignore`, "*.log")
  342. yield* write(`${tmp.path}/.config`, "config content")
  343. const patch = yield* snapshot.patch(before)
  344. expect(patch.files).toContain(fwd(tmp.path, ".hidden"))
  345. expect(patch.files).toContain(fwd(tmp.path, ".gitignore"))
  346. expect(patch.files).toContain(fwd(tmp.path, ".config"))
  347. }),
  348. ),
  349. { git: true },
  350. )
  351. it.instance(
  352. "nested symlinks",
  353. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  354. Effect.gen(function* () {
  355. yield* mkdirp(`${tmp.path}/sub/dir`)
  356. yield* write(`${tmp.path}/sub/dir/target.txt`, "target content")
  357. yield* Effect.promise(() => fs.symlink(`${tmp.path}/sub/dir/target.txt`, `${tmp.path}/sub/dir/link.txt`, "file"))
  358. yield* Effect.promise(() => fs.symlink(`${tmp.path}/sub`, `${tmp.path}/sub-link`, "dir"))
  359. const patch = yield* snapshot.patch(before)
  360. expect(patch.files).toContain(fwd(tmp.path, "sub", "dir", "link.txt"))
  361. expect(patch.files).toContain(fwd(tmp.path, "sub-link"))
  362. }),
  363. ),
  364. { git: true },
  365. )
  366. it.instance(
  367. "file permissions and ownership changes",
  368. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  369. Effect.gen(function* () {
  370. yield* Effect.promise(() => fs.chmod(`${tmp.path}/a.txt`, 0o600))
  371. yield* Effect.promise(() => fs.chmod(`${tmp.path}/a.txt`, 0o755))
  372. yield* Effect.promise(() => fs.chmod(`${tmp.path}/a.txt`, 0o644))
  373. expect((yield* snapshot.patch(before)).files.length).toBe(0)
  374. }),
  375. ),
  376. { git: true },
  377. )
  378. it.instance(
  379. "circular symlinks",
  380. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  381. Effect.gen(function* () {
  382. yield* Effect.promise(() =>
  383. fs.symlink(`${tmp.path}/circular`, `${tmp.path}/circular`, "dir").catch(() => undefined),
  384. )
  385. expect((yield* snapshot.patch(before)).files.length).toBeGreaterThanOrEqual(0)
  386. }),
  387. ),
  388. { git: true },
  389. )
  390. it.live(
  391. "source project gitignore is respected - ignored files are not snapshotted",
  392. Effect.gen(function* () {
  393. const dir = yield* scopedGitTmpdir()
  394. yield* write(`${dir}/.gitignore`, "*.ignored\nbuild/\nnode_modules/\n")
  395. yield* write(`${dir}/tracked.txt`, "tracked content")
  396. yield* write(`${dir}/ignored.ignored`, "ignored content")
  397. yield* mkdirp(`${dir}/build`)
  398. yield* write(`${dir}/build/output.js`, "build output")
  399. yield* write(`${dir}/normal.js`, "normal js")
  400. yield* exec(dir, ["git", "add", "."])
  401. yield* exec(dir, ["git", "commit", "-m", "init"])
  402. yield* Effect.gen(function* () {
  403. const snapshot = yield* Snapshot.Service
  404. const before = yield* snapshot.track()
  405. expect(before).toBeTruthy()
  406. yield* write(`${dir}/tracked.txt`, "modified tracked")
  407. yield* write(`${dir}/new.ignored`, "new ignored")
  408. yield* write(`${dir}/new-tracked.txt`, "new tracked")
  409. yield* write(`${dir}/build/new-build.js`, "new build file")
  410. const patch = yield* snapshot.patch(before!)
  411. expect(patch.files).toContain(fwd(dir, "new-tracked.txt"))
  412. expect(patch.files).toContain(fwd(dir, "tracked.txt"))
  413. expect(patch.files).not.toContain(fwd(dir, "new.ignored"))
  414. expect(patch.files).not.toContain(fwd(dir, "ignored.ignored"))
  415. expect(patch.files).not.toContain(fwd(dir, "build/output.js"))
  416. expect(patch.files).not.toContain(fwd(dir, "build/new-build.js"))
  417. }).pipe(provideInstance(dir))
  418. }),
  419. )
  420. it.live(
  421. "subdirectory snapshots include scoped changes only",
  422. Effect.gen(function* () {
  423. const dir = yield* scopedGitTmpdir()
  424. const frontend = path.join(dir, "frontend")
  425. yield* write(`${frontend}/tracked.txt`, "initial")
  426. yield* write(`${frontend}/deleted.txt`, "initial")
  427. yield* write(`${dir}/backend/tracked.txt`, "initial")
  428. yield* write(`${dir}/backend/deleted.txt`, "initial")
  429. yield* exec(dir, ["git", "add", "."])
  430. yield* exec(dir, ["git", "commit", "-m", "init"])
  431. yield* Effect.gen(function* () {
  432. const snapshot = yield* Snapshot.Service
  433. const before = yield* snapshot.track()
  434. expect(before).toBeTruthy()
  435. yield* write(`${frontend}/tracked.txt`, "changed")
  436. yield* write(`${frontend}/untracked.txt`, "new")
  437. yield* rm(`${frontend}/deleted.txt`)
  438. yield* write(`${dir}/backend/tracked.txt`, "changed")
  439. yield* rm(`${dir}/backend/deleted.txt`)
  440. const patch = yield* snapshot.patch(before!)
  441. const diff = yield* snapshot.diff(before!)
  442. expect(patch.files).toContain(fwd(frontend, "tracked.txt"))
  443. expect(patch.files).toContain(fwd(frontend, "untracked.txt"))
  444. expect(patch.files).toContain(fwd(frontend, "deleted.txt"))
  445. expect(patch.files).not.toContain(fwd(dir, "backend", "tracked.txt"))
  446. expect(patch.files).not.toContain(fwd(dir, "backend", "deleted.txt"))
  447. expect(diff).not.toContain("backend/tracked.txt")
  448. expect(diff).not.toContain("backend/deleted.txt")
  449. }).pipe(provideInstance(frontend))
  450. }),
  451. )
  452. nonWindowsIt(
  453. "subdirectory snapshots treat wildcard characters literally",
  454. Effect.gen(function* () {
  455. const dir = yield* scopedGitTmpdir()
  456. const subdir = path.join(dir, "src*")
  457. yield* write(`${subdir}/file.txt`, "initial")
  458. yield* write(`${subdir}/later-ignored.txt`, "initial")
  459. yield* write(`${dir}/srca/file.txt`, "initial")
  460. yield* exec(dir, ["git", "add", "."])
  461. yield* exec(dir, ["git", "commit", "-m", "init"])
  462. yield* Effect.gen(function* () {
  463. const snapshot = yield* Snapshot.Service
  464. const before = yield* snapshot.track()
  465. expect(before).toBeTruthy()
  466. yield* write(`${subdir}/file.txt`, "changed")
  467. yield* write(`${subdir}/later-ignored.txt`, "changed")
  468. yield* write(`${subdir}/.gitignore`, "later-ignored.txt\n")
  469. yield* write(`${dir}/srca/file.txt`, "changed")
  470. const patch = yield* snapshot.patch(before!)
  471. const diff = yield* snapshot.diff(before!)
  472. expect(patch.files).toContain(fwd(subdir, "file.txt"))
  473. expect(patch.files).toContain(fwd(subdir, ".gitignore"))
  474. expect(patch.files).not.toContain(fwd(subdir, "later-ignored.txt"))
  475. expect(patch.files).not.toContain(fwd(dir, "srca", "file.txt"))
  476. expect(diff).toContain("src*/later-ignored.txt")
  477. expect(diff).toContain("deleted file mode")
  478. expect(diff).not.toContain("srca/file.txt")
  479. }).pipe(provideInstance(subdir))
  480. }),
  481. )
  482. nonWindowsIt(
  483. "subdirectory snapshots treat leading colons literally",
  484. Effect.gen(function* () {
  485. const dir = yield* scopedGitTmpdir()
  486. const subdir = path.join(dir, ":src")
  487. yield* write(`${subdir}/kept.txt`, "initial")
  488. yield* write(`${subdir}/later-ignored.txt`, "initial")
  489. yield* exec(dir, ["git", "add", "."])
  490. yield* exec(dir, ["git", "commit", "-m", "init"])
  491. yield* Effect.gen(function* () {
  492. const snapshot = yield* Snapshot.Service
  493. const before = yield* snapshot.track()
  494. expect(before).toBeTruthy()
  495. yield* write(`${subdir}/kept.txt`, "changed")
  496. yield* write(`${subdir}/later-ignored.txt`, "changed")
  497. yield* write(`${subdir}/.gitignore`, "later-ignored.txt\n")
  498. const patch = yield* snapshot.patch(before!)
  499. const diff = yield* snapshot.diff(before!)
  500. expect(patch.files).toContain(fwd(subdir, "kept.txt"))
  501. expect(patch.files).toContain(fwd(subdir, ".gitignore"))
  502. expect(patch.files).not.toContain(fwd(subdir, "later-ignored.txt"))
  503. expect(diff).toContain(":src/later-ignored.txt")
  504. expect(diff).toContain("deleted file mode")
  505. }).pipe(provideInstance(subdir))
  506. }),
  507. )
  508. it.instance(
  509. "gitignore changes",
  510. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  511. Effect.gen(function* () {
  512. yield* write(`${tmp.path}/.gitignore`, "*.ignored")
  513. yield* write(`${tmp.path}/test.ignored`, "ignored content")
  514. yield* write(`${tmp.path}/normal.txt`, "normal content")
  515. const patch = yield* snapshot.patch(before)
  516. expect(patch.files).toContain(fwd(tmp.path, ".gitignore"))
  517. expect(patch.files).toContain(fwd(tmp.path, "normal.txt"))
  518. expect(patch.files).not.toContain(fwd(tmp.path, "test.ignored"))
  519. }),
  520. ),
  521. { git: true },
  522. )
  523. it.instance(
  524. "files tracked in snapshot but now gitignored are filtered out",
  525. Effect.gen(function* () {
  526. const tmp = yield* bootstrap()
  527. const snapshot = yield* Snapshot.Service
  528. yield* write(`${tmp.path}/later-ignored.txt`, "initial content")
  529. const before = yield* snapshot.track()
  530. expect(before).toBeTruthy()
  531. yield* write(`${tmp.path}/later-ignored.txt`, "modified content")
  532. yield* write(`${tmp.path}/.gitignore`, "later-ignored.txt\n")
  533. yield* write(`${tmp.path}/still-tracked.txt`, "new tracked file")
  534. const patch = yield* snapshot.patch(before!)
  535. expect(patch.files).not.toContain(fwd(tmp.path, "later-ignored.txt"))
  536. expect(patch.files).toContain(fwd(tmp.path, ".gitignore"))
  537. expect(patch.files).toContain(fwd(tmp.path, "still-tracked.txt"))
  538. }),
  539. { git: true },
  540. )
  541. it.instance(
  542. "gitignore updated between track calls filters from diff",
  543. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  544. Effect.gen(function* () {
  545. yield* write(`${tmp.path}/a.txt`, "modified content")
  546. yield* write(`${tmp.path}/.gitignore`, "a.txt\n")
  547. yield* write(`${tmp.path}/b.txt`, "also modified")
  548. const after = yield* snapshot.track()
  549. expect(after).toBeTruthy()
  550. const diffs = yield* snapshot.diffFull(before, after!)
  551. expect(diffs.some((x) => x.file === "a.txt")).toBe(false)
  552. expect(diffs.some((x) => x.file === ".gitignore")).toBe(true)
  553. expect(diffs.some((x) => x.file === "b.txt")).toBe(true)
  554. }),
  555. ),
  556. { git: true },
  557. )
  558. it.instance(
  559. "git info exclude changes",
  560. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  561. Effect.gen(function* () {
  562. const file = `${tmp.path}/.git/info/exclude`
  563. yield* write(file, `${(yield* Effect.promise(() => Bun.file(file).text())).trimEnd()}\nignored.txt\n`)
  564. yield* write(`${tmp.path}/ignored.txt`, "ignored content")
  565. yield* write(`${tmp.path}/normal.txt`, "normal content")
  566. const patch = yield* snapshot.patch(before)
  567. expect(patch.files).toContain(fwd(tmp.path, "normal.txt"))
  568. expect(patch.files).not.toContain(fwd(tmp.path, "ignored.txt"))
  569. const after = yield* snapshot.track()
  570. const diffs = yield* snapshot.diffFull(before, after!)
  571. expect(diffs.some((x) => x.file === "normal.txt")).toBe(true)
  572. expect(diffs.some((x) => x.file === "ignored.txt")).toBe(false)
  573. }),
  574. ),
  575. { git: true },
  576. )
  577. it.instance(
  578. "git info exclude keeps global excludes",
  579. Effect.gen(function* () {
  580. const tmp = yield* bootstrap()
  581. const global = `${tmp.path}/global.ignore`
  582. const config = `${tmp.path}/global.gitconfig`
  583. yield* write(global, "global.tmp\n")
  584. yield* write(config, `[core]\n\texcludesFile = ${global.replaceAll("\\", "/")}\n`)
  585. yield* withGitConfigGlobal(
  586. config,
  587. Effect.gen(function* () {
  588. const snapshot = yield* Snapshot.Service
  589. const before = yield* snapshot.track()
  590. expect(before).toBeTruthy()
  591. const file = `${tmp.path}/.git/info/exclude`
  592. yield* write(file, `${(yield* Effect.promise(() => Bun.file(file).text())).trimEnd()}\ninfo.tmp\n`)
  593. yield* write(`${tmp.path}/global.tmp`, "global content")
  594. yield* write(`${tmp.path}/info.tmp`, "info content")
  595. yield* write(`${tmp.path}/normal.txt`, "normal content")
  596. const patch = yield* snapshot.patch(before!)
  597. expect(patch.files).toContain(fwd(tmp.path, "normal.txt"))
  598. expect(patch.files).not.toContain(fwd(tmp.path, "global.tmp"))
  599. expect(patch.files).not.toContain(fwd(tmp.path, "info.tmp"))
  600. }),
  601. )
  602. }),
  603. { git: true },
  604. )
  605. it.instance(
  606. "concurrent file operations during patch",
  607. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  608. Effect.gen(function* () {
  609. const fiber = yield* Effect.gen(function* () {
  610. for (let i = 0; i < 10; i++) {
  611. yield* write(`${tmp.path}/concurrent${i}.txt`, `concurrent${i}`)
  612. yield* Effect.sleep("1 millis")
  613. }
  614. }).pipe(Effect.forkScoped)
  615. const patch = yield* snapshot.patch(before)
  616. yield* Fiber.join(fiber)
  617. expect(patch.files.length).toBeGreaterThanOrEqual(0)
  618. }),
  619. ),
  620. { git: true },
  621. )
  622. it.live(
  623. "snapshot state isolation between projects",
  624. Effect.gen(function* () {
  625. const tmp1 = yield* bootstrapScoped()
  626. const tmp2 = yield* bootstrapScoped()
  627. yield* Effect.gen(function* () {
  628. const snapshot = yield* Snapshot.Service
  629. const before1 = yield* snapshot.track()
  630. yield* write(`${tmp1.path}/project1.txt`, "project1 content")
  631. const patch1 = yield* snapshot.patch(before1!)
  632. expect(patch1.files).toContain(fwd(tmp1.path, "project1.txt"))
  633. }).pipe(provideInstance(tmp1.path))
  634. yield* Effect.gen(function* () {
  635. const snapshot = yield* Snapshot.Service
  636. const before2 = yield* snapshot.track()
  637. yield* write(`${tmp2.path}/project2.txt`, "project2 content")
  638. const patch2 = yield* snapshot.patch(before2!)
  639. expect(patch2.files).toContain(fwd(tmp2.path, "project2.txt"))
  640. expect(patch2.files).not.toContain(fwd(tmp1.path, "project1.txt"))
  641. }).pipe(provideInstance(tmp2.path))
  642. }),
  643. )
  644. it.live(
  645. "patch detects changes in secondary worktree",
  646. Effect.gen(function* () {
  647. const tmp = yield* bootstrapScoped()
  648. const worktreePath = `${tmp.path}-worktree`
  649. yield* exec(tmp.path, ["git", "worktree", "add", worktreePath, "HEAD"])
  650. yield* Effect.addFinalizer(() => cleanupWorktree(tmp.path, worktreePath))
  651. yield* Effect.gen(function* () {
  652. const snapshot = yield* Snapshot.Service
  653. expect(yield* snapshot.track()).toBeTruthy()
  654. }).pipe(provideInstance(tmp.path))
  655. yield* Effect.gen(function* () {
  656. const snapshot = yield* Snapshot.Service
  657. const before = yield* snapshot.track()
  658. expect(before).toBeTruthy()
  659. const worktreeFile = fwd(worktreePath, "worktree.txt")
  660. yield* write(worktreeFile, "worktree content")
  661. expect((yield* snapshot.patch(before!)).files).toContain(worktreeFile)
  662. }).pipe(provideInstance(worktreePath))
  663. }),
  664. )
  665. it.live(
  666. "revert only removes files in invoking worktree",
  667. Effect.gen(function* () {
  668. const tmp = yield* bootstrapScoped()
  669. const worktreePath = `${tmp.path}-worktree`
  670. const primaryFile = `${tmp.path}/worktree.txt`
  671. yield* exec(tmp.path, ["git", "worktree", "add", worktreePath, "HEAD"])
  672. yield* Effect.addFinalizer(() => cleanupWorktree(tmp.path, worktreePath, [primaryFile]))
  673. yield* Effect.gen(function* () {
  674. const snapshot = yield* Snapshot.Service
  675. expect(yield* snapshot.track()).toBeTruthy()
  676. }).pipe(provideInstance(tmp.path))
  677. yield* write(primaryFile, "primary content")
  678. yield* Effect.gen(function* () {
  679. const snapshot = yield* Snapshot.Service
  680. const before = yield* snapshot.track()
  681. expect(before).toBeTruthy()
  682. const worktreeFile = fwd(worktreePath, "worktree.txt")
  683. yield* write(worktreeFile, "worktree content")
  684. const patch = yield* snapshot.patch(before!)
  685. yield* snapshot.revert([patch])
  686. expect(yield* exists(worktreeFile)).toBe(false)
  687. }).pipe(provideInstance(worktreePath))
  688. expect(yield* readText(primaryFile)).toBe("primary content")
  689. }),
  690. )
  691. it.live(
  692. "diff reports worktree-only/shared edits and ignores primary-only",
  693. Effect.gen(function* () {
  694. const tmp = yield* bootstrapScoped()
  695. const worktreePath = `${tmp.path}-worktree`
  696. yield* exec(tmp.path, ["git", "worktree", "add", worktreePath, "HEAD"])
  697. yield* Effect.addFinalizer(() =>
  698. cleanupWorktree(tmp.path, worktreePath, [`${tmp.path}/shared.txt`, `${tmp.path}/primary-only.txt`]),
  699. )
  700. yield* Effect.gen(function* () {
  701. const snapshot = yield* Snapshot.Service
  702. expect(yield* snapshot.track()).toBeTruthy()
  703. }).pipe(provideInstance(tmp.path))
  704. yield* Effect.gen(function* () {
  705. const snapshot = yield* Snapshot.Service
  706. const before = yield* snapshot.track()
  707. expect(before).toBeTruthy()
  708. yield* write(`${worktreePath}/worktree-only.txt`, "worktree diff content")
  709. yield* write(`${worktreePath}/shared.txt`, "worktree edit")
  710. yield* write(`${tmp.path}/shared.txt`, "primary edit")
  711. yield* write(`${tmp.path}/primary-only.txt`, "primary change")
  712. const diff = yield* snapshot.diff(before!)
  713. expect(diff).toContain("worktree-only.txt")
  714. expect(diff).toContain("shared.txt")
  715. expect(diff).not.toContain("primary-only.txt")
  716. }).pipe(provideInstance(worktreePath))
  717. }),
  718. )
  719. it.instance(
  720. "track with no changes returns same hash",
  721. withTrackedSnapshot(({ snapshot, before }) =>
  722. Effect.gen(function* () {
  723. expect(yield* snapshot.track()).toBe(before)
  724. expect(yield* snapshot.track()).toBe(before)
  725. }),
  726. ),
  727. { git: true },
  728. )
  729. it.instance(
  730. "diff function with various changes",
  731. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  732. Effect.gen(function* () {
  733. yield* rm(`${tmp.path}/a.txt`)
  734. yield* write(`${tmp.path}/new.txt`, "new content")
  735. yield* write(`${tmp.path}/b.txt`, "modified content")
  736. const diff = yield* snapshot.diff(before)
  737. expect(diff).toContain("a.txt")
  738. expect(diff).toContain("b.txt")
  739. expect(diff).toContain("new.txt")
  740. }),
  741. ),
  742. { git: true },
  743. )
  744. it.instance(
  745. "restore function",
  746. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  747. Effect.gen(function* () {
  748. yield* rm(`${tmp.path}/a.txt`)
  749. yield* write(`${tmp.path}/new.txt`, "new content")
  750. yield* write(`${tmp.path}/b.txt`, "modified")
  751. yield* snapshot.restore(before)
  752. expect(yield* exists(`${tmp.path}/a.txt`)).toBe(true)
  753. expect(yield* readText(`${tmp.path}/a.txt`)).toBe(tmp.extra.aContent)
  754. expect(yield* exists(`${tmp.path}/new.txt`)).toBe(true)
  755. expect(yield* readText(`${tmp.path}/b.txt`)).toBe(tmp.extra.bContent)
  756. }),
  757. ),
  758. { git: true },
  759. )
  760. it.instance(
  761. "revert should not delete files that existed but were deleted in snapshot",
  762. Effect.gen(function* () {
  763. const tmp = yield* bootstrap()
  764. const snapshot = yield* Snapshot.Service
  765. const snapshot1 = yield* snapshot.track()
  766. expect(snapshot1).toBeTruthy()
  767. yield* rm(`${tmp.path}/a.txt`)
  768. const snapshot2 = yield* snapshot.track()
  769. expect(snapshot2).toBeTruthy()
  770. yield* write(`${tmp.path}/a.txt`, "recreated content")
  771. const patch = yield* snapshot.patch(snapshot2!)
  772. expect(patch.files).toContain(fwd(tmp.path, "a.txt"))
  773. yield* snapshot.revert([patch])
  774. expect(yield* exists(`${tmp.path}/a.txt`)).toBe(false)
  775. }),
  776. { git: true },
  777. )
  778. it.instance(
  779. "revert preserves file that existed in snapshot when deleted then recreated",
  780. Effect.gen(function* () {
  781. const tmp = yield* bootstrap()
  782. const snapshot = yield* Snapshot.Service
  783. yield* write(`${tmp.path}/existing.txt`, "original content")
  784. const hash = yield* snapshot.track()
  785. expect(hash).toBeTruthy()
  786. yield* rm(`${tmp.path}/existing.txt`)
  787. yield* write(`${tmp.path}/existing.txt`, "recreated")
  788. yield* write(`${tmp.path}/newfile.txt`, "new")
  789. const patch = yield* snapshot.patch(hash!)
  790. expect(patch.files).toContain(fwd(tmp.path, "existing.txt"))
  791. expect(patch.files).toContain(fwd(tmp.path, "newfile.txt"))
  792. yield* snapshot.revert([patch])
  793. expect(yield* exists(`${tmp.path}/newfile.txt`)).toBe(false)
  794. expect(yield* exists(`${tmp.path}/existing.txt`)).toBe(true)
  795. expect(yield* readText(`${tmp.path}/existing.txt`)).toBe("original content")
  796. }),
  797. { git: true },
  798. )
  799. it.instance(
  800. "diffFull sets status based on git change type",
  801. Effect.gen(function* () {
  802. const tmp = yield* bootstrap()
  803. const snapshot = yield* Snapshot.Service
  804. yield* write(`${tmp.path}/grow.txt`, "one\n")
  805. yield* write(`${tmp.path}/trim.txt`, "line1\nline2\n")
  806. yield* write(`${tmp.path}/delete.txt`, "gone")
  807. const before = yield* snapshot.track()
  808. expect(before).toBeTruthy()
  809. yield* write(`${tmp.path}/grow.txt`, "one\ntwo\n")
  810. yield* write(`${tmp.path}/trim.txt`, "line1\n")
  811. yield* rm(`${tmp.path}/delete.txt`)
  812. yield* write(`${tmp.path}/added.txt`, "new")
  813. const after = yield* snapshot.track()
  814. expect(after).toBeTruthy()
  815. const diffs = yield* snapshot.diffFull(before!, after!)
  816. expect(diffs.length).toBe(4)
  817. expect(diffs.find((d) => d.file === "added.txt")!.status).toBe("added")
  818. expect(diffs.find((d) => d.file === "delete.txt")!.status).toBe("deleted")
  819. const grow = diffs.find((d) => d.file === "grow.txt")!
  820. expect(grow.status).toBe("modified")
  821. expect(grow.additions).toBeGreaterThan(0)
  822. expect(grow.deletions).toBe(0)
  823. const trim = diffs.find((d) => d.file === "trim.txt")!
  824. expect(trim.status).toBe("modified")
  825. expect(trim.additions).toBe(0)
  826. expect(trim.deletions).toBeGreaterThan(0)
  827. }),
  828. { git: true },
  829. )
  830. it.instance(
  831. "diffFull with new file additions",
  832. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  833. Effect.gen(function* () {
  834. yield* write(`${tmp.path}/new.txt`, "new content")
  835. const after = yield* snapshot.track()
  836. expect(after).toBeTruthy()
  837. const diffs = yield* snapshot.diffFull(before, after!)
  838. expect(diffs.length).toBe(1)
  839. expect(diffs[0].file).toBe("new.txt")
  840. expect(diffs[0].patch).toContain("+new content")
  841. expect(diffs[0].additions).toBe(1)
  842. expect(diffs[0].deletions).toBe(0)
  843. }),
  844. ),
  845. { git: true },
  846. )
  847. it.instance(
  848. "diffFull with a large interleaved mixed diff",
  849. Effect.gen(function* () {
  850. const tmp = yield* bootstrap()
  851. const snapshot = yield* Snapshot.Service
  852. const ids = Array.from({ length: MIXED_BATCH_GROUP_COUNT }, (_, i) => i.toString().padStart(3, "0"))
  853. const mod = ids.map((id) => fwd(tmp.path, "mix", `${id}-mod.txt`))
  854. const del = ids.map((id) => fwd(tmp.path, "mix", `${id}-del.txt`))
  855. const add = ids.map((id) => fwd(tmp.path, "mix", `${id}-add.txt`))
  856. const bin = ids.map((id) => fwd(tmp.path, "mix", `${id}-bin.bin`))
  857. yield* mkdirp(`${tmp.path}/mix`)
  858. yield* Effect.all(
  859. [
  860. ...mod.map((file, i) => write(file, `before-${ids[i]}-é\n🙂\nline`)),
  861. ...del.map((file, i) => write(file, `gone-${ids[i]}\n你好`)),
  862. ...bin.map((file, i) => write(file, new Uint8Array([0, i, 255, i % 251]))),
  863. ],
  864. { concurrency: "unbounded" },
  865. )
  866. const before = yield* snapshot.track()
  867. expect(before).toBeTruthy()
  868. yield* Effect.all(
  869. [
  870. ...mod.map((file, i) => write(file, `after-${ids[i]}-é\n🚀\nline`)),
  871. ...add.map((file, i) => write(file, `new-${ids[i]}\nこんにちは`)),
  872. ...bin.map((file, i) => write(file, new Uint8Array([9, i, 8, i % 251]))),
  873. ...del.map((file) => rm(file)),
  874. ],
  875. { concurrency: "unbounded" },
  876. )
  877. const after = yield* snapshot.track()
  878. expect(after).toBeTruthy()
  879. const diffs = yield* snapshot.diffFull(before!, after!)
  880. expect(diffs).toHaveLength(ids.length * 4)
  881. const map = new Map(diffs.map((item) => [item.file, item]))
  882. for (let i = 0; i < ids.length; i++) {
  883. const m = map.get(fwd("mix", `${ids[i]}-mod.txt`))
  884. expect(m).toBeDefined()
  885. expect(m!.patch).toContain(`-before-${ids[i]}-é`)
  886. expect(m!.patch).toContain(`+after-${ids[i]}-é`)
  887. expect(m!.status).toBe("modified")
  888. const d = map.get(fwd("mix", `${ids[i]}-del.txt`))
  889. expect(d).toBeDefined()
  890. expect(d!.patch).toContain(`-gone-${ids[i]}`)
  891. expect(d!.status).toBe("deleted")
  892. const a = map.get(fwd("mix", `${ids[i]}-add.txt`))
  893. expect(a).toBeDefined()
  894. expect(a!.patch).toContain(`+new-${ids[i]}`)
  895. expect(a!.status).toBe("added")
  896. const b = map.get(fwd("mix", `${ids[i]}-bin.bin`))
  897. expect(b).toBeDefined()
  898. expect(b!.patch).toBe("")
  899. expect(b!.additions).toBe(0)
  900. expect(b!.deletions).toBe(0)
  901. expect(b!.status).toBe("modified")
  902. }
  903. }),
  904. { git: true },
  905. )
  906. it.instance(
  907. "diffFull preserves git diff order across batch boundaries",
  908. Effect.gen(function* () {
  909. const tmp = yield* bootstrap()
  910. const snapshot = yield* Snapshot.Service
  911. const ids = Array.from({ length: OVER_BATCH_COUNT }, (_, i) => i.toString().padStart(3, "0"))
  912. yield* mkdirp(`${tmp.path}/order`)
  913. yield* Effect.all(
  914. ids.map((id) => write(`${tmp.path}/order/${id}.txt`, `before-${id}`)),
  915. { concurrency: "unbounded" },
  916. )
  917. const before = yield* snapshot.track()
  918. expect(before).toBeTruthy()
  919. yield* Effect.all(
  920. ids.map((id) => write(`${tmp.path}/order/${id}.txt`, `after-${id}`)),
  921. { concurrency: "unbounded" },
  922. )
  923. const after = yield* snapshot.track()
  924. expect(after).toBeTruthy()
  925. expect((yield* snapshot.diffFull(before!, after!)).map((item) => item.file)).toEqual(
  926. ids.map((id) => `order/${id}.txt`),
  927. )
  928. }),
  929. { git: true },
  930. )
  931. it.instance(
  932. "diffFull with file modifications",
  933. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  934. Effect.gen(function* () {
  935. yield* write(`${tmp.path}/b.txt`, "modified content")
  936. const after = yield* snapshot.track()
  937. expect(after).toBeTruthy()
  938. const diffs = yield* snapshot.diffFull(before, after!)
  939. expect(diffs.length).toBe(1)
  940. expect(diffs[0].file).toBe("b.txt")
  941. expect(diffs[0].patch).toContain(`-${tmp.extra.bContent}`)
  942. expect(diffs[0].patch).toContain("+modified content")
  943. expect(diffs[0].additions).toBeGreaterThan(0)
  944. expect(diffs[0].deletions).toBeGreaterThan(0)
  945. }),
  946. ),
  947. { git: true },
  948. )
  949. it.instance(
  950. "diffFull with file deletions",
  951. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  952. Effect.gen(function* () {
  953. yield* rm(`${tmp.path}/a.txt`)
  954. const after = yield* snapshot.track()
  955. expect(after).toBeTruthy()
  956. const diffs = yield* snapshot.diffFull(before, after!)
  957. expect(diffs.length).toBe(1)
  958. expect(diffs[0].file).toBe("a.txt")
  959. expect(diffs[0].patch).toContain(`-${tmp.extra.aContent}`)
  960. expect(diffs[0].additions).toBe(0)
  961. expect(diffs[0].deletions).toBe(1)
  962. }),
  963. ),
  964. { git: true },
  965. )
  966. it.instance(
  967. "diffFull with multiple line additions",
  968. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  969. Effect.gen(function* () {
  970. yield* write(`${tmp.path}/multi.txt`, "line1\nline2\nline3")
  971. const after = yield* snapshot.track()
  972. expect(after).toBeTruthy()
  973. const diffs = yield* snapshot.diffFull(before, after!)
  974. expect(diffs.length).toBe(1)
  975. expect(diffs[0].file).toBe("multi.txt")
  976. expect(diffs[0].patch).toContain("+line1")
  977. expect(diffs[0].patch).toContain("+line3")
  978. expect(diffs[0].additions).toBe(3)
  979. expect(diffs[0].deletions).toBe(0)
  980. }),
  981. ),
  982. { git: true },
  983. )
  984. it.instance(
  985. "diffFull with addition and deletion",
  986. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  987. Effect.gen(function* () {
  988. yield* write(`${tmp.path}/added.txt`, "added content")
  989. yield* rm(`${tmp.path}/a.txt`)
  990. const after = yield* snapshot.track()
  991. expect(after).toBeTruthy()
  992. const diffs = yield* snapshot.diffFull(before, after!)
  993. expect(diffs.length).toBe(2)
  994. const added = diffs.find((d) => d.file === "added.txt")!
  995. expect(added.patch).toContain("+added content")
  996. expect(added.additions).toBe(1)
  997. expect(added.deletions).toBe(0)
  998. const removed = diffs.find((d) => d.file === "a.txt")!
  999. expect(removed.patch).toContain(`-${tmp.extra.aContent}`)
  1000. expect(removed.additions).toBe(0)
  1001. expect(removed.deletions).toBe(1)
  1002. }),
  1003. ),
  1004. { git: true },
  1005. )
  1006. it.instance(
  1007. "diffFull with multiple additions and deletions",
  1008. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  1009. Effect.gen(function* () {
  1010. yield* write(`${tmp.path}/multi1.txt`, "line1\nline2\nline3")
  1011. yield* write(`${tmp.path}/multi2.txt`, "single line")
  1012. yield* rm(`${tmp.path}/a.txt`)
  1013. yield* rm(`${tmp.path}/b.txt`)
  1014. const after = yield* snapshot.track()
  1015. expect(after).toBeTruthy()
  1016. const diffs = yield* snapshot.diffFull(before, after!)
  1017. expect(diffs.length).toBe(4)
  1018. expect(diffs.find((d) => d.file === "multi1.txt")!.additions).toBe(3)
  1019. expect(diffs.find((d) => d.file === "multi1.txt")!.deletions).toBe(0)
  1020. expect(diffs.find((d) => d.file === "multi2.txt")!.additions).toBe(1)
  1021. expect(diffs.find((d) => d.file === "multi2.txt")!.deletions).toBe(0)
  1022. expect(diffs.find((d) => d.file === "a.txt")!.additions).toBe(0)
  1023. expect(diffs.find((d) => d.file === "a.txt")!.deletions).toBe(1)
  1024. expect(diffs.find((d) => d.file === "b.txt")!.additions).toBe(0)
  1025. expect(diffs.find((d) => d.file === "b.txt")!.deletions).toBe(1)
  1026. }),
  1027. ),
  1028. { git: true },
  1029. )
  1030. it.instance(
  1031. "diffFull with no changes",
  1032. withTrackedSnapshot(({ snapshot, before }) =>
  1033. Effect.gen(function* () {
  1034. const after = yield* snapshot.track()
  1035. expect(after).toBeTruthy()
  1036. expect((yield* snapshot.diffFull(before, after!)).length).toBe(0)
  1037. }),
  1038. ),
  1039. { git: true },
  1040. )
  1041. it.instance(
  1042. "diffFull with binary file changes",
  1043. withTrackedSnapshot(({ tmp, snapshot, before }) =>
  1044. Effect.gen(function* () {
  1045. yield* write(`${tmp.path}/binary.bin`, new Uint8Array([0x00, 0x01, 0x02, 0x03]))
  1046. const after = yield* snapshot.track()
  1047. expect(after).toBeTruthy()
  1048. const diffs = yield* snapshot.diffFull(before, after!)
  1049. expect(diffs.length).toBe(1)
  1050. expect(diffs[0].file).toBe("binary.bin")
  1051. expect(diffs[0].patch).toBe("")
  1052. }),
  1053. ),
  1054. { git: true },
  1055. )
  1056. it.instance(
  1057. "diffFull with whitespace changes",
  1058. Effect.gen(function* () {
  1059. const tmp = yield* bootstrap()
  1060. const snapshot = yield* Snapshot.Service
  1061. yield* write(`${tmp.path}/whitespace.txt`, "line1\nline2")
  1062. const before = yield* snapshot.track()
  1063. expect(before).toBeTruthy()
  1064. yield* write(`${tmp.path}/whitespace.txt`, "line1\n\nline2\n")
  1065. const after = yield* snapshot.track()
  1066. expect(after).toBeTruthy()
  1067. const diffs = yield* snapshot.diffFull(before!, after!)
  1068. expect(diffs.length).toBe(1)
  1069. expect(diffs[0].file).toBe("whitespace.txt")
  1070. expect(diffs[0].additions).toBeGreaterThan(0)
  1071. }),
  1072. { git: true },
  1073. )
  1074. it.instance(
  1075. "revert with overlapping files across patches uses first patch hash",
  1076. Effect.gen(function* () {
  1077. const tmp = yield* bootstrap()
  1078. const snapshot = yield* Snapshot.Service
  1079. yield* write(`${tmp.path}/shared.txt`, "v1")
  1080. const snap1 = yield* snapshot.track()
  1081. expect(snap1).toBeTruthy()
  1082. yield* write(`${tmp.path}/shared.txt`, "v2")
  1083. const snap2 = yield* snapshot.track()
  1084. expect(snap2).toBeTruthy()
  1085. yield* write(`${tmp.path}/shared.txt`, "v3")
  1086. const patch1 = yield* snapshot.patch(snap1!)
  1087. const patch2 = yield* snapshot.patch(snap2!)
  1088. expect(patch1.files).toContain(fwd(tmp.path, "shared.txt"))
  1089. expect(patch2.files).toContain(fwd(tmp.path, "shared.txt"))
  1090. yield* snapshot.revert([patch1, patch2])
  1091. expect(yield* readText(`${tmp.path}/shared.txt`)).toBe("v1")
  1092. }),
  1093. { git: true },
  1094. )
  1095. it.instance(
  1096. "revert preserves patch order when the same hash appears again",
  1097. Effect.gen(function* () {
  1098. const tmp = yield* bootstrap()
  1099. const snapshot = yield* Snapshot.Service
  1100. yield* mkdirp(`${tmp.path}/foo`)
  1101. yield* write(`${tmp.path}/foo/bar`, "v1")
  1102. yield* write(`${tmp.path}/a.txt`, "v1")
  1103. const snap1 = yield* snapshot.track()
  1104. expect(snap1).toBeTruthy()
  1105. yield* rm(`${tmp.path}/foo`)
  1106. yield* write(`${tmp.path}/foo`, "v2")
  1107. yield* write(`${tmp.path}/a.txt`, "v2")
  1108. const snap2 = yield* snapshot.track()
  1109. expect(snap2).toBeTruthy()
  1110. yield* rm(`${tmp.path}/foo`)
  1111. yield* write(`${tmp.path}/a.txt`, "v3")
  1112. yield* snapshot.revert([
  1113. { hash: snap1!, files: [fwd(tmp.path, "a.txt")] },
  1114. { hash: snap2!, files: [fwd(tmp.path, "foo")] },
  1115. { hash: snap1!, files: [fwd(tmp.path, "foo", "bar")] },
  1116. ])
  1117. expect(yield* readText(`${tmp.path}/a.txt`)).toBe("v1")
  1118. expect((yield* Effect.promise(() => fs.stat(`${tmp.path}/foo`))).isDirectory()).toBe(true)
  1119. expect(yield* readText(`${tmp.path}/foo/bar`)).toBe("v1")
  1120. }),
  1121. { git: true },
  1122. )
  1123. it.instance(
  1124. "revert handles large mixed batches across chunk boundaries",
  1125. Effect.gen(function* () {
  1126. const tmp = yield* bootstrap()
  1127. const snapshot = yield* Snapshot.Service
  1128. const base = Array.from({ length: OVER_BATCH_COUNT }, (_, i) => fwd(tmp.path, "batch", `${i}.txt`))
  1129. const fresh = [fwd(tmp.path, "fresh", "0.txt")]
  1130. yield* mkdirp(`${tmp.path}/batch`)
  1131. yield* mkdirp(`${tmp.path}/fresh`)
  1132. yield* Effect.all(
  1133. base.map((file, i) => write(file, `base-${i}`)),
  1134. { concurrency: "unbounded" },
  1135. )
  1136. const snap = yield* snapshot.track()
  1137. expect(snap).toBeTruthy()
  1138. yield* Effect.all(
  1139. [...base.map((file, i) => write(file, `next-${i}`)), ...fresh.map((file, i) => write(file, `fresh-${i}`))],
  1140. { concurrency: "unbounded" },
  1141. )
  1142. const patch = yield* snapshot.patch(snap!)
  1143. expect(patch.files.length).toBe(base.length + fresh.length)
  1144. yield* snapshot.revert([patch])
  1145. for (let i = 0; i < base.length; i++) expect(yield* readText(base[i])).toBe(`base-${i}`)
  1146. for (const file of fresh) expect(yield* exists(file)).toBe(false)
  1147. }),
  1148. { git: true },
  1149. )