diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts index 968e394a95..91e194d4c6 100644 --- a/packages/opencode/src/git/index.ts +++ b/packages/opencode/src/git/index.ts @@ -6,14 +6,10 @@ import { ChildProcess } from "effect/unstable/process" const cfg = [ "--no-optional-locks", "-c", - "core.autocrlf=false", - "-c", "core.fsmonitor=false", "-c", "core.longpaths=true", "-c", - "core.symlinks=true", - "-c", "core.quotepath=false", ] as const diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 4da9bc3ca8..b7a4e33933 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -22,9 +22,11 @@ export type FileDiff = typeof FileDiff.Type const prune = "7.days" const limit = 2 * 1024 * 1024 -const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"] -const cfg = ["-c", "core.autocrlf=false", ...core] -const quote = [...cfg, "-c", "core.quotepath=false"] +// Operational flags are safe for both repositories. Working-tree semantics are +// resolved from the source repository and persisted in the private snapshot repository. +const operational = ["-c", "core.longpaths=true"] +const quote = [...operational, "-c", "core.quotepath=false"] +const mirrored = ["core.autocrlf", "core.symlinks"] interface GitResult { readonly code: ChildProcessSpawner.ExitCode readonly text: string @@ -99,27 +101,25 @@ const layer: Layer.Layer; stdin?: string }, + ) { + const result = yield* git(cmd, opts) + if (result.code === 0) return result + return yield* Effect.die( + new Error(`git ${cmd.join(" ")} failed with exit code ${result.code}: ${result.stderr.trim()}`), + ) + }) + const ignore = Effect.fnUntraced(function* (files: string[]) { if (!files.length) return new Set() // check-ignore treats a leading colon as pathspec magic but accepts and echoes a protective ./ prefix. const checkIgnorePaths = files.map((item) => (item.startsWith(":") ? `./${item}` : item)) - const check = yield* git( - [ - ...quote, - "--git-dir", - path.join(state.worktree, ".git"), - "--work-tree", - state.worktree, - "check-ignore", - "--no-index", - "--stdin", - "-z", - ], - { - cwd: state.worktree, - stdin: encodeNulTerminatedPaths(checkIgnorePaths), - }, - ) + const check = yield* git([...quote, "check-ignore", "--no-index", "--stdin", "-z"], { + cwd: state.worktree, + stdin: encodeNulTerminatedPaths(checkIgnorePaths), + }) if (check.code !== 0 && check.code !== 1) return new Set() return new Set( check.text @@ -131,9 +131,9 @@ const layer: Layer.Layer fs.exists(file).pipe(Effect.orDie) @@ -226,12 +221,62 @@ const layer: Layer.Layer Effect.void)) } }) + const prepare = Effect.fnUntraced(function* () { + const existed = yield* exists(state.gitdir) + yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie) + if (!(yield* exists(path.join(state.gitdir, "config")))) { + yield* required(["init"], { + env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree }, + }) + } + + const semantics = yield* Effect.forEach(mirrored, (key) => + git(["config", "--get", key], { cwd: state.worktree }).pipe( + Effect.map((result) => { + if (result.code === 0) return [key, result.text.trim()] as const + if (result.code === 1) return [key, key === "core.autocrlf" ? "false" : "true"] as const + throw new Error(`failed to resolve ${key}: ${result.stderr.trim()}`) + }), + ), + ) + const fingerprint = `v1;${semantics.map(([key, value]) => `${key}=${value}`).join(";")}` + const previous = yield* git([ + "--git-dir", + state.gitdir, + "config", + "--local", + "--get", + "opencode.snapshotSemantics", + ]) + if (previous.text.trim() === fingerprint) return + const config = [ + ...semantics, + ["core.longpaths", "true"], + ["core.fsmonitor", "false"], + ["feature.manyFiles", "true"], + ["index.version", "4"], + ["index.threads", "true"], + ["core.untrackedCache", "true"], + ] as const + yield* Effect.forEach( + config, + ([key, value]) => required(["--git-dir", state.gitdir, "config", "--local", key, value]), + { discard: true }, + ) + yield* seed() + yield* required(["--git-dir", state.gitdir, "config", "--local", "opencode.snapshotSemantics", fingerprint]) + if (!existed) yield* Effect.logInfo("initialized") + }) + const add = Effect.fnUntraced(function* () { yield* sync() const [diff, other] = yield* Effect.all( @@ -246,13 +291,11 @@ const layer: Layer.Layer() for (const item of patches) { @@ -424,20 +439,21 @@ const layer: Layer.Layer item.rel)])], + [ + ...operational, + ...args(["ls-tree", "--name-only", first.hash, "--", ...run.map((item) => item.rel)]), + ], { cwd: state.worktree, }, @@ -493,7 +512,7 @@ const layer: Layer.Layer item.file)])], + [...operational, ...args(["checkout", first.hash, "--", ...list.map((item) => item.file)])], { cwd: state.worktree, }, @@ -526,6 +545,7 @@ const layer: Layer.Layer item.text)), + yield* git([...operational, ...args(["show", `${to}:${row.file}`])]).pipe( + Effect.map((item) => item.text), + ), ] } if (row.status === "deleted") { return [ - yield* git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe( + yield* git([...operational, ...args(["show", `${from}:${row.file}`])]).pipe( Effect.map((item) => item.text), ), "", @@ -578,8 +601,10 @@ const layer: Layer.Layer item.text)), - git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)), + git([...operational, ...args(["show", `${from}:${row.file}`])]).pipe( + Effect.map((item) => item.text), + ), + git([...operational, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)), ], { concurrency: 2 }, ) @@ -602,7 +627,7 @@ const layer: Layer.Layer() const batch = yield* appProcess.run( - ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], { + ChildProcess.make("git", [...operational, ...args(["cat-file", "--batch"])], { cwd: state.directory, extendEnv: true, }), diff --git a/packages/opencode/test/git/git.test.ts b/packages/opencode/test/git/git.test.ts index 56f19a4a46..e68a6de31b 100644 --- a/packages/opencode/test/git/git.test.ts +++ b/packages/opencode/test/git/git.test.ts @@ -115,6 +115,39 @@ describe("Git", () => { }), ) + it.live("respects repository line ending configuration", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + yield* Effect.promise(() => $`git config core.autocrlf true`.cwd(tmp.path).quiet()) + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "line-endings.txt"), "before\n", "utf-8")) + yield* Effect.promise(() => $`git add line-endings.txt`.cwd(tmp.path).quiet()) + yield* Effect.promise(() => $`git commit --no-gpg-sign -m "add line endings"`.cwd(tmp.path).quiet()) + yield* Effect.promise(() => fs.rm(path.join(tmp.path, "line-endings.txt"))) + yield* Effect.promise(() => $`git checkout -- line-endings.txt`.cwd(tmp.path).quiet()) + + const git = yield* Git.Service + expect(yield* git.status(tmp.path)).toEqual([]) + expect(yield* git.diff(tmp.path, "HEAD")).toEqual([]) + }), + ) + + it.live("respects repository symlink configuration", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + const blob = yield* Effect.promise(() => $`echo -n target.txt | git hash-object -w --stdin`.cwd(tmp.path).text()) + yield* Effect.promise(() => + $`git update-index --add --cacheinfo 120000,${blob.trim()},link.txt`.cwd(tmp.path).quiet(), + ) + yield* Effect.promise(() => $`git commit --no-gpg-sign -m "add symlink"`.cwd(tmp.path).quiet()) + yield* Effect.promise(() => $`git config core.symlinks false`.cwd(tmp.path).quiet()) + yield* Effect.promise(() => $`git checkout-index -f link.txt`.cwd(tmp.path).quiet()) + + const git = yield* Git.Service + expect(yield* git.status(tmp.path)).toEqual([]) + expect(yield* git.diff(tmp.path, "HEAD")).toEqual([]) + }), + ) + it.live("patch() returns capped native patch output", () => Effect.gen(function* () { const tmp = yield* scopedTmpdir({ git: true }) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index a39624087c..949a213266 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -5,7 +5,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import fs from "fs/promises" import path from "path" -import { Effect, Fiber, Layer } from "effect" +import { Effect, Exit, Fiber, Layer } from "effect" import { Snapshot } from "../../src/snapshot" import { disposeAllInstances, @@ -117,6 +117,68 @@ it.instance( { git: true }, ) +it.instance( + "uses source repository line ending semantics", + Effect.gen(function* () { + const tmp = yield* TestInstance + const file = path.join(tmp.directory, "line-endings.txt") + yield* exec(tmp.directory, ["git", "config", "core.autocrlf", "true"]) + yield* write(file, "before\n") + yield* exec(tmp.directory, ["git", "add", "line-endings.txt"]) + yield* exec(tmp.directory, ["git", "commit", "--no-gpg-sign", "-m", "add line endings"]) + yield* rm(file) + yield* exec(tmp.directory, ["git", "checkout", "--", "line-endings.txt"]) + + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() + yield* write(file, "before\n") + const after = yield* snapshot.track() + expect(after).toBe(before) + + yield* exec(tmp.directory, ["git", "config", "core.autocrlf", "false"]) + yield* write(file, "before\r\n") + expect(yield* snapshot.track()).not.toBe(before) + + yield* exec(tmp.directory, ["git", "config", "core.autocrlf", "true"]) + expect(yield* snapshot.track()).toBe(before) + + yield* exec(tmp.directory, ["git", "config", "core.autocrlf", "input"]) + yield* write(file, "changed\n") + yield* snapshot.restore(before!) + expect(yield* readText(file)).toBe("before\n") + + yield* exec(tmp.directory, ["git", "config", "core.autocrlf", "true"]) + yield* write(file, "changed\n") + yield* snapshot.restore(before!) + expect(yield* readText(file)).toBe("before\r\n") + }), + { git: true }, +) + +it.instance( + "uses source repository symlink semantics", + Effect.gen(function* () { + const tmp = yield* TestInstance + const blob = yield* Effect.promise(() => + $`printf target.txt | git hash-object -w --stdin`.cwd(tmp.directory).text(), + ) + yield* exec(tmp.directory, ["git", "update-index", "--add", "--cacheinfo", `120000,${blob.trim()},link.txt`]) + yield* exec(tmp.directory, ["git", "commit", "--no-gpg-sign", "-m", "add symlink"]) + yield* exec(tmp.directory, ["git", "config", "core.symlinks", "false"]) + yield* exec(tmp.directory, ["git", "checkout-index", "-f", "link.txt"]) + + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() + + yield* exec(tmp.directory, ["git", "config", "core.symlinks", "true"]) + expect(yield* snapshot.track()).not.toBe(before) + + yield* exec(tmp.directory, ["git", "config", "core.symlinks", "false"]) + expect(yield* snapshot.track()).toBe(before) + }), + { git: true }, +) + it.instance( "revert should remove new files", withTrackedSnapshot(({ tmp, snapshot, before }) => @@ -287,6 +349,20 @@ it.instance( { git: true }, ) +it.instance( + "revert preserves files when snapshot lookup fails", + withTrackedSnapshot(({ tmp, snapshot }) => + Effect.gen(function* () { + const file = path.join(tmp.path, "protected.txt") + yield* write(file, "keep") + const result = yield* Effect.exit(snapshot.revert([{ hash: "invalid-hash", files: [file] }])) + expect(Exit.isFailure(result)).toBe(true) + expect(yield* readText(file)).toBe("keep") + }), + ), + { git: true }, +) + it.instance( "unicode filenames", withTrackedSnapshot(({ tmp, snapshot, before }) =>