Compare commits

...
1 Commits
Author SHA1 Message Date
Hona c5b22986b4 fix(core): respect repository git semantics 2026-08-12 07:38:01 +00:00
6 changed files with 155 additions and 20 deletions
+56 -9
View File
@@ -22,10 +22,10 @@ const snapshotConfigFile = "opencode.gitconfig"
const snapshotConfigInclude = `[include]
path = ${snapshotConfigFile}
`
const snapshotConfig = `[core]
autocrlf = false
const snapshotConfig = (input: { autocrlf: string; symlinks: string }) => `[core]
autocrlf = ${input.autocrlf}
longpaths = true
symlinks = true
symlinks = ${input.symlinks}
fsmonitor = false
untrackedCache = true
[feature]
@@ -341,6 +341,46 @@ const layer = Layer.effect(
})
})
const sourceConfig = Effect.fnUntraced(function* (
repository: Repository | undefined,
key: string,
fallback: string,
allowInput: boolean,
) {
if (!repository) return fallback
const result = yield* execute(
repository.worktree,
proc,
)(["config", "--get", key]).pipe(
Effect.mapError(
(cause) =>
new OperationError({
operation: "create",
directory: repository.worktree,
message: `Failed to resolve ${key}`,
cause,
}),
),
)
if (result.exitCode === 0) {
const value = result.text.trim().toLowerCase()
if (allowInput && value === "input") return value
if (["true", "yes", "on", "1"].includes(value)) return "true"
if (["false", "no", "off", "0"].includes(value)) return "false"
return yield* new OperationError({
operation: "create",
directory: repository.worktree,
message: `Invalid ${key} value: ${value}`,
})
}
if (result.exitCode === 1) return fallback
return yield* new OperationError({
operation: "create",
directory: repository.worktree,
message: result.stderr.trim() || `Failed to resolve ${key}`,
})
})
const create = Effect.fn("Git.repo.create")(function* (input: {
worktree: AbsolutePath
gitDirectory: AbsolutePath
@@ -363,14 +403,20 @@ const layer = Layer.effect(
commonDirectory: input.gitDirectory,
})
yield* repositoryOperation("create", repository, ["init"])
yield* Effect.gen(function* () {
yield* fs.writeFileString(path.join(input.gitDirectory, snapshotConfigFile), snapshotConfig)
const semantics = {
autocrlf: yield* sourceConfig(input.seed, "core.autocrlf", "false", true),
symlinks: yield* sourceConfig(input.seed, "core.symlinks", "true", false),
}
const reseed = yield* Effect.gen(function* () {
const owned = path.join(input.gitDirectory, snapshotConfigFile)
const desired = snapshotConfig(semantics)
const previous = yield* fs.readFileString(owned).pipe(Effect.catch(() => Effect.succeed("")))
yield* fs.writeFileString(owned, desired)
const config = path.join(input.gitDirectory, "config")
const current = yield* fs.readFileString(config)
if (current.includes(snapshotConfigInclude)) return
yield* fs.writeFileString(config, `${current.endsWith("\n") ? "\n" : "\n\n"}${snapshotConfigInclude}`, {
flag: "a",
})
const base = current.replace(/^\s*path\s*=\s*opencode\.gitconfig\s*\r?\n?/gm, "").trimEnd()
yield* fs.writeFileString(config, `${base}\n\n${snapshotConfigInclude}`)
return previous !== desired
}).pipe(
Effect.mapError(
(cause) =>
@@ -410,6 +456,7 @@ const layer = Layer.effect(
}),
),
)
if (!reseed) return repository
yield* fs
.copyFile(path.join(input.seed.gitDirectory, "index"), path.join(input.gitDirectory, "index"))
.pipe(Effect.catch(() => Effect.void))
+3 -5
View File
@@ -83,11 +83,9 @@ const layer = Layer.effect(
const gitDirectory = AbsolutePath.make(
path.join(global.data, "snapshot", location.project.id, Hash.fast(worktree)),
)
const snapshotRepository = (yield* fs.existsSafe(path.join(gitDirectory, "HEAD")))
? new Git.Repository({ worktree, gitDirectory, commonDirectory: gitDirectory })
: yield* git.repo
.create({ worktree, gitDirectory, seed: source })
.pipe(Effect.mapError((cause) => failure("capture", cause)))
const snapshotRepository = yield* git.repo
.create({ worktree, gitDirectory, seed: source })
.pipe(Effect.mapError((cause) => failure("capture", cause)))
return { source, worktree, snapshotRepository }
}).pipe(Effect.forkIn(lifetime)),
)
-4
View File
@@ -103,14 +103,10 @@ type GitOps = ReturnType<typeof makeGit>
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
+15 -2
View File
@@ -148,21 +148,34 @@ describe("Git trees", () => {
const git = yield* Git.Service
const source = yield* git.repo.discover(AbsolutePath.make(root.path))
if (!source) throw new Error("Repository not found")
yield* Effect.promise(() => $`git config core.autocrlf true`.cwd(root.path).quiet())
yield* Effect.promise(() => $`git config core.symlinks false`.cwd(root.path).quiet())
const storage = AbsolutePath.make(path.join(root.path, ".snapshot storage"))
const repository = yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
yield* Effect.promise(() => $`git --git-dir ${storage} config --add include.path first.gitconfig`.quiet())
yield* Effect.promise(() => $`git --git-dir ${storage} config --add include.path second.gitconfig`.quiet())
yield* Effect.promise(() => $`git --git-dir ${storage} config core.autocrlf true`.quiet())
yield* Effect.promise(() => $`git --git-dir ${storage} config core.autocrlf false`.quiet())
yield* Effect.promise(() => $`git --git-dir ${storage} config core.symlinks true`.quiet())
yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
expect(
yield* Effect.promise(() => $`git --git-dir ${storage} config --local --includes core.autocrlf`.text()),
).toBe("true\n")
expect(
yield* Effect.promise(() => $`git --git-dir ${storage} config --local --includes core.symlinks`.text()),
).toBe("false\n")
yield* Effect.promise(() => $`git config core.autocrlf input`.cwd(root.path).quiet())
yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
expect(
yield* Effect.promise(() => $`git --git-dir ${storage} config --local --includes core.autocrlf`.text()),
).toBe("input\n")
yield* Effect.promise(() => $`git config core.autocrlf true`.cwd(root.path).quiet())
yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
expect(
(yield* Effect.promise(() => fs.readFile(path.join(storage, "config"), "utf8"))).match(/opencode\.gitconfig/g),
).toHaveLength(1)
expect(
yield* Effect.promise(() => $`git --git-dir ${storage} config --local --get-all include.path`.text()),
).toBe("opencode.gitconfig\nfirst.gitconfig\nsecond.gitconfig\n")
).toBe("first.gitconfig\nsecond.gitconfig\nopencode.gitconfig\n")
yield* git.index.refresh({ repository, scope: RelativePath.make("scope") })
const before = yield* git.tree.write(repository)
+45
View File
@@ -127,6 +127,51 @@ describe("Snapshot", () => {
),
)
testEffect(Layer.empty).live("repairs existing storage with source repository semantics", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
const file = path.join(project, "line-endings.txt")
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(file, "before\n")
await initGit(project, true)
await $`git config core.autocrlf false`.cwd(project).quiet()
})
const git = yield* Git.Service.pipe(Effect.provide(AppNodeBuilder.build(Git.node)))
const source = yield* git.repo.discover(AbsolutePath.make(project))
if (!source) throw new Error("Repository not found")
const location = yield* Location.Service.pipe(
Effect.provide(
AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))),
),
)
yield* git.repo.create({
worktree: source.worktree,
gitDirectory: AbsolutePath.make(path.join(tmp.path, "snapshot", location.project.id, Hash.fast(project))),
seed: source,
})
yield* Effect.promise(async () => {
await $`git config core.autocrlf true`.cwd(project).quiet()
await fs.rm(file)
await $`git checkout -- line-endings.txt`.cwd(project).quiet()
})
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.capture()
expect(before).toBeDefined()
yield* Effect.promise(() => fs.writeFile(file, "before\n"))
expect(yield* snapshot.capture()).toBe(before)
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
testEffect(Layer.empty).live("treats capture outside Git as unavailable", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
+36
View File
@@ -93,6 +93,42 @@ describe("Vcs", () => {
),
)
it.live("respects repository line ending configuration", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await $`git config core.autocrlf true`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, "line-endings.txt"), "before\n")
await commitAll(directory, "line endings")
await fs.rm(path.join(directory, "line-endings.txt"))
await $`git checkout -- line-endings.txt`.cwd(directory).quiet()
})
const vcs = yield* Vcs.Service
expect(yield* vcs.status()).toEqual([])
expect(yield* vcs.diff("working")).toEqual([])
}),
),
)
it.live("respects repository symlink configuration", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
const blob = await $`printf target.txt | git hash-object -w --stdin`.cwd(directory).text()
await $`git update-index --add --cacheinfo 120000,${blob.trim()},link.txt`.cwd(directory).quiet()
await $`git commit -m symlink`.cwd(directory).quiet()
await $`git config core.symlinks false`.cwd(directory).quiet()
await $`git checkout-index -f link.txt`.cwd(directory).quiet()
})
const vcs = yield* Vcs.Service
expect(yield* vcs.status()).toEqual([])
expect(yield* vcs.diff("working")).toEqual([])
}),
),
)
it.live("caches branch info and publishes HEAD changes", () =>
withGit((directory) =>
Effect.gen(function* () {