Skip to content
Open
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
43 changes: 33 additions & 10 deletions packages/core/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,18 +580,41 @@ const layer = Layer.effect(
input.excluded ??
(input.ignores ? yield* ignored({ repository: input.ignores, paths: candidates }) : new Set<RelativePath>())
const maximum = input.maximumUntrackedFileBytes
const untracked = new Set(input.changes.untracked)
// One stat per candidate decides both limits. A missing path is a deletion, which `--remove` handles.
const stats = new Map(
yield* Effect.forEach(
candidates.filter((item) => !excluded.has(item)),
(item) =>
fs.stat(path.join(input.repository.worktree, item)).pipe(
Comment thread
xzyangha marked this conversation as resolved.
Resolved
Effect.map((info) => [item, info] as const),
Effect.orElseSucceed(() => [item, undefined] as const),
),
{ concurrency: 8 },
),
)
// update-index refuses sockets, FIFOs and device nodes, and a single one aborts the whole call,
// so such a path keeps whatever the index already holds for it. `stat` follows links, and Git
// records a link itself without opening its target, so a link is never left out.
const special = (yield* Effect.forEach(
candidates.filter((item) => {
const type = stats.get(item)?.type
return type !== undefined && type !== "File" && type !== "Directory"
}),
(item) =>
fs.readLink(path.join(input.repository.worktree, item)).pipe(
Effect.as(undefined),
Effect.orElseSucceed(() => item),
),
{ concurrency: 8 },
)).filter((item): item is RelativePath => item !== undefined)
const skipped = maximum
? (yield* Effect.forEach(
input.changes.untracked.filter((item) => !excluded.has(item)),
(item) =>
fs.stat(path.join(input.repository.worktree, item)).pipe(
Effect.map((info) => (info.type === "File" && Number(info.size) > maximum ? item : undefined)),
Effect.orElseSucceed(() => undefined),
),
{ concurrency: 8 },
)).filter((item): item is RelativePath => item !== undefined)
? candidates.filter((item) => {
const info = stats.get(item)
return untracked.has(item) && info?.type === "File" && Number(info.size) > maximum
})
: []
const skip = new Set(skipped)
const skip = new Set([...special, ...skipped])
const added = candidates.filter((item) => !excluded.has(item) && !skip.has(item))
// Untracked paths are not in the index, so only tracked paths that are now ignored need removing.
const removed = input.changes.tracked.filter((item) => excluded.has(item))
Expand Down
44 changes: 44 additions & 0 deletions packages/core/test/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,50 @@ describe("Git capture recovery", () => {
}),
)

const unixTest = process.platform === "win32" ? it.live.skip : it.live
unixTest("leaves a tracked path that became a unix socket out of the capture", () =>
Effect.gen(function* () {
const { project, repository, capture } = yield* setup
const git = yield* Git.Service
const before = yield* capture()
yield* Effect.acquireRelease(
Effect.promise(async () => {
await fs.rm(path.join(project, "a.txt"))
return Bun.listen({ unix: path.join(project, "a.txt"), socket: { data() {} } })
}),
(server) => Effect.sync(() => server.stop(true)),
)
yield* Effect.promise(() => Bun.write(path.join(project, "b.txt"), "changed\n"))
const after = yield* capture()
expect(yield* git.tree.files({ repository, from: before, to: after })).toEqual([RelativePath.make("b.txt")])
expect(
yield* Effect.promise(() => $`git --git-dir ${repository.gitDirectory} ls-tree ${after} a.txt`.text()),
).toStartWith("100644 blob ")
}),
)

unixTest("keeps a symlink whose target is a socket in the capture", () =>
Effect.gen(function* () {
const { root, project, repository, capture } = yield* setup
const git = yield* Git.Service
const before = yield* capture()
yield* Effect.acquireRelease(
Effect.promise(async () => {
const server = Bun.listen({ unix: path.join(root.path, "target.sock"), socket: { data() {} } })
await fs.rm(path.join(project, "a.txt"))
await fs.symlink(path.join(root.path, "target.sock"), path.join(project, "a.txt"))
return server
}),
(server) => Effect.sync(() => server.stop(true)),
)
const after = yield* capture()
expect(yield* git.tree.files({ repository, from: before, to: after })).toEqual([RelativePath.make("a.txt")])
expect(
yield* Effect.promise(() => $`git --git-dir ${repository.gitDirectory} ls-tree ${after} a.txt`.text()),
).toStartWith("120000 blob ")
}),
)

it.live("keeps the index when Git cannot even start", () =>
Effect.gen(function* () {
const { root, project, repository, capture } = yield* setup
Expand Down
1 change: 1 addition & 0 deletions services/www/src/docs/content/snapshots.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ captures eligible files below `packages/app`, not changes elsewhere in the repos
| Non-ignored untracked files up to 2 MiB each | Yes | `packages/app/notes.txt` |
| Git-ignored files | No | `packages/app/dist/app.js` |
| Individual untracked files larger than 2 MiB | No | `packages/app/debug.log` |
| Sockets, FIFOs, and device nodes | No | `.codex/app-server-control/app-server-control.sock` |
| Files outside the active directory | No | `packages/server/src/index.ts` |
| Git metadata and other side effects | No | commits, branches, databases, or processes |

Expand Down
Loading