Răsfoiți Sursa

fix: snapshot history when running from git worktrees (#4312)

Chris Olszewski 3 luni în urmă
părinte
comite
69a45ef7d7

+ 34 - 18
packages/opencode/src/snapshot/index.ts

@@ -26,8 +26,12 @@ export namespace Snapshot {
         .nothrow()
       log.info("initialized")
     }
-    await $`git --git-dir ${git} add .`.quiet().cwd(Instance.directory).nothrow()
-    const hash = await $`git --git-dir ${git} write-tree`.quiet().cwd(Instance.directory).nothrow().text()
+    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()
   }
@@ -40,8 +44,11 @@ export namespace Snapshot {
 
   export async function patch(hash: string): Promise<Patch> {
     const git = gitdir()
-    await $`git --git-dir ${git} add .`.quiet().cwd(Instance.directory).nothrow()
-    const result = await $`git --git-dir ${git} diff --name-only ${hash} -- .`.quiet().cwd(Instance.directory).nothrow()
+    await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
+    const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} diff --name-only ${hash} -- .`
+      .quiet()
+      .cwd(Instance.directory)
+      .nothrow()
 
     // If git diff fails, return empty patch
     if (result.exitCode !== 0) {
@@ -64,10 +71,11 @@ export namespace Snapshot {
   export async function restore(snapshot: string) {
     log.info("restore", { commit: snapshot })
     const git = gitdir()
-    const result = await $`git --git-dir=${git} read-tree ${snapshot} && git --git-dir=${git} checkout-index -a -f`
-      .quiet()
-      .cwd(Instance.worktree)
-      .nothrow()
+    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", {
@@ -86,16 +94,17 @@ export namespace Snapshot {
       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} checkout ${item.hash} -- ${file}`
+        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} ls-tree ${item.hash} -- ${relativePath}`
-            .quiet()
-            .cwd(Instance.worktree)
-            .nothrow()
+          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,
@@ -112,8 +121,11 @@ export namespace Snapshot {
 
   export async function diff(hash: string) {
     const git = gitdir()
-    await $`git --git-dir ${git} add .`.quiet().cwd(Instance.directory).nothrow()
-    const result = await $`git --git-dir=${git} diff ${hash} -- .`.quiet().cwd(Instance.worktree).nothrow()
+    await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
+    const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} diff ${hash} -- .`
+      .quiet()
+      .cwd(Instance.worktree)
+      .nothrow()
 
     if (result.exitCode !== 0) {
       log.warn("failed to get diff", {
@@ -143,7 +155,7 @@ export namespace Snapshot {
   export async function diffFull(from: string, to: string): Promise<FileDiff[]> {
     const git = gitdir()
     const result: FileDiff[] = []
-    for await (const line of $`git --git-dir=${git} diff --no-renames --numstat ${from} ${to} -- .`
+    for await (const line of $`git --git-dir ${git} --work-tree ${Instance.worktree} diff --no-renames --numstat ${from} ${to} -- .`
       .quiet()
       .cwd(Instance.directory)
       .nothrow()
@@ -151,8 +163,12 @@ export namespace Snapshot {
       if (!line) continue
       const [additions, deletions, file] = line.split("\t")
       const isBinaryFile = additions === "-" && deletions === "-"
-      const before = isBinaryFile ? "" : await $`git --git-dir=${git} show ${from}:${file}`.quiet().nothrow().text()
-      const after = isBinaryFile ? "" : await $`git --git-dir=${git} show ${to}:${file}`.quiet().nothrow().text()
+      const before = isBinaryFile
+        ? ""
+        : await $`git --git-dir ${git} --work-tree ${Instance.worktree} show ${from}:${file}`.quiet().nothrow().text()
+      const after = isBinaryFile
+        ? ""
+        : await $`git --git-dir ${git} --work-tree ${Instance.worktree} show ${to}:${file}`.quiet().nothrow().text()
       result.push({
         file,
         before,

+ 109 - 0
packages/opencode/test/snapshot/snapshot.test.ts

@@ -469,6 +469,115 @@ test("snapshot state isolation between projects", async () => {
   })
 })
 
+test("patch detects changes in secondary worktree", async () => {
+  await using tmp = await bootstrap()
+  const worktreePath = `${tmp.path}-worktree`
+  await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet()
+
+  try {
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        expect(await Snapshot.track()).toBeTruthy()
+      },
+    })
+
+    await Instance.provide({
+      directory: worktreePath,
+      fn: async () => {
+        const before = await Snapshot.track()
+        expect(before).toBeTruthy()
+
+        const worktreeFile = `${worktreePath}/worktree.txt`
+        await Bun.write(worktreeFile, "worktree content")
+
+        const patch = await Snapshot.patch(before!)
+        expect(patch.files).toContain(worktreeFile)
+      },
+    })
+  } finally {
+    await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow()
+    await $`rm -rf ${worktreePath}`.quiet()
+  }
+})
+
+test("revert only removes files in invoking worktree", async () => {
+  await using tmp = await bootstrap()
+  const worktreePath = `${tmp.path}-worktree`
+  await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet()
+
+  try {
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        expect(await Snapshot.track()).toBeTruthy()
+      },
+    })
+    const primaryFile = `${tmp.path}/worktree.txt`
+    await Bun.write(primaryFile, "primary content")
+
+    await Instance.provide({
+      directory: worktreePath,
+      fn: async () => {
+        const before = await Snapshot.track()
+        expect(before).toBeTruthy()
+
+        const worktreeFile = `${worktreePath}/worktree.txt`
+        await Bun.write(worktreeFile, "worktree content")
+
+        const patch = await Snapshot.patch(before!)
+        await Snapshot.revert([patch])
+
+        expect(await Bun.file(worktreeFile).exists()).toBe(false)
+      },
+    })
+
+    expect(await Bun.file(primaryFile).text()).toBe("primary content")
+  } finally {
+    await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow()
+    await $`rm -rf ${worktreePath}`.quiet()
+    await $`rm -f ${tmp.path}/worktree.txt`.quiet()
+  }
+})
+
+test("diff reports worktree-only/shared edits and ignores primary-only", async () => {
+  await using tmp = await bootstrap()
+  const worktreePath = `${tmp.path}-worktree`
+  await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet()
+
+  try {
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        expect(await Snapshot.track()).toBeTruthy()
+      },
+    })
+
+    await Instance.provide({
+      directory: worktreePath,
+      fn: async () => {
+        const before = await Snapshot.track()
+        expect(before).toBeTruthy()
+
+        await Bun.write(`${worktreePath}/worktree-only.txt`, "worktree diff content")
+        await Bun.write(`${worktreePath}/shared.txt`, "worktree edit")
+        await Bun.write(`${tmp.path}/shared.txt`, "primary edit")
+        await Bun.write(`${tmp.path}/primary-only.txt`, "primary change")
+
+        const diff = await Snapshot.diff(before!)
+        expect(diff).toContain("worktree-only.txt")
+        expect(diff).toContain("shared.txt")
+        expect(diff).not.toContain("primary-only.txt")
+      },
+    })
+  } finally {
+    await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow()
+    await $`rm -rf ${worktreePath}`.quiet()
+    await $`rm -f ${tmp.path}/shared.txt`.quiet()
+    await $`rm -f ${tmp.path}/primary-only.txt`.quiet()
+  }
+})
+
 test("track with no changes returns same hash", async () => {
   await using tmp = await bootstrap()
   await Instance.provide({