History

Extract git core interfaces

Julius Marminge committed 9 months ago

Commit 98eb87f1 parent 1768d313 files · +341 −233Browse files

Unified · Split

3 files
  1. apps/server/src/git.test.ts +63 −0
  2. apps/server/src/git.ts +258 −16
  3. apps/server/src/gitManager.ts +20 −217
apps/server/src/git.test.ts+63−0
@@ -5,6 +5,7 @@ import path from "node:path"; · 4 unmodified lines
55 import { describe, expect, it } from "vitest";
66
77 import {
8+ GitCoreService,
89 checkoutGitBranch,
910 createGitBranch,
1011 createGitWorktree,
@@ -420,4 +421,66 @@ describe("git integration", () => { · 409 unmodified lines
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"; · 2 unmodified lines
33 import os from "node:os";
44 import path from "node:path";
55
6−import type {
7− GitCheckoutInput,
8− GitCreateBranchInput,
9− GitCreateWorktreeInput,
10− GitCreateWorktreeResult,
11− GitInitInput,
12− GitListBranchesInput,
13− GitListBranchesResult,
14− GitRemoveWorktreeInput,
15− TerminalCommandInput,
16− TerminalCommandResult,
6+import {
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
23+export interface GitStatusDetails extends GitStatusResult {
24+ upstreamRef: string | null;
25+}
26+
27+export interface GitPreparedCommitContext {
28+ stagedSummary: string;
29+ stagedPatch: string;
30+}
31+
32+export interface GitPushResult {
33+ status: "pushed" | "skipped_up_to_date";
34+ branch: string;
35+ upstreamBranch?: string | undefined;
36+ setUpstream?: boolean | undefined;
37+}
38+
39+export interface GitRangeContext {
40+ commitSummary: string;
41+ diffSummary: string;
42+ diffPatch: string;
43+}
44+
45+interface RunGitOptions {
46+ timeoutMs?: number | undefined;
47+ allowNonZeroExit?: boolean | undefined;
48+}
49+
1950 /** Spawn git directly with an argv array — no shell, no quoting needed. */
20−function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise<TerminalCommandResult> {
51+function 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 · 30 unmodified lines
5485 });
5586 }
5687
88+function trimStdout(value: string): string {
89+ return value.trim();
90+}
91+
92+function 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+
101+function commandLabel(args: readonly string[]): string {
102+ return `git ${args.join(" ")}`;
103+}
104+
105+function 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+
116+function 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+
129+async 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+
147+export 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+
57303 export async function runTerminalCommand(
58304 input: TerminalCommandInput,
59305 ): Promise<TerminalCommandResult> {
@@ -123,7 +369,6 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL · 63 unmodified lines
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 · 1 unmodified line
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( · 39 unmodified lines
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"; · 5 unmodified lines
66 import {
77 gitRunStackedActionInputSchema,
88 gitRunStackedActionResultSchema,
9− gitStatusInputSchema,
10− gitStatusResultSchema,
119 type GitRunStackedActionInput,
1210 type GitRunStackedActionResult,
1311 type GitStatusInput,
@@ -20,12 +18,9 @@ import type { · 6 unmodified lines
2018 TextGenerationService,
2119 } from "./coreServices";
2220 import { CodexTextGenerator } from "./codexTextGenerator";
21+import { GitCoreService } from "./git";
2322 import { type ProcessRunOptions, runProcess } from "./processRunner";
2423
25−interface GitStatusDetails extends GitStatusResult {
26− upstreamRef: string | null;
27−}
28−
2924 interface GitManagerDeps {
3025 runProcess?: (
3126 command: string,
@@ -39,6 +34,7 @@ interface GitManagerDeps { · 7 unmodified lines
3934 timedOut: boolean;
4035 }>;
4136 textGenerator?: TextGenerationService;
37+ gitCore?: GitCoreService;
4238 }
4339
4440 interface OpenPrInfo {
@@ -117,15 +113,6 @@ function extractBranchFromRef(ref: string): string { · 72 unmodified lines
117113 return afterSlash.trim();
118114 }
119115
120−function 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−
129116 function asCommandNotFound(
130117 command: string,
131118 error: unknown,
@@ -138,9 +125,6 @@ function asCommandNotFound( · 6 unmodified lines
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 { · 11 unmodified lines
158142 return undefined;
159143 }
160144
161−function 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−
169145 export 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 { · 3 unmodified lines
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 { · 4 unmodified lines
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 { · 8 unmodified lines
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 { · 2 unmodified lines
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 { · 1 unmodified line
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 { · 5 unmodified lines
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 { · 16 unmodified lines
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 { · 1 unmodified line
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 { · 92 unmodified lines
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 { · 20 unmodified lines
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 });