| ... | @@ -65,6 +65,243 @@ runVcsDriverContractSuite<GitVcsDriver.GitVcsDriver, GitContractError>({ | ... | @@ -65,6 +65,243 @@ runVcsDriverContractSuite<GitVcsDriver.GitVcsDriver, GitContractError>({ |
| 65 | }, | 65 | }, |
| 66 | }); | 66 | }); |
| 67 | | 67 | |
| | 68 | const makeCheckpointFixture = Effect.fn("makeCheckpointFixture")(function* ( |
| | 69 | driver: Effect.Success<ReturnType<typeof GitVcsDriver.makeVcsDriverShape>>, |
| | 70 | cwd: string, |
| | 71 | ) { |
| | 72 | const fileSystem = yield* FileSystem.FileSystem; |
| | 73 | const path = yield* Path.Path; |
| | 74 | const git = (args: ReadonlyArray<string>) => |
| | 75 | driver.execute({ operation: "checkpoint-test", cwd, args }); |
| | 76 | yield* git(["init"]); |
| | 77 | yield* git(["config", "user.name", "Test"]); |
| | 78 | yield* git(["config", "user.email", "test@test.com"]); |
| | 79 | yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "initial\n"); |
| | 80 | yield* git(["add", "."]); |
| | 81 | yield* git(["commit", "-m", "initial"]); |
| | 82 | const checkpointRef = CheckpointRef.make("refs/t3/checkpoints/test"); |
| | 83 | yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "staged\n"); |
| | 84 | yield* git(["add", "."]); |
| | 85 | yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "unstaged\n"); |
| | 86 | return { git, checkpointRef }; |
| | 87 | }); |
| | 88 | |
| | 89 | it.effect("checkpoint capture does not rerun clean filters for unchanged indexed files", () => |
| | 90 | Effect.gen(function* () { |
| | 91 | const fileSystem = yield* FileSystem.FileSystem; |
| | 92 | const path = yield* Path.Path; |
| | 93 | const driver = yield* GitVcsDriver.makeVcsDriverShape(); |
| | 94 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-cache-" }); |
| | 95 | const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); |
| | 96 | yield* fileSystem.writeFileString( |
| | 97 | path.join(cwd, ".gitattributes"), |
| | 98 | "stable.txt filter=probe\n", |
| | 99 | ); |
| | 100 | yield* fileSystem.writeFileString(path.join(cwd, "stable.txt"), "unchanged\n"); |
| | 101 | yield* fileSystem.writeFileString( |
| | 102 | path.join(cwd, ".git", "filter.cjs"), |
| | 103 | 'require("node:fs").appendFileSync(".git/filter-runs", "read\\n"); process.stdin.pipe(process.stdout);', |
| | 104 | ); |
| | 105 | yield* git(["config", "filter.probe.clean", "node .git/filter.cjs"]); |
| | 106 | yield* fileSystem.utimes(path.join(cwd, "stable.txt"), 1_700_000_000, 1_700_000_000); |
| | 107 | yield* git(["add", "."]); |
| | 108 | yield* git(["commit", "-m", "record stable file"]); |
| | 109 | yield* fileSystem.writeFileString(path.join(cwd, ".git", "filter-runs"), ""); |
| | 110 | yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "changed\n"); |
| | 111 | const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); |
| | 112 | |
| | 113 | yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); |
| | 114 | |
| | 115 | assert.strictEqual(yield* fileSystem.readFileString(path.join(cwd, ".git", "filter-runs")), ""); |
| | 116 | assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "changed\n"); |
| | 117 | assert.strictEqual((yield* git(["show", `${checkpointRef}:stable.txt`])).stdout, "unchanged\n"); |
| | 118 | assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex); |
| | 119 | }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), |
| | 120 | ); |
| | 121 | |
| | 122 | for (const timestamp of [1_700_000_000, 1_700_000_000.9999]) { |
| | 123 | it.effect( |
| | 124 | `checkpoint capture preserves same-size edits with racy index timestamps (${timestamp})`, |
| | 125 | () => |
| | 126 | Effect.gen(function* () { |
| | 127 | const fileSystem = yield* FileSystem.FileSystem; |
| | 128 | const path = yield* Path.Path; |
| | 129 | const driver = yield* GitVcsDriver.makeVcsDriverShape(); |
| | 130 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-racy-" }); |
| | 131 | const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); |
| | 132 | const filePath = path.join(cwd, "file.txt"); |
| | 133 | const indexPath = path.join(cwd, ".git", "index"); |
| | 134 | yield* git(["config", "core.trustctime", "false"]); |
| | 135 | yield* fileSystem.writeFileString(filePath, "before\n"); |
| | 136 | yield* fileSystem.utimes(filePath, timestamp, timestamp); |
| | 137 | yield* git(["add", "file.txt"]); |
| | 138 | yield* git(["commit", "-m", "record racy file"]); |
| | 139 | yield* fileSystem.utimes(indexPath, timestamp, timestamp); |
| | 140 | const originalIndex = yield* fileSystem.readFile(indexPath); |
| | 141 | const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime; |
| | 142 | yield* fileSystem.writeFileString(filePath, "after!\n"); |
| | 143 | yield* fileSystem.utimes(filePath, timestamp, timestamp); |
| | 144 | |
| | 145 | yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); |
| | 146 | |
| | 147 | assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "after!\n"); |
| | 148 | assert.deepEqual(yield* fileSystem.readFile(indexPath), originalIndex); |
| | 149 | assert.deepEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime); |
| | 150 | }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), |
| | 151 | ); |
| | 152 | } |
| | 153 | |
| | 154 | it.effect("checkpoint capture preserves racy edits made after resetting the index", () => |
| | 155 | Effect.gen(function* () { |
| | 156 | const fileSystem = yield* FileSystem.FileSystem; |
| | 157 | const path = yield* Path.Path; |
| | 158 | const liveProcess = yield* VcsProcess.VcsProcess; |
| | 159 | const driver = yield* GitVcsDriver.makeVcsDriverShape(); |
| | 160 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-racy-reset-" }); |
| | 161 | const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); |
| | 162 | const racyPath = path.join(cwd, "racy.txt"); |
| | 163 | const indexPath = path.join(cwd, ".git", "index"); |
| | 164 | const timestamp = 1_700_000_000; |
| | 165 | yield* git(["config", "core.trustctime", "false"]); |
| | 166 | yield* fileSystem.writeFileString(racyPath, "before\n"); |
| | 167 | yield* fileSystem.utimes(racyPath, timestamp, timestamp); |
| | 168 | yield* git(["add", "."]); |
| | 169 | yield* git(["commit", "-m", "record racy file"]); |
| | 170 | yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "staged\n"); |
| | 171 | yield* git(["add", "file.txt"]); |
| | 172 | yield* fileSystem.utimes(indexPath, timestamp, timestamp); |
| | 173 | const originalIndex = yield* fileSystem.readFile(indexPath); |
| | 174 | const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime; |
| | 175 | const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( |
| | 176 | Effect.provideService(VcsProcess.VcsProcess, { |
| | 177 | run: Effect.fn(function* (input: VcsProcess.VcsProcessInput) { |
| | 178 | const result = yield* liveProcess.run(input); |
| | 179 | if (input.args.includes("read-tree") && input.args.includes("--reset")) { |
| | 180 | yield* fileSystem.writeFileString(racyPath, "after!\n").pipe(Effect.orDie); |
| | 181 | yield* fileSystem.utimes(racyPath, timestamp, timestamp).pipe(Effect.orDie); |
| | 182 | } |
| | 183 | return result; |
| | 184 | }), |
| | 185 | }), |
| | 186 | ); |
| | 187 | |
| | 188 | yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); |
| | 189 | |
| | 190 | assert.strictEqual((yield* git(["show", `${checkpointRef}:racy.txt`])).stdout, "after!\n"); |
| | 191 | assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "staged\n"); |
| | 192 | assert.deepEqual(yield* fileSystem.readFile(indexPath), originalIndex); |
| | 193 | assert.deepEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime); |
| | 194 | }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), |
| | 195 | ); |
| | 196 | |
| | 197 | for (const nested of [false, true]) { |
| | 198 | for (const indexMode of ["normal", "flags", "split"] as const) { |
| | 199 | it.effect( |
| | 200 | `checkpoint index reuse preserves two turns (nested=${nested}, index=${indexMode})`, |
| | 201 | () => |
| | 202 | Effect.gen(function* () { |
| | 203 | const fileSystem = yield* FileSystem.FileSystem; |
| | 204 | const path = yield* Path.Path; |
| | 205 | const driver = yield* GitVcsDriver.makeVcsDriverShape(); |
| | 206 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-turns-" }); |
| | 207 | const { git } = yield* makeCheckpointFixture(driver, cwd); |
| | 208 | const write = (name: string, contents: string) => |
| | 209 | fileSystem.writeFileString(path.join(cwd, name), contents); |
| | 210 | yield* fileSystem.makeDirectory(path.join(cwd, "scope")); |
| | 211 | for (const name of [ |
| | 212 | "scope/staged", |
| | 213 | "scope/deleted", |
| | 214 | "scope/assumed", |
| | 215 | "scope/skipped", |
| | 216 | "outside", |
| | 217 | ]) { |
| | 218 | yield* write(name, "original\n"); |
| | 219 | } |
| | 220 | yield* git(["add", "."]); |
| | 221 | yield* git(["commit", "-m", "initial scoped files"]); |
| | 222 | yield* write("scope/staged", "staged\n"); |
| | 223 | yield* write("scope/new-deleted", "staged then deleted\n"); |
| | 224 | yield* write("outside", "staged outside\n"); |
| | 225 | yield* git(["add", "."]); |
| | 226 | if (indexMode === "flags") { |
| | 227 | yield* git(["update-index", "--assume-unchanged", "scope/assumed"]); |
| | 228 | yield* git(["update-index", "--skip-worktree", "scope/skipped"]); |
| | 229 | } |
| | 230 | if (indexMode === "split") { |
| | 231 | yield* git(["update-index", "--split-index"]); |
| | 232 | } |
| | 233 | const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); |
| | 234 | for (const name of ["scope/staged", "scope/assumed", "scope/skipped", "outside"]) { |
| | 235 | yield* write(name, "working\n"); |
| | 236 | } |
| | 237 | yield* write("scope/new", "first\n"); |
| | 238 | yield* fileSystem.remove(path.join(cwd, "scope/deleted")); |
| | 239 | yield* fileSystem.remove(path.join(cwd, "scope/new-deleted")); |
| | 240 | const captureCwd = nested ? path.join(cwd, "scope") : cwd; |
| | 241 | const first = CheckpointRef.make("refs/t3/checkpoints/turns/1"); |
| | 242 | const second = CheckpointRef.make("refs/t3/checkpoints/turns/2"); |
| | 243 | yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: first }); |
| | 244 | for (const name of ["scope/staged", "scope/assumed", "scope/skipped"]) { |
| | 245 | assert.strictEqual((yield* git(["show", `${first}:${name}`])).stdout, "working\n"); |
| | 246 | } |
| | 247 | assert.strictEqual( |
| | 248 | (yield* git(["show", `${first}:outside`])).stdout, |
| | 249 | nested ? "original\n" : "working\n", |
| | 250 | ); |
| | 251 | const files = (yield* git(["ls-tree", "-r", "--name-only", first])).stdout.split("\n"); |
| | 252 | assert.notInclude(files, "scope/deleted"); |
| | 253 | assert.notInclude(files, "scope/new-deleted"); |
| | 254 | assert.include(files, "scope/new"); |
| | 255 | |
| | 256 | yield* write("scope/staged", "second\n"); |
| | 257 | yield* fileSystem.remove(path.join(cwd, "scope/new")); |
| | 258 | yield* write("scope/second", "added in second turn\n"); |
| | 259 | yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: second }); |
| | 260 | assert.strictEqual( |
| | 261 | (yield* git(["diff", "--name-only", first, second])).stdout, |
| | 262 | "scope/new\nscope/second\nscope/staged\n", |
| | 263 | ); |
| | 264 | assert.strictEqual((yield* git(["show", `${second}:scope/staged`])).stdout, "second\n"); |
| | 265 | assert.strictEqual( |
| | 266 | (yield* git(["show", `${second}:scope/second`])).stdout, |
| | 267 | "added in second turn\n", |
| | 268 | ); |
| | 269 | assert.deepEqual( |
| | 270 | yield* fileSystem.readFile(path.join(cwd, ".git", "index")), |
| | 271 | originalIndex, |
| | 272 | ); |
| | 273 | }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), |
| | 274 | ); |
| | 275 | } |
| | 276 | } |
| | 277 | |
| | 278 | for (const indexState of ["missing", "invalid"] as const) { |
| | 279 | it.effect(`checkpoint capture falls back when the user index is ${indexState}`, () => |
| | 280 | Effect.gen(function* () { |
| | 281 | const fileSystem = yield* FileSystem.FileSystem; |
| | 282 | const path = yield* Path.Path; |
| | 283 | const driver = yield* GitVcsDriver.makeVcsDriverShape(); |
| | 284 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-index-" }); |
| | 285 | const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); |
| | 286 | const indexPath = path.join(cwd, ".git", "index"); |
| | 287 | if (indexState === "missing") { |
| | 288 | yield* fileSystem.remove(indexPath); |
| | 289 | } else { |
| | 290 | yield* fileSystem.writeFileString(indexPath, "invalid index"); |
| | 291 | } |
| | 292 | |
| | 293 | yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); |
| | 294 | |
| | 295 | assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n"); |
| | 296 | if (indexState === "missing") { |
| | 297 | assert.isFalse(yield* fileSystem.exists(indexPath)); |
| | 298 | } else { |
| | 299 | assert.strictEqual(yield* fileSystem.readFileString(indexPath), "invalid index"); |
| | 300 | } |
| | 301 | }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), |
| | 302 | ); |
| | 303 | } |
| | 304 | |
| 68 | it.effect("restores empty checkpoints without changing paths outside the workspace", () => | 305 | it.effect("restores empty checkpoints without changing paths outside the workspace", () => |
| 69 | Effect.gen(function* () { | 306 | Effect.gen(function* () { |
| 70 | const fileSystem = yield* FileSystem.FileSystem; | 307 | const fileSystem = yield* FileSystem.FileSystem; |