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";
55import { describe, expect, it } from "vitest";
66
77import {
8 GitCoreService,
89 checkoutGitBranch,
910 createGitBranch,
1011 createGitWorktree,
......@@ -420,4 +421,66 @@ describe("git integration", () => {
420421 expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch);
421422 });
422423 });
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 });
423486});
apps/server/src/git.ts+258-16
......@@ -3,21 +3,52 @@ import fs from "node:fs";
33import os from "node:os";
44import path from "node:path";
55
6import type {
7 GitCheckoutInput,
8 GitCreateBranchInput,
9 GitCreateWorktreeInput,
10 GitCreateWorktreeResult,
11 GitInitInput,
12 GitListBranchesInput,
13 GitListBranchesResult,
14 GitRemoveWorktreeInput,
15 TerminalCommandInput,
16 TerminalCommandResult,
6import {
7 gitStatusInputSchema,
8 gitStatusResultSchema,
9 type GitCheckoutInput,
10 type GitCreateBranchInput,
11 type GitCreateWorktreeInput,
12 type GitCreateWorktreeResult,
13 type GitInitInput,
14 type GitListBranchesInput,
15 type GitListBranchesResult,
16 type GitRemoveWorktreeInput,
17 type GitStatusInput,
18 type GitStatusResult,
19 type TerminalCommandInput,
20 type TerminalCommandResult,
1721} 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
1950/** 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> {
2152 return new Promise((resolve, reject) => {
2253 const child = spawn("git", args, {
2354 cwd,
......@@ -54,6 +85,221 @@ function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise<Termin
5485 });
5586}
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
57303export async function runTerminalCommand(
58304 input: TerminalCommandInput,
59305): Promise<TerminalCommandResult> {
......@@ -123,7 +369,6 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL
123369 throw new Error(stderr || "git branch failed");
124370 }
125371
126 // Resolve the real default branch from the remote
127372 const [defaultRef, worktreeList] = await Promise.all([
128373 runGit(["symbolic-ref", "refs/remotes/origin/HEAD"], input.cwd, 5_000),
129374 runGit(["worktree", "list", "--porcelain"], input.cwd, 5_000),
......@@ -131,8 +376,6 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL
131376 const defaultBranch =
132377 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).
136379 const worktreeMap = new Map<string, string>();
137380 if (worktreeList.code === 0) {
138381 let currentPath: string | null = null;
......@@ -178,7 +421,6 @@ export async function createGitWorktree(
178421 const worktreePath =
179422 input.path ?? path.join(os.homedir(), ".t3", "worktrees", repoName, sanitizedBranch);
180423
181 // Create a new branch from the base branch in a new worktree
182424 const result = await runGit(
183425 ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch],
184426 input.cwd,
apps/server/src/gitManager.ts+20-217
......@@ -6,8 +6,6 @@ import path from "node:path";
66import {
77 gitRunStackedActionInputSchema,
88 gitRunStackedActionResultSchema,
9 gitStatusInputSchema,
10 gitStatusResultSchema,
119 type GitRunStackedActionInput,
1210 type GitRunStackedActionResult,
1311 type GitStatusInput,
......@@ -20,12 +18,9 @@ import type {
2018 TextGenerationService,
2119} from "./coreServices";
2220import { CodexTextGenerator } from "./codexTextGenerator";
21import { GitCoreService } from "./git";
2322import { type ProcessRunOptions, runProcess } from "./processRunner";
2423
25interface GitStatusDetails extends GitStatusResult {
26 upstreamRef: string | null;
27}
28
2924interface GitManagerDeps {
3025 runProcess?: (
3126 command: string,
......@@ -39,6 +34,7 @@ interface GitManagerDeps {
3934 timedOut: boolean;
4035 }>;
4136 textGenerator?: TextGenerationService;
37 gitCore?: GitCoreService;
4238}
4339
4440interface OpenPrInfo {
......@@ -117,15 +113,6 @@ function extractBranchFromRef(ref: string): string {
117113 return afterSlash.trim();
118114}
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
129116function asCommandNotFound(
130117 command: string,
131118 error: unknown,
......@@ -138,9 +125,6 @@ function asCommandNotFound(
138125 if (command === "codex") {
139126 return new Error("Codex CLI (`codex`) is required but not available on PATH.");
140127 }
141 if (command === "git") {
142 return new Error("Git is required but not available on PATH.");
143 }
144128 return new Error(`${command} is required but not available on PATH.`);
145129}
146130
......@@ -158,33 +142,19 @@ function normalizeGitHubAuthError(error: unknown): Error | undefined {
158142 return undefined;
159143}
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
169145export class GitManager {
170146 private readonly run: NonNullable<GitManagerDeps["runProcess"]>;
171147 private readonly textGenerator: TextGenerationService;
148 private readonly gitCore: GitCoreService;
172149
173150 constructor(deps: GitManagerDeps = {}) {
174151 this.run = deps.runProcess ?? runProcess;
175152 this.textGenerator = deps.textGenerator ?? new CodexTextGenerator();
153 this.gitCore = deps.gitCore ?? new GitCoreService();
176154 }
177155
178156 async status(raw: GitStatusInput): Promise<GitStatusResult> {
179 const input = gitStatusInputSchema.parse(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 });
157 return this.gitCore.status(raw);
188158 }
189159
190160 async runStackedAction(
......@@ -194,7 +164,7 @@ export class GitManager {
194164 const wantsPush = input.action !== "commit";
195165 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);
198168 if (wantsPush && !initialStatus.branch) {
199169 throw new Error("Cannot push from detached HEAD.");
200170 }
......@@ -205,7 +175,7 @@ export class GitManager {
205175 const commit = await this.runCommitStep(input.cwd, initialStatus.branch);
206176
207177 const push = wantsPush
208 ? await this.runPushStep(input.cwd, initialStatus.branch)
178 ? await this.gitCore.pushCurrentBranch(input.cwd, initialStatus.branch)
209179 : { status: "skipped_not_requested" as const };
210180
211181 const pr = wantsPr
......@@ -220,57 +190,6 @@ export class GitManager {
220190 });
221191 }
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
274193 private async runCommitStep(
275194 cwd: string,
276195 branch: string | null,
......@@ -279,44 +198,26 @@ export class GitManager {
279198 commitSha?: string | undefined;
280199 subject?: string | undefined;
281200 }> {
282 await this.runGit(cwd, ["add", "-A"]);
283 const stagedSummary = await this.runGitStdout(cwd, [
284 "diff",
285 "--cached",
286 "--name-status",
287 ]);
288 if (trimStdout(stagedSummary).length === 0) {
201 const context = await this.gitCore.prepareCommitContext(cwd);
202 if (!context) {
289203 return { status: "skipped_no_changes" };
290204 }
291205
292 const stagedPatch = await this.runGitStdout(cwd, [
293 "diff",
294 "--cached",
295 "--patch",
296 "--minimal",
297 ]);
298
299206 let generated: CommitMessageGenerationResult;
300207 try {
301208 generated = sanitizeCommitMessage(
302209 await this.textGenerator.generateCommitMessage({
303210 cwd,
304211 branch,
305 stagedSummary: limitContext(stagedSummary, 8_000),
306 stagedPatch: limitContext(stagedPatch, 50_000),
212 stagedSummary: limitContext(context.stagedSummary, 8_000),
213 stagedPatch: limitContext(context.stagedPatch, 50_000),
307214 }),
308215 );
309216 } catch (error) {
310217 throw asCommandNotFound("codex", error) ?? error;
311218 }
312219
313 const commitArgs = ["commit", "-m", generated.subject];
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"]));
220 const { commitSha } = await this.gitCore.commit(cwd, generated.subject, generated.body);
320221 return {
321222 status: "created",
322223 commitSha,
......@@ -324,48 +225,6 @@ export class GitManager {
324225 };
325226 }
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
369228 private async runPrStep(
370229 cwd: string,
371230 fallbackBranch: string | null,
......@@ -377,7 +236,7 @@ export class GitManager {
377236 headBranch?: string | undefined;
378237 title?: string | undefined;
379238 }> {
380 const details = await this.readStatusDetails(cwd);
239 const details = await this.gitCore.statusDetails(cwd);
381240 const branch = details.branch ?? fallbackBranch;
382241 if (!branch) {
383242 throw new Error("Cannot create a pull request from detached HEAD.");
......@@ -400,22 +259,7 @@ export class GitManager {
400259 }
401260
402261 const baseBranch = await this.resolveBaseBranch(cwd, branch, details.upstreamRef);
403 const commitSummary = await this.runGitStdout(cwd, [
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 ]);
262 const rangeContext = await this.gitCore.readRangeContext(cwd, baseBranch);
419263
420264 let generated: PrContentGenerationResult;
421265 try {
......@@ -423,9 +267,9 @@ export class GitManager {
423267 cwd,
424268 baseBranch,
425269 headBranch: branch,
426 commitSummary: limitContext(commitSummary, 20_000),
427 diffSummary: limitContext(diffSummary, 20_000),
428 diffPatch: limitContext(diffPatch, 60_000),
270 commitSummary: limitContext(rangeContext.commitSummary, 20_000),
271 diffSummary: limitContext(rangeContext.diffSummary, 20_000),
272 diffPatch: limitContext(rangeContext.diffPatch, 60_000),
429273 });
430274 } catch (error) {
431275 throw asCommandNotFound("codex", error) ?? error;
......@@ -524,13 +368,11 @@ export class GitManager {
524368 branch: string,
525369 upstreamRef: string | null,
526370 ): Promise<string> {
527 const mergeBaseConfig = await this.runGitStdout(
371 const configured = await this.gitCore.readConfigValue(
528372 cwd,
529 ["config", "--get", `branch.${branch}.gh-merge-base`],
530 true,
373 `branch.${branch}.gh-merge-base`,
531374 );
532 const configured = trimStdout(mergeBaseConfig);
533 if (configured.length > 0) return configured;
375 if (configured) return configured;
534376
535377 if (upstreamRef) {
536378 const upstreamBranch = extractBranchFromRef(upstreamRef);
......@@ -557,45 +399,6 @@ export class GitManager {
557399 return "main";
558400 }
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
599402 private async runGh(cwd: string, args: readonly string[]): Promise<void> {
600403 try {
601404 await this.run("gh", args, { cwd });