index.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  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"
  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. // Configure git to not convert line endings on Windows
  26. await $`git --git-dir ${git} config core.autocrlf false`.quiet().nothrow()
  27. log.info("initialized")
  28. }
  29. await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
  30. const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree`
  31. .quiet()
  32. .cwd(Instance.directory)
  33. .nothrow()
  34. .text()
  35. log.info("tracking", { hash, cwd: Instance.directory, git })
  36. return hash.trim()
  37. }
  38. export const Patch = z.object({
  39. hash: z.string(),
  40. files: z.string().array(),
  41. })
  42. export type Patch = z.infer<typeof Patch>
  43. export async function patch(hash: string): Promise<Patch> {
  44. const git = gitdir()
  45. await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
  46. const result =
  47. await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .`
  48. .quiet()
  49. .cwd(Instance.directory)
  50. .nothrow()
  51. // If git diff fails, return empty patch
  52. if (result.exitCode !== 0) {
  53. log.warn("failed to get diff", { hash, exitCode: result.exitCode })
  54. return { hash, files: [] }
  55. }
  56. const files = result.text()
  57. return {
  58. hash,
  59. files: files
  60. .trim()
  61. .split("\n")
  62. .map((x) => x.trim())
  63. .filter(Boolean)
  64. .map((x) => path.join(Instance.worktree, x)),
  65. }
  66. }
  67. export async function restore(snapshot: string) {
  68. log.info("restore", { commit: snapshot })
  69. const git = gitdir()
  70. const result =
  71. await $`git --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f`
  72. .quiet()
  73. .cwd(Instance.worktree)
  74. .nothrow()
  75. if (result.exitCode !== 0) {
  76. log.error("failed to restore snapshot", {
  77. snapshot,
  78. exitCode: result.exitCode,
  79. stderr: result.stderr.toString(),
  80. stdout: result.stdout.toString(),
  81. })
  82. }
  83. }
  84. export async function revert(patches: Patch[]) {
  85. const files = new Set<string>()
  86. const git = gitdir()
  87. for (const item of patches) {
  88. for (const file of item.files) {
  89. if (files.has(file)) continue
  90. log.info("reverting", { file, hash: item.hash })
  91. const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} checkout ${item.hash} -- ${file}`
  92. .quiet()
  93. .cwd(Instance.worktree)
  94. .nothrow()
  95. if (result.exitCode !== 0) {
  96. const relativePath = path.relative(Instance.worktree, file)
  97. const checkTree =
  98. await $`git --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${item.hash} -- ${relativePath}`
  99. .quiet()
  100. .cwd(Instance.worktree)
  101. .nothrow()
  102. if (checkTree.exitCode === 0 && checkTree.text().trim()) {
  103. log.info("file existed in snapshot but checkout failed, keeping", {
  104. file,
  105. })
  106. } else {
  107. log.info("file did not exist in snapshot, deleting", { file })
  108. await fs.unlink(file).catch(() => {})
  109. }
  110. }
  111. files.add(file)
  112. }
  113. }
  114. }
  115. export async function diff(hash: string) {
  116. const git = gitdir()
  117. await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
  118. const result =
  119. await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
  120. .quiet()
  121. .cwd(Instance.worktree)
  122. .nothrow()
  123. if (result.exitCode !== 0) {
  124. log.warn("failed to get diff", {
  125. hash,
  126. exitCode: result.exitCode,
  127. stderr: result.stderr.toString(),
  128. stdout: result.stdout.toString(),
  129. })
  130. return ""
  131. }
  132. return result.text().trim()
  133. }
  134. export const FileDiff = z
  135. .object({
  136. file: z.string(),
  137. before: z.string(),
  138. after: z.string(),
  139. additions: z.number(),
  140. deletions: z.number(),
  141. })
  142. .meta({
  143. ref: "FileDiff",
  144. })
  145. export type FileDiff = z.infer<typeof FileDiff>
  146. export async function diffFull(from: string, to: string): Promise<FileDiff[]> {
  147. const git = gitdir()
  148. const result: FileDiff[] = []
  149. for await (const line of $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .`
  150. .quiet()
  151. .cwd(Instance.directory)
  152. .nothrow()
  153. .lines()) {
  154. if (!line) continue
  155. const [additions, deletions, file] = line.split("\t")
  156. const isBinaryFile = additions === "-" && deletions === "-"
  157. const before = isBinaryFile
  158. ? ""
  159. : await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} show ${from}:${file}`
  160. .quiet()
  161. .nothrow()
  162. .text()
  163. const after = isBinaryFile
  164. ? ""
  165. : await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} show ${to}:${file}`
  166. .quiet()
  167. .nothrow()
  168. .text()
  169. result.push({
  170. file,
  171. before,
  172. after,
  173. additions: parseInt(additions),
  174. deletions: parseInt(deletions),
  175. })
  176. }
  177. return result
  178. }
  179. function gitdir() {
  180. const project = Instance.project
  181. return path.join(Global.Path.data, "snapshot", project.id)
  182. }
  183. }