fix(snapshot): avoid ENAMETOOLONG and improve staging perf via stdin pathspecs (#22560)
This commit is contained in:
@@ -90,12 +90,19 @@ export namespace Snapshot {
|
|||||||
|
|
||||||
const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
|
const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
|
||||||
|
|
||||||
|
const enc = new TextEncoder()
|
||||||
|
const feed = (list: string[]) => Stream.make(enc.encode(list.join("\0") + "\0"))
|
||||||
|
|
||||||
const git = Effect.fnUntraced(
|
const git = Effect.fnUntraced(
|
||||||
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
|
function* (
|
||||||
|
cmd: string[],
|
||||||
|
opts?: { cwd?: string; env?: Record<string, string>; stdin?: ChildProcess.CommandInput },
|
||||||
|
) {
|
||||||
const proc = ChildProcess.make("git", cmd, {
|
const proc = ChildProcess.make("git", cmd, {
|
||||||
cwd: opts?.cwd,
|
cwd: opts?.cwd,
|
||||||
env: opts?.env,
|
env: opts?.env,
|
||||||
extendEnv: true,
|
extendEnv: true,
|
||||||
|
stdin: opts?.stdin,
|
||||||
})
|
})
|
||||||
const handle = yield* spawner.spawn(proc)
|
const handle = yield* spawner.spawn(proc)
|
||||||
const [text, stderr] = yield* Effect.all(
|
const [text, stderr] = yield* Effect.all(
|
||||||
@@ -115,6 +122,59 @@ export namespace Snapshot {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const ignore = Effect.fnUntraced(function* (files: string[]) {
|
||||||
|
if (!files.length) return new Set<string>()
|
||||||
|
const check = yield* git(
|
||||||
|
[
|
||||||
|
...quote,
|
||||||
|
"--git-dir",
|
||||||
|
path.join(state.worktree, ".git"),
|
||||||
|
"--work-tree",
|
||||||
|
state.worktree,
|
||||||
|
"check-ignore",
|
||||||
|
"--no-index",
|
||||||
|
"--stdin",
|
||||||
|
"-z",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: state.directory,
|
||||||
|
stdin: feed(files),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (check.code !== 0 && check.code !== 1) return new Set<string>()
|
||||||
|
return new Set(check.text.split("\0").filter(Boolean))
|
||||||
|
})
|
||||||
|
|
||||||
|
const drop = Effect.fnUntraced(function* (files: string[]) {
|
||||||
|
if (!files.length) return
|
||||||
|
yield* git(
|
||||||
|
[
|
||||||
|
...cfg,
|
||||||
|
...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: state.directory,
|
||||||
|
stdin: feed(files),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const stage = Effect.fnUntraced(function* (files: string[]) {
|
||||||
|
if (!files.length) return
|
||||||
|
const result = yield* git(
|
||||||
|
[...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])],
|
||||||
|
{
|
||||||
|
cwd: state.directory,
|
||||||
|
stdin: feed(files),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (result.code === 0) return
|
||||||
|
log.warn("failed to add snapshot files", {
|
||||||
|
exitCode: result.code,
|
||||||
|
stderr: result.stderr,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
|
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
|
||||||
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
|
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
|
||||||
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
|
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
|
||||||
@@ -176,60 +236,41 @@ export namespace Snapshot {
|
|||||||
const all = Array.from(new Set([...tracked, ...untracked]))
|
const all = Array.from(new Set([...tracked, ...untracked]))
|
||||||
if (!all.length) return
|
if (!all.length) return
|
||||||
|
|
||||||
// Filter out files that are now gitignored even if previously tracked
|
// Resolve source-repo ignore rules against the exact candidate set.
|
||||||
// Files may have been tracked before being gitignored, so we need to check
|
// --no-index keeps this pattern-based even when a path is already tracked.
|
||||||
// against the source project's current gitignore rules
|
const ignored = yield* ignore(all)
|
||||||
// Use --no-index to check purely against patterns (ignoring whether file is tracked)
|
|
||||||
const checkArgs = [
|
|
||||||
...quote,
|
|
||||||
"--git-dir",
|
|
||||||
path.join(state.worktree, ".git"),
|
|
||||||
"--work-tree",
|
|
||||||
state.worktree,
|
|
||||||
"check-ignore",
|
|
||||||
"--no-index",
|
|
||||||
"--",
|
|
||||||
...all,
|
|
||||||
]
|
|
||||||
const check = yield* git(checkArgs, { cwd: state.directory })
|
|
||||||
const ignored =
|
|
||||||
check.code === 0 ? new Set(check.text.trim().split("\n").filter(Boolean)) : new Set<string>()
|
|
||||||
const filtered = all.filter((item) => !ignored.has(item))
|
|
||||||
|
|
||||||
// Remove newly-ignored files from snapshot index to prevent re-adding
|
// Remove newly-ignored files from snapshot index to prevent re-adding
|
||||||
if (ignored.size > 0) {
|
if (ignored.size > 0) {
|
||||||
const ignoredFiles = Array.from(ignored)
|
const ignoredFiles = Array.from(ignored)
|
||||||
log.info("removing gitignored files from snapshot", { count: ignoredFiles.length })
|
log.info("removing gitignored files from snapshot", { count: ignoredFiles.length })
|
||||||
yield* git([...cfg, ...args(["rm", "--cached", "-f", "--", ...ignoredFiles])], {
|
yield* drop(ignoredFiles)
|
||||||
cwd: state.directory,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!filtered.length) return
|
const allow = all.filter((item) => !ignored.has(item))
|
||||||
|
if (!allow.length) return
|
||||||
|
|
||||||
const large = (yield* Effect.all(
|
const large = new Set(
|
||||||
filtered.map((item) =>
|
(yield* Effect.all(
|
||||||
fs
|
allow.map((item) =>
|
||||||
.stat(path.join(state.directory, item))
|
fs
|
||||||
.pipe(Effect.catch(() => Effect.void))
|
.stat(path.join(state.directory, item))
|
||||||
.pipe(
|
.pipe(Effect.catch(() => Effect.void))
|
||||||
Effect.map((stat) => {
|
.pipe(
|
||||||
if (!stat || stat.type !== "File") return
|
Effect.map((stat) => {
|
||||||
const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size
|
if (!stat || stat.type !== "File") return
|
||||||
return size > limit ? item : undefined
|
const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size
|
||||||
}),
|
return size > limit ? item : undefined
|
||||||
),
|
}),
|
||||||
),
|
),
|
||||||
{ concurrency: 8 },
|
),
|
||||||
)).filter((item): item is string => Boolean(item))
|
{ concurrency: 8 },
|
||||||
yield* sync(large)
|
)).filter((item): item is string => Boolean(item)),
|
||||||
const result = yield* git([...cfg, ...args(["add", "--sparse", "."])], { cwd: state.directory })
|
)
|
||||||
if (result.code !== 0) {
|
const block = new Set(untracked.filter((item) => large.has(item)))
|
||||||
log.warn("failed to add snapshot files", {
|
yield* sync(Array.from(block))
|
||||||
exitCode: result.code,
|
// Stage only the allowed candidate paths so snapshot updates stay scoped.
|
||||||
stderr: result.stderr,
|
yield* stage(allow.filter((item) => !block.has(item)))
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const cleanup = Effect.fnUntraced(function* () {
|
const cleanup = Effect.fnUntraced(function* () {
|
||||||
@@ -295,33 +336,14 @@ export namespace Snapshot {
|
|||||||
.map((x) => x.trim())
|
.map((x) => x.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
||||||
// Filter out files that are now gitignored
|
// Hide ignored-file removals from the user-facing patch output.
|
||||||
if (files.length > 0) {
|
const ignored = yield* ignore(files)
|
||||||
const checkArgs = [
|
|
||||||
...quote,
|
|
||||||
"--git-dir",
|
|
||||||
path.join(state.worktree, ".git"),
|
|
||||||
"--work-tree",
|
|
||||||
state.worktree,
|
|
||||||
"check-ignore",
|
|
||||||
"--no-index",
|
|
||||||
"--",
|
|
||||||
...files,
|
|
||||||
]
|
|
||||||
const check = yield* git(checkArgs, { cwd: state.directory })
|
|
||||||
if (check.code === 0) {
|
|
||||||
const ignored = new Set(check.text.trim().split("\n").filter(Boolean))
|
|
||||||
const filtered = files.filter((item) => !ignored.has(item))
|
|
||||||
return {
|
|
||||||
hash,
|
|
||||||
files: filtered.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hash,
|
hash,
|
||||||
files: files.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
files: files
|
||||||
|
.filter((item) => !ignored.has(item))
|
||||||
|
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -672,27 +694,12 @@ export namespace Snapshot {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
// Filter out files that are now gitignored
|
// Hide ignored-file removals from the user-facing diff output.
|
||||||
if (rows.length > 0) {
|
const ignored = yield* ignore(rows.map((r) => r.file))
|
||||||
const files = rows.map((r) => r.file)
|
if (ignored.size > 0) {
|
||||||
const checkArgs = [
|
const filtered = rows.filter((r) => !ignored.has(r.file))
|
||||||
...quote,
|
rows.length = 0
|
||||||
"--git-dir",
|
rows.push(...filtered)
|
||||||
path.join(state.worktree, ".git"),
|
|
||||||
"--work-tree",
|
|
||||||
state.worktree,
|
|
||||||
"check-ignore",
|
|
||||||
"--no-index",
|
|
||||||
"--",
|
|
||||||
...files,
|
|
||||||
]
|
|
||||||
const check = yield* git(checkArgs, { cwd: state.directory })
|
|
||||||
if (check.code === 0) {
|
|
||||||
const ignored = new Set(check.text.trim().split("\n").filter(Boolean))
|
|
||||||
const filtered = rows.filter((r) => !ignored.has(r.file))
|
|
||||||
rows.length = 0
|
|
||||||
rows.push(...filtered)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const step = 100
|
const step = 100
|
||||||
|
|||||||
Reference in New Issue
Block a user