Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 42 additions & 9 deletions packages/core/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,15 +459,39 @@ const layer = Layer.effect(
)
const candidates = Array.from(new Set([...tracked, ...untracked]))
if (!candidates.length) return { skipped: [] }
const ignored = input.ignores
? new Set(
(yield* repositoryOperation("refresh", input.ignores, ["check-ignore", "--no-index", "--stdin", "-z"], {
stdin: candidates.join("\0") + "\0",
}).pipe(Effect.catch(() => Effect.succeed({ text: "", stderr: "" })))).text
.split("\0")
.filter(Boolean),
const ignored = new Set<string>()
if (input.ignores) {
// Git check-ignore exits 1 for "nothing ignored"; any other failure
// must abort the capture, rather than silently treating ignored files
// as safe to stage into a snapshot.
const check = yield* proc
.run(
ChildProcess.make(
"git",
repositoryArgs(input.ignores, ["check-ignore", "--no-index", "--stdin", "-z"]),
{ cwd: input.ignores.worktree, extendEnv: true },
),
{ stdin: candidates.join("\0") + "\0" },
)
.pipe(
Effect.mapError(
(cause) =>
new OperationError({
operation: "refresh",
directory: input.ignores!.worktree,
message: cause.message,
cause,
}),
),
)
: new Set<string>()
if (check.exitCode !== 0 && check.exitCode !== 1)
return yield* new OperationError({
operation: "refresh",
directory: input.ignores.worktree,
message: check.stderr.toString("utf8").trim() || "Failed to check ignored paths",
})
for (const file of check.stdout.toString("utf8").split("\0").filter(Boolean)) ignored.add(file)
}
const allowed = candidates.filter((item) => !ignored.has(item))
const maximum = input.maximumUntrackedFileBytes
const skipped = maximum
Expand Down Expand Up @@ -573,7 +597,16 @@ const layer = Layer.effect(
})

const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) {
return TreeID.make((yield* repositoryOperation("write_tree", repository, ["write-tree"])).text.trim())
const result = yield* repositoryOperation("write_tree", repository, ["write-tree"])
const oid = result.text.trim()
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(oid)) {
return yield* new OperationError({
operation: "write_tree",
directory: repository.worktree,
message: "Git write-tree did not return a valid tree object ID",
})
}
return TreeID.make(oid)
})

type CaptureInput = {
Expand Down
37 changes: 37 additions & 0 deletions packages/core/src/snapshot-index-lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import path from "path"
import { Effect } from "effect"
import { Flock } from "./util/flock"

/**
* Shared writer lease for the V1 and core/V2 snapshot implementations.
*
* Both services intentionally use data/snapshot/<project>/<worktree> as their
* shadow gitdir. Their separate in-process semaphores cannot protect that
* Git index from each other or from another OpenFork server process.
*
* Use a different lock directory from Git's index.lock: Flock provides atomic
* mkdir acquisition, heartbeat, owner token validation and stale lease recovery.
* Keep this lock across the entire index transaction, not merely git add.
*/
export namespace SnapshotIndexLock {
export const key = (gitDirectory: string) => `snapshot-index:${path.normalize(gitDirectory)}`
export const root = (dataDirectory: string) => path.join(dataDirectory, "snapshot-locks")

export const withLock = <A, E, R>(
gitDirectory: string,
dataDirectory: string,
body: Effect.Effect<A, E, R>,
) =>
Effect.scoped(
Effect.gen(function* () {
yield* Flock.effect(key(gitDirectory), {
dir: root(dataDirectory),
staleMs: 180_000,
timeoutMs: 45_000,
baseDelayMs: 60,
maxDelayMs: 750,
})
return yield* body
}),
)
}
104 changes: 64 additions & 40 deletions packages/core/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Location } from "./location"
import { AppProcess } from "./process"
import { AbsolutePath, RelativePath } from "./schema"
import { Hash } from "./util/hash"
import { SnapshotIndexLock } from "./snapshot-index-lock"
import { GitRuntime } from "./git-runtime"

export const ID = Schema.String.pipe(Schema.brand("Snapshot.ID"))
Expand Down Expand Up @@ -153,6 +154,13 @@ const layer = Layer.effect(
: location.project.directory
const gitDirectory = AbsolutePath.make(path.join(global.data, "snapshot", location.project.id, Hash.fast(worktree)))

// V1 Snapshot and this V2/core service use the *same* shadow gitdir.
// Git.tree has an in-process lock, but that cannot coordinate V1 or
// independent OpenFork server processes. Reuse the exact V1 filesystem
// lease key and hold it across initialization, refresh and write-tree.
const withShadowIndex = <A, E, R>(body: Effect.Effect<A, E, R>) =>
SnapshotIndexLock.withLock(gitDirectory, global.data, body)

const scope = Effect.fnUntraced(function* () {
const relative = path.relative(worktree, location.directory)
if (relative.startsWith("..") || path.isAbsolute(relative))
Expand Down Expand Up @@ -218,19 +226,23 @@ const layer = Layer.effect(

const capture = Effect.fn("Snapshot.capture")(function* () {
if (!(yield* enabled())) return undefined
return yield* Effect.gen(function* () {
const repo = yield* repository()
yield* healIndex(repo)
return ID.make(
yield* git.tree.capture({
repository: repo,
scopes: [yield* scope()],
ignores: source,
maximumUntrackedFileBytes: 2 * 1024 * 1024,
}),
)
}).pipe(
Effect.catch((cause) => Effect.logWarning("failed to capture snapshot", { cause }).pipe(Effect.as(undefined))),
return yield* withShadowIndex(
Effect.gen(function* () {
const repo = yield* repository()
yield* healIndex(repo)
return ID.make(
yield* git.tree.capture({
repository: repo,
scopes: [yield* scope()],
ignores: source,
maximumUntrackedFileBytes: 2 * 1024 * 1024,
}),
)
}),
).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to capture snapshot; no tree published", { cause }).pipe(Effect.as(undefined)),
),
)
})

Expand Down Expand Up @@ -279,40 +291,52 @@ const layer = Layer.effect(

const preview = Effect.fn("Snapshot.preview")(function* (input: PreviewInput) {
if (!(yield* enabled())) return yield* new Error({ operation: "preview", message: "Snapshots are disabled" })
const repo = yield* repository().pipe(Effect.mapError((cause) => failure("preview", cause)))
yield* healIndex(repo).pipe(Effect.catch(() => Effect.void))
const files = yield* plan("preview", input)
const current = yield* git.tree
.capture({
repository: repo,
scopes: Array.from(files.keys()),
ignores: source,
maximumUntrackedFileBytes: 2 * 1024 * 1024,
})
.pipe(Effect.mapError((cause) => failure("preview", cause)))
return yield* git.tree
.preview({
repository: repo,
current,
files,
context: input.context,
})
.pipe(Effect.mapError((cause) => failure("preview", cause)))
return yield* withShadowIndex(
Effect.gen(function* () {
const repo = yield* repository().pipe(Effect.mapError((cause) => failure("preview", cause)))
yield* healIndex(repo)
const files = yield* plan("preview", input)
const current = yield* git.tree
.capture({
repository: repo,
scopes: Array.from(files.keys()),
ignores: source,
maximumUntrackedFileBytes: 2 * 1024 * 1024,
})
.pipe(Effect.mapError((cause) => failure("preview", cause)))
return yield* git.tree
.preview({
repository: repo,
current,
files,
context: input.context,
})
.pipe(Effect.mapError((cause) => failure("preview", cause)))
}),
)
})

const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) {
if (!(yield* enabled())) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" })
const repo = yield* repository().pipe(Effect.mapError((cause) => failure("restore", cause)))
yield* git.tree
.restore({ repository: repo, files: yield* plan("restore", input) })
.pipe(Effect.mapError((cause) => failure("restore", cause)))
return yield* withShadowIndex(
Effect.gen(function* () {
const repo = yield* repository().pipe(Effect.mapError((cause) => failure("restore", cause)))
yield* git.tree
.restore({ repository: repo, files: yield* plan("restore", input) })
.pipe(Effect.mapError((cause) => failure("restore", cause)))
}),
)
})

const checkout = Effect.fn("Snapshot.checkout")(function* (snapshot: ID) {
const repo = yield* repository().pipe(Effect.mapError((cause) => failure("restore", cause)))
yield* git.tree
.checkout({ repository: repo, tree: Git.TreeID.make(snapshot) })
.pipe(Effect.mapError((cause) => failure("restore", cause)))
return yield* withShadowIndex(
Effect.gen(function* () {
const repo = yield* repository().pipe(Effect.mapError((cause) => failure("restore", cause)))
yield* git.tree
.checkout({ repository: repo, tree: Git.TreeID.make(snapshot) })
.pipe(Effect.mapError((cause) => failure("restore", cause)))
}),
)
})

const epoch = Effect.fnUntraced(function* () {
Expand Down
Loading
Loading