Skip to content

Commit ec602a3

Browse files
committed
fix(server): blank ignore lines no longer omit checkpoint files
1 parent 257e407 commit ec602a3

2 files changed

Lines changed: 146 additions & 38 deletions

File tree

‎apps/server/src/vcs/GitVcsDriver.test.ts‎

Lines changed: 115 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -367,43 +367,120 @@ it.effect("checkpoint capture refuses a truncated nested repository listing", ()
367367
}).pipe(Effect.scoped, Effect.provide(layerGitContract)),
368368
);
369369

370-
it.effect("checkpoint capture skips an ignored project while keeping its allowed sibling", () =>
371-
Effect.gen(function* () {
372-
const fs = yield* FileSystem.FileSystem;
373-
const path = yield* Path.Path;
374-
const driver = yield* GitVcsDriver.makeVcsDriverShape();
375-
const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-ignored-" });
376-
const { git } = yield* makeCheckpointFixture(driver, parent);
377-
yield* fs.writeFileString(
378-
path.join(parent, ".gitignore"),
379-
"*\n!.gitignore\n!file.txt\n!keep/\n!keep/**\n",
380-
);
381-
const ignoredCwd = path.join(parent, "ignored");
382-
const keepCwd = path.join(parent, "keep");
383-
yield* fs.makeDirectory(ignoredCwd);
384-
yield* fs.makeDirectory(keepCwd);
385-
yield* fs.writeFileString(path.join(ignoredCwd, "secret.txt"), "private\n");
386-
yield* fs.writeFileString(path.join(keepCwd, "new.txt"), "new\n");
387-
const originalIndex = yield* fs.readFile(path.join(parent, ".git", "index"));
388-
const ignoredRef = CheckpointRef.make("refs/t3/checkpoints/ignored");
389-
const keepRef = CheckpointRef.make("refs/t3/checkpoints/keep");
390-
391-
const ignored = yield* Effect.result(
392-
driver.checkpoints.captureCheckpoint({ cwd: ignoredCwd, checkpointRef: ignoredRef }),
393-
);
394-
yield* driver.checkpoints.captureCheckpoint({ cwd: keepCwd, checkpointRef: keepRef });
370+
it.effect.each([
371+
{ cwdName: ".", source: "root", patterns: "a.txt\r\n\r\nb.txt\r\n" },
372+
{ cwdName: ".", source: "root", patterns: " \n" },
373+
{ cwdName: ".", source: "info", patterns: "\r\n" },
374+
{ cwdName: ".", source: "global", patterns: "\r\n" },
375+
{ cwdName: "sub", source: "root", patterns: "\r\n" },
376+
{ cwdName: "sub", source: "workspace", patterns: "\r\n" },
377+
{ cwdName: "sub", source: "root", patterns: "sub/\n\r\n!sub/\n" },
378+
{ cwdName: "sub", source: "root", patterns: "sub/\n!sub/\n\r\n" },
379+
])(
380+
"checkpoint captures and restores untracked files with blank $source ignore patterns in $cwdName: $patterns",
381+
({ cwdName, source, patterns }) =>
382+
Effect.gen(function* () {
383+
const fs = yield* FileSystem.FileSystem;
384+
const path = yield* Path.Path;
385+
const driver = yield* GitVcsDriver.makeVcsDriverShape();
386+
const sandbox = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-blank-ignore-" });
387+
const repo = path.join(sandbox, "repo");
388+
yield* fs.makeDirectory(repo);
389+
const { git, checkpointRef } = yield* makeCheckpointFixture(driver, repo);
390+
const cwd = path.join(repo, cwdName);
391+
yield* fs.makeDirectory(cwd, { recursive: true });
392+
const prefix = cwdName === "." ? "" : `${cwdName}/`;
393+
yield* fs.writeFileString(path.join(cwd, "staged.txt"), "staged\n");
394+
yield* git(["add", `${prefix}staged.txt`]);
395+
yield* fs.writeFileString(path.join(cwd, "staged.txt"), "working\n");
396+
yield* fs.writeFileString(path.join(cwd, "notes.md"), "before turn\n");
397+
yield* fs.writeFileString(path.join(cwd, ".gitignore"), "ignored.txt\n");
398+
const ignoreFile =
399+
source === "info"
400+
? path.join(repo, ".git", "info", "exclude")
401+
: source === "global"
402+
? path.join(sandbox, "excludes")
403+
: path.join(source === "root" ? repo : cwd, ".gitignore");
404+
yield* fs.writeFileString(ignoreFile, `${patterns}ignored.txt\n`);
405+
if (source === "global") yield* git(["config", "core.excludesFile", ignoreFile]);
406+
yield* fs.writeFileString(path.join(cwd, "ignored.txt"), "private\n");
407+
const originalIndex = yield* fs.readFile(path.join(repo, ".git", "index"));
395408

396-
assert.strictEqual((yield* git(["show", `${keepRef}:keep/new.txt`])).stdout, "new\n");
397-
assert.strictEqual((yield* git(["ls-tree", "-r", keepRef, "--", "ignored"])).stdout, "");
398-
assert.isFalse(
399-
yield* driver.checkpoints.hasCheckpointRef({ cwd: parent, checkpointRef: ignoredRef }),
400-
);
401-
assert.deepEqual(yield* fs.readFile(path.join(parent, ".git", "index")), originalIndex);
402-
assert.strictEqual(ignored._tag, "Failure");
403-
if (ignored._tag === "Failure") {
404-
assert.strictEqual(ignored.failure._tag, "VcsUnsupportedOperationError");
405-
}
406-
}).pipe(Effect.scoped, Effect.provide(layerGitContract)),
409+
yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef });
410+
411+
assert.include(
412+
(yield* git(["ls-tree", "-r", "--name-only", checkpointRef])).stdout.split("\n"),
413+
`${prefix}notes.md`,
414+
);
415+
assert.strictEqual(
416+
(yield* git(["show", `${checkpointRef}:${prefix}notes.md`])).stdout,
417+
"before turn\n",
418+
);
419+
assert.strictEqual(
420+
(yield* git(["show", `${checkpointRef}:${prefix}staged.txt`])).stdout,
421+
"working\n",
422+
);
423+
yield* fs.writeFileString(path.join(cwd, "notes.md"), "after turn\n");
424+
yield* fs.writeFileString(path.join(cwd, "new.txt"), "created during turn\n");
425+
const afterRef = CheckpointRef.make("refs/t3/checkpoints/after");
426+
yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef: afterRef });
427+
assert.strictEqual(
428+
(yield* git(["show", `${afterRef}:${prefix}new.txt`])).stdout,
429+
"created during turn\n",
430+
);
431+
assert.strictEqual(
432+
(yield* git(["ls-tree", "-r", afterRef, "--", `${prefix}ignored.txt`])).stdout,
433+
"",
434+
);
435+
assert.deepEqual(yield* fs.readFile(path.join(repo, ".git", "index")), originalIndex);
436+
437+
assert.isTrue(yield* driver.checkpoints.restoreCheckpoint({ cwd, checkpointRef }));
438+
assert.strictEqual(yield* fs.readFileString(path.join(cwd, "notes.md")), "before turn\n");
439+
assert.strictEqual(yield* fs.readFileString(path.join(cwd, "staged.txt")), "working\n");
440+
assert.isFalse(yield* fs.exists(path.join(cwd, "new.txt")));
441+
assert.strictEqual(yield* fs.readFileString(path.join(cwd, "ignored.txt")), "private\n");
442+
}).pipe(Effect.scoped, Effect.provide(layerGitContract)),
443+
);
444+
445+
it.effect.each(["", "\r\n", " \n"])(
446+
"checkpoint capture skips an ignored project while keeping its allowed sibling with blank pattern %j",
447+
(blank) =>
448+
Effect.gen(function* () {
449+
const fs = yield* FileSystem.FileSystem;
450+
const path = yield* Path.Path;
451+
const driver = yield* GitVcsDriver.makeVcsDriverShape();
452+
const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-ignored-" });
453+
const { git } = yield* makeCheckpointFixture(driver, parent);
454+
yield* fs.writeFileString(
455+
path.join(parent, ".gitignore"),
456+
`*\n!.gitignore\n!file.txt\n!keep/\n!keep/**\n${blank}`,
457+
);
458+
const ignoredCwd = path.join(parent, "ignored");
459+
const keepCwd = path.join(parent, "keep");
460+
yield* fs.makeDirectory(ignoredCwd);
461+
yield* fs.makeDirectory(keepCwd);
462+
yield* fs.writeFileString(path.join(ignoredCwd, "secret.txt"), "private\n");
463+
yield* fs.writeFileString(path.join(keepCwd, "new.txt"), "new\n");
464+
const originalIndex = yield* fs.readFile(path.join(parent, ".git", "index"));
465+
const ignoredRef = CheckpointRef.make("refs/t3/checkpoints/ignored");
466+
const keepRef = CheckpointRef.make("refs/t3/checkpoints/keep");
467+
468+
const ignored = yield* Effect.result(
469+
driver.checkpoints.captureCheckpoint({ cwd: ignoredCwd, checkpointRef: ignoredRef }),
470+
);
471+
yield* driver.checkpoints.captureCheckpoint({ cwd: keepCwd, checkpointRef: keepRef });
472+
473+
assert.strictEqual((yield* git(["show", `${keepRef}:keep/new.txt`])).stdout, "new\n");
474+
assert.strictEqual((yield* git(["ls-tree", "-r", keepRef, "--", "ignored"])).stdout, "");
475+
assert.isFalse(
476+
yield* driver.checkpoints.hasCheckpointRef({ cwd: parent, checkpointRef: ignoredRef }),
477+
);
478+
assert.deepEqual(yield* fs.readFile(path.join(parent, ".git", "index")), originalIndex);
479+
assert.strictEqual(ignored._tag, "Failure");
480+
if (ignored._tag === "Failure") {
481+
assert.strictEqual(ignored.failure._tag, "VcsUnsupportedOperationError");
482+
}
483+
}).pipe(Effect.scoped, Effect.provide(layerGitContract)),
407484
);
408485

409486
it.effect.each([
@@ -433,7 +510,8 @@ it.effect.each([
433510
yield* git(["add", `sub/${file}`]);
434511
}
435512
const tracked = [...committed, ...staged];
436-
yield* fs.writeFileString(path.join(parent, ".gitignore"), "sub/\n");
513+
yield* fs.writeFileString(path.join(parent, ".gitignore"), "\r\nsub/\n\r\n");
514+
yield* fs.writeFileString(path.join(cwd, ".gitignore"), "\r\n");
437515
for (const file of tracked) {
438516
yield* fs.writeFileString(path.join(cwd, file), `${file} working\n`);
439517
}

‎apps/server/src/vcs/GitVcsDriver.ts‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1108,7 +1108,37 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
11081108
}
11091109
}
11101110

1111-
const workspaceIgnored = (yield* filterIgnoredPaths(input.cwd, ["."])).length === 0;
1111+
const ignoreMatch = yield* execute({
1112+
operation,
1113+
cwd: input.cwd,
1114+
args: [
1115+
...WORKSPACE_GIT_HARDENED_CONFIG_ARGS,
1116+
"check-ignore",
1117+
"--no-index",
1118+
"--verbose",
1119+
"-z",
1120+
"--stdin",
1121+
],
1122+
stdin: ".\0",
1123+
allowNonZeroExit: true,
1124+
timeoutMs: 20_000,
1125+
maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES,
1126+
outputMode: "error",
1127+
});
1128+
if (ignoreMatch.exitCode !== 0 && ignoreMatch.exitCode !== 1) {
1129+
return yield* new VcsProcessExitError({
1130+
operation,
1131+
command: "git check-ignore",
1132+
cwd: input.cwd,
1133+
exitCode: ignoreMatch.exitCode,
1134+
detail: ignoreMatch.stderr.trim() || "git check-ignore failed",
1135+
});
1136+
}
1137+
// Blank CRLF or spaces-only ignore lines can match '.' as an empty pattern.
1138+
// Verbose NUL output preserves that pattern and distinguishes negated matches.
1139+
const pattern = ignoreMatch.stdout.split("\0")[2];
1140+
const workspaceIgnored =
1141+
pattern !== undefined && pattern.length > 0 && !pattern.startsWith("!");
11121142
if (workspaceIgnored) {
11131143
// HEAD omits staged-only paths. Seed just this workspace's additions, keeping
11141144
// unrelated staged changes and the scratch index's sparse flags untouched.

0 commit comments

Comments
 (0)