| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197 |
- import { $ } from "bun"
- import path from "path"
- import fs from "fs/promises"
- import { Log } from "../util/log"
- import { Global } from "../global"
- import z from "zod"
- import { Config } from "../config/config"
- import { Instance } from "../project/instance"
- export namespace Snapshot {
- const log = Log.create({ service: "snapshot" })
- export async function track() {
- if (Instance.project.vcs !== "git") return
- const cfg = await Config.get()
- if (cfg.snapshot === false) return
- const git = gitdir()
- if (await fs.mkdir(git, { recursive: true })) {
- await $`git init`
- .env({
- ...process.env,
- GIT_DIR: git,
- GIT_WORK_TREE: Instance.worktree,
- })
- .quiet()
- .nothrow()
- // Configure git to not convert line endings on Windows
- await $`git --git-dir ${git} config core.autocrlf false`.quiet().nothrow()
- log.info("initialized")
- }
- await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
- const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree`
- .quiet()
- .cwd(Instance.directory)
- .nothrow()
- .text()
- log.info("tracking", { hash, cwd: Instance.directory, git })
- return hash.trim()
- }
- export const Patch = z.object({
- hash: z.string(),
- files: z.string().array(),
- })
- export type Patch = z.infer<typeof Patch>
- export async function patch(hash: string): Promise<Patch> {
- const git = gitdir()
- await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
- const result =
- await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .`
- .quiet()
- .cwd(Instance.directory)
- .nothrow()
- // If git diff fails, return empty patch
- if (result.exitCode !== 0) {
- log.warn("failed to get diff", { hash, exitCode: result.exitCode })
- return { hash, files: [] }
- }
- const files = result.text()
- return {
- hash,
- files: files
- .trim()
- .split("\n")
- .map((x) => x.trim())
- .filter(Boolean)
- .map((x) => path.join(Instance.worktree, x)),
- }
- }
- export async function restore(snapshot: string) {
- log.info("restore", { commit: snapshot })
- const git = gitdir()
- const result =
- await $`git --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f`
- .quiet()
- .cwd(Instance.worktree)
- .nothrow()
- if (result.exitCode !== 0) {
- log.error("failed to restore snapshot", {
- snapshot,
- exitCode: result.exitCode,
- stderr: result.stderr.toString(),
- stdout: result.stdout.toString(),
- })
- }
- }
- export async function revert(patches: Patch[]) {
- const files = new Set<string>()
- const git = gitdir()
- for (const item of patches) {
- for (const file of item.files) {
- if (files.has(file)) continue
- log.info("reverting", { file, hash: item.hash })
- const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} checkout ${item.hash} -- ${file}`
- .quiet()
- .cwd(Instance.worktree)
- .nothrow()
- if (result.exitCode !== 0) {
- const relativePath = path.relative(Instance.worktree, file)
- const checkTree =
- await $`git --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${item.hash} -- ${relativePath}`
- .quiet()
- .cwd(Instance.worktree)
- .nothrow()
- if (checkTree.exitCode === 0 && checkTree.text().trim()) {
- log.info("file existed in snapshot but checkout failed, keeping", {
- file,
- })
- } else {
- log.info("file did not exist in snapshot, deleting", { file })
- await fs.unlink(file).catch(() => {})
- }
- }
- files.add(file)
- }
- }
- }
- export async function diff(hash: string) {
- const git = gitdir()
- await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
- const result =
- await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
- .quiet()
- .cwd(Instance.worktree)
- .nothrow()
- if (result.exitCode !== 0) {
- log.warn("failed to get diff", {
- hash,
- exitCode: result.exitCode,
- stderr: result.stderr.toString(),
- stdout: result.stdout.toString(),
- })
- return ""
- }
- return result.text().trim()
- }
- export const FileDiff = z
- .object({
- file: z.string(),
- before: z.string(),
- after: z.string(),
- additions: z.number(),
- deletions: z.number(),
- })
- .meta({
- ref: "FileDiff",
- })
- export type FileDiff = z.infer<typeof FileDiff>
- export async function diffFull(from: string, to: string): Promise<FileDiff[]> {
- const git = gitdir()
- const result: FileDiff[] = []
- 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} -- .`
- .quiet()
- .cwd(Instance.directory)
- .nothrow()
- .lines()) {
- if (!line) continue
- const [additions, deletions, file] = line.split("\t")
- const isBinaryFile = additions === "-" && deletions === "-"
- const before = isBinaryFile
- ? ""
- : await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} show ${from}:${file}`
- .quiet()
- .nothrow()
- .text()
- const after = isBinaryFile
- ? ""
- : await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} show ${to}:${file}`
- .quiet()
- .nothrow()
- .text()
- result.push({
- file,
- before,
- after,
- additions: parseInt(additions),
- deletions: parseInt(deletions),
- })
- }
- return result
- }
- function gitdir() {
- const project = Instance.project
- return path.join(Global.Path.data, "snapshot", project.id)
- }
- }
|