diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index fd85564a7dadd..4d442d7194a91 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -193,6 +193,7 @@ it.effect("checkpoint capture skips untracked nested repositories without a comm `160000 commit ${nestedHead}\tcommitted\n`, ); assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex); + assert.isTrue(yield* driver.checkpoints.restoreCheckpoint({ cwd, checkpointRef })); assert.strictEqual( yield* fileSystem.readFileString(path.join(cwd, nested, "private.txt")), "nested\n", @@ -1113,6 +1114,8 @@ it.effect("restores empty checkpoints without changing paths outside the workspa yield* fileSystem.writeFileString(path.join(cwd, "ignored.txt"), "keep\n"); yield* fileSystem.makeDirectory(path.join(cwd, "untracked")); yield* fileSystem.writeFileString(path.join(cwd, "untracked", "file.txt"), "remove\n"); + yield* fileSystem.writeFileString(path.join(cwd, "untracked", ".gitignore"), "hidden.txt\n"); + yield* fileSystem.writeFileString(path.join(cwd, "untracked", "hidden.txt"), "remove\n"); assert.isTrue( yield* driver.checkpoints.restoreCheckpoint({ cwd, checkpointRef, fallbackToHead: false }), ); @@ -1130,6 +1133,43 @@ it.effect("restores empty checkpoints without changing paths outside the workspa }); assert.strictEqual(staged.stdout.trim(), "outside.txt"); } + yield* fileSystem.writeFileString(path.join(cwd, "checkpoint.txt"), "checkpoint\n"); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + yield* fileSystem.writeFileString(path.join(cwd, "checkpoint.txt"), "later\n"); + let deepest = cwd; + for (let depth = 0; depth < 4; depth++) { + yield* fileSystem.writeFileString(path.join(deepest, ".gitignore"), "d/.gitignore\n"); + deepest = path.join(deepest, "d"); + yield* fileSystem.makeDirectory(deepest); + } + yield* fileSystem.writeFileString(path.join(deepest, ".gitignore"), "hidden.txt\n"); + yield* fileSystem.writeFileString(path.join(deepest, "hidden.txt"), "remove\n"); + const incomplete = yield* driver.checkpoints + .restoreCheckpoint({ cwd, checkpointRef, fallbackToHead: false }) + .pipe(Effect.flip); + assert.strictEqual(incomplete._tag, "VcsProcessExitError"); + assert.strictEqual( + yield* fileSystem.readFileString(path.join(cwd, "checkpoint.txt")), + "checkpoint\n", + ); + assert.strictEqual( + (yield* driver.execute({ + operation: "test", + cwd, + args: ["diff", "--cached", "--name-only", "--", "."], + })).stdout, + "", + ); + if (nested) { + assert.strictEqual( + (yield* driver.execute({ + operation: "test", + cwd: root, + args: ["diff", "--cached", "--name-only"], + })).stdout.trim(), + "outside.txt", + ); + } } }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index d3ecfcfa95d73..569eda70cd7d8 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -1339,13 +1339,52 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }), ), ); + const resetIndex = Effect.gen(function* () { + // On Windows clean can remove './' and exit 0. + yield* recreateWorkspace; + yield* execute({ + operation, + cwd: input.cwd, + args: [...longPaths, "reset", "--quiet", "--", "."], + }); + }); yield* recreateWorkspace; - const cleaned = yield* execute({ + const clean = execute({ operation, cwd: input.cwd, args: [...longPaths, "clean", "-fd", "--", "."], + env: { LC_ALL: "C" }, allowNonZeroExit: true, }); + let cleaned = yield* clean; + // Removing a new .gitignore exposes the files it hid; clean again until nothing is left. + for ( + let pass = 1; + pass < 4 && cleaned.exitCode === 0 && /^Removing /m.test(cleaned.stdout); + pass++ + ) { + yield* recreateWorkspace; + cleaned = yield* clean; + } + if (cleaned.exitCode === 0 && /^Removing /m.test(cleaned.stdout)) { + yield* recreateWorkspace; + const remaining = yield* execute({ + operation, + cwd: input.cwd, + args: [...longPaths, "clean", "-dn", "--", "."], + env: { LC_ALL: "C" }, + }); + if (/^Would remove /m.test(remaining.stdout)) { + yield* resetIndex; + return yield* new VcsProcessExitError({ + operation, + command: "git clean", + cwd: input.cwd, + exitCode: 0, + detail: "Could not clean the checkpoint workspace: untracked files remain.", + }); + } + } if (cleaned.exitCode !== 0) { // Git can remove every child, then fail trying to remove './' itself. const emptiedWorkspace = @@ -1355,7 +1394,8 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( Effect.map((entries) => entries.length === 0), Effect.orElseSucceed(() => false), )); - if (!emptiedWorkspace) + if (!emptiedWorkspace) { + yield* resetIndex; return yield* new VcsProcessExitError({ operation, command: "git clean", @@ -1363,15 +1403,9 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( exitCode: cleaned.exitCode, detail: cleaned.stderr.trim() || "Could not clean the checkpoint workspace.", }); + } } - // On Windows the same clean can remove './' and exit 0. - yield* recreateWorkspace; - - yield* execute({ - operation, - cwd: input.cwd, - args: [...longPaths, "reset", "--quiet", "--", "."], - }); + yield* resetIndex; return true; }),