authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-04 09:20:04-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-04 09:20:04-03:00
log4ee6bfd50ef4a089440d5c3662db2298da9cc50e
tree06527ddd71ba6e525a67dfb45b66f4d689584fea
parent3b0093d7164a59bea721e2582e385e984561cd41
signature Signed by PGP key B5690EEEBB952194

fix(server): preserve staging during commit message generation (#15532)


5 files changed, 264 insertions(+), 44 deletions(-)

apps/server/src/git/GitManager.test.ts+47
......@@ -3116,6 +3116,53 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
31163116 }),
31173117 );
31183118
3119 it.effect.each([undefined, ["README.md"]])(
3120 "a failed generation preserves staging changed while generating (paths: %s)",
3121 (filePaths) =>
3122 Effect.gen(function* () {
3123 const repoDir = yield* makeTempDir("t3code-git-manager-");
3124 yield* initRepo(repoDir);
3125 NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nstaged\n");
3126 yield* runGit(repoDir, ["add", "README.md"]);
3127 NodeFS.appendFileSync(NodePath.join(repoDir, "README.md"), "unstaged\n");
3128 NodeFS.writeFileSync(NodePath.join(repoDir, "untracked.txt"), "untracked\n");
3129 const indexBefore = NodeFS.readFileSync(NodePath.join(repoDir, ".git/index"));
3130 const headBefore = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout;
3131 const gitDriver = yield* GitVcsDriver.GitVcsDriver;
3132 let indexDuring: Buffer | undefined;
3133 let indexAfterUserStage: Buffer | undefined;
3134 const { manager } = yield* makeManager({
3135 textGeneration: {
3136 generateCommitMessage: () =>
3137 Effect.gen(function* () {
3138 indexDuring = NodeFS.readFileSync(NodePath.join(repoDir, ".git/index"));
3139 yield* runGit(repoDir, ["add", "untracked.txt"]).pipe(
3140 Effect.provideService(GitVcsDriver.GitVcsDriver, gitDriver),
3141 Effect.orDie,
3142 );
3143 indexAfterUserStage = NodeFS.readFileSync(NodePath.join(repoDir, ".git/index"));
3144 return yield* new TextGenerationError({
3145 operation: "generateCommitMessage",
3146 detail: "Provider rejected generation",
3147 });
3148 }),
3149 },
3150 });
3151 const result = yield* runStackedAction(manager, {
3152 cwd: repoDir,
3153 action: "commit",
3154 ...(filePaths ? { filePaths } : {}),
3155 }).pipe(Effect.result);
3156 expect(Result.isFailure(result)).toBe(true);
3157 expect(indexDuring).toEqual(indexBefore);
3158 expect(NodeFS.readFileSync(NodePath.join(repoDir, ".git/index"))).toEqual(
3159 indexAfterUserStage,
3160 );
3161 expect((yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout).toBe(headBefore);
3162 expect((yield* runGit(repoDir, ["diff"])).stdout).toContain("+unstaged");
3163 }),
3164 );
3165
31193166 it.effect("uses custom commit message when provided", () =>
31203167 Effect.gen(function* () {
31213168 const repoDir = yield* makeTempDir("t3code-git-manager-");
apps/server/src/git/GitManager.ts+1
......@@ -1985,6 +1985,7 @@ export const make = Effect.gen(function* () {
19851985 : null;
19861986 const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body, {
19871987 timeoutMs: COMMIT_TIMEOUT_MS,
1988 stage: filePaths ? { filePaths } : {},
19881989 ...(commitProgress ? { progress: commitProgress } : {}),
19891990 });
19901991 if (currentHookName !== null) {
apps/server/src/vcs/GitVcsDriver.ts+2
......@@ -174,6 +174,8 @@ export interface GitCommitProgress {
174174export interface GitCommitOptions {
175175 readonly timeoutMs?: number;
176176 readonly progress?: GitCommitProgress;
177 /** Stage the current working tree immediately before committing. */
178 readonly stage?: { readonly filePaths?: readonly string[] };
177179}
178180
179181export interface GitDeleteLocalBranchInput {
apps/server/src/vcs/GitVcsDriverCore.test.ts+92-9
......@@ -3052,7 +3052,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
30523052 });
30533053
30543054 describe("commit context", () => {
3055 it.effect("stages selected files and commits only those files", () =>
3055 it.effect("prepares selected files without staging and commits only those files", () =>
30563056 Effect.gen(function* () {
30573057 const cwd = yield* makeTmpDir();
30583058 yield* initRepoWithCommit(cwd);
......@@ -3064,8 +3064,9 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
30643064 const context = yield* driver.prepareCommitContext(cwd, ["a.txt"]);
30653065 assert.include(context?.stagedSummary ?? "", "a.txt");
30663066 assert.notInclude(context?.stagedSummary ?? "", "b.txt");
3067 assert.equal(yield* git(cwd, ["diff", "--cached", "--name-only"]), "");
30673068
3068 const commit = yield* driver.commit(cwd, "Add a", "");
3069 const commit = yield* driver.commit(cwd, "Add a", "", { stage: { filePaths: ["a.txt"] } });
30693070 assert.match(commit.commitSha, /^[a-f0-9]{40}$/);
30703071 assert.equal(yield* git(cwd, ["log", "-1", "--pretty=%s"]), "Add a");
30713072
......@@ -3084,14 +3085,96 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
30843085 yield* writeTextFile(cwd, "selected[1].txt", "literal\n");
30853086 yield* writeTextFile(cwd, "selected1.txt", "pattern match\n");
30863087
3087 yield* driver.prepareCommitContext(cwd, ["selected[1].txt"]);
3088 const context = yield* driver.prepareCommitContext(cwd, ["selected[1].txt"]);
30883089
3089 assert.equal(yield* git(cwd, ["diff", "--cached", "--name-only"]), "selected[1].txt");
3090 assert.include(context?.stagedSummary ?? "", "selected[1].txt");
3091 assert.notInclude(context?.stagedSummary ?? "", "selected1.txt");
3092 assert.equal(yield* git(cwd, ["diff", "--cached", "--name-only"]), "");
3093 yield* driver.commit(cwd, "Add literal path", "", {
3094 stage: { filePaths: ["selected[1].txt"] },
3095 });
3096 assert.equal(
3097 yield* git(cwd, ["show", "--pretty=", "--name-only", "HEAD"]),
3098 "selected[1].txt",
3099 );
30903100
30913101 const status = yield* git(cwd, ["status", "--porcelain"]);
30923102 assert.include(status, "?? selected1.txt");
30933103 }),
30943104 );
3105
3106 it.effect.each([false, true])(
3107 "preserves partial staging and split-index files (split: %s)",
3108 (split) =>
3109 Effect.gen(function* () {
3110 const cwd = yield* makeTmpDir();
3111 yield* initRepoWithCommit(cwd);
3112 if (split) yield* git(cwd, ["config", "core.splitIndex", "true"]);
3113 yield* writeTextFile(cwd, "README.md", "# test\nstaged\n");
3114 yield* git(cwd, ["add", "README.md"]);
3115 yield* writeTextFile(cwd, "README.md", "# test\nstaged\nunstaged\n");
3116 yield* writeTextFile(cwd, "untracked.txt", "untracked\n");
3117 yield* writeTextFile(cwd, ".gitignore", "ignored.txt\n");
3118 yield* writeTextFile(cwd, "ignored.txt", "ignored\n");
3119 const before = NodeFS.readFileSync(`${cwd}/.git/index`);
3120 const shared = NodeFS.readdirSync(`${cwd}/.git`).filter((name) =>
3121 name.startsWith("sharedindex."),
3122 );
3123 const driver = yield* GitVcsDriver.GitVcsDriver;
3124 for (const paths of [undefined, ["README.md"]]) {
3125 const context = yield* driver.prepareCommitContext(cwd, paths);
3126 assert.include(context?.stagedPatch ?? "", "unstaged");
3127 assert.notInclude(context?.stagedSummary ?? "", "ignored.txt");
3128 assert.deepEqual(NodeFS.readFileSync(`${cwd}/.git/index`), before);
3129 assert.deepEqual(
3130 NodeFS.readdirSync(`${cwd}/.git`).filter((name) => name.startsWith("sharedindex.")),
3131 shared,
3132 );
3133 }
3134 }),
3135 );
3136
3137 it.effect("prepares an unborn repository without creating a real index", () =>
3138 Effect.gen(function* () {
3139 const cwd = yield* makeTmpDir();
3140 const driver = yield* GitVcsDriver.GitVcsDriver;
3141 yield* driver.initRepo({ cwd });
3142 yield* git(cwd, ["config", "user.email", "test@test.com"]);
3143 yield* git(cwd, ["config", "user.name", "Test"]);
3144 yield* writeTextFile(cwd, "initial.txt", "initial\n");
3145 for (const paths of [undefined, ["initial.txt"]]) {
3146 const context = yield* driver.prepareCommitContext(cwd, paths);
3147 assert.include(context?.stagedSummary ?? "", "initial.txt");
3148 assert.isFalse(NodeFS.existsSync(`${cwd}/.git/index`));
3149 }
3150 yield* driver.commit(cwd, "Initial", "", { stage: { filePaths: ["initial.txt"] } });
3151 assert.equal(yield* git(cwd, ["show", "HEAD:initial.txt"]), "initial");
3152 }),
3153 );
3154
3155 it.effect("preserves merge metadata while preparing selected and all files", () =>
3156 Effect.gen(function* () {
3157 const cwd = yield* makeTmpDir();
3158 const { initialBranch } = yield* initRepoWithCommit(cwd);
3159 yield* git(cwd, ["checkout", "-b", "feature"]);
3160 yield* writeTextFile(cwd, "feature.txt", "feature\n");
3161 yield* git(cwd, ["add", "."]);
3162 yield* git(cwd, ["commit", "-m", "Feature"]);
3163 yield* git(cwd, ["checkout", initialBranch]);
3164 yield* git(cwd, ["merge", "--no-commit", "--no-ff", "feature"]);
3165 yield* writeTextFile(cwd, "README.md", "# test\nmerge edit\n");
3166 const names = ["index", "MERGE_HEAD", "MERGE_MSG", "ORIG_HEAD"];
3167 const before = names.map((name) => NodeFS.readFileSync(`${cwd}/.git/${name}`));
3168 const driver = yield* GitVcsDriver.GitVcsDriver;
3169 for (const paths of [undefined, ["README.md"]]) {
3170 yield* driver.prepareCommitContext(cwd, paths);
3171 assert.deepEqual(
3172 names.map((name) => NodeFS.readFileSync(`${cwd}/.git/${name}`)),
3173 before,
3174 );
3175 }
3176 }),
3177 );
30953178 });
30963179
30973180 describe("remote operations", () => {
......@@ -3271,7 +3354,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
32713354 refName: "feature/push",
32723355 });
32733356 yield* writeTextFile(cwd, "feature.txt", "feature\n");
3274 yield* (yield* GitVcsDriver.GitVcsDriver).prepareCommitContext(cwd);
3357 yield* git(cwd, ["add", "-A"]);
32753358 yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add feature", "");
32763359
32773360 const pushed = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null);
......@@ -3343,7 +3426,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
33433426 yield* git(cwd, ["remote", "add", "origin", remote]);
33443427 yield* git(cwd, ["push", "-u", "origin", "main"]);
33453428 yield* writeTextFile(cwd, "upstream.txt", "upstream\n");
3346 yield* driver.prepareCommitContext(cwd);
3429 yield* git(cwd, ["add", "-A"]);
33473430 yield* driver.commit(cwd, "Add upstream update", "");
33483431
33493432 const pushed = yield* driver.pushCurrentBranch(cwd, null);
......@@ -3384,7 +3467,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
33843467 const devSha = yield* git(cwd, ["rev-parse", "HEAD"]);
33853468 yield* git(cwd, ["checkout", "-b", "feature/x", "origin/dev"]);
33863469 yield* writeTextFile(cwd, "feature.txt", "feature\n");
3387 yield* driver.prepareCommitContext(cwd);
3470 yield* git(cwd, ["add", "-A"]);
33883471 yield* driver.commit(cwd, "Add feature", "");
33893472
33903473 const pushed = yield* driver.pushCurrentBranch(cwd, null);
......@@ -3418,7 +3501,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
34183501 yield* git(cwd, ["checkout", "-b", "feature/y", "origin/main"]);
34193502 yield* git(cwd, ["config", "branch.feature/y.gh-merge-base", "release/v2"]);
34203503 yield* writeTextFile(cwd, "feature.txt", "feature\n");
3421 yield* driver.prepareCommitContext(cwd);
3504 yield* git(cwd, ["add", "-A"]);
34223505 yield* driver.commit(cwd, "Add feature", "");
34233506
34243507 const pushed = yield* driver.pushCurrentBranch(cwd, null);
......@@ -3456,7 +3539,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
34563539 "upstream/effect-atom",
34573540 );
34583541 yield* writeTextFile(cwd, "alias.txt", "alias\n");
3459 yield* driver.prepareCommitContext(cwd);
3542 yield* git(cwd, ["add", "-A"]);
34603543 yield* driver.commit(cwd, "Add alias update", "");
34613544
34623545 const pushed = yield* driver.pushCurrentBranch(cwd, null);
apps/server/src/vcs/GitVcsDriverCore.ts+122-35
......@@ -2025,48 +2025,132 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
20252025 })),
20262026 );
20272027
2028 const prepareCommitContext: GitVcsDriver.GitVcsDriver["Service"]["prepareCommitContext"] =
2029 Effect.fn("prepareCommitContext")(function* (cwd, filePaths) {
2030 if (filePaths && filePaths.length > 0) {
2031 yield* runGit("GitVcsDriver.prepareCommitContext.reset", cwd, ["reset"]).pipe(
2032 Effect.catchTags({
2033 GitCommandError: () => Effect.void,
2034 }),
2028 const stageCommitChanges = Effect.fnUntraced(function* (
2029 cwd: string,
2030 filePaths?: readonly string[],
2031 env?: NodeJS.ProcessEnv,
2032 ) {
2033 const config = env
2034 ? ["-c", "core.splitIndex=false", "-c", "splitIndex.sharedIndexExpire=never"]
2035 : [];
2036 if (filePaths && filePaths.length > 0) {
2037 if (env) {
2038 const head = yield* executeGit(
2039 "GitVcsDriver.prepareCommitContext.head",
2040 cwd,
2041 ["rev-parse", "--verify", "--quiet", "HEAD"],
2042 { allowNonZeroExit: true },
2043 );
2044 // Even with GIT_INDEX_FILE, reset clears the repository's merge state.
2045 yield* runGit(
2046 "GitVcsDriver.prepareCommitContext.readTree",
2047 cwd,
2048 [...config, "read-tree", ...(head.exitCode === 0 ? ["HEAD"] : ["--empty"])],
2049 { env },
20352050 );
2036 yield* runGit("GitVcsDriver.prepareCommitContext.addSelected", cwd, [
2037 "--literal-pathspecs",
2038 "add",
2039 "-A",
2040 "--",
2041 ...filePaths,
2042 ]);
20432051 } else {
2044 yield* runGit("GitVcsDriver.prepareCommitContext.addAll", cwd, ["add", "-A"]);
2052 yield* runGit("GitVcsDriver.commit.reset", cwd, ["reset"]).pipe(
2053 Effect.catchTags({ GitCommandError: () => Effect.void }),
2054 );
20452055 }
2046
2047 const stagedSummary = yield* runGitStdout(
2048 "GitVcsDriver.prepareCommitContext.stagedSummary",
2056 yield* runGit(
2057 "GitVcsDriver.commit.addSelected",
20492058 cwd,
2050 ["diff", "--cached", "--name-status"],
2051 ).pipe(Effect.map((stdout) => stdout.trim()));
2052 if (stagedSummary.length === 0) {
2053 return null;
2054 }
2055
2056 const stagedPatch = yield* runGitStdoutWithOptions(
2057 "GitVcsDriver.prepareCommitContext.stagedPatch",
2059 [...config, "--literal-pathspecs", "add", "-A", "--", ...filePaths],
2060 env ? { env } : {},
2061 );
2062 } else {
2063 yield* runGit(
2064 "GitVcsDriver.commit.addAll",
20582065 cwd,
2059 ["diff", "--no-ext-diff", "--cached", "--patch", "--minimal"],
2060 {
2061 maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES,
2062 appendTruncationMarker: true,
2063 },
2066 [...config, "add", "-A"],
2067 env ? { env } : {},
20642068 );
2069 }
2070 });
20652071
2066 return {
2067 stagedSummary,
2068 stagedPatch,
2069 };
2072 const prepareCommitContext: GitVcsDriver.GitVcsDriver["Service"]["prepareCommitContext"] =
2073 Effect.fn("prepareCommitContext")(function* (cwd, filePaths) {
2074 return yield* Effect.scoped(
2075 Effect.gen(function* () {
2076 const indexValue = yield* runGitStdout(
2077 "GitVcsDriver.prepareCommitContext.indexPath",
2078 cwd,
2079 ["rev-parse", "--git-path", "index"],
2080 );
2081 const indexPath = path.resolve(cwd, indexValue.trim());
2082 const directory = yield* fileSystem.makeTempDirectoryScoped({
2083 prefix: "t3code-commit-index-",
2084 });
2085 const tempIndexPath = path.join(directory, "index");
2086 const env = { GIT_INDEX_FILE: tempIndexPath } satisfies NodeJS.ProcessEnv;
2087 const config = [
2088 "-c",
2089 "core.splitIndex=false",
2090 "-c",
2091 "splitIndex.sharedIndexExpire=never",
2092 ];
2093 if (yield* fileSystem.exists(indexPath)) {
2094 const { mtime } = yield* fileSystem.stat(indexPath);
2095 yield* fileSystem.copyFile(indexPath, tempIndexPath);
2096 const indexTime = Option.isSome(mtime)
2097 ? Math.max(0, Math.floor(mtime.value.getTime() / 1000))
2098 : 0;
2099 yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime);
2100 yield* runGit(
2101 "GitVcsDriver.prepareCommitContext.expandSplitIndex",
2102 cwd,
2103 [...config, "update-index", "--no-split-index"],
2104 { env },
2105 );
2106 } else {
2107 yield* runGit(
2108 "GitVcsDriver.prepareCommitContext.emptyIndex",
2109 cwd,
2110 [...config, "read-tree", "--empty"],
2111 { env },
2112 );
2113 }
2114 yield* stageCommitChanges(cwd, filePaths, env);
2115
2116 const stagedSummary = yield* runGitStdoutWithOptions(
2117 "GitVcsDriver.prepareCommitContext.stagedSummary",
2118 cwd,
2119 ["diff", "--cached", "--name-status"],
2120 { env },
2121 ).pipe(Effect.map((stdout) => stdout.trim()));
2122 if (stagedSummary.length === 0) {
2123 return null;
2124 }
2125
2126 const stagedPatch = yield* runGitStdoutWithOptions(
2127 "GitVcsDriver.prepareCommitContext.stagedPatch",
2128 cwd,
2129 ["diff", "--no-ext-diff", "--cached", "--patch", "--minimal"],
2130 {
2131 env,
2132 maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES,
2133 appendTruncationMarker: true,
2134 },
2135 );
2136
2137 return {
2138 stagedSummary,
2139 stagedPatch,
2140 };
2141 }),
2142 ).pipe(
2143 Effect.catchTags({
2144 PlatformError: (cause) =>
2145 new GitCommandError({
2146 operation: "GitVcsDriver.prepareCommitContext",
2147 command: "git",
2148 cwd,
2149 detail: "Failed to prepare the temporary commit index.",
2150 cause,
2151 }),
2152 }),
2153 );
20702154 });
20712155
20722156 const commit: GitVcsDriver.GitVcsDriver["Service"]["commit"] = Effect.fn("commit")(function* (
......@@ -2075,6 +2159,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
20752159 body,
20762160 options?: GitVcsDriver.GitCommitOptions,
20772161 ) {
2162 if (options?.stage !== undefined) {
2163 yield* stageCommitChanges(cwd, options.stage.filePaths);
2164 }
20782165 const args = ["commit", "-m", subject];
20792166 const trimmedBody = body.trim();
20802167 if (trimmedBody.length > 0) {