diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts index 1778879facea..3695d6a6d2a8 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -580,18 +580,41 @@ const layer = Layer.effect( input.excluded ?? (input.ignores ? yield* ignored({ repository: input.ignores, paths: candidates }) : new Set()) 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( + 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)) diff --git a/packages/core/test/git.test.ts b/packages/core/test/git.test.ts index d040949fce58..6f143ef5f033 100644 --- a/packages/core/test/git.test.ts +++ b/packages/core/test/git.test.ts @@ -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 diff --git a/services/www/src/docs/content/snapshots.mdx b/services/www/src/docs/content/snapshots.mdx index 88bca613bcb7..c9119141f11d 100644 --- a/services/www/src/docs/content/snapshots.mdx +++ b/services/www/src/docs/content/snapshots.mdx @@ -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 |