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
5import { describe, expect, it } from "vitest";5import { describe, expect, it } from "vitest";
6 6
7import {7import {
8 GitCoreService,
8 checkoutGitBranch,9 checkoutGitBranch,
9 createGitBranch,10 createGitBranch,
10 createGitWorktree,11 createGitWorktree,
@@ -420,4 +421,66 @@ describe("git integration", () => { · 409 unmodified lines
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"; · 2 unmodified lines
3import os from "node:os";3import os from "node:os";
4import path from "node:path";4import path from "node:path";
5 5
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";
18 22
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 · 30 unmodified lines
54 });85 });
55}86}
56 87
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 · 63 unmodified lines
123 throw new Error(stderr || "git branch failed");369 throw new Error(stderr || "git branch failed");
124 }370 }
125 371
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 · 1 unmodified line
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;
133 378
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( · 39 unmodified lines
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);
180 423
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"; · 5 unmodified lines
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 { · 6 unmodified lines
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";
24 23
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 { · 7 unmodified lines
39 timedOut: boolean;34 timedOut: boolean;
40 }>;35 }>;
41 textGenerator?: TextGenerationService;36 textGenerator?: TextGenerationService;
37 gitCore?: GitCoreService;
42}38}
43 39
44interface OpenPrInfo {40interface OpenPrInfo {
@@ -117,15 +113,6 @@ function extractBranchFromRef(ref: string): string { · 72 unmodified lines
117 return afterSlash.trim();113 return afterSlash.trim();
118}114}
119 115
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( · 6 unmodified lines
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}
146 130
@@ -158,33 +142,19 @@ function normalizeGitHubAuthError(error: unknown): Error | undefined { · 11 unmodified lines
158 return undefined;142 return undefined;
159}143}
160 144
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;
172 149
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 }
177 155
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 }
189 159
190 async runStackedAction(160 async runStackedAction(
@@ -194,7 +164,7 @@ export class GitManager { · 3 unmodified lines
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";
196 166
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 { · 4 unmodified lines
205 const commit = await this.runCommitStep(input.cwd, initialStatus.branch);175 const commit = await this.runCommitStep(input.cwd, initialStatus.branch);
206 176
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 };
210 180
211 const pr = wantsPr181 const pr = wantsPr
@@ -220,57 +190,6 @@ export class GitManager { · 8 unmodified lines
220 });190 });
221 }191 }
222 192
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 { · 2 unmodified lines
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 }
291 205
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 }
312 219
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 { · 1 unmodified line
324 };225 };
325 }226 }
326 227
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 { · 5 unmodified lines
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 { · 16 unmodified lines
400 }259 }
401 260
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 ]);
419 263
420 let generated: PrContentGenerationResult;264 let generated: PrContentGenerationResult;
421 try {265 try {
@@ -423,9 +267,9 @@ export class GitManager { · 1 unmodified line
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 { · 92 unmodified lines
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;
534 376
535 if (upstreamRef) {377 if (upstreamRef) {
536 const upstreamBranch = extractBranchFromRef(upstreamRef);378 const upstreamBranch = extractBranchFromRef(upstreamRef);
@@ -557,45 +399,6 @@ export class GitManager { · 20 unmodified lines
557 return "main";399 return "main";
558 }400 }
559 401
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 });