fix(win32): add git flags for snapshot operations and fix tests for cross-platform (#14890)

This commit is contained in:
Luke Parker
2026-02-24 21:14:16 +10:00
committed by GitHub
parent 659068942e
commit 13cabae29f
3 changed files with 67 additions and 54 deletions

View File

@@ -64,6 +64,9 @@ export namespace Snapshot {
.nothrow() .nothrow()
// Configure git to not convert line endings on Windows // Configure git to not convert line endings on Windows
await $`git --git-dir ${git} config core.autocrlf false`.quiet().nothrow() await $`git --git-dir ${git} config core.autocrlf false`.quiet().nothrow()
await $`git --git-dir ${git} config core.longpaths true`.quiet().nothrow()
await $`git --git-dir ${git} config core.symlinks true`.quiet().nothrow()
await $`git --git-dir ${git} config core.fsmonitor false`.quiet().nothrow()
log.info("initialized") log.info("initialized")
} }
await add(git) await add(git)
@@ -86,7 +89,7 @@ export namespace Snapshot {
const git = gitdir() const git = gitdir()
await add(git) await add(git)
const result = const result =
await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .` await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .`
.quiet() .quiet()
.cwd(Instance.directory) .cwd(Instance.directory)
.nothrow() .nothrow()
@@ -113,7 +116,7 @@ export namespace Snapshot {
log.info("restore", { commit: snapshot }) log.info("restore", { commit: snapshot })
const git = gitdir() const git = gitdir()
const result = 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` await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f`
.quiet() .quiet()
.cwd(Instance.worktree) .cwd(Instance.worktree)
.nothrow() .nothrow()
@@ -135,14 +138,15 @@ export namespace Snapshot {
for (const file of item.files) { for (const file of item.files) {
if (files.has(file)) continue if (files.has(file)) continue
log.info("reverting", { file, hash: item.hash }) log.info("reverting", { file, hash: item.hash })
const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} checkout ${item.hash} -- ${file}` const result =
.quiet() await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout ${item.hash} -- ${file}`
.cwd(Instance.worktree) .quiet()
.nothrow() .cwd(Instance.worktree)
.nothrow()
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
const relativePath = path.relative(Instance.worktree, file) const relativePath = path.relative(Instance.worktree, file)
const checkTree = const checkTree =
await $`git --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${item.hash} -- ${relativePath}` await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${item.hash} -- ${relativePath}`
.quiet() .quiet()
.cwd(Instance.worktree) .cwd(Instance.worktree)
.nothrow() .nothrow()
@@ -164,7 +168,7 @@ export namespace Snapshot {
const git = gitdir() const git = gitdir()
await add(git) await add(git)
const result = const result =
await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .` await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
.quiet() .quiet()
.cwd(Instance.worktree) .cwd(Instance.worktree)
.nothrow() .nothrow()
@@ -201,7 +205,7 @@ export namespace Snapshot {
const status = new Map<string, "added" | "deleted" | "modified">() const status = new Map<string, "added" | "deleted" | "modified">()
const statuses = const statuses =
await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-status --no-renames ${from} ${to} -- .` await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-status --no-renames ${from} ${to} -- .`
.quiet() .quiet()
.cwd(Instance.directory) .cwd(Instance.directory)
.nothrow() .nothrow()
@@ -215,7 +219,7 @@ export namespace Snapshot {
status.set(file, kind) status.set(file, kind)
} }
for await (const line of $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .` for await (const line of $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .`
.quiet() .quiet()
.cwd(Instance.directory) .cwd(Instance.directory)
.nothrow() .nothrow()
@@ -225,13 +229,13 @@ export namespace Snapshot {
const isBinaryFile = additions === "-" && deletions === "-" const isBinaryFile = additions === "-" && deletions === "-"
const before = isBinaryFile const before = isBinaryFile
? "" ? ""
: await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} show ${from}:${file}` : await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${from}:${file}`
.quiet() .quiet()
.nothrow() .nothrow()
.text() .text()
const after = isBinaryFile const after = isBinaryFile
? "" ? ""
: await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} show ${to}:${file}` : await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${to}:${file}`
.quiet() .quiet()
.nothrow() .nothrow()
.text() .text()
@@ -256,7 +260,10 @@ export namespace Snapshot {
async function add(git: string) { async function add(git: string) {
await syncExclude(git) await syncExclude(git)
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow() await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} add .`
.quiet()
.cwd(Instance.directory)
.nothrow()
} }
async function syncExclude(git: string) { async function syncExclude(git: string) {

View File

@@ -10,7 +10,7 @@ import { afterAll } from "bun:test"
const dir = path.join(os.tmpdir(), "opencode-test-data-" + process.pid) const dir = path.join(os.tmpdir(), "opencode-test-data-" + process.pid)
await fs.mkdir(dir, { recursive: true }) await fs.mkdir(dir, { recursive: true })
afterAll(() => { afterAll(() => {
fsSync.rmSync(dir, { recursive: true, force: true }) fsSync.rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 500 })
}) })
process.env["XDG_DATA_HOME"] = path.join(dir, "share") process.env["XDG_DATA_HOME"] = path.join(dir, "share")

View File

@@ -1,11 +1,17 @@
import { test, expect } from "bun:test" import { test, expect } from "bun:test"
import { $ } from "bun" import { $ } from "bun"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path"
import { Snapshot } from "../../src/snapshot" import { Snapshot } from "../../src/snapshot"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
// Git always outputs /-separated paths internally. Snapshot.patch() joins them
// with path.join (which produces \ on Windows) then normalizes back to /.
// This helper does the same for expected values so assertions match cross-platform.
const fwd = (...parts: string[]) => path.join(...parts).replaceAll("\\", "/")
async function bootstrap() { async function bootstrap() {
return tmpdir({ return tmpdir({
git: true, git: true,
@@ -35,7 +41,7 @@ test("tracks deleted files correctly", async () => {
await $`rm ${tmp.path}/a.txt`.quiet() await $`rm ${tmp.path}/a.txt`.quiet()
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.path}/a.txt`) expect((await Snapshot.patch(before!)).files).toContain(fwd(tmp.path, "a.txt"))
}, },
}) })
}) })
@@ -143,7 +149,7 @@ test("binary file handling", async () => {
await Filesystem.write(`${tmp.path}/image.png`, new Uint8Array([0x89, 0x50, 0x4e, 0x47])) await Filesystem.write(`${tmp.path}/image.png`, new Uint8Array([0x89, 0x50, 0x4e, 0x47]))
const patch = await Snapshot.patch(before!) const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(`${tmp.path}/image.png`) expect(patch.files).toContain(fwd(tmp.path, "image.png"))
await Snapshot.revert([patch]) await Snapshot.revert([patch])
expect( expect(
@@ -164,9 +170,9 @@ test("symlink handling", async () => {
const before = await Snapshot.track() const before = await Snapshot.track()
expect(before).toBeTruthy() expect(before).toBeTruthy()
await $`ln -s ${tmp.path}/a.txt ${tmp.path}/link.txt`.quiet() await fs.symlink(`${tmp.path}/a.txt`, `${tmp.path}/link.txt`, "file")
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.path}/link.txt`) expect((await Snapshot.patch(before!)).files).toContain(fwd(tmp.path, "link.txt"))
}, },
}) })
}) })
@@ -181,7 +187,7 @@ test("large file handling", async () => {
await Filesystem.write(`${tmp.path}/large.txt`, "x".repeat(1024 * 1024)) await Filesystem.write(`${tmp.path}/large.txt`, "x".repeat(1024 * 1024))
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.path}/large.txt`) expect((await Snapshot.patch(before!)).files).toContain(fwd(tmp.path, "large.txt"))
}, },
}) })
}) })
@@ -222,9 +228,9 @@ test("special characters in filenames", async () => {
await Filesystem.write(`${tmp.path}/file_with_underscores.txt`, "UNDERSCORES") await Filesystem.write(`${tmp.path}/file_with_underscores.txt`, "UNDERSCORES")
const files = (await Snapshot.patch(before!)).files const files = (await Snapshot.patch(before!)).files
expect(files).toContain(`${tmp.path}/file with spaces.txt`) expect(files).toContain(fwd(tmp.path, "file with spaces.txt"))
expect(files).toContain(`${tmp.path}/file-with-dashes.txt`) expect(files).toContain(fwd(tmp.path, "file-with-dashes.txt"))
expect(files).toContain(`${tmp.path}/file_with_underscores.txt`) expect(files).toContain(fwd(tmp.path, "file_with_underscores.txt"))
}, },
}) })
}) })
@@ -293,10 +299,10 @@ test("unicode filenames", async () => {
expect(before).toBeTruthy() expect(before).toBeTruthy()
const unicodeFiles = [ const unicodeFiles = [
{ path: `${tmp.path}/文件.txt`, content: "chinese content" }, { path: fwd(tmp.path, "文件.txt"), content: "chinese content" },
{ path: `${tmp.path}/🚀rocket.txt`, content: "emoji content" }, { path: fwd(tmp.path, "🚀rocket.txt"), content: "emoji content" },
{ path: `${tmp.path}/café.txt`, content: "accented content" }, { path: fwd(tmp.path, "café.txt"), content: "accented content" },
{ path: `${tmp.path}/файл.txt`, content: "cyrillic content" }, { path: fwd(tmp.path, "файл.txt"), content: "cyrillic content" },
] ]
for (const file of unicodeFiles) { for (const file of unicodeFiles) {
@@ -329,8 +335,8 @@ test.skip("unicode filenames modification and restore", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const chineseFile = `${tmp.path}/文件.txt` const chineseFile = fwd(tmp.path, "文件.txt")
const cyrillicFile = `${tmp.path}/файл.txt` const cyrillicFile = fwd(tmp.path, "файл.txt")
await Filesystem.write(chineseFile, "original chinese") await Filesystem.write(chineseFile, "original chinese")
await Filesystem.write(cyrillicFile, "original cyrillic") await Filesystem.write(cyrillicFile, "original cyrillic")
@@ -362,7 +368,7 @@ test("unicode filenames in subdirectories", async () => {
expect(before).toBeTruthy() expect(before).toBeTruthy()
await $`mkdir -p "${tmp.path}/目录/подкаталог"`.quiet() await $`mkdir -p "${tmp.path}/目录/подкаталог"`.quiet()
const deepFile = `${tmp.path}/目录/подкаталог/文件.txt` const deepFile = fwd(tmp.path, "目录", "подкаталог", "文件.txt")
await Filesystem.write(deepFile, "deep unicode content") await Filesystem.write(deepFile, "deep unicode content")
const patch = await Snapshot.patch(before!) const patch = await Snapshot.patch(before!)
@@ -388,7 +394,7 @@ test("very long filenames", async () => {
expect(before).toBeTruthy() expect(before).toBeTruthy()
const longName = "a".repeat(200) + ".txt" const longName = "a".repeat(200) + ".txt"
const longFile = `${tmp.path}/${longName}` const longFile = fwd(tmp.path, longName)
await Filesystem.write(longFile, "long filename content") await Filesystem.write(longFile, "long filename content")
@@ -419,9 +425,9 @@ test("hidden files", async () => {
await Filesystem.write(`${tmp.path}/.config`, "config content") await Filesystem.write(`${tmp.path}/.config`, "config content")
const patch = await Snapshot.patch(before!) const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(`${tmp.path}/.hidden`) expect(patch.files).toContain(fwd(tmp.path, ".hidden"))
expect(patch.files).toContain(`${tmp.path}/.gitignore`) expect(patch.files).toContain(fwd(tmp.path, ".gitignore"))
expect(patch.files).toContain(`${tmp.path}/.config`) expect(patch.files).toContain(fwd(tmp.path, ".config"))
}, },
}) })
}) })
@@ -436,12 +442,12 @@ test("nested symlinks", async () => {
await $`mkdir -p ${tmp.path}/sub/dir`.quiet() await $`mkdir -p ${tmp.path}/sub/dir`.quiet()
await Filesystem.write(`${tmp.path}/sub/dir/target.txt`, "target content") await Filesystem.write(`${tmp.path}/sub/dir/target.txt`, "target content")
await $`ln -s ${tmp.path}/sub/dir/target.txt ${tmp.path}/sub/dir/link.txt`.quiet() await fs.symlink(`${tmp.path}/sub/dir/target.txt`, `${tmp.path}/sub/dir/link.txt`, "file")
await $`ln -s ${tmp.path}/sub ${tmp.path}/sub-link`.quiet() await fs.symlink(`${tmp.path}/sub`, `${tmp.path}/sub-link`, "dir")
const patch = await Snapshot.patch(before!) const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(`${tmp.path}/sub/dir/link.txt`) expect(patch.files).toContain(fwd(tmp.path, "sub", "dir", "link.txt"))
expect(patch.files).toContain(`${tmp.path}/sub-link`) expect(patch.files).toContain(fwd(tmp.path, "sub-link"))
}, },
}) })
}) })
@@ -476,7 +482,7 @@ test("circular symlinks", async () => {
expect(before).toBeTruthy() expect(before).toBeTruthy()
// Create circular symlink // Create circular symlink
await $`ln -s ${tmp.path}/circular ${tmp.path}/circular`.quiet().nothrow() await fs.symlink(`${tmp.path}/circular`, `${tmp.path}/circular`, "dir").catch(() => {})
const patch = await Snapshot.patch(before!) const patch = await Snapshot.patch(before!)
expect(patch.files.length).toBeGreaterThanOrEqual(0) // Should not crash expect(patch.files.length).toBeGreaterThanOrEqual(0) // Should not crash
@@ -499,11 +505,11 @@ test("gitignore changes", async () => {
const patch = await Snapshot.patch(before!) const patch = await Snapshot.patch(before!)
// Should track gitignore itself // Should track gitignore itself
expect(patch.files).toContain(`${tmp.path}/.gitignore`) expect(patch.files).toContain(fwd(tmp.path, ".gitignore"))
// Should track normal files // Should track normal files
expect(patch.files).toContain(`${tmp.path}/normal.txt`) expect(patch.files).toContain(fwd(tmp.path, "normal.txt"))
// Should not track ignored files (git won't see them) // Should not track ignored files (git won't see them)
expect(patch.files).not.toContain(`${tmp.path}/test.ignored`) expect(patch.files).not.toContain(fwd(tmp.path, "test.ignored"))
}, },
}) })
}) })
@@ -523,8 +529,8 @@ test("git info exclude changes", async () => {
await Bun.write(`${tmp.path}/normal.txt`, "normal content") await Bun.write(`${tmp.path}/normal.txt`, "normal content")
const patch = await Snapshot.patch(before!) const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(`${tmp.path}/normal.txt`) expect(patch.files).toContain(fwd(tmp.path, "normal.txt"))
expect(patch.files).not.toContain(`${tmp.path}/ignored.txt`) expect(patch.files).not.toContain(fwd(tmp.path, "ignored.txt"))
const after = await Snapshot.track() const after = await Snapshot.track()
const diffs = await Snapshot.diffFull(before!, after!) const diffs = await Snapshot.diffFull(before!, after!)
@@ -559,9 +565,9 @@ test("git info exclude keeps global excludes", async () => {
await Bun.write(`${tmp.path}/normal.txt`, "normal content") await Bun.write(`${tmp.path}/normal.txt`, "normal content")
const patch = await Snapshot.patch(before!) const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(`${tmp.path}/normal.txt`) expect(patch.files).toContain(fwd(tmp.path, "normal.txt"))
expect(patch.files).not.toContain(`${tmp.path}/global.tmp`) expect(patch.files).not.toContain(fwd(tmp.path, "global.tmp"))
expect(patch.files).not.toContain(`${tmp.path}/info.tmp`) expect(patch.files).not.toContain(fwd(tmp.path, "info.tmp"))
} finally { } finally {
if (prev) process.env.GIT_CONFIG_GLOBAL = prev if (prev) process.env.GIT_CONFIG_GLOBAL = prev
else delete process.env.GIT_CONFIG_GLOBAL else delete process.env.GIT_CONFIG_GLOBAL
@@ -610,7 +616,7 @@ test("snapshot state isolation between projects", async () => {
const before1 = await Snapshot.track() const before1 = await Snapshot.track()
await Filesystem.write(`${tmp1.path}/project1.txt`, "project1 content") await Filesystem.write(`${tmp1.path}/project1.txt`, "project1 content")
const patch1 = await Snapshot.patch(before1!) const patch1 = await Snapshot.patch(before1!)
expect(patch1.files).toContain(`${tmp1.path}/project1.txt`) expect(patch1.files).toContain(fwd(tmp1.path, "project1.txt"))
}, },
}) })
@@ -620,10 +626,10 @@ test("snapshot state isolation between projects", async () => {
const before2 = await Snapshot.track() const before2 = await Snapshot.track()
await Filesystem.write(`${tmp2.path}/project2.txt`, "project2 content") await Filesystem.write(`${tmp2.path}/project2.txt`, "project2 content")
const patch2 = await Snapshot.patch(before2!) const patch2 = await Snapshot.patch(before2!)
expect(patch2.files).toContain(`${tmp2.path}/project2.txt`) expect(patch2.files).toContain(fwd(tmp2.path, "project2.txt"))
// Ensure project1 files don't appear in project2 // Ensure project1 files don't appear in project2
expect(patch2.files).not.toContain(`${tmp1?.path}/project1.txt`) expect(patch2.files).not.toContain(fwd(tmp1?.path ?? "", "project1.txt"))
}, },
}) })
}) })
@@ -647,7 +653,7 @@ test("patch detects changes in secondary worktree", async () => {
const before = await Snapshot.track() const before = await Snapshot.track()
expect(before).toBeTruthy() expect(before).toBeTruthy()
const worktreeFile = `${worktreePath}/worktree.txt` const worktreeFile = fwd(worktreePath, "worktree.txt")
await Filesystem.write(worktreeFile, "worktree content") await Filesystem.write(worktreeFile, "worktree content")
const patch = await Snapshot.patch(before!) const patch = await Snapshot.patch(before!)
@@ -681,7 +687,7 @@ test("revert only removes files in invoking worktree", async () => {
const before = await Snapshot.track() const before = await Snapshot.track()
expect(before).toBeTruthy() expect(before).toBeTruthy()
const worktreeFile = `${worktreePath}/worktree.txt` const worktreeFile = fwd(worktreePath, "worktree.txt")
await Filesystem.write(worktreeFile, "worktree content") await Filesystem.write(worktreeFile, "worktree content")
const patch = await Snapshot.patch(before!) const patch = await Snapshot.patch(before!)
@@ -832,7 +838,7 @@ test("revert should not delete files that existed but were deleted in snapshot",
await Filesystem.write(`${tmp.path}/a.txt`, "recreated content") await Filesystem.write(`${tmp.path}/a.txt`, "recreated content")
const patch = await Snapshot.patch(snapshot2!) const patch = await Snapshot.patch(snapshot2!)
expect(patch.files).toContain(`${tmp.path}/a.txt`) expect(patch.files).toContain(fwd(tmp.path, "a.txt"))
await Snapshot.revert([patch]) await Snapshot.revert([patch])
@@ -861,8 +867,8 @@ test("revert preserves file that existed in snapshot when deleted then recreated
await Filesystem.write(`${tmp.path}/newfile.txt`, "new") await Filesystem.write(`${tmp.path}/newfile.txt`, "new")
const patch = await Snapshot.patch(snapshot!) const patch = await Snapshot.patch(snapshot!)
expect(patch.files).toContain(`${tmp.path}/existing.txt`) expect(patch.files).toContain(fwd(tmp.path, "existing.txt"))
expect(patch.files).toContain(`${tmp.path}/newfile.txt`) expect(patch.files).toContain(fwd(tmp.path, "newfile.txt"))
await Snapshot.revert([patch]) await Snapshot.revert([patch])