diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 7802a254f127a..cc66c329ff003 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -8373,6 +8373,53 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("keeps other files conflicted through a selected-file commit", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + // git stash pop leaves README.md conflicted with no merge in progress. + yield* writeTextFile(cwd, "README.md", "stashed version\n"); + yield* git(cwd, ["stash"]); + yield* writeTextFile(cwd, "README.md", "committed version\n"); + yield* git(cwd, ["commit", "-am", "Change README"]); + const pop = yield* driver.execute({ + operation: "test.stashPop", + cwd, + args: ["stash", "pop"], + allowNonZeroExit: true, + }); + assert.equal(pop.exitCode, 1); + yield* writeTextFile(cwd, "other.txt", "other\n"); + const head = yield* git(cwd, ["rev-parse", "HEAD"]); + const unmerged = yield* git(cwd, ["ls-files", "--unmerged"]); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const nested = path.join(cwd, "nested"); + yield* fs.makeDirectory(nested); + // A formatting hook stages its edit into the temporary index that --only commits from. + yield* writeTextFile( + cwd, + ".git/hooks/pre-commit", + "#!/bin/sh\necho formatted >> other.txt\ngit add other.txt\n", + ); + yield* fs.chmod(path.join(cwd, ".git/hooks/pre-commit"), 0o755); + + yield* driver.commit(cwd, "Add other", "", { stage: { filePaths: ["other.txt"] } }); + + assert.equal(yield* git(cwd, ["rev-parse", "HEAD~1"]), head); + assert.equal(yield* git(cwd, ["show", "--pretty=", "--name-only", "HEAD"]), "other.txt"); + assert.equal(yield* git(cwd, ["show", "HEAD:other.txt"]), "other\nformatted"); + assert.equal(yield* git(cwd, ["show", ":other.txt"]), "other\nformatted"); + assert.equal(yield* git(cwd, ["ls-files", "--unmerged"]), unmerged); + const all = yield* driver.prepareCommitContext(nested).pipe(Effect.flip); + assert.equal( + all.detail, + "Cannot commit with unresolved merge conflicts in README.md. Resolve them, then commit.", + ); + }), + ); + const commitWithHooks = (hooks: Record) => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 36bb3fe576cd7..316e0acb2884a 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2679,6 +2679,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const config = env ? ["-c", "core.splitIndex=false", "-c", "splitIndex.sharedIndexExpire=never"] : []; + let preserveUnselectedConflicts = false; if (filePaths && filePaths.length > 0) { if (env) { const head = yield* executeGit( @@ -2755,7 +2756,20 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* detail: `Cannot commit selected files during a ${operation}. Commit all files to finish the ${operation}.`, }); } - yield* runGit("GitVcsDriver.commit.reset", cwd, ["reset", "--quiet"]); + const selectedFiles = new Set(filePaths); + const unmergedPaths = yield* runGitStdoutWithOptions( + "GitVcsDriver.commit.unmergedPaths", + cwd, + ["-c", "diff.relative=false", "diff", "--cached", "--name-only", "--diff-filter=U", "-z"], + { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, + ); + // Reset drops every conflict stage, so keep the index while unselected files are conflicted. + preserveUnselectedConflicts = unmergedPaths + .split("\0") + .some((file) => file.length > 0 && !selectedFiles.has(file)); + if (!preserveUnselectedConflicts) { + yield* runGit("GitVcsDriver.commit.reset", cwd, ["reset", "--quiet"]); + } } // Status paths are repository-relative, while cwd can be a subdirectory. const pathspecs = filePaths.map((file) => `:(top,literal)${file}`); @@ -2871,6 +2885,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* env ? { env } : {}, ); } + return preserveUnselectedConflicts && filePaths + ? filePaths.map((file) => `:(top,literal)${file}`) + : undefined; }); const prepareCommitContext: GitVcsDriver.GitVcsDriver["Service"]["prepareCommitContext"] = @@ -3015,14 +3032,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* body, options?: GitVcsDriver.GitCommitOptions, ) { - if (options?.stage !== undefined) { - yield* stageCommitChanges(cwd, options.stage.filePaths); - } + const partialCommitPaths = + options?.stage !== undefined + ? yield* stageCommitChanges(cwd, options.stage.filePaths) + : undefined; const args = ["commit", "-m", subject]; const trimmedBody = body.trim(); if (trimmedBody.length > 0) { args.push("-m", trimmedBody); } + const onlyFromStdin = partialCommitPaths ? yield* supportsPathspecFromFile(cwd) : false; + const onlyPathspecArgs = onlyFromStdin + ? ["--pathspec-from-file=-", "--pathspec-file-nul"] + : ["--", ...(partialCommitPaths ?? [])]; + const onlyPathspecStdin = + partialCommitPaths && onlyFromStdin + ? { stdin: partialCommitPaths.map((pathspec) => `${pathspec}\0`).join("") } + : {}; + if (partialCommitPaths) { + args.push("--only", ...onlyPathspecArgs); + } const progress = options?.progress?.onOutputLine === undefined ? options?.progress @@ -3035,9 +3064,19 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; yield* executeGit("GitVcsDriver.commit.commit", cwd, args, { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, + ...onlyPathspecStdin, ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), ...(progress ? { progress } : {}), }).pipe(Effect.asVoid); + if (partialCommitPaths) { + // --only runs hooks on a temporary index, so a hook's `git add` never reaches the real one. + yield* runGit( + "GitVcsDriver.commit.syncSelectedIndex", + cwd, + ["reset", "--quiet", "HEAD", ...onlyPathspecArgs], + onlyPathspecStdin, + ); + } const commitSha = yield* runGitStdout("GitVcsDriver.commit.revParseHead", cwd, [ "rev-parse", "HEAD",