diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index dc58fc2543c..71694e2bcc8 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -209,6 +209,37 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("includes untracked files before the repository has its first commit", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + yield* writeTextFile(cwd, "first.txt", "first\n"); + + const preview = yield* driver.getReviewDiffPreview({ cwd, ignoreWhitespace: false }); + const diff = preview.sources.find((source) => source.kind === "working-tree")?.diff ?? ""; + + assert.include(diff, "diff --git a/first.txt b/first.txt"); + assert.include(diff, "+first"); + }), + ); + + it.effect("includes staged files before the repository has its first commit", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + yield* writeTextFile(cwd, "staged.txt", "staged\n"); + yield* git(cwd, ["add", "staged.txt"]); + + const preview = yield* driver.getReviewDiffPreview({ cwd, ignoreWhitespace: false }); + const diff = preview.sources.find((source) => source.kind === "working-tree")?.diff ?? ""; + + assert.include(diff, "diff --git a/staged.txt b/staged.txt"); + assert.include(diff, "+staged"); + }), + ); + it.effect("honors whitespace filtering for worktree and branch previews", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -243,6 +274,143 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { ); }), ); + + it.effect("detects an unstaged rename with edits as one working-tree diff", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* writeTextFile( + cwd, + "before.ts", + [ + "export const first = 1;", + "export const second = 2;", + "export const third = 3;", + "export const fourth = 4;", + "", + ].join("\n"), + ); + yield* git(cwd, ["add", "before.ts"]); + yield* git(cwd, ["commit", "-m", "add source file"]); + yield* fileSystem.rename( + pathService.join(cwd, "before.ts"), + pathService.join(cwd, "after.ts"), + ); + yield* writeTextFile( + cwd, + "after.ts", + [ + "export const first = 1;", + "export const second = 2;", + "export const third = 30;", + "export const fourth = 4;", + "", + ].join("\n"), + ); + + const preview = yield* driver.getReviewDiffPreview({ cwd, ignoreWhitespace: false }); + const diff = preview.sources.find((source) => source.kind === "working-tree")?.diff ?? ""; + + assert.include(diff, "rename from before.ts"); + assert.include(diff, "rename to after.ts"); + assert.include(diff, "-export const third = 3;"); + assert.include(diff, "+export const third = 30;"); + assert.notInclude(diff, "--- /dev/null"); + assert.notInclude(diff, "+++ /dev/null"); + }), + ); + + it.effect("preserves sparse-checkout entries in the working-tree diff", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* writeTextFile(cwd, "included/file.ts", "export const included = 1;\n"); + yield* writeTextFile(cwd, "excluded/file.ts", "export const excluded = 1;\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "add sparse files"]); + yield* git(cwd, ["sparse-checkout", "set", "included"]); + yield* writeTextFile(cwd, "included/file.ts", "export const included = 2;\n"); + + const preview = yield* driver.getReviewDiffPreview({ cwd, ignoreWhitespace: false }); + const diff = preview.sources.find((source) => source.kind === "working-tree")?.diff ?? ""; + + assert.include(diff, "diff --git a/included/file.ts b/included/file.ts"); + assert.include(diff, "+export const included = 2;"); + assert.notInclude(diff, "excluded/file.ts"); + }), + ); + + it.effect("includes staged index-only deletions in the working-tree diff", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* writeTextFile(cwd, ".gitignore", "secret.env\n"); + yield* writeTextFile(cwd, "secret.env", "SECRET=value\n"); + yield* git(cwd, ["add", "-f", ".gitignore", "secret.env"]); + yield* git(cwd, ["commit", "-m", "add ignored tracked file"]); + yield* git(cwd, ["rm", "--cached", "secret.env"]); + + const preview = yield* driver.getReviewDiffPreview({ cwd, ignoreWhitespace: false }); + const diff = preview.sources.find((source) => source.kind === "working-tree")?.diff ?? ""; + + assert.include(diff, "diff --git a/secret.env b/secret.env"); + assert.include(diff, "deleted file mode"); + assert.include(diff, "-SECRET=value"); + }), + ); + + it.effect("detects a committed rename with edits in the branch diff", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* writeTextFile( + cwd, + "before.ts", + [ + "export const first = 1;", + "export const second = 2;", + "export const third = 3;", + "export const fourth = 4;", + "", + ].join("\n"), + ); + yield* git(cwd, ["add", "before.ts"]); + yield* git(cwd, ["commit", "-m", "add source file"]); + yield* git(cwd, ["checkout", "-b", "feature/rename"]); + yield* git(cwd, ["mv", "before.ts", "after.ts"]); + yield* writeTextFile( + cwd, + "after.ts", + [ + "export const first = 1;", + "export const second = 2;", + "export const third = 30;", + "export const fourth = 4;", + "", + ].join("\n"), + ); + yield* git(cwd, ["add", "after.ts"]); + yield* git(cwd, ["commit", "-m", "rename source file"]); + + const preview = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + ignoreWhitespace: false, + }); + const diff = preview.sources.find((source) => source.kind === "branch-range")?.diff ?? ""; + + assert.include(diff, "rename from before.ts"); + assert.include(diff, "rename to after.ts"); + assert.include(diff, "-export const third = 3;"); + assert.include(diff, "+export const third = 30;"); + }), + ); }); describe("repository status", () => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a406cbce549..50cfa391872 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -45,8 +45,6 @@ const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000; const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000; -const REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES = 80_000; -const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 120_000; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); @@ -1807,43 +1805,103 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); - const readUntrackedReviewDiffs = Effect.fn("readUntrackedReviewDiffs")(function* (cwd: string) { - const untrackedResult = yield* executeGit( - "GitVcsDriver.readUntrackedReviewDiffs.list", - cwd, - ["ls-files", "--others", "--exclude-standard", "-z"], - { - maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, - appendTruncationMarker: true, - }, - ); - const untrackedPaths = splitNullSeparatedGitStdoutPaths(untrackedResult); - if (untrackedPaths.length === 0) { - return { diff: "", truncated: untrackedResult.stdoutTruncated }; - } + const readWorkingTreeReviewDiff = Effect.fn("readWorkingTreeReviewDiff")(function* ( + cwd: string, + ignoreWhitespace: boolean | undefined, + ) { + const tempDirectory = yield* fileSystem + .makeTempDirectory({ prefix: "t3code-review-index-" }) + .pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + operation: "GitVcsDriver.readWorkingTreeReviewDiff.createTempIndex", + command: "git diff", + cwd, + detail: "Failed to create a temporary Git index for the review diff.", + cause, + }), + ), + ); + const indexPath = path.join(tempDirectory, "index"); + const env = { GIT_INDEX_FILE: indexPath } satisfies NodeJS.ProcessEnv; + + return yield* Effect.gen(function* () { + const headResult = yield* executeGit( + "GitVcsDriver.readWorkingTreeReviewDiff.resolveHead", + cwd, + ["rev-parse", "--verify", "--quiet", "HEAD"], + { allowNonZeroExit: true }, + ); + const hasHead = headResult.exitCode === 0; + const baseline = hasHead + ? "HEAD" + : (yield* executeGit( + "GitVcsDriver.readWorkingTreeReviewDiff.resolveEmptyTree", + cwd, + ["hash-object", "-t", "tree", "--stdin"], + { stdin: "" }, + )).stdout.trim(); + const gitIndexResult = yield* executeGit( + "GitVcsDriver.readWorkingTreeReviewDiff.resolveIndex", + cwd, + ["rev-parse", "--git-path", "index"], + ); + const gitIndexPath = path.isAbsolute(gitIndexResult.stdout.trim()) + ? gitIndexResult.stdout.trim() + : path.resolve(cwd, gitIndexResult.stdout.trim()); - const diffs = yield* Effect.forEach( - untrackedPaths, - (relativePath) => - executeGit( - "GitVcsDriver.readUntrackedReviewDiffs.diff", + if (yield* fileSystem.exists(gitIndexPath)) { + yield* fileSystem.copyFile(gitIndexPath, indexPath).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + operation: "GitVcsDriver.readWorkingTreeReviewDiff.copyIndex", + command: "git diff", + cwd, + detail: "Failed to copy the Git index for the review diff.", + cause, + }), + ), + ); + } else { + yield* executeGit( + "GitVcsDriver.readWorkingTreeReviewDiff.readEmptyTree", cwd, - ["diff", "--no-index", "--patch", "--minimal", "--", "/dev/null", relativePath], - { - allowNonZeroExit: true, - maxOutputBytes: REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES, - appendTruncationMarker: true, - }, - ), - { concurrency: 4 }, - ); + ["read-tree", "--empty"], + { env }, + ); + } + yield* executeGit( + "GitVcsDriver.readWorkingTreeReviewDiff.add", + cwd, + ["add", "--intent-to-add", "-A", "--", "."], + { env }, + ); - return { - diff: Arr.filterMap(diffs, (result) => - result.stdout.trim().length > 0 ? Result.succeed(result.stdout) : Result.failVoid, - ).join("\n"), - truncated: untrackedResult.stdoutTruncated || diffs.some((result) => result.stdoutTruncated), - }; + return yield* executeGit( + "GitVcsDriver.readWorkingTreeReviewDiff.diff", + cwd, + [ + "diff", + "--patch", + "--minimal", + "--find-renames", + ...(ignoreWhitespace ? ["--ignore-all-space"] : []), + baseline, + "--", + ], + { + env, + maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ); + }).pipe( + Effect.ensuring( + fileSystem.remove(tempDirectory, { recursive: true, force: true }).pipe(Effect.ignore), + ), + ); }); const getReviewDiffPreview = Effect.fn("getReviewDiffPreview")(function* ( @@ -1867,22 +1925,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ) : null); - const dirtyTrackedResult = yield* executeGit( - "GitVcsDriver.getReviewDiffPreview.dirtyTracked", - input.cwd, - [ - "diff", - "--patch", - "--minimal", - ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), - "HEAD", - "--", - ], - { - maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, - appendTruncationMarker: true, - }, - ).pipe( + const dirtyResult = yield* readWorkingTreeReviewDiff(input.cwd, input.ignoreWhitespace).pipe( Effect.orElseSucceed(() => ({ exitCode: 0, stdout: "", @@ -1891,12 +1934,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* stderrTruncated: false, })), ); - const dirtyUntracked = yield* readUntrackedReviewDiffs(input.cwd).pipe( - Effect.orElseSucceed(() => ({ diff: "", truncated: false })), - ); - const dirtyDiff = [dirtyTrackedResult.stdout.trimEnd(), dirtyUntracked.diff.trimEnd()] - .filter((diff) => diff.length > 0) - .join("\n"); + const dirtyDiff = dirtyResult.stdout; const baseResult = baseRef && branch @@ -1907,6 +1945,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "diff", "--patch", "--minimal", + "--find-renames", ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), `${baseRef}...HEAD`, ], @@ -1953,7 +1992,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* headRef: null, diff: dirtyDiff, diffHash: dirtyDiffHash, - truncated: dirtyTrackedResult.stdoutTruncated || dirtyUntracked.truncated, + truncated: dirtyResult.stdoutTruncated, }, { id: "branch-range",