| author | |
| committer | |
| log | 98eb87f5cd1a448bd4d237242bf87d4bad0a1b31 |
| tree | 9a2efa97ccc97e0918c5c3e92194336a3b68d1c5 |
| parent | 1768d3166965187f6d769e228783ae1250a2f6f0 |
3 files changed, 341 insertions(+), 233 deletions(-)
apps/server/src/git.test.ts+63| ... | @@ -5,6 +5,7 @@ import path from "node:path"; | ... | @@ -5,6 +5,7 @@ import path from "node:path"; |
| 5 | import { describe, expect, it } from "vitest"; | 5 | import { describe, expect, it } from "vitest"; |
| 6 | 6 | ||
| 7 | import { | 7 | import { |
| 8 | GitCoreService, | ||
| 8 | checkoutGitBranch, | 9 | checkoutGitBranch, |
| 9 | createGitBranch, | 10 | createGitBranch, |
| 10 | createGitWorktree, | 11 | createGitWorktree, |
| ... | @@ -420,4 +421,66 @@ describe("git integration", () => { | ... | @@ -420,4 +421,66 @@ describe("git integration", () => { |
| 420 | expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch); | 421 | expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch); |
| 421 | }); | 422 | }); |
| 422 | }); | 423 | }); |
| 424 | |||
| 425 | describe("GitCoreService", () => { | ||
| 426 | it("reports status details and dirty state", async () => { | ||
| 427 | await using tmp = await makeTmpDir(); | ||
| 428 | await initRepoWithCommit(tmp.path); | ||
| 429 | const core = new GitCoreService(); | ||
| 430 | |||
| 431 | const clean = await core.status({ cwd: tmp.path }); | ||
| 432 | expect(clean.hasWorkingTreeChanges).toBe(false); | ||
| 433 | expect(clean.branch).toBeTruthy(); | ||
| 434 | |||
| 435 | await writeFile(path.join(tmp.path, "README.md"), "updated\n"); | ||
| 436 | const dirty = await core.statusDetails(tmp.path); | ||
| 437 | expect(dirty.hasWorkingTreeChanges).toBe(true); | ||
| 438 | }); | ||
| 439 | |||
| 440 | it("prepares commit context by auto-staging and creates commit", async () => { | ||
| 441 | await using tmp = await makeTmpDir(); | ||
| 442 | await initRepoWithCommit(tmp.path); | ||
| 443 | const core = new GitCoreService(); | ||
| 444 | |||
| 445 | await writeFile(path.join(tmp.path, "README.md"), "new content\n"); | ||
| 446 | const context = await core.prepareCommitContext(tmp.path); | ||
| 447 | expect(context).not.toBeNull(); | ||
| 448 | expect(context!.stagedSummary.length).toBeGreaterThan(0); | ||
| 449 | expect(context!.stagedPatch.length).toBeGreaterThan(0); | ||
| 450 | |||
| 451 | const created = await core.commit( | ||
| 452 | tmp.path, | ||
| 453 | "Add README update", | ||
| 454 | "- include updated content", | ||
| 455 | ); | ||
| 456 | expect(created.commitSha.length).toBeGreaterThan(0); | ||
| 457 | expect(await git(tmp.path, "log -1 --pretty=%s")).toBe("Add README update"); | ||
| 458 | }); | ||
| 459 | |||
| 460 | it("pushes with upstream setup and then skips when up to date", async () => { | ||
| 461 | await using tmp = await makeTmpDir(); | ||
| 462 | await using remote = await makeTmpDir(); | ||
| 463 | await initRepoWithCommit(tmp.path); | ||
| 464 | await git(remote.path, "init --bare"); | ||
| 465 | await git(tmp.path, `remote add origin '${remote.path}'`); | ||
| 466 | await createGitBranch({ cwd: tmp.path, branch: "feature/core-push" }); | ||
| 467 | await checkoutGitBranch({ cwd: tmp.path, branch: "feature/core-push" }); | ||
| 468 | |||
| 469 | await writeFile(path.join(tmp.path, "feature.txt"), "push me\n"); | ||
| 470 | const core = new GitCoreService(); | ||
| 471 | const context = await core.prepareCommitContext(tmp.path); | ||
| 472 | expect(context).not.toBeNull(); | ||
| 473 | await core.commit(tmp.path, "Add feature file", ""); | ||
| 474 | |||
| 475 | const pushed = await core.pushCurrentBranch(tmp.path, null); | ||
| 476 | expect(pushed.status).toBe("pushed"); | ||
| 477 | expect(pushed.setUpstream).toBe(true); | ||
| 478 | expect(await git(tmp.path, "rev-parse --abbrev-ref @{upstream}")).toBe( | ||
| 479 | "origin/feature/core-push", | ||
| 480 | ); | ||
| 481 | |||
| 482 | const skipped = await core.pushCurrentBranch(tmp.path, null); | ||
| 483 | expect(skipped.status).toBe("skipped_up_to_date"); | ||
| 484 | }); | ||
| 485 | }); | ||
| 423 | }); | 486 | }); |
apps/server/src/git.ts+258-16| ... | @@ -3,21 +3,52 @@ import fs from "node:fs"; | ... | @@ -3,21 +3,52 @@ import fs from "node:fs"; |
| 3 | import os from "node:os"; | 3 | import os from "node:os"; |
| 4 | import path from "node:path"; | 4 | import path from "node:path"; |
| 5 | 5 | ||
| 6 | import type { | 6 | import { |
| 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 | ||
| 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 | /** 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. */ |
| 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 | return new Promise((resolve, reject) => { | 52 | return new Promise((resolve, reject) => { |
| 22 | const child = spawn("git", args, { | 53 | const child = spawn("git", args, { |
| 23 | cwd, | 54 | cwd, |
| ... | @@ -54,6 +85,221 @@ function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise<Termin | ... | @@ -54,6 +85,221 @@ function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise<Termin |
| 54 | }); | 85 | }); |
| 55 | } | 86 | } |
| 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 | export async function runTerminalCommand( | 303 | export async function runTerminalCommand( |
| 58 | input: TerminalCommandInput, | 304 | input: TerminalCommandInput, |
| 59 | ): Promise<TerminalCommandResult> { | 305 | ): Promise<TerminalCommandResult> { |
| ... | @@ -123,7 +369,6 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL | ... | @@ -123,7 +369,6 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL |
| 123 | throw new Error(stderr || "git branch failed"); | 369 | throw new Error(stderr || "git branch failed"); |
| 124 | } | 370 | } |
| 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 | ... | @@ -131,8 +376,6 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL |
| 131 | const defaultBranch = | 376 | const defaultBranch = |
| 132 | defaultRef.code === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null; | 377 | defaultRef.code === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null; |
| 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( | ... | @@ -178,7 +421,6 @@ export async function createGitWorktree( |
| 178 | const worktreePath = | 421 | const worktreePath = |
| 179 | input.path ?? path.join(os.homedir(), ".t3", "worktrees", repoName, sanitizedBranch); | 422 | input.path ?? path.join(os.homedir(), ".t3", "worktrees", repoName, sanitizedBranch); |
| 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"; | ... | @@ -6,8 +6,6 @@ import path from "node:path"; |
| 6 | import { | 6 | import { |
| 7 | gitRunStackedActionInputSchema, | 7 | gitRunStackedActionInputSchema, |
| 8 | gitRunStackedActionResultSchema, | 8 | gitRunStackedActionResultSchema, |
| 9 | gitStatusInputSchema, | ||
| 10 | gitStatusResultSchema, | ||
| 11 | type GitRunStackedActionInput, | 9 | type GitRunStackedActionInput, |
| 12 | type GitRunStackedActionResult, | 10 | type GitRunStackedActionResult, |
| 13 | type GitStatusInput, | 11 | type GitStatusInput, |
| ... | @@ -20,12 +18,9 @@ import type { | ... | @@ -20,12 +18,9 @@ import type { |
| 20 | TextGenerationService, | 18 | TextGenerationService, |
| 21 | } from "./coreServices"; | 19 | } from "./coreServices"; |
| 22 | import { CodexTextGenerator } from "./codexTextGenerator"; | 20 | import { CodexTextGenerator } from "./codexTextGenerator"; |
| 21 | import { GitCoreService } from "./git"; | ||
| 23 | import { type ProcessRunOptions, runProcess } from "./processRunner"; | 22 | import { type ProcessRunOptions, runProcess } from "./processRunner"; |
| 24 | 23 | ||
| 25 | interface GitStatusDetails extends GitStatusResult { | ||
| 26 | upstreamRef: string | null; | ||
| 27 | } | ||
| 28 | |||
| 29 | interface GitManagerDeps { | 24 | interface GitManagerDeps { |
| 30 | runProcess?: ( | 25 | runProcess?: ( |
| 31 | command: string, | 26 | command: string, |
| ... | @@ -39,6 +34,7 @@ interface GitManagerDeps { | ... | @@ -39,6 +34,7 @@ interface GitManagerDeps { |
| 39 | timedOut: boolean; | 34 | timedOut: boolean; |
| 40 | }>; | 35 | }>; |
| 41 | textGenerator?: TextGenerationService; | 36 | textGenerator?: TextGenerationService; |
| 37 | gitCore?: GitCoreService; | ||
| 42 | } | 38 | } |
| 43 | 39 | ||
| 44 | interface OpenPrInfo { | 40 | interface OpenPrInfo { |
| ... | @@ -117,15 +113,6 @@ function extractBranchFromRef(ref: string): string { | ... | @@ -117,15 +113,6 @@ function extractBranchFromRef(ref: string): string { |
| 117 | return afterSlash.trim(); | 113 | return afterSlash.trim(); |
| 118 | } | 114 | } |
| 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 | function asCommandNotFound( | 116 | function asCommandNotFound( |
| 130 | command: string, | 117 | command: string, |
| 131 | error: unknown, | 118 | error: unknown, |
| ... | @@ -138,9 +125,6 @@ function asCommandNotFound( | ... | @@ -138,9 +125,6 @@ function asCommandNotFound( |
| 138 | if (command === "codex") { | 125 | if (command === "codex") { |
| 139 | return new Error("Codex CLI (`codex`) is required but not available on PATH."); | 126 | return new Error("Codex CLI (`codex`) is required but not available on PATH."); |
| 140 | } | 127 | } |
| 141 | if (command === "git") { | ||
| 142 | return new Error("Git is required but not available on PATH."); | ||
| 143 | } | ||
| 144 | return new Error(`${command} is required but not available on PATH.`); | 128 | return new Error(`${command} is required but not available on PATH.`); |
| 145 | } | 129 | } |
| 146 | 130 | ||
| ... | @@ -158,33 +142,19 @@ function normalizeGitHubAuthError(error: unknown): Error | undefined { | ... | @@ -158,33 +142,19 @@ function normalizeGitHubAuthError(error: unknown): Error | undefined { |
| 158 | return undefined; | 142 | return undefined; |
| 159 | } | 143 | } |
| 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 | export class GitManager { | 145 | export 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 { | ... | @@ -194,7 +164,7 @@ export class GitManager { |
| 194 | const wantsPush = input.action !== "commit"; | 164 | const wantsPush = input.action !== "commit"; |
| 195 | const wantsPr = input.action === "commit_push_pr"; | 165 | const wantsPr = input.action === "commit_push_pr"; |
| 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 { | ... | @@ -205,7 +175,7 @@ export class GitManager { |
| 205 | const commit = await this.runCommitStep(input.cwd, initialStatus.branch); | 175 | const commit = await this.runCommitStep(input.cwd, initialStatus.branch); |
| 206 | 176 | ||
| 207 | const push = wantsPush | 177 | 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 = wantsPr | 181 | const pr = wantsPr |
| ... | @@ -220,57 +190,6 @@ export class GitManager { | ... | @@ -220,57 +190,6 @@ export class GitManager { |
| 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 { | ... | @@ -279,44 +198,26 @@ export class GitManager { |
| 279 | commitSha?: string | undefined; | 198 | commitSha?: string | undefined; |
| 280 | subject?: string | undefined; | 199 | subject?: string | undefined; |
| 281 | }> { | 200 | }> { |
| 282 | await this.runGit(cwd, ["add", "-A"]); | 201 | const context = await this.gitCore.prepareCommitContext(cwd); |
| 283 | const stagedSummary = await this.runGitStdout(cwd, [ | 202 | if (!context) { |
| 284 | "diff", | ||
| 285 | "--cached", | ||
| 286 | "--name-status", | ||
| 287 | ]); | ||
| 288 | if (trimStdout(stagedSummary).length === 0) { | ||
| 289 | return { status: "skipped_no_changes" }; | 203 | return { status: "skipped_no_changes" }; |
| 290 | } | 204 | } |
| 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 { | ... | @@ -324,48 +225,6 @@ export class GitManager { |
| 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 { | ... | @@ -377,7 +236,7 @@ export class GitManager { |
| 377 | headBranch?: string | undefined; | 236 | headBranch?: string | undefined; |
| 378 | title?: string | undefined; | 237 | title?: string | undefined; |
| 379 | }> { | 238 | }> { |
| 380 | const details = await this.readStatusDetails(cwd); | 239 | const details = await this.gitCore.statusDetails(cwd); |
| 381 | const branch = details.branch ?? fallbackBranch; | 240 | const branch = details.branch ?? fallbackBranch; |
| 382 | if (!branch) { | 241 | if (!branch) { |
| 383 | throw new Error("Cannot create a pull request from detached HEAD."); | 242 | throw new Error("Cannot create a pull request from detached HEAD."); |
| ... | @@ -400,22 +259,7 @@ export class GitManager { | ... | @@ -400,22 +259,7 @@ export class GitManager { |
| 400 | } | 259 | } |
| 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 { | ... | @@ -423,9 +267,9 @@ export class GitManager { |
| 423 | cwd, | 267 | cwd, |
| 424 | baseBranch, | 268 | baseBranch, |
| 425 | headBranch: branch, | 269 | headBranch: branch, |
| 426 | commitSummary: limitContext(commitSummary, 20_000), | 270 | commitSummary: limitContext(rangeContext.commitSummary, 20_000), |
| 427 | diffSummary: limitContext(diffSummary, 20_000), | 271 | diffSummary: limitContext(rangeContext.diffSummary, 20_000), |
| 428 | diffPatch: limitContext(diffPatch, 60_000), | 272 | diffPatch: limitContext(rangeContext.diffPatch, 60_000), |
| 429 | }); | 273 | }); |
| 430 | } catch (error) { | 274 | } catch (error) { |
| 431 | throw asCommandNotFound("codex", error) ?? error; | 275 | throw asCommandNotFound("codex", error) ?? error; |
| ... | @@ -524,13 +368,11 @@ export class GitManager { | ... | @@ -524,13 +368,11 @@ export class GitManager { |
| 524 | branch: string, | 368 | branch: string, |
| 525 | upstreamRef: string | null, | 369 | upstreamRef: string | null, |
| 526 | ): Promise<string> { | 370 | ): Promise<string> { |
| 527 | const mergeBaseConfig = await this.runGitStdout( | 371 | const configured = await this.gitCore.readConfigValue( |
| 528 | cwd, | 372 | cwd, |
| 529 | ["config", "--get", `branch.${branch}.gh-merge-base`], | 373 | `branch.${branch}.gh-merge-base`, |
| 530 | true, | ||
| 531 | ); | 374 | ); |
| 532 | const configured = trimStdout(mergeBaseConfig); | 375 | if (configured) return configured; |
| 533 | if (configured.length > 0) return configured; | ||
| 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 { | ... | @@ -557,45 +399,6 @@ export class GitManager { |
| 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 }); |