| 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import * as NodePath from "node:path"; |
| 3 | |
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | import { it } from "@effect/vitest"; |
| 6 | import { ThreadId, type VcsError } from "@t3tools/contracts"; |
| 7 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as FileSystem from "effect/FileSystem"; |
| 10 | import * as Layer from "effect/Layer"; |
| 11 | import * as PlatformError from "effect/PlatformError"; |
| 12 | import * as Scope from "effect/Scope"; |
| 13 | import { describe, expect } from "vite-plus/test"; |
| 14 | |
| 15 | import { checkpointRefForThreadTurn } from "./Utils.ts"; |
| 16 | import { parseTurnDiffFilesFromNumstat } from "./Diffs.ts"; |
| 17 | import * as CheckpointStore from "./CheckpointStore.ts"; |
| 18 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 19 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 20 | import * as ServerConfig from "../config.ts"; |
| 21 | |
| 22 | const layerServerConfig = ServerConfig.ServerConfig.layerTest(process.cwd(), { |
| 23 | prefix: "t3-checkpoint-store-test-", |
| 24 | }); |
| 25 | const layerVcsProcessTest = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); |
| 26 | const layerVcsDriverTest = VcsDriverRegistry.layer.pipe(Layer.provide(layerVcsProcessTest)); |
| 27 | const layerCheckpointStoreTest = CheckpointStore.layer.pipe( |
| 28 | Layer.provideMerge(layerVcsDriverTest), |
| 29 | Layer.provideMerge(NodeServices.layer), |
| 30 | ); |
| 31 | const layerTest = layerCheckpointStoreTest.pipe( |
| 32 | Layer.provideMerge(layerVcsProcessTest), |
| 33 | Layer.provideMerge(layerVcsDriverTest), |
| 34 | Layer.provideMerge(layerServerConfig), |
| 35 | Layer.provideMerge(NodeServices.layer), |
| 36 | ); |
| 37 | |
| 38 | function makeTmpDir( |
| 39 | prefix = "checkpoint-store-test-", |
| 40 | ): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> { |
| 41 | return Effect.gen(function* () { |
| 42 | const fileSystem = yield* FileSystem.FileSystem; |
| 43 | return yield* fileSystem.makeTempDirectoryScoped({ prefix }); |
| 44 | }); |
| 45 | } |
| 46 | |
| 47 | function writeTextFile( |
| 48 | filePath: string, |
| 49 | contents: string, |
| 50 | ): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> { |
| 51 | return Effect.gen(function* () { |
| 52 | const fileSystem = yield* FileSystem.FileSystem; |
| 53 | yield* fileSystem.writeFileString(filePath, contents); |
| 54 | }); |
| 55 | } |
| 56 | |
| 57 | function git( |
| 58 | cwd: string, |
| 59 | args: ReadonlyArray<string>, |
| 60 | options: { readonly committedAt?: string; readonly allowNonZeroExit?: boolean } = {}, |
| 61 | ): Effect.Effect<string, VcsError, VcsProcess.VcsProcess> { |
| 62 | return Effect.gen(function* () { |
| 63 | const vcsProcess = yield* VcsProcess.VcsProcess; |
| 64 | const result = yield* vcsProcess.run({ |
| 65 | operation: "CheckpointStore.test.git", |
| 66 | command: "git", |
| 67 | cwd, |
| 68 | args, |
| 69 | timeoutMs: 10_000, |
| 70 | ...(options.committedAt === undefined |
| 71 | ? {} |
| 72 | : { env: { ...process.env, GIT_COMMITTER_DATE: options.committedAt } }), |
| 73 | ...(options.allowNonZeroExit === undefined |
| 74 | ? {} |
| 75 | : { allowNonZeroExit: options.allowNonZeroExit }), |
| 76 | }); |
| 77 | return result.stdout.trim(); |
| 78 | }); |
| 79 | } |
| 80 | |
| 81 | function initRepoWithCommit( |
| 82 | cwd: string, |
| 83 | ): Effect.Effect< |
| 84 | void, |
| 85 | VcsError | PlatformError.PlatformError, |
| 86 | VcsProcess.VcsProcess | FileSystem.FileSystem |
| 87 | > { |
| 88 | return Effect.gen(function* () { |
| 89 | yield* git(cwd, ["init"]); |
| 90 | yield* git(cwd, ["config", "user.email", "test@test.com"]); |
| 91 | yield* git(cwd, ["config", "user.name", "Test"]); |
| 92 | yield* writeTextFile(NodePath.join(cwd, "README.md"), "# test\n"); |
| 93 | yield* git(cwd, ["add", "."]); |
| 94 | yield* git(cwd, ["commit", "-m", "initial commit"]); |
| 95 | }); |
| 96 | } |
| 97 | |
| 98 | function buildLargeText(lineCount = 5_000): string { |
| 99 | return Array.from({ length: lineCount }, (_, index) => `line ${String(index).padStart(5, "0")}`) |
| 100 | .join("\n") |
| 101 | .concat("\n"); |
| 102 | } |
| 103 | |
| 104 | it.layer(layerTest)("CheckpointStore.layer", (it) => { |
| 105 | describe("isGitRepository", () => { |
| 106 | it.effect("returns false when no Git repository is detected", () => |
| 107 | Effect.gen(function* () { |
| 108 | const tmp = yield* makeTmpDir(); |
| 109 | const checkpointStore = yield* CheckpointStore.CheckpointStore; |
| 110 | |
| 111 | expect(yield* checkpointStore.isGitRepository(tmp)).toBe(false); |
| 112 | }), |
| 113 | ); |
| 114 | |
| 115 | it.effect("returns true when a Git repository is detected", () => |
| 116 | Effect.gen(function* () { |
| 117 | const tmp = yield* makeTmpDir(); |
| 118 | yield* initRepoWithCommit(tmp); |
| 119 | const checkpointStore = yield* CheckpointStore.CheckpointStore; |
| 120 | |
| 121 | expect(yield* checkpointStore.isGitRepository(tmp)).toBe(true); |
| 122 | }), |
| 123 | ); |
| 124 | }); |
| 125 | |
| 126 | it.effect("detects a nested workspace without its own .git entry", () => |
| 127 | Effect.gen(function* () { |
| 128 | const tmp = yield* makeTmpDir(); |
| 129 | yield* initRepoWithCommit(tmp); |
| 130 | const fileSystem = yield* FileSystem.FileSystem; |
| 131 | const nested = NodePath.join(tmp, "packages", "nested"); |
| 132 | yield* fileSystem.makeDirectory(nested, { recursive: true }); |
| 133 | const checkpointStore = yield* CheckpointStore.CheckpointStore; |
| 134 | expect(yield* checkpointStore.isGitRepository(nested)).toBe(true); |
| 135 | }), |
| 136 | ); |
| 137 | describe("diffCheckpoints", () => { |
| 138 | it.effect("returns full oversized checkpoint diffs without truncation", () => |
| 139 | Effect.gen(function* () { |
| 140 | const tmp = yield* makeTmpDir(); |
| 141 | yield* initRepoWithCommit(tmp); |
| 142 | const checkpointStore = yield* CheckpointStore.CheckpointStore; |
| 143 | const threadId = ThreadId.make("thread-checkpoint-store"); |
| 144 | const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); |
| 145 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); |
| 146 | |
| 147 | yield* checkpointStore.captureCheckpoint({ |
| 148 | cwd: tmp, |
| 149 | checkpointRef: fromCheckpointRef, |
| 150 | }); |
| 151 | yield* writeTextFile(NodePath.join(tmp, "README.md"), buildLargeText()); |
| 152 | yield* checkpointStore.captureCheckpoint({ |
| 153 | cwd: tmp, |
| 154 | checkpointRef: toCheckpointRef, |
| 155 | }); |
| 156 | |
| 157 | const diff = yield* checkpointStore.diffCheckpoints({ |
| 158 | cwd: tmp, |
| 159 | fromCheckpointRef, |
| 160 | toCheckpointRef, |
| 161 | ignoreWhitespace: true, |
| 162 | }); |
| 163 | |
| 164 | expect(diff).toContain("diff --git"); |
| 165 | expect(diff).not.toContain("[truncated]"); |
| 166 | expect(diff).toContain("+line 04999"); |
| 167 | }), |
| 168 | ); |
| 169 | |
| 170 | it.effect("keeps a/ and b/ patch prefixes when the repository disables them", () => |
| 171 | Effect.gen(function* () { |
| 172 | const tmp = yield* makeTmpDir(); |
| 173 | yield* initRepoWithCommit(tmp); |
| 174 | yield* git(tmp, ["config", "diff.noprefix", "true"]); |
| 175 | const checkpointStore = yield* CheckpointStore.CheckpointStore; |
| 176 | const threadId = ThreadId.make("thread-checkpoint-store-noprefix"); |
| 177 | const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); |
| 178 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); |
| 179 | |
| 180 | yield* checkpointStore.captureCheckpoint({ |
| 181 | cwd: tmp, |
| 182 | checkpointRef: fromCheckpointRef, |
| 183 | }); |
| 184 | yield* writeTextFile(NodePath.join(tmp, "README.md"), "# changed\n"); |
| 185 | yield* checkpointStore.captureCheckpoint({ |
| 186 | cwd: tmp, |
| 187 | checkpointRef: toCheckpointRef, |
| 188 | }); |
| 189 | |
| 190 | const diff = yield* checkpointStore.diffCheckpoints({ |
| 191 | cwd: tmp, |
| 192 | fromCheckpointRef, |
| 193 | toCheckpointRef, |
| 194 | ignoreWhitespace: false, |
| 195 | }); |
| 196 | |
| 197 | expect(diff).toContain("diff --git a/README.md b/README.md"); |
| 198 | }), |
| 199 | ); |
| 200 | |
| 201 | it.effect("can hide indentation churn when changes wrap existing lines", () => |
| 202 | Effect.gen(function* () { |
| 203 | const tmp = yield* makeTmpDir(); |
| 204 | yield* initRepoWithCommit(tmp); |
| 205 | const checkpointStore = yield* CheckpointStore.CheckpointStore; |
| 206 | const threadId = ThreadId.make("thread-checkpoint-store-whitespace"); |
| 207 | const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); |
| 208 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); |
| 209 | |
| 210 | const componentPath = NodePath.join(tmp, "Component.tsx"); |
| 211 | yield* writeTextFile( |
| 212 | componentPath, |
| 213 | [ |
| 214 | "export function View() {", |
| 215 | " return (", |
| 216 | " <section>", |
| 217 | " <h1>Title</h1>", |
| 218 | " <p>Body</p>", |
| 219 | " </section>", |
| 220 | " );", |
| 221 | "}", |
| 222 | "", |
| 223 | ].join("\n"), |
| 224 | ); |
| 225 | yield* checkpointStore.captureCheckpoint({ |
| 226 | cwd: tmp, |
| 227 | checkpointRef: fromCheckpointRef, |
| 228 | }); |
| 229 | yield* writeTextFile( |
| 230 | componentPath, |
| 231 | [ |
| 232 | "export function View() {", |
| 233 | " return (", |
| 234 | " <section>", |
| 235 | " {isReady ? (", |
| 236 | " <div>", |
| 237 | " <h1>Title</h1>", |
| 238 | " <p>Body</p>", |
| 239 | " </div>", |
| 240 | " ) : null}", |
| 241 | " </section>", |
| 242 | " );", |
| 243 | "}", |
| 244 | "", |
| 245 | ].join("\n"), |
| 246 | ); |
| 247 | yield* checkpointStore.captureCheckpoint({ |
| 248 | cwd: tmp, |
| 249 | checkpointRef: toCheckpointRef, |
| 250 | }); |
| 251 | |
| 252 | const normalDiff = yield* checkpointStore.diffCheckpoints({ |
| 253 | cwd: tmp, |
| 254 | fromCheckpointRef, |
| 255 | toCheckpointRef, |
| 256 | ignoreWhitespace: false, |
| 257 | }); |
| 258 | const whitespaceIgnoredDiff = yield* checkpointStore.diffCheckpoints({ |
| 259 | cwd: tmp, |
| 260 | fromCheckpointRef, |
| 261 | toCheckpointRef, |
| 262 | ignoreWhitespace: true, |
| 263 | }); |
| 264 | |
| 265 | expect(normalDiff).toContain("diff --git"); |
| 266 | expect(normalDiff).toContain("- <h1>Title</h1>"); |
| 267 | expect(normalDiff).toContain("+ <h1>Title</h1>"); |
| 268 | expect(whitespaceIgnoredDiff).toContain("diff --git"); |
| 269 | expect(whitespaceIgnoredDiff).toContain("+ {isReady ? ("); |
| 270 | expect(whitespaceIgnoredDiff).toContain("+ <div>"); |
| 271 | expect(whitespaceIgnoredDiff).not.toContain("- <h1>Title</h1>"); |
| 272 | expect(whitespaceIgnoredDiff).not.toContain("+ <h1>Title</h1>"); |
| 273 | |
| 274 | for (const ignoreWhitespace of [false, true]) { |
| 275 | const numstat = yield* checkpointStore.diffCheckpoints({ |
| 276 | cwd: tmp, |
| 277 | fromCheckpointRef, |
| 278 | toCheckpointRef, |
| 279 | ignoreWhitespace, |
| 280 | format: "numstat", |
| 281 | }); |
| 282 | expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ |
| 283 | { |
| 284 | path: "Component.tsx", |
| 285 | additions: ignoreWhitespace ? 4 : 6, |
| 286 | deletions: ignoreWhitespace ? 0 : 2, |
| 287 | }, |
| 288 | ]); |
| 289 | } |
| 290 | }), |
| 291 | ); |
| 292 | }); |
| 293 | |
| 294 | describe("checkpoint file summaries", () => { |
| 295 | it.effect("counts changes whose full patch exceeds the output limit", () => |
| 296 | Effect.gen(function* () { |
| 297 | const tmp = yield* makeTmpDir(); |
| 298 | yield* initRepoWithCommit(tmp); |
| 299 | const checkpointStore = yield* CheckpointStore.CheckpointStore; |
| 300 | const threadId = ThreadId.make("large-checkpoint-summary"); |
| 301 | const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); |
| 302 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); |
| 303 | const filePath = NodePath.join(tmp, "README.md"); |
| 304 | const lineCount = 20_000; |
| 305 | yield* writeTextFile(filePath, `${"before".repeat(50)}\n`.repeat(lineCount)); |
| 306 | yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: fromCheckpointRef }); |
| 307 | yield* writeTextFile(filePath, `${"after".repeat(60)}\n`.repeat(lineCount)); |
| 308 | yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: toCheckpointRef }); |
| 309 | |
| 310 | const numstat = yield* checkpointStore.diffCheckpoints({ |
| 311 | cwd: tmp, |
| 312 | fromCheckpointRef, |
| 313 | toCheckpointRef, |
| 314 | ignoreWhitespace: false, |
| 315 | format: "numstat", |
| 316 | }); |
| 317 | |
| 318 | expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ |
| 319 | { path: "README.md", additions: lineCount, deletions: lineCount }, |
| 320 | ]); |
| 321 | expect(numstat.length).toBeLessThan(100); |
| 322 | }), |
| 323 | ); |
| 324 | |
| 325 | it.effect("preserves file paths and turn ranges without changing the user index", () => |
| 326 | Effect.gen(function* () { |
| 327 | const tmp = yield* makeTmpDir(); |
| 328 | yield* initRepoWithCommit(tmp); |
| 329 | yield* git(tmp, ["config", "diff.renames", "copies"]); |
| 330 | const fileSystem = yield* FileSystem.FileSystem; |
| 331 | const checkpointStore = yield* CheckpointStore.CheckpointStore; |
| 332 | const threadId = ThreadId.make("checkpoint-summary-paths"); |
| 333 | const baseline = checkpointRefForThreadTurn(threadId, 0); |
| 334 | const firstTurn = checkpointRefForThreadTurn(threadId, 1); |
| 335 | const secondTurn = checkpointRefForThreadTurn(threadId, 2); |
| 336 | const copiedText = Array.from({ length: 20 }, (_, index) => `copy line ${index}\n`).join( |
| 337 | "", |
| 338 | ); |
| 339 | const platform = yield* HostProcess.Platform; |
| 340 | const renamedPath = platform === "win32" ? "renamed café.txt" : "renamed\tcafé\nname.txt"; |
| 341 | const addedPath = platform === "win32" ? "new café.txt" : "new\tfile\n名.txt"; |
| 342 | for (const [path, contents] of Object.entries({ |
| 343 | "copy-source.txt": copiedText, |
| 344 | "deleted.txt": "delete me\n", |
| 345 | "rename-old.txt": "before\nkeep one\nkeep two\nkeep three\n", |
| 346 | "binary.bin": "\0before", |
| 347 | })) { |
| 348 | yield* writeTextFile(NodePath.join(tmp, path), contents); |
| 349 | } |
| 350 | yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: baseline }); |
| 351 | |
| 352 | yield* fileSystem.rename( |
| 353 | NodePath.join(tmp, "rename-old.txt"), |
| 354 | NodePath.join(tmp, renamedPath), |
| 355 | ); |
| 356 | yield* fileSystem.remove(NodePath.join(tmp, "deleted.txt")); |
| 357 | for (const [path, contents] of Object.entries({ |
| 358 | "copy-source.txt": `${copiedText}one more\n`, |
| 359 | "copied.txt": copiedText, |
| 360 | [renamedPath]: "after\nkeep one\nkeep two\nkeep three\n", |
| 361 | "binary.bin": "\0after", |
| 362 | "empty.txt": "", |
| 363 | [addedPath]: "first\nsecond\n", |
| 364 | })) { |
| 365 | yield* writeTextFile(NodePath.join(tmp, path), contents); |
| 366 | } |
| 367 | yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: firstTurn }); |
| 368 | const userIndex = yield* fileSystem.readFile(NodePath.join(tmp, ".git/index")); |
| 369 | const input = { |
| 370 | cwd: tmp, |
| 371 | fromCheckpointRef: baseline, |
| 372 | toCheckpointRef: firstTurn, |
| 373 | ignoreWhitespace: false, |
| 374 | format: "numstat" as const, |
| 375 | }; |
| 376 | const firstSummary = parseTurnDiffFilesFromNumstat( |
| 377 | yield* checkpointStore.diffCheckpoints(input), |
| 378 | ); |
| 379 | const expectedFiles = [ |
| 380 | { path: "binary.bin", additions: 0, deletions: 0 }, |
| 381 | { path: "copied.txt", previousPath: "copy-source.txt", additions: 0, deletions: 0 }, |
| 382 | { path: "copy-source.txt", additions: 1, deletions: 0 }, |
| 383 | { path: "deleted.txt", additions: 0, deletions: 1 }, |
| 384 | { path: "empty.txt", additions: 0, deletions: 0 }, |
| 385 | { path: addedPath, additions: 2, deletions: 0 }, |
| 386 | { path: renamedPath, previousPath: "rename-old.txt", additions: 1, deletions: 1 }, |
| 387 | ].toSorted((left, right) => left.path.localeCompare(right.path)); |
| 388 | expect(firstSummary).toEqual(expectedFiles); |
| 389 | |
| 390 | yield* fileSystem.remove(NodePath.join(tmp, "empty.txt")); |
| 391 | yield* writeTextFile(NodePath.join(tmp, "copy-source.txt"), "replacement\n"); |
| 392 | yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: secondTurn }); |
| 393 | const secondSummary = parseTurnDiffFilesFromNumstat( |
| 394 | yield* checkpointStore.diffCheckpoints({ |
| 395 | ...input, |
| 396 | fromCheckpointRef: firstTurn, |
| 397 | toCheckpointRef: secondTurn, |
| 398 | }), |
| 399 | ); |
| 400 | expect(secondSummary).toEqual([ |
| 401 | { path: "copy-source.txt", additions: 1, deletions: 21 }, |
| 402 | { path: "empty.txt", additions: 0, deletions: 0 }, |
| 403 | ]); |
| 404 | |
| 405 | const inclusiveSummary = parseTurnDiffFilesFromNumstat( |
| 406 | yield* checkpointStore.diffCheckpoints({ ...input, toCheckpointRef: secondTurn }), |
| 407 | ); |
| 408 | expect(inclusiveSummary).toEqual( |
| 409 | expectedFiles |
| 410 | .filter((file) => file.path !== "empty.txt") |
| 411 | .map((file) => |
| 412 | file.path === "copy-source.txt" ? { ...file, additions: 1, deletions: 20 } : file, |
| 413 | ), |
| 414 | ); |
| 415 | expect( |
| 416 | yield* checkpointStore.diffCheckpoints({ ...input, toCheckpointRef: baseline }), |
| 417 | ).toBe(""); |
| 418 | expect(yield* fileSystem.readFile(NodePath.join(tmp, ".git/index"))).toEqual(userIndex); |
| 419 | }), |
| 420 | ); |
| 421 | |
| 422 | it.effect("uses HEAD for a missing baseline only when requested", () => |
| 423 | Effect.gen(function* () { |
| 424 | const tmp = yield* makeTmpDir(); |
| 425 | yield* initRepoWithCommit(tmp); |
| 426 | const checkpointStore = yield* CheckpointStore.CheckpointStore; |
| 427 | const threadId = ThreadId.make("checkpoint-summary-fallback"); |
| 428 | const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); |
| 429 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); |
| 430 | yield* writeTextFile(NodePath.join(tmp, "README.md"), "changed\n"); |
| 431 | yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: toCheckpointRef }); |
| 432 | const input = { |
| 433 | cwd: tmp, |
| 434 | fromCheckpointRef, |
| 435 | toCheckpointRef, |
| 436 | ignoreWhitespace: false, |
| 437 | format: "numstat" as const, |
| 438 | }; |
| 439 | |
| 440 | const error = yield* Effect.flip(checkpointStore.diffCheckpoints(input)); |
| 441 | expect(error._tag).toBe("VcsProcessExitError"); |
| 442 | const numstat = yield* checkpointStore.diffCheckpoints({ |
| 443 | ...input, |
| 444 | fallbackFromToHead: true, |
| 445 | }); |
| 446 | expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ |
| 447 | { path: "README.md", additions: 1, deletions: 1 }, |
| 448 | ]); |
| 449 | }), |
| 450 | ); |
| 451 | it.effect("limits a diff to repository paths from a subdirectory workspace", () => |
| 452 | Effect.gen(function* () { |
| 453 | const tmp = yield* makeTmpDir(); |
| 454 | yield* initRepoWithCommit(tmp); |
| 455 | const fileSystem = yield* FileSystem.FileSystem; |
| 456 | const cwd = NodePath.join(tmp, "app"); |
| 457 | yield* fileSystem.makeDirectory(cwd); |
| 458 | const checkpointStore = yield* CheckpointStore.CheckpointStore; |
| 459 | const threadId = ThreadId.make("checkpoint-file-paths-subdirectory"); |
| 460 | const refs = { |
| 461 | cwd, |
| 462 | fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), |
| 463 | toCheckpointRef: checkpointRefForThreadTurn(threadId, 1), |
| 464 | }; |
| 465 | yield* checkpointStore.captureCheckpoint({ cwd, checkpointRef: refs.fromCheckpointRef }); |
| 466 | yield* writeTextFile(NodePath.join(cwd, "kept.txt"), "kept\n"); |
| 467 | yield* writeTextFile(NodePath.join(tmp, "README.md"), "# hidden\n"); |
| 468 | yield* checkpointStore.captureCheckpoint({ cwd, checkpointRef: refs.toCheckpointRef }); |
| 469 | |
| 470 | const patch = yield* checkpointStore.diffCheckpoints({ |
| 471 | ...refs, |
| 472 | ignoreWhitespace: false, |
| 473 | filePaths: ["app/kept.txt"], |
| 474 | }); |
| 475 | expect(patch).toContain("+kept"); |
| 476 | expect(patch).not.toContain("hidden"); |
| 477 | }), |
| 478 | ); |
| 479 | }); |
| 480 | |
| 481 | describe("listAuthoredPaths", () => { |
| 482 | it.effect("leaves out paths that only a merged upstream changed", () => |
| 483 | Effect.gen(function* () { |
| 484 | const tmp = yield* makeTmpDir(); |
| 485 | yield* initRepoWithCommit(tmp); |
| 486 | const checkpointStore = yield* CheckpointStore.CheckpointStore; |
| 487 | const threadId = ThreadId.make("checkpoint-authored-paths"); |
| 488 | const refs = { |
| 489 | cwd: tmp, |
| 490 | fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), |
| 491 | toCheckpointRef: checkpointRefForThreadTurn(threadId, 1), |
| 492 | }; |
| 493 | const commitBeforeTurn = (file: string, contents: string) => |
| 494 | Effect.gen(function* () { |
| 495 | yield* writeTextFile(NodePath.join(tmp, file), contents); |
| 496 | yield* git(tmp, ["add", "."]); |
| 497 | yield* git(tmp, ["commit", "-m", file], { committedAt: "2020-01-01T00:00:00Z" }); |
| 498 | }); |
| 499 | |
| 500 | yield* git(tmp, ["checkout", "-b", "upstream"]); |
| 501 | yield* commitBeforeTurn("upstream.txt", "upstream\n"); |
| 502 | yield* commitBeforeTurn("shared.txt", "upstream\n"); |
| 503 | yield* git(tmp, ["checkout", "-"]); |
| 504 | yield* commitBeforeTurn("shared.txt", "ours\n"); |
| 505 | yield* checkpointStore.captureCheckpoint({ |
| 506 | cwd: tmp, |
| 507 | checkpointRef: refs.fromCheckpointRef, |
| 508 | }); |
| 509 | |
| 510 | yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: refs.toCheckpointRef }); |
| 511 | expect(yield* checkpointStore.listAuthoredPaths(refs)).toBeNull(); |
| 512 | |
| 513 | // The turn merges upstream, fixes the conflict by taking upstream, and edits one more file. |
| 514 | yield* git(tmp, ["merge", "upstream", "--no-edit"], { allowNonZeroExit: true }); |
| 515 | yield* writeTextFile(NodePath.join(tmp, "shared.txt"), "upstream\n"); |
| 516 | yield* git(tmp, ["commit", "-am", "merge upstream"]); |
| 517 | yield* writeTextFile(NodePath.join(tmp, "README.md"), "# edited\n"); |
| 518 | yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: refs.toCheckpointRef }); |
| 519 | |
| 520 | const numstat = yield* checkpointStore.diffCheckpoints({ |
| 521 | ...refs, |
| 522 | ignoreWhitespace: false, |
| 523 | format: "numstat", |
| 524 | }); |
| 525 | expect(parseTurnDiffFilesFromNumstat(numstat).map((file) => file.path)).toEqual([ |
| 526 | "README.md", |
| 527 | "shared.txt", |
| 528 | "upstream.txt", |
| 529 | ]); |
| 530 | expect(yield* checkpointStore.listAuthoredPaths(refs)).toEqual( |
| 531 | new Set(["README.md", "shared.txt"]), |
| 532 | ); |
| 533 | }), |
| 534 | ); |
| 535 | |
| 536 | it.effect("leaves out paths that only a rebase onto upstream brought in", () => |
| 537 | Effect.gen(function* () { |
| 538 | const tmp = yield* makeTmpDir(); |
| 539 | yield* initRepoWithCommit(tmp); |
| 540 | const checkpointStore = yield* CheckpointStore.CheckpointStore; |
| 541 | const threadId = ThreadId.make("checkpoint-authored-paths-rebase"); |
| 542 | const refs = { |
| 543 | cwd: tmp, |
| 544 | fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), |
| 545 | toCheckpointRef: checkpointRefForThreadTurn(threadId, 1), |
| 546 | }; |
| 547 | const commitBeforeTurn = (file: string) => |
| 548 | Effect.gen(function* () { |
| 549 | yield* writeTextFile(NodePath.join(tmp, file), `${file}\n`); |
| 550 | yield* git(tmp, ["add", "."]); |
| 551 | yield* git(tmp, ["commit", "-m", file], { committedAt: "2020-01-01T00:00:00Z" }); |
| 552 | }); |
| 553 | |
| 554 | yield* git(tmp, ["checkout", "-b", "upstream"]); |
| 555 | yield* commitBeforeTurn("upstream.txt"); |
| 556 | yield* git(tmp, ["checkout", "-"]); |
| 557 | yield* commitBeforeTurn("feature.txt"); |
| 558 | yield* checkpointStore.captureCheckpoint({ |
| 559 | cwd: tmp, |
| 560 | checkpointRef: refs.fromCheckpointRef, |
| 561 | }); |
| 562 | yield* git(tmp, ["rebase", "upstream"]); |
| 563 | yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: refs.toCheckpointRef }); |
| 564 | |
| 565 | expect(yield* checkpointStore.listAuthoredPaths(refs)).toEqual(new Set(["feature.txt"])); |
| 566 | }), |
| 567 | ); |
| 568 | }); |
| 569 | }); |