index.ts 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. import { $ } from "bun"
  2. import path from "path"
  3. import fs from "fs/promises"
  4. import { Log } from "../util/log"
  5. import { Global } from "../global"
  6. import z from "zod/v4"
  7. import { Config } from "../config/config"
  8. import { Instance } from "../project/instance"
  9. export namespace Snapshot {
  10. const log = Log.create({ service: "snapshot" })
  11. export async function track() {
  12. if (Instance.project.vcs !== "git") return
  13. const cfg = await Config.get()
  14. if (cfg.snapshot === false) return
  15. const git = gitdir()
  16. if (await fs.mkdir(git, { recursive: true })) {
  17. await $`git init`
  18. .env({
  19. ...process.env,
  20. GIT_DIR: git,
  21. GIT_WORK_TREE: Instance.worktree,
  22. })
  23. .quiet()
  24. .nothrow()
  25. log.info("initialized")
  26. }
  27. await $`git --git-dir ${git} add .`
  28. .quiet()
  29. .cwd(Instance.directory)
  30. .nothrow()
  31. const hash = await $`git --git-dir ${git} write-tree`
  32. .quiet()
  33. .cwd(Instance.directory)
  34. .nothrow()
  35. .text()
  36. log.info("tracking", { hash, cwd: Instance.directory, git })
  37. return hash.trim()
  38. }
  39. export const Patch = z.object({
  40. hash: z.string(),
  41. files: z.string().array(),
  42. })
  43. export type Patch = z.infer<typeof Patch>
  44. export async function patch(hash: string): Promise<Patch> {
  45. const git = gitdir()
  46. await $`git --git-dir ${git} add .`
  47. .quiet()
  48. .cwd(Instance.directory)
  49. .nothrow()
  50. const result = await $`git --git-dir ${git} diff --name-only ${hash} -- .`
  51. .quiet()
  52. .cwd(Instance.directory)
  53. .nothrow()
  54. // If git diff fails, return empty patch
  55. if (result.exitCode !== 0) {
  56. log.warn("failed to get diff", { hash, exitCode: result.exitCode })
  57. return { hash, files: [] }
  58. }
  59. const files = result.text()
  60. return {
  61. hash,
  62. files: files
  63. .trim()
  64. .split("\n")
  65. .map((x) => x.trim())
  66. .filter(Boolean)
  67. .map((x) => path.join(Instance.worktree, x)),
  68. }
  69. }
  70. export async function restore(snapshot: string) {
  71. log.info("restore", { commit: snapshot })
  72. const git = gitdir()
  73. const result =
  74. await $`git --git-dir=${git} read-tree ${snapshot} && git --git-dir=${git} checkout-index -a -f`
  75. .quiet()
  76. .cwd(Instance.worktree)
  77. .nothrow()
  78. if (result.exitCode !== 0) {
  79. log.error("failed to restore snapshot", {
  80. snapshot,
  81. exitCode: result.exitCode,
  82. stderr: result.stderr.toString(),
  83. stdout: result.stdout.toString(),
  84. })
  85. }
  86. }
  87. export async function revert(patches: Patch[]) {
  88. const files = new Set<string>()
  89. const git = gitdir()
  90. for (const item of patches) {
  91. for (const file of item.files) {
  92. if (files.has(file)) continue
  93. log.info("reverting", { file, hash: item.hash })
  94. const result =
  95. await $`git --git-dir=${git} checkout ${item.hash} -- ${file}`
  96. .quiet()
  97. .cwd(Instance.worktree)
  98. .nothrow()
  99. if (result.exitCode !== 0) {
  100. const relativePath = path.relative(Instance.worktree, file)
  101. const checkTree =
  102. await $`git --git-dir=${git} ls-tree ${item.hash} -- ${relativePath}`
  103. .quiet()
  104. .cwd(Instance.worktree)
  105. .nothrow()
  106. if (checkTree.exitCode === 0 && checkTree.text().trim()) {
  107. log.info("file existed in snapshot but checkout failed, keeping", {
  108. file,
  109. })
  110. } else {
  111. log.info("file did not exist in snapshot, deleting", { file })
  112. await fs.unlink(file).catch(() => {})
  113. }
  114. }
  115. files.add(file)
  116. }
  117. }
  118. }
  119. export async function diff(hash: string) {
  120. const git = gitdir()
  121. await $`git --git-dir ${git} add .`
  122. .quiet()
  123. .cwd(Instance.directory)
  124. .nothrow()
  125. const result = await $`git --git-dir=${git} diff ${hash} -- .`
  126. .quiet()
  127. .cwd(Instance.worktree)
  128. .nothrow()
  129. if (result.exitCode !== 0) {
  130. log.warn("failed to get diff", {
  131. hash,
  132. exitCode: result.exitCode,
  133. stderr: result.stderr.toString(),
  134. stdout: result.stdout.toString(),
  135. })
  136. return ""
  137. }
  138. return result.text().trim()
  139. }
  140. export const FileDiff = z
  141. .object({
  142. file: z.string(),
  143. left: z.string(),
  144. right: z.string(),
  145. })
  146. .meta({
  147. ref: "FileDiff",
  148. })
  149. export type FileDiff = z.infer<typeof FileDiff>
  150. export async function diffFull(
  151. from: string,
  152. to: string,
  153. ): Promise<FileDiff[]> {
  154. const git = gitdir()
  155. const result: FileDiff[] = []
  156. for await (const line of $`git --git-dir=${git} diff --name-only ${from} ${to} -- .`
  157. .quiet()
  158. .cwd(Instance.directory)
  159. .nothrow()
  160. .lines()) {
  161. if (!line) continue
  162. const left = await $`git --git-dir=${git} show ${from}:${line}`
  163. .quiet()
  164. .nothrow()
  165. .text()
  166. const right = await $`git --git-dir=${git} show ${to}:${line}`
  167. .quiet()
  168. .nothrow()
  169. .text()
  170. result.push({
  171. file: line,
  172. left,
  173. right,
  174. })
  175. }
  176. return result
  177. }
  178. function gitdir() {
  179. const project = Instance.project
  180. return path.join(Global.Path.data, "snapshot", project.id)
  181. }
  182. }