index.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777
  1. import { Cause, Duration, Effect, Layer, Schedule, Semaphore, Context, Stream } from "effect"
  2. import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
  3. import { formatPatch, structuredPatch } from "diff"
  4. import path from "path"
  5. import z from "zod"
  6. import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
  7. import { InstanceState } from "@/effect"
  8. import { AppFileSystem } from "@opencode-ai/shared/filesystem"
  9. import { Hash } from "@opencode-ai/shared/util/hash"
  10. import { Config } from "../config"
  11. import { Global } from "../global"
  12. import { Log } from "../util"
  13. export const Patch = z.object({
  14. hash: z.string(),
  15. files: z.string().array(),
  16. })
  17. export type Patch = z.infer<typeof Patch>
  18. export const FileDiff = z
  19. .object({
  20. file: z.string(),
  21. patch: z.string(),
  22. additions: z.number(),
  23. deletions: z.number(),
  24. status: z.enum(["added", "deleted", "modified"]).optional(),
  25. })
  26. .meta({
  27. ref: "SnapshotFileDiff",
  28. })
  29. export type FileDiff = z.infer<typeof FileDiff>
  30. const log = Log.create({ service: "snapshot" })
  31. const prune = "7.days"
  32. const limit = 2 * 1024 * 1024
  33. const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
  34. const cfg = ["-c", "core.autocrlf=false", ...core]
  35. const quote = [...cfg, "-c", "core.quotepath=false"]
  36. interface GitResult {
  37. readonly code: ChildProcessSpawner.ExitCode
  38. readonly text: string
  39. readonly stderr: string
  40. }
  41. type State = Omit<Interface, "init">
  42. export interface Interface {
  43. readonly init: () => Effect.Effect<void>
  44. readonly cleanup: () => Effect.Effect<void>
  45. readonly track: () => Effect.Effect<string | undefined>
  46. readonly patch: (hash: string) => Effect.Effect<Patch>
  47. readonly restore: (snapshot: string) => Effect.Effect<void>
  48. readonly revert: (patches: Patch[]) => Effect.Effect<void>
  49. readonly diff: (hash: string) => Effect.Effect<string>
  50. readonly diffFull: (from: string, to: string) => Effect.Effect<FileDiff[]>
  51. }
  52. export class Service extends Context.Service<Service, Interface>()("@opencode/Snapshot") {}
  53. export const layer: Layer.Layer<
  54. Service,
  55. never,
  56. AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner | Config.Service
  57. > = Layer.effect(
  58. Service,
  59. Effect.gen(function* () {
  60. const fs = yield* AppFileSystem.Service
  61. const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
  62. const config = yield* Config.Service
  63. const locks = new Map<string, Semaphore.Semaphore>()
  64. const lock = (key: string) => {
  65. const hit = locks.get(key)
  66. if (hit) return hit
  67. const next = Semaphore.makeUnsafe(1)
  68. locks.set(key, next)
  69. return next
  70. }
  71. const state = yield* InstanceState.make<State>(
  72. Effect.fn("Snapshot.state")(function* (ctx) {
  73. const state = {
  74. directory: ctx.directory,
  75. worktree: ctx.worktree,
  76. gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)),
  77. vcs: ctx.project.vcs,
  78. }
  79. const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
  80. const enc = new TextEncoder()
  81. const feed = (list: string[]) => Stream.make(enc.encode(list.join("\0") + "\0"))
  82. const git = Effect.fnUntraced(
  83. function* (
  84. cmd: string[],
  85. opts?: { cwd?: string; env?: Record<string, string>; stdin?: ChildProcess.CommandInput },
  86. ) {
  87. const proc = ChildProcess.make("git", cmd, {
  88. cwd: opts?.cwd,
  89. env: opts?.env,
  90. extendEnv: true,
  91. stdin: opts?.stdin,
  92. })
  93. const handle = yield* spawner.spawn(proc)
  94. const [text, stderr] = yield* Effect.all(
  95. [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
  96. { concurrency: 2 },
  97. )
  98. const code = yield* handle.exitCode
  99. return { code, text, stderr } satisfies GitResult
  100. },
  101. Effect.scoped,
  102. Effect.catch((err) =>
  103. Effect.succeed({
  104. code: ChildProcessSpawner.ExitCode(1),
  105. text: "",
  106. stderr: err instanceof Error ? err.message : String(err),
  107. }),
  108. ),
  109. )
  110. const ignore = Effect.fnUntraced(function* (files: string[]) {
  111. if (!files.length) return new Set<string>()
  112. const check = yield* git(
  113. [
  114. ...quote,
  115. "--git-dir",
  116. path.join(state.worktree, ".git"),
  117. "--work-tree",
  118. state.worktree,
  119. "check-ignore",
  120. "--no-index",
  121. "--stdin",
  122. "-z",
  123. ],
  124. {
  125. cwd: state.directory,
  126. stdin: feed(files),
  127. },
  128. )
  129. if (check.code !== 0 && check.code !== 1) return new Set<string>()
  130. return new Set(check.text.split("\0").filter(Boolean))
  131. })
  132. const drop = Effect.fnUntraced(function* (files: string[]) {
  133. if (!files.length) return
  134. yield* git(
  135. [
  136. ...cfg,
  137. ...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]),
  138. ],
  139. {
  140. cwd: state.directory,
  141. stdin: feed(files),
  142. },
  143. )
  144. })
  145. const stage = Effect.fnUntraced(function* (files: string[]) {
  146. if (!files.length) return
  147. const result = yield* git(
  148. [...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])],
  149. {
  150. cwd: state.directory,
  151. stdin: feed(files),
  152. },
  153. )
  154. if (result.code === 0) return
  155. log.warn("failed to add snapshot files", {
  156. exitCode: result.code,
  157. stderr: result.stderr,
  158. })
  159. })
  160. const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
  161. const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
  162. const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
  163. const locked = <A, E, R>(fx: Effect.Effect<A, E, R>) => lock(state.gitdir).withPermits(1)(fx)
  164. const enabled = Effect.fnUntraced(function* () {
  165. if (state.vcs !== "git") return false
  166. return (yield* config.get()).snapshot !== false
  167. })
  168. const excludes = Effect.fnUntraced(function* () {
  169. const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
  170. cwd: state.worktree,
  171. })
  172. const file = result.text.trim()
  173. if (!file) return
  174. if (!(yield* exists(file))) return
  175. return file
  176. })
  177. const sync = Effect.fnUntraced(function* (list: string[] = []) {
  178. const file = yield* excludes()
  179. const target = path.join(state.gitdir, "info", "exclude")
  180. const text = [
  181. file ? (yield* read(file)).trimEnd() : "",
  182. ...list.map((item) => `/${item.replaceAll("\\", "/")}`),
  183. ]
  184. .filter(Boolean)
  185. .join("\n")
  186. yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
  187. yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie)
  188. })
  189. const add = Effect.fnUntraced(function* () {
  190. yield* sync()
  191. const [diff, other] = yield* Effect.all(
  192. [
  193. git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
  194. cwd: state.directory,
  195. }),
  196. git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
  197. cwd: state.directory,
  198. }),
  199. ],
  200. { concurrency: 2 },
  201. )
  202. if (diff.code !== 0 || other.code !== 0) {
  203. log.warn("failed to list snapshot files", {
  204. diffCode: diff.code,
  205. diffStderr: diff.stderr,
  206. otherCode: other.code,
  207. otherStderr: other.stderr,
  208. })
  209. return
  210. }
  211. const tracked = diff.text.split("\0").filter(Boolean)
  212. const untracked = other.text.split("\0").filter(Boolean)
  213. const all = Array.from(new Set([...tracked, ...untracked]))
  214. if (!all.length) return
  215. // Resolve source-repo ignore rules against the exact candidate set.
  216. // --no-index keeps this pattern-based even when a path is already tracked.
  217. const ignored = yield* ignore(all)
  218. // Remove newly-ignored files from snapshot index to prevent re-adding
  219. if (ignored.size > 0) {
  220. const ignoredFiles = Array.from(ignored)
  221. log.info("removing gitignored files from snapshot", { count: ignoredFiles.length })
  222. yield* drop(ignoredFiles)
  223. }
  224. const allow = all.filter((item) => !ignored.has(item))
  225. if (!allow.length) return
  226. const large = new Set(
  227. (yield* Effect.all(
  228. allow.map((item) =>
  229. fs
  230. .stat(path.join(state.directory, item))
  231. .pipe(Effect.catch(() => Effect.void))
  232. .pipe(
  233. Effect.map((stat) => {
  234. if (!stat || stat.type !== "File") return
  235. const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size
  236. return size > limit ? item : undefined
  237. }),
  238. ),
  239. ),
  240. { concurrency: 8 },
  241. )).filter((item): item is string => Boolean(item)),
  242. )
  243. const block = new Set(untracked.filter((item) => large.has(item)))
  244. yield* sync(Array.from(block))
  245. // Stage only the allowed candidate paths so snapshot updates stay scoped.
  246. yield* stage(allow.filter((item) => !block.has(item)))
  247. })
  248. const cleanup = Effect.fnUntraced(function* () {
  249. return yield* locked(
  250. Effect.gen(function* () {
  251. if (!(yield* enabled())) return
  252. if (!(yield* exists(state.gitdir))) return
  253. const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
  254. if (result.code !== 0) {
  255. log.warn("cleanup failed", {
  256. exitCode: result.code,
  257. stderr: result.stderr,
  258. })
  259. return
  260. }
  261. log.info("cleanup", { prune })
  262. }),
  263. )
  264. })
  265. const track = Effect.fnUntraced(function* () {
  266. return yield* locked(
  267. Effect.gen(function* () {
  268. if (!(yield* enabled())) return
  269. const existed = yield* exists(state.gitdir)
  270. yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
  271. if (!existed) {
  272. yield* git(["init"], {
  273. env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
  274. })
  275. yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
  276. yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
  277. yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
  278. yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
  279. log.info("initialized")
  280. }
  281. yield* add()
  282. const result = yield* git(args(["write-tree"]), { cwd: state.directory })
  283. const hash = result.text.trim()
  284. log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
  285. return hash
  286. }),
  287. )
  288. })
  289. const patch = Effect.fnUntraced(function* (hash: string) {
  290. return yield* locked(
  291. Effect.gen(function* () {
  292. yield* add()
  293. const result = yield* git(
  294. [...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
  295. {
  296. cwd: state.directory,
  297. },
  298. )
  299. if (result.code !== 0) {
  300. log.warn("failed to get diff", { hash, exitCode: result.code })
  301. return { hash, files: [] }
  302. }
  303. const files = result.text
  304. .trim()
  305. .split("\n")
  306. .map((x) => x.trim())
  307. .filter(Boolean)
  308. // Hide ignored-file removals from the user-facing patch output.
  309. const ignored = yield* ignore(files)
  310. return {
  311. hash,
  312. files: files
  313. .filter((item) => !ignored.has(item))
  314. .map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
  315. }
  316. }),
  317. )
  318. })
  319. const restore = Effect.fnUntraced(function* (snapshot: string) {
  320. return yield* locked(
  321. Effect.gen(function* () {
  322. log.info("restore", { commit: snapshot })
  323. const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
  324. if (result.code === 0) {
  325. const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], {
  326. cwd: state.worktree,
  327. })
  328. if (checkout.code === 0) return
  329. log.error("failed to restore snapshot", {
  330. snapshot,
  331. exitCode: checkout.code,
  332. stderr: checkout.stderr,
  333. })
  334. return
  335. }
  336. log.error("failed to restore snapshot", {
  337. snapshot,
  338. exitCode: result.code,
  339. stderr: result.stderr,
  340. })
  341. }),
  342. )
  343. })
  344. const revert = Effect.fnUntraced(function* (patches: Patch[]) {
  345. return yield* locked(
  346. Effect.gen(function* () {
  347. const ops: { hash: string; file: string; rel: string }[] = []
  348. const seen = new Set<string>()
  349. for (const item of patches) {
  350. for (const file of item.files) {
  351. if (seen.has(file)) continue
  352. seen.add(file)
  353. ops.push({
  354. hash: item.hash,
  355. file,
  356. rel: path.relative(state.worktree, file).replaceAll("\\", "/"),
  357. })
  358. }
  359. }
  360. const single = Effect.fnUntraced(function* (op: (typeof ops)[number]) {
  361. log.info("reverting", { file: op.file, hash: op.hash })
  362. const result = yield* git([...core, ...args(["checkout", op.hash, "--", op.file])], {
  363. cwd: state.worktree,
  364. })
  365. if (result.code === 0) return
  366. const tree = yield* git([...core, ...args(["ls-tree", op.hash, "--", op.rel])], {
  367. cwd: state.worktree,
  368. })
  369. if (tree.code === 0 && tree.text.trim()) {
  370. log.info("file existed in snapshot but checkout failed, keeping", { file: op.file, hash: op.hash })
  371. return
  372. }
  373. log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash })
  374. yield* remove(op.file)
  375. })
  376. const clash = (a: string, b: string) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`)
  377. for (let i = 0; i < ops.length; ) {
  378. const first = ops[i]!
  379. const run = [first]
  380. let j = i + 1
  381. // Only batch adjacent files when their paths cannot affect each other.
  382. while (j < ops.length && run.length < 100) {
  383. const next = ops[j]!
  384. if (next.hash !== first.hash) break
  385. if (run.some((item) => clash(item.rel, next.rel))) break
  386. run.push(next)
  387. j += 1
  388. }
  389. if (run.length === 1) {
  390. yield* single(first)
  391. i = j
  392. continue
  393. }
  394. const tree = yield* git(
  395. [...core, ...args(["ls-tree", "--name-only", first.hash, "--", ...run.map((item) => item.rel)])],
  396. {
  397. cwd: state.worktree,
  398. },
  399. )
  400. if (tree.code !== 0) {
  401. log.info("batched ls-tree failed, falling back to single-file revert", {
  402. hash: first.hash,
  403. files: run.length,
  404. })
  405. for (const op of run) {
  406. yield* single(op)
  407. }
  408. i = j
  409. continue
  410. }
  411. const have = new Set(
  412. tree.text
  413. .trim()
  414. .split("\n")
  415. .map((item) => item.trim())
  416. .filter(Boolean),
  417. )
  418. const list = run.filter((item) => have.has(item.rel))
  419. if (list.length) {
  420. log.info("reverting", { hash: first.hash, files: list.length })
  421. const result = yield* git(
  422. [...core, ...args(["checkout", first.hash, "--", ...list.map((item) => item.file)])],
  423. {
  424. cwd: state.worktree,
  425. },
  426. )
  427. if (result.code !== 0) {
  428. log.info("batched checkout failed, falling back to single-file revert", {
  429. hash: first.hash,
  430. files: list.length,
  431. })
  432. for (const op of run) {
  433. yield* single(op)
  434. }
  435. i = j
  436. continue
  437. }
  438. }
  439. for (const op of run) {
  440. if (have.has(op.rel)) continue
  441. log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash })
  442. yield* remove(op.file)
  443. }
  444. i = j
  445. }
  446. }),
  447. )
  448. })
  449. const diff = Effect.fnUntraced(function* (hash: string) {
  450. return yield* locked(
  451. Effect.gen(function* () {
  452. yield* add()
  453. const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], {
  454. cwd: state.worktree,
  455. })
  456. if (result.code !== 0) {
  457. log.warn("failed to get diff", {
  458. hash,
  459. exitCode: result.code,
  460. stderr: result.stderr,
  461. })
  462. return ""
  463. }
  464. return result.text.trim()
  465. }),
  466. )
  467. })
  468. const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
  469. return yield* locked(
  470. Effect.gen(function* () {
  471. type Row = {
  472. file: string
  473. status: "added" | "deleted" | "modified"
  474. binary: boolean
  475. additions: number
  476. deletions: number
  477. }
  478. type Ref = {
  479. file: string
  480. side: "before" | "after"
  481. ref: string
  482. }
  483. const show = Effect.fnUntraced(function* (row: Row) {
  484. if (row.binary) return ["", ""]
  485. if (row.status === "added") {
  486. return [
  487. "",
  488. yield* git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
  489. ]
  490. }
  491. if (row.status === "deleted") {
  492. return [
  493. yield* git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(
  494. Effect.map((item) => item.text),
  495. ),
  496. "",
  497. ]
  498. }
  499. return yield* Effect.all(
  500. [
  501. git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
  502. git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
  503. ],
  504. { concurrency: 2 },
  505. )
  506. })
  507. const load = Effect.fnUntraced(
  508. function* (rows: Row[]) {
  509. const refs = rows.flatMap((row) => {
  510. if (row.binary) return []
  511. if (row.status === "added")
  512. return [{ file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref]
  513. if (row.status === "deleted") {
  514. return [{ file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref]
  515. }
  516. return [
  517. { file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref,
  518. { file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref,
  519. ]
  520. })
  521. if (!refs.length) return new Map<string, { before: string; after: string }>()
  522. const proc = ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], {
  523. cwd: state.directory,
  524. extendEnv: true,
  525. stdin: Stream.make(new TextEncoder().encode(refs.map((item) => item.ref).join("\n") + "\n")),
  526. })
  527. const handle = yield* spawner.spawn(proc)
  528. const [out, err] = yield* Effect.all(
  529. [Stream.mkUint8Array(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))],
  530. { concurrency: 2 },
  531. )
  532. const code = yield* handle.exitCode
  533. if (code !== 0) {
  534. log.info("git cat-file --batch failed during snapshot diff, falling back to per-file git show", {
  535. stderr: err,
  536. refs: refs.length,
  537. })
  538. return
  539. }
  540. const fail = (msg: string, extra?: Record<string, string>) => {
  541. log.info(msg, { ...extra, refs: refs.length })
  542. return undefined
  543. }
  544. const map = new Map<string, { before: string; after: string }>()
  545. const dec = new TextDecoder()
  546. let i = 0
  547. for (const ref of refs) {
  548. let end = i
  549. while (end < out.length && out[end] !== 10) end += 1
  550. if (end >= out.length) {
  551. return fail(
  552. "git cat-file --batch returned a truncated header during snapshot diff, falling back to per-file git show",
  553. )
  554. }
  555. const head = dec.decode(out.slice(i, end))
  556. i = end + 1
  557. const hit = map.get(ref.file) ?? { before: "", after: "" }
  558. if (head.endsWith(" missing")) {
  559. map.set(ref.file, hit)
  560. continue
  561. }
  562. const match = head.match(/^[0-9a-f]+ blob (\d+)$/)
  563. if (!match) {
  564. return fail(
  565. "git cat-file --batch returned an unexpected header during snapshot diff, falling back to per-file git show",
  566. { head },
  567. )
  568. }
  569. const size = Number(match[1])
  570. if (!Number.isInteger(size) || size < 0 || i + size >= out.length || out[i + size] !== 10) {
  571. return fail(
  572. "git cat-file --batch returned truncated content during snapshot diff, falling back to per-file git show",
  573. { head },
  574. )
  575. }
  576. const text = dec.decode(out.slice(i, i + size))
  577. if (ref.side === "before") hit.before = text
  578. if (ref.side === "after") hit.after = text
  579. map.set(ref.file, hit)
  580. i += size + 1
  581. }
  582. if (i !== out.length) {
  583. return fail(
  584. "git cat-file --batch returned trailing data during snapshot diff, falling back to per-file git show",
  585. )
  586. }
  587. return map
  588. },
  589. Effect.scoped,
  590. Effect.catch(() =>
  591. Effect.succeed<Map<string, { before: string; after: string }> | undefined>(undefined),
  592. ),
  593. )
  594. const result: FileDiff[] = []
  595. const status = new Map<string, "added" | "deleted" | "modified">()
  596. const statuses = yield* git(
  597. [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
  598. { cwd: state.directory },
  599. )
  600. for (const line of statuses.text.trim().split("\n")) {
  601. if (!line) continue
  602. const [code, file] = line.split("\t")
  603. if (!code || !file) continue
  604. status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
  605. }
  606. const numstat = yield* git(
  607. [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
  608. {
  609. cwd: state.directory,
  610. },
  611. )
  612. const rows = numstat.text
  613. .trim()
  614. .split("\n")
  615. .filter(Boolean)
  616. .flatMap((line) => {
  617. const [adds, dels, file] = line.split("\t")
  618. if (!file) return []
  619. const binary = adds === "-" && dels === "-"
  620. const additions = binary ? 0 : parseInt(adds)
  621. const deletions = binary ? 0 : parseInt(dels)
  622. return [
  623. {
  624. file,
  625. status: status.get(file) ?? "modified",
  626. binary,
  627. additions: Number.isFinite(additions) ? additions : 0,
  628. deletions: Number.isFinite(deletions) ? deletions : 0,
  629. } satisfies Row,
  630. ]
  631. })
  632. // Hide ignored-file removals from the user-facing diff output.
  633. const ignored = yield* ignore(rows.map((r) => r.file))
  634. if (ignored.size > 0) {
  635. const filtered = rows.filter((r) => !ignored.has(r.file))
  636. rows.length = 0
  637. rows.push(...filtered)
  638. }
  639. const step = 100
  640. const patch = (file: string, before: string, after: string) =>
  641. formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
  642. for (let i = 0; i < rows.length; i += step) {
  643. const run = rows.slice(i, i + step)
  644. const text = yield* load(run)
  645. for (const row of run) {
  646. const hit = text?.get(row.file) ?? { before: "", after: "" }
  647. const [before, after] = row.binary ? ["", ""] : text ? [hit.before, hit.after] : yield* show(row)
  648. result.push({
  649. file: row.file,
  650. patch: row.binary ? "" : patch(row.file, before, after),
  651. additions: row.additions,
  652. deletions: row.deletions,
  653. status: row.status,
  654. })
  655. }
  656. }
  657. return result
  658. }),
  659. )
  660. })
  661. yield* cleanup().pipe(
  662. Effect.catchCause((cause) => {
  663. log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
  664. return Effect.void
  665. }),
  666. Effect.repeat(Schedule.spaced(Duration.hours(1))),
  667. Effect.delay(Duration.minutes(1)),
  668. Effect.forkScoped,
  669. )
  670. return { cleanup, track, patch, restore, revert, diff, diffFull }
  671. }),
  672. )
  673. return Service.of({
  674. init: Effect.fn("Snapshot.init")(function* () {
  675. yield* InstanceState.get(state)
  676. }),
  677. cleanup: Effect.fn("Snapshot.cleanup")(function* () {
  678. return yield* InstanceState.useEffect(state, (s) => s.cleanup())
  679. }),
  680. track: Effect.fn("Snapshot.track")(function* () {
  681. return yield* InstanceState.useEffect(state, (s) => s.track())
  682. }),
  683. patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
  684. return yield* InstanceState.useEffect(state, (s) => s.patch(hash))
  685. }),
  686. restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) {
  687. return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot))
  688. }),
  689. revert: Effect.fn("Snapshot.revert")(function* (patches: Patch[]) {
  690. return yield* InstanceState.useEffect(state, (s) => s.revert(patches))
  691. }),
  692. diff: Effect.fn("Snapshot.diff")(function* (hash: string) {
  693. return yield* InstanceState.useEffect(state, (s) => s.diff(hash))
  694. }),
  695. diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
  696. return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to))
  697. }),
  698. })
  699. }),
  700. )
  701. export const defaultLayer = layer.pipe(
  702. Layer.provide(CrossSpawnSpawner.defaultLayer),
  703. Layer.provide(AppFileSystem.defaultLayer),
  704. Layer.provide(Config.defaultLayer),
  705. )
  706. export * as Snapshot from "."