diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 1b6b44008467..501e97cedad5 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -3116,6 +3116,53 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect.each([undefined, ["README.md"]])( + "a failed generation preserves staging changed while generating (paths: %s)", + (filePaths) => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nstaged\n"); + yield* runGit(repoDir, ["add", "README.md"]); + NodeFS.appendFileSync(NodePath.join(repoDir, "README.md"), "unstaged\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "untracked.txt"), "untracked\n"); + const indexBefore = NodeFS.readFileSync(NodePath.join(repoDir, ".git/index")); + const headBefore = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout; + const gitDriver = yield* GitVcsDriver.GitVcsDriver; + let indexDuring: Buffer | undefined; + let indexAfterUserStage: Buffer | undefined; + const { manager } = yield* makeManager({ + textGeneration: { + generateCommitMessage: () => + Effect.gen(function* () { + indexDuring = NodeFS.readFileSync(NodePath.join(repoDir, ".git/index")); + yield* runGit(repoDir, ["add", "untracked.txt"]).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, gitDriver), + Effect.orDie, + ); + indexAfterUserStage = NodeFS.readFileSync(NodePath.join(repoDir, ".git/index")); + return yield* new TextGenerationError({ + operation: "generateCommitMessage", + detail: "Provider rejected generation", + }); + }), + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + ...(filePaths ? { filePaths } : {}), + }).pipe(Effect.result); + expect(Result.isFailure(result)).toBe(true); + expect(indexDuring).toEqual(indexBefore); + expect(NodeFS.readFileSync(NodePath.join(repoDir, ".git/index"))).toEqual( + indexAfterUserStage, + ); + expect((yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout).toBe(headBefore); + expect((yield* runGit(repoDir, ["diff"])).stdout).toContain("+unstaged"); + }), + ); + it.effect("uses custom commit message when provided", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 2b88cd79f8e6..2829502c8cbd 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1985,6 +1985,7 @@ export const make = Effect.gen(function* () { : null; const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body, { timeoutMs: COMMIT_TIMEOUT_MS, + stage: filePaths ? { filePaths } : {}, ...(commitProgress ? { progress: commitProgress } : {}), }); if (currentHookName !== null) { diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 99220a8bf78f..8e9147669433 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -174,6 +174,8 @@ export interface GitCommitProgress { export interface GitCommitOptions { readonly timeoutMs?: number; readonly progress?: GitCommitProgress; + /** Stage the current working tree immediately before committing. */ + readonly stage?: { readonly filePaths?: readonly string[] }; } export interface GitDeleteLocalBranchInput { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index c720a2e4958a..17ae6e309fee 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -3052,7 +3052,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("commit context", () => { - it.effect("stages selected files and commits only those files", () => + it.effect("prepares selected files without staging and commits only those files", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); yield* initRepoWithCommit(cwd); @@ -3064,8 +3064,9 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const context = yield* driver.prepareCommitContext(cwd, ["a.txt"]); assert.include(context?.stagedSummary ?? "", "a.txt"); assert.notInclude(context?.stagedSummary ?? "", "b.txt"); + assert.equal(yield* git(cwd, ["diff", "--cached", "--name-only"]), ""); - const commit = yield* driver.commit(cwd, "Add a", ""); + const commit = yield* driver.commit(cwd, "Add a", "", { stage: { filePaths: ["a.txt"] } }); assert.match(commit.commitSha, /^[a-f0-9]{40}$/); assert.equal(yield* git(cwd, ["log", "-1", "--pretty=%s"]), "Add a"); @@ -3084,14 +3085,96 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* writeTextFile(cwd, "selected[1].txt", "literal\n"); yield* writeTextFile(cwd, "selected1.txt", "pattern match\n"); - yield* driver.prepareCommitContext(cwd, ["selected[1].txt"]); + const context = yield* driver.prepareCommitContext(cwd, ["selected[1].txt"]); - assert.equal(yield* git(cwd, ["diff", "--cached", "--name-only"]), "selected[1].txt"); + assert.include(context?.stagedSummary ?? "", "selected[1].txt"); + assert.notInclude(context?.stagedSummary ?? "", "selected1.txt"); + assert.equal(yield* git(cwd, ["diff", "--cached", "--name-only"]), ""); + yield* driver.commit(cwd, "Add literal path", "", { + stage: { filePaths: ["selected[1].txt"] }, + }); + assert.equal( + yield* git(cwd, ["show", "--pretty=", "--name-only", "HEAD"]), + "selected[1].txt", + ); const status = yield* git(cwd, ["status", "--porcelain"]); assert.include(status, "?? selected1.txt"); }), ); + + it.effect.each([false, true])( + "preserves partial staging and split-index files (split: %s)", + (split) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + if (split) yield* git(cwd, ["config", "core.splitIndex", "true"]); + yield* writeTextFile(cwd, "README.md", "# test\nstaged\n"); + yield* git(cwd, ["add", "README.md"]); + yield* writeTextFile(cwd, "README.md", "# test\nstaged\nunstaged\n"); + yield* writeTextFile(cwd, "untracked.txt", "untracked\n"); + yield* writeTextFile(cwd, ".gitignore", "ignored.txt\n"); + yield* writeTextFile(cwd, "ignored.txt", "ignored\n"); + const before = NodeFS.readFileSync(`${cwd}/.git/index`); + const shared = NodeFS.readdirSync(`${cwd}/.git`).filter((name) => + name.startsWith("sharedindex."), + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + for (const paths of [undefined, ["README.md"]]) { + const context = yield* driver.prepareCommitContext(cwd, paths); + assert.include(context?.stagedPatch ?? "", "unstaged"); + assert.notInclude(context?.stagedSummary ?? "", "ignored.txt"); + assert.deepEqual(NodeFS.readFileSync(`${cwd}/.git/index`), before); + assert.deepEqual( + NodeFS.readdirSync(`${cwd}/.git`).filter((name) => name.startsWith("sharedindex.")), + shared, + ); + } + }), + ); + + it.effect("prepares an unborn repository without creating a real index", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(cwd, "initial.txt", "initial\n"); + for (const paths of [undefined, ["initial.txt"]]) { + const context = yield* driver.prepareCommitContext(cwd, paths); + assert.include(context?.stagedSummary ?? "", "initial.txt"); + assert.isFalse(NodeFS.existsSync(`${cwd}/.git/index`)); + } + yield* driver.commit(cwd, "Initial", "", { stage: { filePaths: ["initial.txt"] } }); + assert.equal(yield* git(cwd, ["show", "HEAD:initial.txt"]), "initial"); + }), + ); + + it.effect("preserves merge metadata while preparing selected and all files", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["checkout", "-b", "feature"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "Feature"]); + yield* git(cwd, ["checkout", initialBranch]); + yield* git(cwd, ["merge", "--no-commit", "--no-ff", "feature"]); + yield* writeTextFile(cwd, "README.md", "# test\nmerge edit\n"); + const names = ["index", "MERGE_HEAD", "MERGE_MSG", "ORIG_HEAD"]; + const before = names.map((name) => NodeFS.readFileSync(`${cwd}/.git/${name}`)); + const driver = yield* GitVcsDriver.GitVcsDriver; + for (const paths of [undefined, ["README.md"]]) { + yield* driver.prepareCommitContext(cwd, paths); + assert.deepEqual( + names.map((name) => NodeFS.readFileSync(`${cwd}/.git/${name}`)), + before, + ); + } + }), + ); }); describe("remote operations", () => { @@ -3271,7 +3354,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { refName: "feature/push", }); yield* writeTextFile(cwd, "feature.txt", "feature\n"); - yield* (yield* GitVcsDriver.GitVcsDriver).prepareCommitContext(cwd); + yield* git(cwd, ["add", "-A"]); yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add feature", ""); const pushed = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null); @@ -3343,7 +3426,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["remote", "add", "origin", remote]); yield* git(cwd, ["push", "-u", "origin", "main"]); yield* writeTextFile(cwd, "upstream.txt", "upstream\n"); - yield* driver.prepareCommitContext(cwd); + yield* git(cwd, ["add", "-A"]); yield* driver.commit(cwd, "Add upstream update", ""); const pushed = yield* driver.pushCurrentBranch(cwd, null); @@ -3384,7 +3467,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const devSha = yield* git(cwd, ["rev-parse", "HEAD"]); yield* git(cwd, ["checkout", "-b", "feature/x", "origin/dev"]); yield* writeTextFile(cwd, "feature.txt", "feature\n"); - yield* driver.prepareCommitContext(cwd); + yield* git(cwd, ["add", "-A"]); yield* driver.commit(cwd, "Add feature", ""); const pushed = yield* driver.pushCurrentBranch(cwd, null); @@ -3418,7 +3501,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["checkout", "-b", "feature/y", "origin/main"]); yield* git(cwd, ["config", "branch.feature/y.gh-merge-base", "release/v2"]); yield* writeTextFile(cwd, "feature.txt", "feature\n"); - yield* driver.prepareCommitContext(cwd); + yield* git(cwd, ["add", "-A"]); yield* driver.commit(cwd, "Add feature", ""); const pushed = yield* driver.pushCurrentBranch(cwd, null); @@ -3456,7 +3539,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { "upstream/effect-atom", ); yield* writeTextFile(cwd, "alias.txt", "alias\n"); - yield* driver.prepareCommitContext(cwd); + yield* git(cwd, ["add", "-A"]); yield* driver.commit(cwd, "Add alias update", ""); const pushed = yield* driver.pushCurrentBranch(cwd, null); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 971d0e7a9a95..935fb3d24a72 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2025,48 +2025,132 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* })), ); - const prepareCommitContext: GitVcsDriver.GitVcsDriver["Service"]["prepareCommitContext"] = - Effect.fn("prepareCommitContext")(function* (cwd, filePaths) { - if (filePaths && filePaths.length > 0) { - yield* runGit("GitVcsDriver.prepareCommitContext.reset", cwd, ["reset"]).pipe( - Effect.catchTags({ - GitCommandError: () => Effect.void, - }), + const stageCommitChanges = Effect.fnUntraced(function* ( + cwd: string, + filePaths?: readonly string[], + env?: NodeJS.ProcessEnv, + ) { + const config = env + ? ["-c", "core.splitIndex=false", "-c", "splitIndex.sharedIndexExpire=never"] + : []; + if (filePaths && filePaths.length > 0) { + if (env) { + const head = yield* executeGit( + "GitVcsDriver.prepareCommitContext.head", + cwd, + ["rev-parse", "--verify", "--quiet", "HEAD"], + { allowNonZeroExit: true }, + ); + // Even with GIT_INDEX_FILE, reset clears the repository's merge state. + yield* runGit( + "GitVcsDriver.prepareCommitContext.readTree", + cwd, + [...config, "read-tree", ...(head.exitCode === 0 ? ["HEAD"] : ["--empty"])], + { env }, ); - yield* runGit("GitVcsDriver.prepareCommitContext.addSelected", cwd, [ - "--literal-pathspecs", - "add", - "-A", - "--", - ...filePaths, - ]); } else { - yield* runGit("GitVcsDriver.prepareCommitContext.addAll", cwd, ["add", "-A"]); + yield* runGit("GitVcsDriver.commit.reset", cwd, ["reset"]).pipe( + Effect.catchTags({ GitCommandError: () => Effect.void }), + ); } - - const stagedSummary = yield* runGitStdout( - "GitVcsDriver.prepareCommitContext.stagedSummary", + yield* runGit( + "GitVcsDriver.commit.addSelected", cwd, - ["diff", "--cached", "--name-status"], - ).pipe(Effect.map((stdout) => stdout.trim())); - if (stagedSummary.length === 0) { - return null; - } - - const stagedPatch = yield* runGitStdoutWithOptions( - "GitVcsDriver.prepareCommitContext.stagedPatch", + [...config, "--literal-pathspecs", "add", "-A", "--", ...filePaths], + env ? { env } : {}, + ); + } else { + yield* runGit( + "GitVcsDriver.commit.addAll", cwd, - ["diff", "--no-ext-diff", "--cached", "--patch", "--minimal"], - { - maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, - appendTruncationMarker: true, - }, + [...config, "add", "-A"], + env ? { env } : {}, ); + } + }); - return { - stagedSummary, - stagedPatch, - }; + const prepareCommitContext: GitVcsDriver.GitVcsDriver["Service"]["prepareCommitContext"] = + Effect.fn("prepareCommitContext")(function* (cwd, filePaths) { + return yield* Effect.scoped( + Effect.gen(function* () { + const indexValue = yield* runGitStdout( + "GitVcsDriver.prepareCommitContext.indexPath", + cwd, + ["rev-parse", "--git-path", "index"], + ); + const indexPath = path.resolve(cwd, indexValue.trim()); + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-commit-index-", + }); + const tempIndexPath = path.join(directory, "index"); + const env = { GIT_INDEX_FILE: tempIndexPath } satisfies NodeJS.ProcessEnv; + const config = [ + "-c", + "core.splitIndex=false", + "-c", + "splitIndex.sharedIndexExpire=never", + ]; + if (yield* fileSystem.exists(indexPath)) { + const { mtime } = yield* fileSystem.stat(indexPath); + yield* fileSystem.copyFile(indexPath, tempIndexPath); + const indexTime = Option.isSome(mtime) + ? Math.max(0, Math.floor(mtime.value.getTime() / 1000)) + : 0; + yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime); + yield* runGit( + "GitVcsDriver.prepareCommitContext.expandSplitIndex", + cwd, + [...config, "update-index", "--no-split-index"], + { env }, + ); + } else { + yield* runGit( + "GitVcsDriver.prepareCommitContext.emptyIndex", + cwd, + [...config, "read-tree", "--empty"], + { env }, + ); + } + yield* stageCommitChanges(cwd, filePaths, env); + + const stagedSummary = yield* runGitStdoutWithOptions( + "GitVcsDriver.prepareCommitContext.stagedSummary", + cwd, + ["diff", "--cached", "--name-status"], + { env }, + ).pipe(Effect.map((stdout) => stdout.trim())); + if (stagedSummary.length === 0) { + return null; + } + + const stagedPatch = yield* runGitStdoutWithOptions( + "GitVcsDriver.prepareCommitContext.stagedPatch", + cwd, + ["diff", "--no-ext-diff", "--cached", "--patch", "--minimal"], + { + env, + maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ); + + return { + stagedSummary, + stagedPatch, + }; + }), + ).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new GitCommandError({ + operation: "GitVcsDriver.prepareCommitContext", + command: "git", + cwd, + detail: "Failed to prepare the temporary commit index.", + cause, + }), + }), + ); }); const commit: GitVcsDriver.GitVcsDriver["Service"]["commit"] = Effect.fn("commit")(function* ( @@ -2075,6 +2159,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* body, options?: GitVcsDriver.GitCommitOptions, ) { + if (options?.stage !== undefined) { + yield* stageCommitChanges(cwd, options.stage.filePaths); + } const args = ["commit", "-m", subject]; const trimmedBody = body.trim(); if (trimmedBody.length > 0) {