Unified · Split
| @@ -5,6 +5,7 @@ import path from "node:path"; · 4 unmodified lines | ||
| 5 | 5 | import { describe, expect, it } from "vitest"; |
| 6 | 6 | |
| 7 | 7 | import { |
| 8 | + GitCoreService, | |
| 8 | 9 | checkoutGitBranch, |
| 9 | 10 | createGitBranch, |
| 10 | 11 | createGitWorktree, |
| @@ -420,4 +421,66 @@ describe("git integration", () => { · 409 unmodified lines | ||
| 420 | 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 | }); |
| @@ -3,21 +3,52 @@ import fs from "node:fs"; · 2 unmodified lines | ||
| 3 | 3 | import os from "node:os"; |
| 4 | 4 | import path from "node:path"; |
| 5 | 5 | |
| 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, | |
| 17 | 21 | } from "@t3tools/contracts"; |
| 18 | 22 | |
| 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 | + | |
| 19 | 50 | /** 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> { | |
| 21 | 52 | return new Promise((resolve, reject) => { |
| 22 | 53 | const child = spawn("git", args, { |
| 23 | 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 | |
| 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 | + | |
| 57 | 303 | export async function runTerminalCommand( |
| 58 | 304 | input: TerminalCommandInput, |
| 59 | 305 | ): Promise<TerminalCommandResult> { |
| @@ -123,7 +369,6 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL · 63 unmodified lines | ||
| 123 | 369 | throw new Error(stderr || "git branch failed"); |
| 124 | 370 | } |
| 125 | 371 | |
| 126 | − // Resolve the real default branch from the remote | |
| 127 | 372 | const [defaultRef, worktreeList] = await Promise.all([ |
| 128 | 373 | runGit(["symbolic-ref", "refs/remotes/origin/HEAD"], input.cwd, 5_000), |
| 129 | 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 | 376 | const defaultBranch = |
| 132 | 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 | 379 | const worktreeMap = new Map<string, string>(); |
| 137 | 380 | if (worktreeList.code === 0) { |
| 138 | 381 | let currentPath: string | null = null; |
| @@ -178,7 +421,6 @@ export async function createGitWorktree( · 39 unmodified lines | ||
| 178 | 421 | const worktreePath = |
| 179 | 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 | 424 | const result = await runGit( |
| 183 | 425 | ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch], |
| 184 | 426 | input.cwd, |
| @@ -6,8 +6,6 @@ import path from "node:path"; · 5 unmodified lines | ||
| 6 | 6 | import { |
| 7 | 7 | gitRunStackedActionInputSchema, |
| 8 | 8 | gitRunStackedActionResultSchema, |
| 9 | − gitStatusInputSchema, | |
| 10 | − gitStatusResultSchema, | |
| 11 | 9 | type GitRunStackedActionInput, |
| 12 | 10 | type GitRunStackedActionResult, |
| 13 | 11 | type GitStatusInput, |
| @@ -20,12 +18,9 @@ import type { · 6 unmodified lines | ||
| 20 | 18 | TextGenerationService, |
| 21 | 19 | } from "./coreServices"; |
| 22 | 20 | import { CodexTextGenerator } from "./codexTextGenerator"; |
| 21 | +import { GitCoreService } from "./git"; | |
| 23 | 22 | import { type ProcessRunOptions, runProcess } from "./processRunner"; |
| 24 | 23 | |
| 25 | −interface GitStatusDetails extends GitStatusResult { | |
| 26 | − upstreamRef: string | null; | |
| 27 | −} | |
| 28 | − | |
| 29 | 24 | interface GitManagerDeps { |
| 30 | 25 | runProcess?: ( |
| 31 | 26 | command: string, |
| @@ -39,6 +34,7 @@ interface GitManagerDeps { · 7 unmodified lines | ||
| 39 | 34 | timedOut: boolean; |
| 40 | 35 | }>; |
| 41 | 36 | textGenerator?: TextGenerationService; |
| 37 | + gitCore?: GitCoreService; | |
| 42 | 38 | } |
| 43 | 39 | |
| 44 | 40 | interface OpenPrInfo { |
| @@ -117,15 +113,6 @@ function extractBranchFromRef(ref: string): string { · 72 unmodified lines | ||
| 117 | 113 | return afterSlash.trim(); |
| 118 | 114 | } |
| 119 | 115 | |
| 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 | − | |
| 129 | 116 | function asCommandNotFound( |
| 130 | 117 | command: string, |
| 131 | 118 | error: unknown, |
| @@ -138,9 +125,6 @@ function asCommandNotFound( · 6 unmodified lines | ||
| 138 | 125 | if (command === "codex") { |
| 139 | 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 | 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 | 142 | return undefined; |
| 159 | 143 | } |
| 160 | 144 | |
| 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 | − | |
| 169 | 145 | export class GitManager { |
| 170 | 146 | private readonly run: NonNullable<GitManagerDeps["runProcess"]>; |
| 171 | 147 | private readonly textGenerator: TextGenerationService; |
| 148 | + private readonly gitCore: GitCoreService; | |
| 172 | 149 | |
| 173 | 150 | constructor(deps: GitManagerDeps = {}) { |
| 174 | 151 | this.run = deps.runProcess ?? runProcess; |
| 175 | 152 | this.textGenerator = deps.textGenerator ?? new CodexTextGenerator(); |
| 153 | + this.gitCore = deps.gitCore ?? new GitCoreService(); | |
| 176 | 154 | } |
| 177 | 155 | |
| 178 | 156 | 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); | |
| 188 | 158 | } |
| 189 | 159 | |
| 190 | 160 | async runStackedAction( |
| @@ -194,7 +164,7 @@ export class GitManager { · 3 unmodified lines | ||
| 194 | 164 | const wantsPush = input.action !== "commit"; |
| 195 | 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 | 168 | if (wantsPush && !initialStatus.branch) { |
| 199 | 169 | throw new Error("Cannot push from detached HEAD."); |
| 200 | 170 | } |
| @@ -205,7 +175,7 @@ export class GitManager { · 4 unmodified lines | ||
| 205 | 175 | const commit = await this.runCommitStep(input.cwd, initialStatus.branch); |
| 206 | 176 | |
| 207 | 177 | const push = wantsPush |
| 208 | − ? await this.runPushStep(input.cwd, initialStatus.branch) | |
| 178 | + ? await this.gitCore.pushCurrentBranch(input.cwd, initialStatus.branch) | |
| 209 | 179 | : { status: "skipped_not_requested" as const }; |
| 210 | 180 | |
| 211 | 181 | 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 | 193 | private async runCommitStep( |
| 275 | 194 | cwd: string, |
| 276 | 195 | branch: string | null, |
| @@ -279,44 +198,26 @@ export class GitManager { · 2 unmodified lines | ||
| 279 | 198 | commitSha?: string | undefined; |
| 280 | 199 | subject?: string | undefined; |
| 281 | 200 | }> { |
| 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) { | |
| 289 | 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 | 206 | let generated: CommitMessageGenerationResult; |
| 300 | 207 | try { |
| 301 | 208 | generated = sanitizeCommitMessage( |
| 302 | 209 | await this.textGenerator.generateCommitMessage({ |
| 303 | 210 | cwd, |
| 304 | 211 | 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), | |
| 307 | 214 | }), |
| 308 | 215 | ); |
| 309 | 216 | } catch (error) { |
| 310 | 217 | throw asCommandNotFound("codex", error) ?? error; |
| 311 | 218 | } |
| 312 | 219 | |
| 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); | |
| 320 | 221 | return { |
| 321 | 222 | status: "created", |
| 322 | 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 | 228 | private async runPrStep( |
| 370 | 229 | cwd: string, |
| 371 | 230 | fallbackBranch: string | null, |
| @@ -377,7 +236,7 @@ export class GitManager { · 5 unmodified lines | ||
| 377 | 236 | headBranch?: string | undefined; |
| 378 | 237 | title?: string | undefined; |
| 379 | 238 | }> { |
| 380 | − const details = await this.readStatusDetails(cwd); | |
| 239 | + const details = await this.gitCore.statusDetails(cwd); | |
| 381 | 240 | const branch = details.branch ?? fallbackBranch; |
| 382 | 241 | if (!branch) { |
| 383 | 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 | 261 | 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); | |
| 419 | 263 | |
| 420 | 264 | let generated: PrContentGenerationResult; |
| 421 | 265 | try { |
| @@ -423,9 +267,9 @@ export class GitManager { · 1 unmodified line | ||
| 423 | 267 | cwd, |
| 424 | 268 | baseBranch, |
| 425 | 269 | 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), | |
| 429 | 273 | }); |
| 430 | 274 | } catch (error) { |
| 431 | 275 | throw asCommandNotFound("codex", error) ?? error; |
| @@ -524,13 +368,11 @@ export class GitManager { · 92 unmodified lines | ||
| 524 | 368 | branch: string, |
| 525 | 369 | upstreamRef: string | null, |
| 526 | 370 | ): Promise<string> { |
| 527 | − const mergeBaseConfig = await this.runGitStdout( | |
| 371 | + const configured = await this.gitCore.readConfigValue( | |
| 528 | 372 | cwd, |
| 529 | − ["config", "--get", `branch.${branch}.gh-merge-base`], | |
| 530 | − true, | |
| 373 | + `branch.${branch}.gh-merge-base`, | |
| 531 | 374 | ); |
| 532 | − const configured = trimStdout(mergeBaseConfig); | |
| 533 | − if (configured.length > 0) return configured; | |
| 375 | + if (configured) return configured; | |
| 534 | 376 | |
| 535 | 377 | if (upstreamRef) { |
| 536 | 378 | const upstreamBranch = extractBranchFromRef(upstreamRef); |
| @@ -557,45 +399,6 @@ export class GitManager { · 20 unmodified lines | ||
| 557 | 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 | 402 | private async runGh(cwd: string, args: readonly string[]): Promise<void> { |
| 600 | 403 | try { |
| 601 | 404 | await this.run("gh", args, { cwd }); |