authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-11 17:40:23-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-11 17:40:23-08:00
log98eb87f5cd1a448bd4d237242bf87d4bad0a1b31
tree9a2efa97ccc97e0918c5c3e92194336a3b68d1c5
parent1768d3166965187f6d769e228783ae1250a2f6f0

Extract git core interfaces


3 files changed, 341 insertions(+), 233 deletions(-)

apps/server/src/git.test.ts+63
...@@ -5,6 +5,7 @@ import path from "node:path";...@@ -5,6 +5,7 @@ import path from "node:path";
5import { describe, expect, it } from "vitest";5import { describe, expect, it } from "vitest";
66
7import {7import {
8 GitCoreService,
8 checkoutGitBranch,9 checkoutGitBranch,
9 createGitBranch,10 createGitBranch,
10 createGitWorktree,11 createGitWorktree,
...@@ -420,4 +421,66 @@ describe("git integration", () => {...@@ -420,4 +421,66 @@ describe("git integration", () => {
420 expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch);421 expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch);
421 });422 });
422 });423 });
424
425 describe("GitCoreService", () => {
426 it("reports status details and dirty state", async () => {
427 await using tmp = await makeTmpDir();
428 await initRepoWithCommit(tmp.path);
429 const core = new GitCoreService();
430
431 const clean = await core.status({ cwd: tmp.path });
432 expect(clean.hasWorkingTreeChanges).toBe(false);
433 expect(clean.branch).toBeTruthy();
434
435 await writeFile(path.join(tmp.path, "README.md"), "updated\n");
436 const dirty = await core.statusDetails(tmp.path);
437 expect(dirty.hasWorkingTreeChanges).toBe(true);
438 });
439
440 it("prepares commit context by auto-staging and creates commit", async () => {
441 await using tmp = await makeTmpDir();
442 await initRepoWithCommit(tmp.path);
443 const core = new GitCoreService();
444
445 await writeFile(path.join(tmp.path, "README.md"), "new content\n");
446 const context = await core.prepareCommitContext(tmp.path);
447 expect(context).not.toBeNull();
448 expect(context!.stagedSummary.length).toBeGreaterThan(0);
449 expect(context!.stagedPatch.length).toBeGreaterThan(0);
450
451 const created = await core.commit(
452 tmp.path,
453 "Add README update",
454 "- include updated content",
455 );
456 expect(created.commitSha.length).toBeGreaterThan(0);
457 expect(await git(tmp.path, "log -1 --pretty=%s")).toBe("Add README update");
458 });
459
460 it("pushes with upstream setup and then skips when up to date", async () => {
461 await using tmp = await makeTmpDir();
462 await using remote = await makeTmpDir();
463 await initRepoWithCommit(tmp.path);
464 await git(remote.path, "init --bare");
465 await git(tmp.path, `remote add origin '${remote.path}'`);
466 await createGitBranch({ cwd: tmp.path, branch: "feature/core-push" });
467 await checkoutGitBranch({ cwd: tmp.path, branch: "feature/core-push" });
468
469 await writeFile(path.join(tmp.path, "feature.txt"), "push me\n");
470 const core = new GitCoreService();
471 const context = await core.prepareCommitContext(tmp.path);
472 expect(context).not.toBeNull();
473 await core.commit(tmp.path, "Add feature file", "");
474
475 const pushed = await core.pushCurrentBranch(tmp.path, null);
476 expect(pushed.status).toBe("pushed");
477 expect(pushed.setUpstream).toBe(true);
478 expect(await git(tmp.path, "rev-parse --abbrev-ref @{upstream}")).toBe(
479 "origin/feature/core-push",
480 );
481
482 const skipped = await core.pushCurrentBranch(tmp.path, null);
483 expect(skipped.status).toBe("skipped_up_to_date");
484 });
485 });
423});486});
apps/server/src/git.ts+258-16
...@@ -3,21 +3,52 @@ import fs from "node:fs";...@@ -3,21 +3,52 @@ import fs from "node:fs";
3import os from "node:os";3import os from "node:os";
4import path from "node:path";4import path from "node:path";
55
6import type {6import {
7 GitCheckoutInput,7 gitStatusInputSchema,
8 GitCreateBranchInput,8 gitStatusResultSchema,
9 GitCreateWorktreeInput,9 type GitCheckoutInput,
10 GitCreateWorktreeResult,10 type GitCreateBranchInput,
11 GitInitInput,11 type GitCreateWorktreeInput,
12 GitListBranchesInput,12 type GitCreateWorktreeResult,
13 GitListBranchesResult,13 type GitInitInput,
14 GitRemoveWorktreeInput,14 type GitListBranchesInput,
15 TerminalCommandInput,15 type GitListBranchesResult,
16 TerminalCommandResult,16 type GitRemoveWorktreeInput,
17 type GitStatusInput,
18 type GitStatusResult,
19 type TerminalCommandInput,
20 type TerminalCommandResult,
17} from "@t3tools/contracts";21} from "@t3tools/contracts";
1822
23export interface GitStatusDetails extends GitStatusResult {
24 upstreamRef: string | null;
25}
26
27export interface GitPreparedCommitContext {
28 stagedSummary: string;
29 stagedPatch: string;
30}
31
32export interface GitPushResult {
33 status: "pushed" | "skipped_up_to_date";
34 branch: string;
35 upstreamBranch?: string | undefined;
36 setUpstream?: boolean | undefined;
37}
38
39export interface GitRangeContext {
40 commitSummary: string;
41 diffSummary: string;
42 diffPatch: string;
43}
44
45interface RunGitOptions {
46 timeoutMs?: number | undefined;
47 allowNonZeroExit?: boolean | undefined;
48}
49
19/** Spawn git directly with an argv array — no shell, no quoting needed. */50/** Spawn git directly with an argv array — no shell, no quoting needed. */
20function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise<TerminalCommandResult> {51function runGit(args: readonly string[], cwd: string, timeoutMs = 30_000): Promise<TerminalCommandResult> {
21 return new Promise((resolve, reject) => {52 return new Promise((resolve, reject) => {
22 const child = spawn("git", args, {53 const child = spawn("git", args, {
23 cwd,54 cwd,
...@@ -54,6 +85,221 @@ function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise<Termin...@@ -54,6 +85,221 @@ function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise<Termin
54 });85 });
55}86}
5687
88function trimStdout(value: string): string {
89 return value.trim();
90}
91
92function parseBranchAb(value: string): { ahead: number; behind: number } {
93 const match = value.match(/^\+(\d+)\s+-(\d+)$/);
94 if (!match) return { ahead: 0, behind: 0 };
95 return {
96 ahead: Number(match[1] ?? "0"),
97 behind: Number(match[2] ?? "0"),
98 };
99}
100
101function commandLabel(args: readonly string[]): string {
102 return `git ${args.join(" ")}`;
103}
104
105function normalizeGitSpawnError(error: unknown): Error {
106 if (!(error instanceof Error)) {
107 return new Error("Failed to run git command.");
108 }
109 const code = (error as NodeJS.ErrnoException).code;
110 if (code === "ENOENT") {
111 return new Error("Git is required but not available on PATH.");
112 }
113 return new Error(`Failed to run git command: ${error.message}`);
114}
115
116function normalizeGitExecutionError(args: readonly string[], result: TerminalCommandResult): Error {
117 const stderr = result.stderr.trim();
118 if (stderr.toLowerCase().includes("not a git repository")) {
119 return new Error("Current folder is not a git repository.");
120 }
121 if (result.timedOut) {
122 return new Error(`${commandLabel(args)} timed out.`);
123 }
124 const detail =
125 stderr.length > 0 ? stderr : `code=${result.code ?? "null"}, signal=${result.signal ?? "null"}`;
126 return new Error(`${commandLabel(args)} failed: ${detail}`);
127}
128
129async function runGitOrThrow(
130 cwd: string,
131 args: readonly string[],
132 options: RunGitOptions = {},
133): Promise<TerminalCommandResult> {
134 let result: TerminalCommandResult;
135 try {
136 result = await runGit(args, cwd, options.timeoutMs);
137 } catch (error) {
138 throw normalizeGitSpawnError(error);
139 }
140
141 if (!options.allowNonZeroExit && (result.code !== 0 || result.timedOut)) {
142 throw normalizeGitExecutionError(args, result);
143 }
144 return result;
145}
146
147export class GitCoreService {
148 async status(raw: GitStatusInput): Promise<GitStatusResult> {
149 const input = gitStatusInputSchema.parse(raw);
150 const details = await this.statusDetails(input.cwd);
151 return gitStatusResultSchema.parse({
152 branch: details.branch,
153 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
154 hasUpstream: details.hasUpstream,
155 aheadCount: details.aheadCount,
156 behindCount: details.behindCount,
157 });
158 }
159
160 async statusDetails(cwd: string): Promise<GitStatusDetails> {
161 const stdout = await this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]);
162
163 let branch: string | null = null;
164 let upstreamRef: string | null = null;
165 let aheadCount = 0;
166 let behindCount = 0;
167 let hasWorkingTreeChanges = false;
168
169 for (const line of stdout.split(/\r?\n/g)) {
170 if (line.startsWith("# branch.head ")) {
171 const value = line.slice("# branch.head ".length).trim();
172 branch = value.startsWith("(") ? null : value;
173 continue;
174 }
175 if (line.startsWith("# branch.upstream ")) {
176 const value = line.slice("# branch.upstream ".length).trim();
177 upstreamRef = value.length > 0 ? value : null;
178 continue;
179 }
180 if (line.startsWith("# branch.ab ")) {
181 const value = line.slice("# branch.ab ".length).trim();
182 const parsed = parseBranchAb(value);
183 aheadCount = parsed.ahead;
184 behindCount = parsed.behind;
185 continue;
186 }
187 if (line.trim().length > 0 && !line.startsWith("#")) {
188 hasWorkingTreeChanges = true;
189 }
190 }
191
192 return {
193 branch,
194 upstreamRef,
195 hasWorkingTreeChanges,
196 hasUpstream: upstreamRef !== null,
197 aheadCount,
198 behindCount,
199 };
200 }
201
202 async prepareCommitContext(cwd: string): Promise<GitPreparedCommitContext | null> {
203 await this.git(cwd, ["add", "-A"]);
204
205 const stagedSummary = await this.gitStdout(cwd, [
206 "diff",
207 "--cached",
208 "--name-status",
209 ]);
210 if (trimStdout(stagedSummary).length === 0) {
211 return null;
212 }
213
214 const stagedPatch = await this.gitStdout(cwd, [
215 "diff",
216 "--cached",
217 "--patch",
218 "--minimal",
219 ]);
220
221 return {
222 stagedSummary,
223 stagedPatch,
224 };
225 }
226
227 async commit(cwd: string, subject: string, body: string): Promise<{ commitSha: string }> {
228 const args = ["commit", "-m", subject];
229 const trimmedBody = body.trim();
230 if (trimmedBody.length > 0) {
231 args.push("-m", trimmedBody);
232 }
233 await this.git(cwd, args);
234 const commitSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"]));
235 return { commitSha };
236 }
237
238 async pushCurrentBranch(cwd: string, fallbackBranch: string | null): Promise<GitPushResult> {
239 const details = await this.statusDetails(cwd);
240 const branch = details.branch ?? fallbackBranch;
241 if (!branch) {
242 throw new Error("Cannot push from detached HEAD.");
243 }
244
245 if (details.hasUpstream && details.aheadCount === 0) {
246 return {
247 status: "skipped_up_to_date",
248 branch,
249 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
250 };
251 }
252
253 if (!details.hasUpstream) {
254 await this.git(cwd, ["push", "-u", "origin", branch]);
255 return {
256 status: "pushed",
257 branch,
258 upstreamBranch: `origin/${branch}`,
259 setUpstream: true,
260 };
261 }
262
263 await this.git(cwd, ["push"]);
264 return {
265 status: "pushed",
266 branch,
267 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
268 setUpstream: false,
269 };
270 }
271
272 async readRangeContext(cwd: string, baseBranch: string): Promise<GitRangeContext> {
273 const range = `${baseBranch}..HEAD`;
274 const [commitSummary, diffSummary, diffPatch] = await Promise.all([
275 this.gitStdout(cwd, ["log", "--oneline", range]),
276 this.gitStdout(cwd, ["diff", "--stat", range]),
277 this.gitStdout(cwd, ["diff", "--patch", "--minimal", range]),
278 ]);
279
280 return {
281 commitSummary,
282 diffSummary,
283 diffPatch,
284 };
285 }
286
287 async readConfigValue(cwd: string, key: string): Promise<string | null> {
288 const stdout = await this.gitStdout(cwd, ["config", "--get", key], true);
289 const value = trimStdout(stdout);
290 return value.length > 0 ? value : null;
291 }
292
293 async git(cwd: string, args: readonly string[], allowNonZeroExit = false): Promise<void> {
294 await runGitOrThrow(cwd, args, { allowNonZeroExit });
295 }
296
297 async gitStdout(cwd: string, args: readonly string[], allowNonZeroExit = false): Promise<string> {
298 const result = await runGitOrThrow(cwd, args, { allowNonZeroExit });
299 return result.stdout;
300 }
301}
302
57export async function runTerminalCommand(303export async function runTerminalCommand(
58 input: TerminalCommandInput,304 input: TerminalCommandInput,
59): Promise<TerminalCommandResult> {305): Promise<TerminalCommandResult> {
...@@ -123,7 +369,6 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL...@@ -123,7 +369,6 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL
123 throw new Error(stderr || "git branch failed");369 throw new Error(stderr || "git branch failed");
124 }370 }
125371
126 // Resolve the real default branch from the remote
127 const [defaultRef, worktreeList] = await Promise.all([372 const [defaultRef, worktreeList] = await Promise.all([
128 runGit(["symbolic-ref", "refs/remotes/origin/HEAD"], input.cwd, 5_000),373 runGit(["symbolic-ref", "refs/remotes/origin/HEAD"], input.cwd, 5_000),
129 runGit(["worktree", "list", "--porcelain"], input.cwd, 5_000),374 runGit(["worktree", "list", "--porcelain"], input.cwd, 5_000),
...@@ -131,8 +376,6 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL...@@ -131,8 +376,6 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL
131 const defaultBranch =376 const defaultBranch =
132 defaultRef.code === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null;377 defaultRef.code === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null;
133378
134 // Build branch-name → worktree-path map from porcelain output.
135 // Only include worktrees whose directories still exist on disk (skip prunable/stale ones).
136 const worktreeMap = new Map<string, string>();379 const worktreeMap = new Map<string, string>();
137 if (worktreeList.code === 0) {380 if (worktreeList.code === 0) {
138 let currentPath: string | null = null;381 let currentPath: string | null = null;
...@@ -178,7 +421,6 @@ export async function createGitWorktree(...@@ -178,7 +421,6 @@ export async function createGitWorktree(
178 const worktreePath =421 const worktreePath =
179 input.path ?? path.join(os.homedir(), ".t3", "worktrees", repoName, sanitizedBranch);422 input.path ?? path.join(os.homedir(), ".t3", "worktrees", repoName, sanitizedBranch);
180423
181 // Create a new branch from the base branch in a new worktree
182 const result = await runGit(424 const result = await runGit(
183 ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch],425 ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch],
184 input.cwd,426 input.cwd,
apps/server/src/gitManager.ts+20-217
...@@ -6,8 +6,6 @@ import path from "node:path";...@@ -6,8 +6,6 @@ import path from "node:path";
6import {6import {
7 gitRunStackedActionInputSchema,7 gitRunStackedActionInputSchema,
8 gitRunStackedActionResultSchema,8 gitRunStackedActionResultSchema,
9 gitStatusInputSchema,
10 gitStatusResultSchema,
11 type GitRunStackedActionInput,9 type GitRunStackedActionInput,
12 type GitRunStackedActionResult,10 type GitRunStackedActionResult,
13 type GitStatusInput,11 type GitStatusInput,
...@@ -20,12 +18,9 @@ import type {...@@ -20,12 +18,9 @@ import type {
20 TextGenerationService,18 TextGenerationService,
21} from "./coreServices";19} from "./coreServices";
22import { CodexTextGenerator } from "./codexTextGenerator";20import { CodexTextGenerator } from "./codexTextGenerator";
21import { GitCoreService } from "./git";
23import { type ProcessRunOptions, runProcess } from "./processRunner";22import { type ProcessRunOptions, runProcess } from "./processRunner";
2423
25interface GitStatusDetails extends GitStatusResult {
26 upstreamRef: string | null;
27}
28
29interface GitManagerDeps {24interface GitManagerDeps {
30 runProcess?: (25 runProcess?: (
31 command: string,26 command: string,
...@@ -39,6 +34,7 @@ interface GitManagerDeps {...@@ -39,6 +34,7 @@ interface GitManagerDeps {
39 timedOut: boolean;34 timedOut: boolean;
40 }>;35 }>;
41 textGenerator?: TextGenerationService;36 textGenerator?: TextGenerationService;
37 gitCore?: GitCoreService;
42}38}
4339
44interface OpenPrInfo {40interface OpenPrInfo {
...@@ -117,15 +113,6 @@ function extractBranchFromRef(ref: string): string {...@@ -117,15 +113,6 @@ function extractBranchFromRef(ref: string): string {
117 return afterSlash.trim();113 return afterSlash.trim();
118}114}
119115
120function parseBranchAb(value: string): { ahead: number; behind: number } {
121 const match = value.match(/^\+(\d+)\s+-(\d+)$/);
122 if (!match) return { ahead: 0, behind: 0 };
123 return {
124 ahead: Number(match[1] ?? "0"),
125 behind: Number(match[2] ?? "0"),
126 };
127}
128
129function asCommandNotFound(116function asCommandNotFound(
130 command: string,117 command: string,
131 error: unknown,118 error: unknown,
...@@ -138,9 +125,6 @@ function asCommandNotFound(...@@ -138,9 +125,6 @@ function asCommandNotFound(
138 if (command === "codex") {125 if (command === "codex") {
139 return new Error("Codex CLI (`codex`) is required but not available on PATH.");126 return new Error("Codex CLI (`codex`) is required but not available on PATH.");
140 }127 }
141 if (command === "git") {
142 return new Error("Git is required but not available on PATH.");
143 }
144 return new Error(`${command} is required but not available on PATH.`);128 return new Error(`${command} is required but not available on PATH.`);
145}129}
146130
...@@ -158,33 +142,19 @@ function normalizeGitHubAuthError(error: unknown): Error | undefined {...@@ -158,33 +142,19 @@ function normalizeGitHubAuthError(error: unknown): Error | undefined {
158 return undefined;142 return undefined;
159}143}
160144
161function normalizeNotGitRepoError(error: unknown): Error | undefined {
162 if (!(error instanceof Error)) return undefined;
163 if (error.message.toLowerCase().includes("not a git repository")) {
164 return new Error("Current folder is not a git repository.");
165 }
166 return undefined;
167}
168
169export class GitManager {145export class GitManager {
170 private readonly run: NonNullable<GitManagerDeps["runProcess"]>;146 private readonly run: NonNullable<GitManagerDeps["runProcess"]>;
171 private readonly textGenerator: TextGenerationService;147 private readonly textGenerator: TextGenerationService;
148 private readonly gitCore: GitCoreService;
172149
173 constructor(deps: GitManagerDeps = {}) {150 constructor(deps: GitManagerDeps = {}) {
174 this.run = deps.runProcess ?? runProcess;151 this.run = deps.runProcess ?? runProcess;
175 this.textGenerator = deps.textGenerator ?? new CodexTextGenerator();152 this.textGenerator = deps.textGenerator ?? new CodexTextGenerator();
153 this.gitCore = deps.gitCore ?? new GitCoreService();
176 }154 }
177155
178 async status(raw: GitStatusInput): Promise<GitStatusResult> {156 async status(raw: GitStatusInput): Promise<GitStatusResult> {
179 const input = gitStatusInputSchema.parse(raw);157 return this.gitCore.status(raw);
180 const details = await this.readStatusDetails(input.cwd);
181 return gitStatusResultSchema.parse({
182 branch: details.branch,
183 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
184 hasUpstream: details.hasUpstream,
185 aheadCount: details.aheadCount,
186 behindCount: details.behindCount,
187 });
188 }158 }
189159
190 async runStackedAction(160 async runStackedAction(
...@@ -194,7 +164,7 @@ export class GitManager {...@@ -194,7 +164,7 @@ export class GitManager {
194 const wantsPush = input.action !== "commit";164 const wantsPush = input.action !== "commit";
195 const wantsPr = input.action === "commit_push_pr";165 const wantsPr = input.action === "commit_push_pr";
196166
197 const initialStatus = await this.readStatusDetails(input.cwd);167 const initialStatus = await this.gitCore.statusDetails(input.cwd);
198 if (wantsPush && !initialStatus.branch) {168 if (wantsPush && !initialStatus.branch) {
199 throw new Error("Cannot push from detached HEAD.");169 throw new Error("Cannot push from detached HEAD.");
200 }170 }
...@@ -205,7 +175,7 @@ export class GitManager {...@@ -205,7 +175,7 @@ export class GitManager {
205 const commit = await this.runCommitStep(input.cwd, initialStatus.branch);175 const commit = await this.runCommitStep(input.cwd, initialStatus.branch);
206176
207 const push = wantsPush177 const push = wantsPush
208 ? await this.runPushStep(input.cwd, initialStatus.branch)178 ? await this.gitCore.pushCurrentBranch(input.cwd, initialStatus.branch)
209 : { status: "skipped_not_requested" as const };179 : { status: "skipped_not_requested" as const };
210180
211 const pr = wantsPr181 const pr = wantsPr
...@@ -220,57 +190,6 @@ export class GitManager {...@@ -220,57 +190,6 @@ export class GitManager {
220 });190 });
221 }191 }
222192
223 private async readStatusDetails(cwd: string): Promise<GitStatusDetails> {
224 let stdout = "";
225 try {
226 stdout = await this.runGitStdout(cwd, ["status", "--porcelain=2", "--branch"]);
227 } catch (error) {
228 throw (
229 asCommandNotFound("git", error) ??
230 normalizeNotGitRepoError(error) ??
231 (error instanceof Error ? error : new Error("Failed to read git status."))
232 );
233 }
234
235 let branch: string | null = null;
236 let upstreamRef: string | null = null;
237 let aheadCount = 0;
238 let behindCount = 0;
239 let hasWorkingTreeChanges = false;
240
241 for (const line of stdout.split(/\r?\n/g)) {
242 if (line.startsWith("# branch.head ")) {
243 const value = line.slice("# branch.head ".length).trim();
244 branch = value.startsWith("(") ? null : value;
245 continue;
246 }
247 if (line.startsWith("# branch.upstream ")) {
248 const value = line.slice("# branch.upstream ".length).trim();
249 upstreamRef = value.length > 0 ? value : null;
250 continue;
251 }
252 if (line.startsWith("# branch.ab ")) {
253 const value = line.slice("# branch.ab ".length).trim();
254 const parsed = parseBranchAb(value);
255 aheadCount = parsed.ahead;
256 behindCount = parsed.behind;
257 continue;
258 }
259 if (line.trim().length > 0 && !line.startsWith("#")) {
260 hasWorkingTreeChanges = true;
261 }
262 }
263
264 return {
265 branch,
266 upstreamRef,
267 hasWorkingTreeChanges,
268 hasUpstream: upstreamRef !== null,
269 aheadCount,
270 behindCount,
271 };
272 }
273
274 private async runCommitStep(193 private async runCommitStep(
275 cwd: string,194 cwd: string,
276 branch: string | null,195 branch: string | null,
...@@ -279,44 +198,26 @@ export class GitManager {...@@ -279,44 +198,26 @@ export class GitManager {
279 commitSha?: string | undefined;198 commitSha?: string | undefined;
280 subject?: string | undefined;199 subject?: string | undefined;
281 }> {200 }> {
282 await this.runGit(cwd, ["add", "-A"]);201 const context = await this.gitCore.prepareCommitContext(cwd);
283 const stagedSummary = await this.runGitStdout(cwd, [202 if (!context) {
284 "diff",
285 "--cached",
286 "--name-status",
287 ]);
288 if (trimStdout(stagedSummary).length === 0) {
289 return { status: "skipped_no_changes" };203 return { status: "skipped_no_changes" };
290 }204 }
291205
292 const stagedPatch = await this.runGitStdout(cwd, [
293 "diff",
294 "--cached",
295 "--patch",
296 "--minimal",
297 ]);
298
299 let generated: CommitMessageGenerationResult;206 let generated: CommitMessageGenerationResult;
300 try {207 try {
301 generated = sanitizeCommitMessage(208 generated = sanitizeCommitMessage(
302 await this.textGenerator.generateCommitMessage({209 await this.textGenerator.generateCommitMessage({
303 cwd,210 cwd,
304 branch,211 branch,
305 stagedSummary: limitContext(stagedSummary, 8_000),212 stagedSummary: limitContext(context.stagedSummary, 8_000),
306 stagedPatch: limitContext(stagedPatch, 50_000),213 stagedPatch: limitContext(context.stagedPatch, 50_000),
307 }),214 }),
308 );215 );
309 } catch (error) {216 } catch (error) {
310 throw asCommandNotFound("codex", error) ?? error;217 throw asCommandNotFound("codex", error) ?? error;
311 }218 }
312219
313 const commitArgs = ["commit", "-m", generated.subject];220 const { commitSha } = await this.gitCore.commit(cwd, generated.subject, generated.body);
314 if (generated.body.length > 0) {
315 commitArgs.push("-m", generated.body);
316 }
317 await this.runGit(cwd, commitArgs);
318
319 const commitSha = trimStdout(await this.runGitStdout(cwd, ["rev-parse", "HEAD"]));
320 return {221 return {
321 status: "created",222 status: "created",
322 commitSha,223 commitSha,
...@@ -324,48 +225,6 @@ export class GitManager {...@@ -324,48 +225,6 @@ export class GitManager {
324 };225 };
325 }226 }
326227
327 private async runPushStep(
328 cwd: string,
329 fallbackBranch: string | null,
330 ): Promise<{
331 status: "pushed" | "skipped_not_requested" | "skipped_up_to_date";
332 branch?: string | undefined;
333 upstreamBranch?: string | undefined;
334 setUpstream?: boolean | undefined;
335 }> {
336 const details = await this.readStatusDetails(cwd);
337 const branch = details.branch ?? fallbackBranch;
338 if (!branch) {
339 throw new Error("Cannot push from detached HEAD.");
340 }
341
342 if (details.hasUpstream && details.aheadCount === 0) {
343 return {
344 status: "skipped_up_to_date",
345 branch,
346 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
347 };
348 }
349
350 if (!details.hasUpstream) {
351 await this.runGit(cwd, ["push", "-u", "origin", branch]);
352 return {
353 status: "pushed",
354 branch,
355 upstreamBranch: `origin/${branch}`,
356 setUpstream: true,
357 };
358 }
359
360 await this.runGit(cwd, ["push"]);
361 return {
362 status: "pushed",
363 branch,
364 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
365 setUpstream: false,
366 };
367 }
368
369 private async runPrStep(228 private async runPrStep(
370 cwd: string,229 cwd: string,
371 fallbackBranch: string | null,230 fallbackBranch: string | null,
...@@ -377,7 +236,7 @@ export class GitManager {...@@ -377,7 +236,7 @@ export class GitManager {
377 headBranch?: string | undefined;236 headBranch?: string | undefined;
378 title?: string | undefined;237 title?: string | undefined;
379 }> {238 }> {
380 const details = await this.readStatusDetails(cwd);239 const details = await this.gitCore.statusDetails(cwd);
381 const branch = details.branch ?? fallbackBranch;240 const branch = details.branch ?? fallbackBranch;
382 if (!branch) {241 if (!branch) {
383 throw new Error("Cannot create a pull request from detached HEAD.");242 throw new Error("Cannot create a pull request from detached HEAD.");
...@@ -400,22 +259,7 @@ export class GitManager {...@@ -400,22 +259,7 @@ export class GitManager {
400 }259 }
401260
402 const baseBranch = await this.resolveBaseBranch(cwd, branch, details.upstreamRef);261 const baseBranch = await this.resolveBaseBranch(cwd, branch, details.upstreamRef);
403 const commitSummary = await this.runGitStdout(cwd, [262 const rangeContext = await this.gitCore.readRangeContext(cwd, baseBranch);
404 "log",
405 "--oneline",
406 `${baseBranch}..HEAD`,
407 ]);
408 const diffSummary = await this.runGitStdout(cwd, [
409 "diff",
410 "--stat",
411 `${baseBranch}..HEAD`,
412 ]);
413 const diffPatch = await this.runGitStdout(cwd, [
414 "diff",
415 "--patch",
416 "--minimal",
417 `${baseBranch}..HEAD`,
418 ]);
419263
420 let generated: PrContentGenerationResult;264 let generated: PrContentGenerationResult;
421 try {265 try {
...@@ -423,9 +267,9 @@ export class GitManager {...@@ -423,9 +267,9 @@ export class GitManager {
423 cwd,267 cwd,
424 baseBranch,268 baseBranch,
425 headBranch: branch,269 headBranch: branch,
426 commitSummary: limitContext(commitSummary, 20_000),270 commitSummary: limitContext(rangeContext.commitSummary, 20_000),
427 diffSummary: limitContext(diffSummary, 20_000),271 diffSummary: limitContext(rangeContext.diffSummary, 20_000),
428 diffPatch: limitContext(diffPatch, 60_000),272 diffPatch: limitContext(rangeContext.diffPatch, 60_000),
429 });273 });
430 } catch (error) {274 } catch (error) {
431 throw asCommandNotFound("codex", error) ?? error;275 throw asCommandNotFound("codex", error) ?? error;
...@@ -524,13 +368,11 @@ export class GitManager {...@@ -524,13 +368,11 @@ export class GitManager {
524 branch: string,368 branch: string,
525 upstreamRef: string | null,369 upstreamRef: string | null,
526 ): Promise<string> {370 ): Promise<string> {
527 const mergeBaseConfig = await this.runGitStdout(371 const configured = await this.gitCore.readConfigValue(
528 cwd,372 cwd,
529 ["config", "--get", `branch.${branch}.gh-merge-base`],373 `branch.${branch}.gh-merge-base`,
530 true,
531 );374 );
532 const configured = trimStdout(mergeBaseConfig);375 if (configured) return configured;
533 if (configured.length > 0) return configured;
534376
535 if (upstreamRef) {377 if (upstreamRef) {
536 const upstreamBranch = extractBranchFromRef(upstreamRef);378 const upstreamBranch = extractBranchFromRef(upstreamRef);
...@@ -557,45 +399,6 @@ export class GitManager {...@@ -557,45 +399,6 @@ export class GitManager {
557 return "main";399 return "main";
558 }400 }
559401
560 private async runGit(
561 cwd: string,
562 args: readonly string[],
563 allowNonZeroExit = false,
564 ): Promise<void> {
565 try {
566 await this.run("git", args, {
567 cwd,
568 allowNonZeroExit,
569 });
570 } catch (error) {
571 throw (
572 asCommandNotFound("git", error) ??
573 normalizeNotGitRepoError(error) ??
574 (error instanceof Error ? error : new Error("Git command failed."))
575 );
576 }
577 }
578
579 private async runGitStdout(
580 cwd: string,
581 args: readonly string[],
582 allowNonZeroExit = false,
583 ): Promise<string> {
584 try {
585 const result = await this.run("git", args, {
586 cwd,
587 allowNonZeroExit,
588 });
589 return result.stdout;
590 } catch (error) {
591 throw (
592 asCommandNotFound("git", error) ??
593 normalizeNotGitRepoError(error) ??
594 (error instanceof Error ? error : new Error("Git command failed."))
595 );
596 }
597 }
598
599 private async runGh(cwd: string, args: readonly string[]): Promise<void> {402 private async runGh(cwd: string, args: readonly string[]): Promise<void> {
600 try {403 try {
601 await this.run("gh", args, { cwd });404 await this.run("gh", args, { cwd });