diff --git a/index.ts b/index.ts index 3eba024..12ecf8c 100755 --- a/index.ts +++ b/index.ts @@ -1,4 +1,5 @@ import { Commit } from "@/cli/commit"; +import { Branch } from "@/cli/branch"; import { Setup } from "@/cli/setup"; import { Doctor } from "@/cli/doctor"; import { ModelCommand } from "@/cli/model"; @@ -11,7 +12,7 @@ import { checkUpdate } from "@/cli/update"; import color from "picocolors"; -const NOTIFIER_COMMANDS = new Set(["generate", "setup", "doctor", "model", "effort"]); +const NOTIFIER_COMMANDS = new Set(["generate", "setup", "doctor", "model", "effort", "branch"]); const main = () => { const args = process.argv.slice(2); @@ -35,6 +36,8 @@ const main = () => { return ModelCommand.create().chain((m) => m.run()); case "effort": return EffortCommand.create().chain((e) => e.run()); + case "branch": + return Branch.create().chain((b) => b.run()); case "update": return Update.create().run(); case "version": diff --git a/src/cli/branch.ts b/src/cli/branch.ts new file mode 100644 index 0000000..6369acc --- /dev/null +++ b/src/cli/branch.ts @@ -0,0 +1,113 @@ +export { Branch }; + +import * as p from "@clack/prompts"; +import * as repo from "@/infra/git/repo"; + +import { Future } from "@/libs/future"; +import { loadConfig } from "@/infra/storage/config"; +import { Setup } from "@/cli/setup"; +import { type Config, type ProviderConfig } from "@/domain/config/config"; +import { resolveProvider } from "@/domain/llm/auth-resolver"; +import { generateBranchNameSuggestions, type BranchSuggestion } from "@/domain/llm/router"; +import { renderBranchNote } from "@/infra/ui/push-note"; +import { loading } from "@/infra/ui/spinner"; +import { Just, Nothing, type Maybe } from "@/libs/maybe"; + +import color from "picocolors"; + +class Branch { + private constructor(private readonly providerConfig: ProviderConfig) {} + + static create(): Future { + return loadConfig() + .chainRej((): Future => { + p.log.warn(color.yellow("No configuration found. Let's set you up first.")); + return Setup.create() + .chain((s) => s.run()) + .chain(() => loadConfig()); + }) + .chain((config) => resolveProvider(config).map((ai) => new Branch(ai))); + } + + run(): Future { + return repo + .checkIsGitRepo() + .chain(() => repo.getLocalChangeContext()) + .bichain( + (e): Future => { + if (repo.isNoLocalChangesError(e)) { + p.log.warn(color.yellow(repo.NO_LOCAL_CHANGES_MESSAGE)); + p.outro("No local changes — nothing to suggest a branch for."); + return Future.resolve(undefined); + } + return Future.reject(e); + }, + (ctx): Future => + loading("Suggesting branch names...", "Suggestions ready!", generateBranchNameSuggestions(this.providerConfig, ctx)) + .chain((s) => + this.promptPick(s.names).chain( + (maybePicked): Future => + maybePicked.maybe>(Future.resolve(undefined), (picked) => + this.confirmForkFromBase().chain((proceed) => { + if (!proceed) { + p.outro("Operation cancelled."); + return Future.resolve(undefined); + } + return repo.createAndSwitchBranch(picked).map(() => ({ picked, metadata: s.metadata })); + }) + ) + ) + ) + .chain((result) => { + if (!result) return Future.resolve(undefined); + return repo.findBaseBranch().map((baseBranch) => { + renderBranchNote({ + branch: result.picked, + baseBranch, + request: Just(result.metadata) + }); + p.outro(color.green("Switched to new branch.")); + }); + }) + ) + .mapRej((e) => { + p.log.error(color.red(e.message)); + return e; + }); + } + + private confirmForkFromBase(): Future { + return Future.concurrently; base: Maybe }>({ + current: repo.findCurrentBranch(), + base: repo.findBaseBranch() + }).chain(({ current, base }) => + current.maybe(Future.resolve(true), (curr) => + base.maybe(Future.resolve(true), (b) => + curr === b ? + Future.resolve(true) + : Future.attemptP(async () => { + p.log.warn(color.yellow(`You're on '${curr}', not the base branch '${b}'. The new branch will fork from '${curr}'.`)); + const ok = await p.confirm({ message: `Create branch off '${curr}' anyway?` }); + return !(p.isCancel(ok) || !ok); + }) + ) + ) + ); + } + + private promptPick(suggestions: readonly [BranchSuggestion, BranchSuggestion, BranchSuggestion]): Future> { + return Future.attemptP(async () => { + const choice = await p.select({ + message: "Create branch", + options: suggestions.map((s) => ({ value: s.name, label: `${s.name} — ${s.rationale}` })) + }); + + if (p.isCancel(choice)) { + p.outro("Operation cancelled."); + return Nothing(); + } + + return Just(choice); + }); + } +} diff --git a/src/cli/parser.ts b/src/cli/parser.ts index 6e8590b..fda9be6 100644 --- a/src/cli/parser.ts +++ b/src/cli/parser.ts @@ -11,6 +11,7 @@ type CliCommand = | { type: "doctor" } | { type: "model" } | { type: "effort" } + | { type: "branch" } | { type: "update" } | { type: "version" } | { type: "help" }; @@ -30,6 +31,9 @@ const cliCommandDecoder: D.Decoder = D.array(D.string).chain((args) return D.succeed({ type: "model" }); case "effort": return D.succeed({ type: "effort" }); + case "branch": + case "new-branch": + return D.succeed({ type: "branch" }); case "update": return D.succeed({ type: "update" }); case "--version": @@ -51,6 +55,8 @@ Usage: commit-tools [command] Commands: generate (default) Generate a commit message + branch Suggest branch names from local changes and create one + new-branch Alias for branch setup Configure authentication and conventions login Alias for setup (re-authenticate) doctor Check installation and environment diff --git a/src/domain/branch/suggestions.ts b/src/domain/branch/suggestions.ts new file mode 100644 index 0000000..1b466b5 --- /dev/null +++ b/src/domain/branch/suggestions.ts @@ -0,0 +1,120 @@ +export { stripOptionalJsonFence, parseBranchSuggestions, validateGitBranchName, parseAndValidateBranchSuggestions, type BranchSuggestion }; + +import * as D from "@/libs/json/decoder"; +import { Failure, Success, type Result } from "@/libs/result"; + +const MAX_BRANCH_NAME_LENGTH = 64; +const MAX_RATIONALE_LENGTH = 120; + +const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +const TRUNK_NAMES = new Set(["main", "master", "develop", "head"]); + +const FORBIDDEN_FIRST_SEGMENTS = new Set([ + "feat", + "fix", + "chore", + "docs", + "refactor", + "test", + "perf", + "build", + "ci", + "style", + "revert", + "feature", + "bugfix", + "hotfix", + "release", + "add", + "update", + "change", + "improve", + "tweak", + "misc", + "wip", + "tmp" +]); + +type BranchSuggestion = { readonly name: string; readonly rationale: string }; + +const nonEmptyString = (label: string): D.Decoder => + D.string.chain((s) => { + const t = s.trim(); + return t.length === 0 ? D.fail(`${label} must be non-empty`) : D.succeed(t); + }); + +const boundedNonEmptyString = (label: string, max: number): D.Decoder => + D.string.chain((s) => { + const t = s.trim(); + if (t.length === 0) return D.fail(`${label} must be non-empty`); + if (t.length > max) return D.fail(`${label} exceeds ${max} chars`); + return D.succeed(t); + }); + +const branchSuggestionDecoder: D.Decoder = D.object({ + name: nonEmptyString("name"), + rationale: boundedNonEmptyString("rationale", MAX_RATIONALE_LENGTH) +}); + +const threeSuggestions: D.Decoder = D.array(branchSuggestionDecoder).chain((xs) => { + if (xs.length !== 3) return D.fail("expected exactly 3 suggestions"); + const [a, b, c] = xs; + if (a === undefined || b === undefined || c === undefined) return D.fail("expected 3 suggestions"); + return D.succeed([a, b, c] as const); +}); + +const suggestionsPayloadDecoder = D.object({ + suggestions: threeSuggestions +}); + +const stripOptionalJsonFence = (s: string): string => { + const t = s.trim(); + if (!t.startsWith("```")) { + return t; + } + const firstNl = t.indexOf("\n"); + const body = firstNl === -1 ? "" : t.slice(firstNl + 1); + const close = body.indexOf("```"); + if (close === -1) { + return body.trim(); + } + return body.slice(0, close).trim(); +}; + +const parseBranchSuggestions = (raw: string): Result => { + const trimmed = stripOptionalJsonFence(raw.trim()); + let json: unknown; + try { + json = JSON.parse(trimmed); + } catch { + return Failure(new Error("Branch suggestions: invalid JSON")); + } + return D.decode(json, suggestionsPayloadDecoder) + .mapFailure((msg) => new Error(`Branch suggestions: ${msg}`)) + .map((row) => row.suggestions); +}; + +const validateGitBranchName = (name: string): Result => { + if (name.length > MAX_BRANCH_NAME_LENGTH) { + return Failure(new Error(`Invalid branch name (max ${MAX_BRANCH_NAME_LENGTH} characters): ${name}`)); + } + if (!SLUG_PATTERN.test(name)) { + return Failure(new Error(`Invalid branch name (use lowercase kebab-case, no slashes): ${name}`)); + } + if (TRUNK_NAMES.has(name.toLowerCase())) { + return Failure(new Error(`Reserved branch name: ${name}`)); + } + const firstSegment = name.split("-")[0]; + if (firstSegment !== undefined && FORBIDDEN_FIRST_SEGMENTS.has(firstSegment)) { + return Failure(new Error(`Branch name must not start with type prefix token: ${name}`)); + } + return Success(name); +}; + +const validateSuggestion = (s: BranchSuggestion): Result => validateGitBranchName(s.name).map(() => s); + +const parseAndValidateBranchSuggestions = (raw: string): Result => + parseBranchSuggestions(raw).chain(([a, b, c]) => + validateSuggestion(a).chain((va) => validateSuggestion(b).chain((vb) => validateSuggestion(c).map((vc) => [va, vb, vc] as const))) + ); diff --git a/src/domain/commit/prompts.ts b/src/domain/commit/prompts.ts index da1ab56..4b988be 100644 --- a/src/domain/commit/prompts.ts +++ b/src/domain/commit/prompts.ts @@ -1,4 +1,4 @@ -export { getPrompt, getRefinePrompt }; +export { getPrompt, getRefinePrompt, getBranchNamePrompt }; import { CommitConvention } from "@/domain/config/config"; import { Just, Nothing, type Maybe } from "@/libs/maybe"; @@ -275,6 +275,100 @@ function promptCustom(gitDiff: string, template: Maybe): string { } } +function getBranchNamePrompt(context: string): string { + return ` + + ${context} + + + + You are a senior engineer reading the work snapshot above. + Propose three distinct branch names for this work. + + + + Return ONE JSON object, no markdown, no prose. + First character "{", last character "}". Schema: + {"suggestions":[ + {"name":"","rationale":""}, + {"name":"","rationale":""}, + {"name":"","rationale":""} + ]} + + + + - Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$ (lowercase, single hyphens, no slashes) + - Length: roughly 15-50 characters, never over 60. + - Grounded in tokens from file paths, symbols, or domain nouns in the snapshot. + - Forbidden as the FIRST token: change-type labels (feat, fix, chore, docs, + refactor, test, perf, build, ci, feature, bugfix, hotfix, release) AND vague + verbs (add, update, change, improve, tweak, misc, wip, tmp). + - ALLOWED as the LAST token: a change-kind word (refactor, cleanup, rewrite, + hardening, migration, feature) when it sharpens the framing. + Example: "frontend-list-ui-refactor" is valid because "refactor" is the suffix. + - Forbidden anywhere: tooling/instruction words — suggestion(s), prompt, + llm, model, cli, tool(s), command(s), workflow, meta, kebab-case, snapshot, + context, branch-name, name-picker. + - Never trunk names: main, master, develop, head. + - Area prefix: if every changed file shares one top-level area visible in + the paths (a monorepo package, a top-level src/ subtree, or a + clearly named layer like "frontend"/"backend"/"api"/"web"), at least one + slug SHOULD start with that area as its leading token (e.g. "frontend-...", + "api-...", "web-..."). Do not invent areas that aren't in the file paths. + - Preferred shape for the broader-theme suggestion: -- + where is a change-kind suffix from the allowed list (e.g. + "frontend-list-ui-refactor", "api-auth-hardening"). Use this shape when + the diff spans multiple files under one area; skip it for narrow diffs. + + + + - One short clause, no more than 80 characters, lowercase start, no trailing period. + - Explains WHY this framing — what facet of the change it emphasizes. + - Do not repeat the slug verbatim. Do not just restate file names. + + + + The three suggestions MUST cover three different axes. Pick three from: + - component/module focus (names the specific code being extracted or built) + - broader feature/theme framing (names the overall shape of the work) + - user-visible change framing (names what a product user would notice) + - refactor/architecture framing (names the structural shift) + - shared/reusable focus (names what becomes reusable across pages) + + + + Before answering, internally (you do NOT output these steps): + 1. List every file in the snapshot and the one-phrase intent of each hunk. + 2. Group the hunks into 1-3 themes that span multiple files. + 3. Pick the three diversity axes that best describe this diff. + 4. Draft a slug for each axis, then verify each slug: + (a) matches the pattern, (b) is grounded in snapshot tokens, + (c) is not just a subset of another slug, + (d) frames a different axis than the other two. + 5. If two slugs frame the same axis, replace one before emitting. + + + + + Diff refactors ops/campaigns + org/campaigns + ops/events + ops/products under app/frontend/ to use shared EmptyState, FilterPill, CampaignCard; adds pagination counts ("Showing X-Y of N") with restyled Pagination component. + {"suggestions":[{"name":"frontend-list-ui-refactor","rationale":"broader framing of the cross-page list restructure"},{"name":"frontend-shared-list-components","rationale":"emphasizes the extracted EmptyState, FilterPill, and CampaignCard"},{"name":"frontend-pagination-with-counts","rationale":"leads with the most user-visible change"}]} + + + Adds null-guard to src/parser/parser.ts and a regression test in src/parser/parser.test.ts. + {"suggestions":[{"name":"parser-null-guard","rationale":"names the specific code path being hardened"},{"name":"parser-hardening","rationale":"broader framing across guard and regression test"},{"name":"crash-on-empty-input","rationale":"user-visible bug being prevented"}]} + + + Adds /api/users/:id/sessions endpoint with handler in api/handlers/sessions.ts, DB query in api/db/sessions.ts, OpenAPI schema in api/openapi.yaml. + {"suggestions":[{"name":"api-user-sessions-endpoint","rationale":"component focus on the new sessions handler"},{"name":"api-sessions-feature","rationale":"broader framing across handler, query, and schema"},{"name":"list-active-sessions","rationale":"user-visible capability the endpoint exposes"}]} + + + + + Emit ONLY the JSON object. No prose, no markdown fences, no commentary. + + `; +} + function getRefinePrompt(params: { diff: string; currentMessage: string; adjustment: string }): { prompt: string; systemInstruction: string; diff --git a/src/domain/llm/retry.ts b/src/domain/llm/retry.ts new file mode 100644 index 0000000..ec18c17 --- /dev/null +++ b/src/domain/llm/retry.ts @@ -0,0 +1,46 @@ +export { withTransientRetry, isTransientLlmError }; + +import * as p from "@clack/prompts"; +import color from "picocolors"; +import { Future } from "@/libs/future"; + +const MAX_AUTO_ATTEMPTS = 3; + +const TRANSIENT_PATTERNS = [ + /terminated/i, + /ECONNRESET/, + /ETIMEDOUT/, + /ENOTFOUND/, + /socket hang up/i, + /\b(502|503|504|529)\b/, + /overloaded/i, + /rate.?limit/i +]; + +const isTransientLlmError = (err: Error): boolean => { + const causeMessage = err.cause instanceof Error ? err.cause.message : ""; + const msg = `${err.message} ${causeMessage}`; + return TRANSIENT_PATTERNS.some((re) => re.test(msg)); +}; + +const backoffMs = (attempt: number): number => Math.min(8_000, 500 * 2 ** attempt); + +const promptRetry = (err: Error): Future => + Future.attemptP(async () => { + p.log.warn(color.yellow(`Transient LLM error after ${MAX_AUTO_ATTEMPTS} retries: ${err.message}`)); + const ok = await p.confirm({ message: "Retry?" }); + return !(p.isCancel(ok) || !ok); + }); + +const withTransientRetry = (make: () => Future): Future => { + const attemptN = (n: number): Future => + make().chainRej((err): Future => { + if (!isTransientLlmError(err)) return Future.reject(err); + if (n + 1 < MAX_AUTO_ATTEMPTS) { + return Future.resolveAfter(backoffMs(n), undefined).chain(() => attemptN(n + 1)); + } + return promptRetry(err).chain((retry) => (retry ? attemptN(0) : Future.reject(err))); + }); + + return attemptN(0); +}; diff --git a/src/domain/llm/router.ts b/src/domain/llm/router.ts index 73a6d27..eb0a451 100644 --- a/src/domain/llm/router.ts +++ b/src/domain/llm/router.ts @@ -5,16 +5,22 @@ export { type ModelRequestMetadata, type ProviderGeneratedContent, type TokenUsage, + type BranchNameSuggestions, + type BranchSuggestion, generateCommitMessage, - refineCommitMessage + refineCommitMessage, + generateBranchNameSuggestions }; import { Future } from "@/libs/future"; +import { type Result } from "@/libs/result"; import { type ProviderConfig, type CommitConvention } from "@/domain/config/config"; import { generateContentWithGemini } from "@/infra/llm/gemini"; import { generateContentWithOpenAI } from "@/infra/llm/openai"; import { generateContentWithAnthropic } from "@/infra/llm/anthropic"; -import { getPrompt, getRefinePrompt } from "@/domain/commit/prompts"; +import { getPrompt, getRefinePrompt, getBranchNamePrompt } from "@/domain/commit/prompts"; +import { parseAndValidateBranchSuggestions, type BranchSuggestion } from "@/domain/branch/suggestions"; +import { withTransientRetry } from "@/domain/llm/retry"; import { Maybe, Nothing } from "@/libs/maybe"; type GenerateContentParams = { @@ -45,6 +51,11 @@ type GeneratedContent = { readonly metadata: LlmRequestMetadata; }; +type BranchNameSuggestions = { + readonly names: readonly [BranchSuggestion, BranchSuggestion, BranchSuggestion]; + readonly metadata: LlmRequestMetadata; +}; + type ProviderGeneratedContent = { readonly text: string; readonly tokens: Maybe; @@ -88,7 +99,23 @@ const generateCommitMessage = ( diff: string, convention: CommitConvention, customTemplate: Maybe = Nothing() -): Future => generateContent(config, { prompt: getPrompt(diff, convention, customTemplate) }); +): Future => withTransientRetry(() => generateContent(config, { prompt: getPrompt(diff, convention, customTemplate) })); const refineCommitMessage = (config: ProviderConfig, currentMessage: string, adjustment: string, diff: string): Future => - generateContent(config, getRefinePrompt({ diff, currentMessage, adjustment })); + withTransientRetry(() => generateContent(config, getRefinePrompt({ diff, currentMessage, adjustment }))); + +const resultToFuture = (r: Result): Future => + r.either( + (err) => Future.reject(err), + (value) => Future.resolve(value) + ); + +const generateBranchNameSuggestions = (config: ProviderConfig, context: string): Future => + withTransientRetry(() => + generateContent(config, { prompt: getBranchNamePrompt(context) }).chain((gc) => + resultToFuture(parseAndValidateBranchSuggestions(gc.text)).map((names) => ({ + names, + metadata: gc.metadata + })) + ) + ); diff --git a/src/infra/git/repo.ts b/src/infra/git/repo.ts index 03a5cf5..47ac8bc 100644 --- a/src/infra/git/repo.ts +++ b/src/infra/git/repo.ts @@ -1,6 +1,8 @@ export { checkIsGitRepo, getStagedDiff, + getLocalChangeContext, + createAndSwitchBranch, performCommit, performPush, getCurrentBranch, @@ -14,6 +16,8 @@ export { getRemoteUrl, getTrackingRemoteUrl, findTrackingRemoteUrl, + NO_LOCAL_CHANGES_MESSAGE, + isNoLocalChangesError, type CommitMetadata, type PushResult, type PushRange @@ -69,6 +73,27 @@ const getStagedDiff = (): Future => stdout.trim() ? Future.resolve(stdout) : Future.reject(new Error("No staged changes found")) ); +const NO_LOCAL_CHANGES_MESSAGE = "No local changes to infer a branch name from"; + +const isNoLocalChangesError = (err: unknown): err is Error => err instanceof Error && err.message === NO_LOCAL_CHANGES_MESSAGE; + +const getLocalChangeContext = (): Future => + execGitChecked(["diff", "HEAD"], "Failed to read local diff").chain((diffStdout) => + execGitChecked(["status", "--porcelain", "--untracked-files=all"], "Failed to read git status").chain((statusStdout) => + Future.attemptP(async () => { + const diffPart = diffStdout.trim(); + const statusPart = statusStdout.trim(); + if (diffPart.length === 0 && statusPart.length === 0) { + throw new Error(NO_LOCAL_CHANGES_MESSAGE); + } + return statusPart.length > 0 ? `${diffPart}\n\n--- git status --porcelain ---\n${statusPart}\n` : diffPart; + }) + ) + ); + +const createAndSwitchBranch = (name: string): Future => + execGitChecked(["switch", "-c", name], `Failed to create branch '${name}'`).map(() => {}); + const performCommit = (message: string): Future => { const tmpPath = join(tmpdir(), `commit-msg-${Date.now()}.txt`); return Future.bracket( diff --git a/src/infra/ui/push-note.ts b/src/infra/ui/push-note.ts index f6a5aed..c202a28 100644 --- a/src/infra/ui/push-note.ts +++ b/src/infra/ui/push-note.ts @@ -1,4 +1,4 @@ -export { renderCommitNote, renderPushNote, type CommitNoteMetadata, type PushMetadata }; +export { renderBranchNote, renderCommitNote, renderPushNote, type BranchNoteMetadata, type CommitNoteMetadata, type PushMetadata }; import * as p from "@clack/prompts"; @@ -25,6 +25,12 @@ type PushMetadata = { request: RequestMetadata; }; +type BranchNoteMetadata = { + branch: string; + baseBranch: Maybe; + request: RequestMetadata; +}; + const formatDate = (d: Date): string => d.toISOString().slice(0, 16).replace("T", " "); const formatDuration = (ms: number): string => (ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`); @@ -93,3 +99,13 @@ const renderPushNote = (m: PushMetadata): void => { p.note(body, "Pushed"); }; + +const renderBranchNote = (m: BranchNoteMetadata): void => { + const baseLine = m.baseBranch.maybe([], (base) => [`base ${base}`]); + + const body = [`branch ${m.branch}`, ...baseLine, ...renderRequestLines(m.request)].join("\n"); + + if (!body) return; + + p.note(body, "Branched"); +}; diff --git a/test/cli/branch.test.ts b/test/cli/branch.test.ts new file mode 100644 index 0000000..1353288 --- /dev/null +++ b/test/cli/branch.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/infra/env", () => ({ + environment: { GOOGLE_CLIENT_ID: "test", GOOGLE_CLIENT_SECRET: "test" } +})); + +import { Branch } from "@/cli/branch"; +import { Future } from "@/libs/future"; +import { Just, Nothing } from "@/libs/maybe"; +import { runFuture } from "@test/helpers/run-future"; +import * as s from "@/libs/json/schema"; +import { Config } from "@/domain/config/config"; + +type ConfigValue = s.Infer; + +const branchMetadata = { + durationMs: 1, + model: { provider: "openai" as const, model: "m", effort: "medium" }, + tokens: Nothing() +}; + +vi.mock("@/infra/storage/config", () => ({ + loadConfig: vi.fn() +})); +vi.mock("@/domain/llm/auth-resolver", () => ({ + resolveProvider: vi.fn((c: ConfigValue) => Future.resolve(c.ai)) +})); +vi.mock("@/infra/git/repo", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + checkIsGitRepo: vi.fn(() => Future.resolve(undefined)), + getLocalChangeContext: vi.fn(() => Future.resolve("diff context")), + createAndSwitchBranch: vi.fn(() => Future.resolve(undefined)), + findCurrentBranch: vi.fn(() => Future.resolve(Just("main"))), + findBaseBranch: vi.fn(() => Future.resolve(Just("main"))) + }; +}); +vi.mock("@/domain/llm/router", () => ({ + generateBranchNameSuggestions: vi.fn(() => + Future.resolve({ + names: [ + { name: "login-form-ui", rationale: "component focus" }, + { name: "auth-wiring", rationale: "broader framing" }, + { name: "signup-flow", rationale: "user-visible change" } + ] as const, + metadata: branchMetadata + }) + ) +})); +vi.mock("@clack/prompts", () => ({ + select: vi.fn(async () => "auth-wiring"), + confirm: vi.fn(async () => true), + isCancel: vi.fn(() => false), + outro: vi.fn(), + log: { warn: vi.fn(), error: vi.fn() } +})); +vi.mock("@/infra/ui/push-note", () => ({ + renderBranchNote: vi.fn() +})); +vi.mock("@/infra/ui/spinner", () => ({ + loading: vi.fn((_a: string, _b: string, f: Future) => f as Future) +})); + +const config = (): ConfigValue => ({ + commit_convention: "conventional", + custom_template: Nothing(), + ai: { provider: "openai", model: "gpt-4.1-mini", effort: Nothing(), auth_method: { type: "api_key", content: "sk" } } +}); + +describe("Branch.run", () => { + beforeEach(async () => { + vi.clearAllMocks(); + const storage = await import("@/infra/storage/config"); + vi.mocked(storage.loadConfig).mockReturnValue(Future.resolve(config())); + + const repo = await import("@/infra/git/repo"); + vi.mocked(repo.findCurrentBranch).mockReturnValue(Future.resolve(Just("main"))); + vi.mocked(repo.findBaseBranch).mockReturnValue(Future.resolve(Just("main"))); + }); + + it("creates branch with selected suggestion when on base branch", async () => { + await runFuture(Branch.create().chain((b) => b.run())); + const repo = await import("@/infra/git/repo"); + const pushNote = await import("@/infra/ui/push-note"); + const prompts = await import("@clack/prompts"); + + expect(prompts.confirm).not.toHaveBeenCalled(); + expect(repo.createAndSwitchBranch).toHaveBeenCalledWith("auth-wiring"); + expect(prompts.select).toHaveBeenCalledWith({ + message: "Create branch", + options: [ + { value: "login-form-ui", label: "login-form-ui — component focus" }, + { value: "auth-wiring", label: "auth-wiring — broader framing" }, + { value: "signup-flow", label: "signup-flow — user-visible change" } + ] + }); + expect(pushNote.renderBranchNote).toHaveBeenCalledWith({ + branch: "auth-wiring", + baseBranch: Just("main"), + request: Just(branchMetadata) + }); + expect(prompts.outro).toHaveBeenCalledWith(expect.stringContaining("Switched to new branch")); + }); + + it("confirms fork off non-base branch when user accepts", async () => { + const repo = await import("@/infra/git/repo"); + const pushNote = await import("@/infra/ui/push-note"); + const prompts = await import("@clack/prompts"); + + vi.mocked(repo.findCurrentBranch).mockReturnValue(Future.resolve(Just("ai-branch-creator"))); + vi.mocked(repo.findBaseBranch).mockReturnValue(Future.resolve(Just("main"))); + vi.mocked(prompts.confirm).mockResolvedValue(true); + + await runFuture(Branch.create().chain((b) => b.run())); + + expect(prompts.log.warn).toHaveBeenCalledWith(expect.stringContaining("ai-branch-creator")); + expect(prompts.confirm).toHaveBeenCalledWith({ message: "Create branch off 'ai-branch-creator' anyway?" }); + expect(repo.createAndSwitchBranch).toHaveBeenCalledWith("auth-wiring"); + expect(pushNote.renderBranchNote).toHaveBeenCalled(); + expect(prompts.outro).toHaveBeenCalledWith(expect.stringContaining("Switched to new branch")); + }); + + it("cancels when user declines fork off non-base branch", async () => { + const repo = await import("@/infra/git/repo"); + const pushNote = await import("@/infra/ui/push-note"); + const prompts = await import("@clack/prompts"); + + vi.mocked(repo.findCurrentBranch).mockReturnValue(Future.resolve(Just("ai-branch-creator"))); + vi.mocked(repo.findBaseBranch).mockReturnValue(Future.resolve(Just("main"))); + vi.mocked(prompts.confirm).mockResolvedValue(false); + + await runFuture(Branch.create().chain((b) => b.run())); + + expect(prompts.confirm).toHaveBeenCalled(); + expect(repo.createAndSwitchBranch).not.toHaveBeenCalled(); + expect(pushNote.renderBranchNote).not.toHaveBeenCalled(); + expect(prompts.outro).toHaveBeenCalledWith("Operation cancelled."); + }); + + it("cancels when user dismisses fork confirmation", async () => { + const repo = await import("@/infra/git/repo"); + const pushNote = await import("@/infra/ui/push-note"); + const prompts = await import("@clack/prompts"); + const cancelled = Symbol("cancel"); + + vi.mocked(repo.findCurrentBranch).mockReturnValue(Future.resolve(Just("ai-branch-creator"))); + vi.mocked(repo.findBaseBranch).mockReturnValue(Future.resolve(Just("main"))); + vi.mocked(prompts.confirm).mockResolvedValue(cancelled as unknown as boolean); + vi.mocked(prompts.isCancel).mockImplementation((value) => value === cancelled); + + await runFuture(Branch.create().chain((b) => b.run())); + + expect(repo.createAndSwitchBranch).not.toHaveBeenCalled(); + expect(pushNote.renderBranchNote).not.toHaveBeenCalled(); + expect(prompts.outro).toHaveBeenCalledWith("Operation cancelled."); + }); + + it("skips fork confirmation when base branch is unknown", async () => { + const repo = await import("@/infra/git/repo"); + const prompts = await import("@clack/prompts"); + + vi.mocked(repo.findCurrentBranch).mockReturnValue(Future.resolve(Just("ai-branch-creator"))); + vi.mocked(repo.findBaseBranch).mockReturnValue(Future.resolve(Nothing())); + + await runFuture(Branch.create().chain((b) => b.run())); + + expect(prompts.confirm).not.toHaveBeenCalled(); + expect(repo.createAndSwitchBranch).toHaveBeenCalledWith("auth-wiring"); + }); + + it("exits gracefully when the branch picker is cancelled", async () => { + const repo = await import("@/infra/git/repo"); + const pushNote = await import("@/infra/ui/push-note"); + const prompts = await import("@clack/prompts"); + const cancelled = Symbol("cancel"); + + vi.mocked(prompts.select).mockResolvedValueOnce(cancelled as unknown as string); + vi.mocked(prompts.isCancel).mockImplementation((v) => v === cancelled); + + await runFuture(Branch.create().chain((b) => b.run())); + + expect(repo.createAndSwitchBranch).not.toHaveBeenCalled(); + expect(pushNote.renderBranchNote).not.toHaveBeenCalled(); + expect(prompts.confirm).not.toHaveBeenCalled(); + expect(prompts.log.error).not.toHaveBeenCalled(); + expect(prompts.outro).toHaveBeenCalledWith("Operation cancelled."); + }); + + it("shows informational outro when there are no local changes", async () => { + const repo = await import("@/infra/git/repo"); + const prompts = await import("@clack/prompts"); + const router = await import("@/domain/llm/router"); + const pushNote = await import("@/infra/ui/push-note"); + + vi.mocked(repo.getLocalChangeContext).mockReturnValue(Future.reject(new Error(repo.NO_LOCAL_CHANGES_MESSAGE))); + + await runFuture(Branch.create().chain((b) => b.run())); + + expect(router.generateBranchNameSuggestions).not.toHaveBeenCalled(); + expect(prompts.confirm).not.toHaveBeenCalled(); + expect(pushNote.renderBranchNote).not.toHaveBeenCalled(); + expect(prompts.log.warn).toHaveBeenCalled(); + expect(prompts.outro).toHaveBeenCalledWith("No local changes — nothing to suggest a branch for."); + expect(repo.createAndSwitchBranch).not.toHaveBeenCalled(); + }); +}); diff --git a/test/cli/parser.test.ts b/test/cli/parser.test.ts index a67d2dc..e40de79 100644 --- a/test/cli/parser.test.ts +++ b/test/cli/parser.test.ts @@ -5,6 +5,8 @@ import { Failure, Success } from "@/libs/result"; describe("parseArgs", () => { it.each([ [["generate"], "generate"], + [["branch"], "branch"], + [["new-branch"], "branch"], [["setup"], "setup"], [["login"], "setup"], [["doctor"], "doctor"], diff --git a/test/domain/branch/suggestions.test.ts b/test/domain/branch/suggestions.test.ts new file mode 100644 index 0000000..2bad7f2 --- /dev/null +++ b/test/domain/branch/suggestions.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { Failure, Success } from "@/libs/result"; +import { parseBranchSuggestions, parseAndValidateBranchSuggestions, stripOptionalJsonFence, validateGitBranchName } from "@/domain/branch/suggestions"; + +const oneTwoThree = '{"suggestions":[{"name":"one","rationale":"r1"},{"name":"two","rationale":"r2"},{"name":"three","rationale":"r3"}]}'; + +describe("stripOptionalJsonFence", () => { + it("returns trimmed input when no fence", () => { + expect(stripOptionalJsonFence(' {"a":1} ')).toBe('{"a":1}'); + }); + + it("strips json code fence", () => { + const raw = "```json\n" + oneTwoThree + "\n```"; + expect(stripOptionalJsonFence(raw)).toBe(oneTwoThree); + }); +}); + +describe("parseBranchSuggestions", () => { + it("parses valid payload", () => { + const r = parseBranchSuggestions(oneTwoThree); + expect(r instanceof Success).toBe(true); + if (r instanceof Success) { + expect(r.value.map((s) => s.name)).toEqual(["one", "two", "three"]); + expect(r.value.map((s) => s.rationale)).toEqual(["r1", "r2", "r3"]); + } + }); + + it("rejects wrong array length", () => { + const r = parseBranchSuggestions('{"suggestions":[{"name":"a","rationale":"r"},{"name":"b","rationale":"r"}]}'); + expect(r instanceof Failure).toBe(true); + }); + + it("rejects empty name", () => { + const r = parseBranchSuggestions('{"suggestions":[{"name":"","rationale":"r"},{"name":"b","rationale":"r"},{"name":"c","rationale":"r"}]}'); + expect(r instanceof Failure).toBe(true); + }); + + it("rejects missing rationale", () => { + const r = parseBranchSuggestions('{"suggestions":[{"name":"a"},{"name":"b","rationale":"r"},{"name":"c","rationale":"r"}]}'); + expect(r instanceof Failure).toBe(true); + }); + + it("rejects rationale exceeding 120 chars", () => { + const longR = "x".repeat(121); + const r = parseBranchSuggestions(`{"suggestions":[{"name":"a","rationale":"${longR}"},{"name":"b","rationale":"r"},{"name":"c","rationale":"r"}]}`); + expect(r instanceof Failure).toBe(true); + }); + + it("rejects invalid JSON", () => { + const r = parseBranchSuggestions("not json"); + expect(r instanceof Failure).toBe(true); + }); +}); + +describe("validateGitBranchName", () => { + it("accepts kebab-case slug", () => { + const r = validateGitBranchName("login-form-ui"); + expect(r instanceof Success).toBe(true); + }); + + it("accepts change-kind word as suffix (e.g. refactor)", () => { + const r = validateGitBranchName("frontend-list-ui-refactor"); + expect(r instanceof Success).toBe(true); + }); + + it("rejects slashes", () => { + const r = validateGitBranchName("feat/foo"); + expect(r instanceof Failure).toBe(true); + }); + + it("rejects reserved trunk names", () => { + expect(validateGitBranchName("main") instanceof Failure).toBe(true); + }); + + it("rejects type-first-segment", () => { + expect(validateGitBranchName("feat-login-form") instanceof Failure).toBe(true); + }); + + it("rejects vague verb as first segment (add-)", () => { + expect(validateGitBranchName("add-foo") instanceof Failure).toBe(true); + }); + + it("rejects vague verb as first segment (update-)", () => { + expect(validateGitBranchName("update-bar") instanceof Failure).toBe(true); + }); +}); + +describe("parseAndValidateBranchSuggestions", () => { + it("accepts fenced JSON with valid names", () => { + const raw = + '```json\n{"suggestions":[{"name":"login-form-ui","rationale":"component focus"},{"name":"auth-wiring","rationale":"broader framing"},{"name":"signup-flow","rationale":"user-visible change"}]}\n```'; + const r = parseAndValidateBranchSuggestions(raw); + expect(r instanceof Success).toBe(true); + if (r instanceof Success) { + expect(r.value.map((s) => s.name)).toEqual(["login-form-ui", "auth-wiring", "signup-flow"]); + expect(r.value.map((s) => s.rationale)).toEqual(["component focus", "broader framing", "user-visible change"]); + } + }); + + it("rejects valid JSON but invalid git names", () => { + const r = parseAndValidateBranchSuggestions( + '{"suggestions":[{"name":"../evil","rationale":"r"},{"name":"b","rationale":"r"},{"name":"c","rationale":"r"}]}' + ); + expect(r instanceof Failure).toBe(true); + }); +}); diff --git a/test/domain/llm/retry.test.ts b/test/domain/llm/retry.test.ts new file mode 100644 index 0000000..9cf104e --- /dev/null +++ b/test/domain/llm/retry.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { withTransientRetry, isTransientLlmError } from "@/domain/llm/retry"; +import { Future } from "@/libs/future"; +import { runFuture } from "@test/helpers/run-future"; + +vi.mock("@clack/prompts", () => ({ + log: { warn: vi.fn() }, + confirm: vi.fn(async () => false), + isCancel: vi.fn(() => false) +})); + +describe("isTransientLlmError", () => { + it("matches 'terminated' error", () => { + expect(isTransientLlmError(new Error("Failed to create Anthropic message: terminated"))).toBe(true); + }); + + it("matches err.cause message", () => { + const cause = new Error("ECONNRESET"); + expect(isTransientLlmError(new Error("Wrapped", { cause }))).toBe(true); + }); + + it("matches 503", () => { + expect(isTransientLlmError(new Error("Server returned 503"))).toBe(true); + }); + + it("does not match auth errors", () => { + expect(isTransientLlmError(new Error("401 unauthorized"))).toBe(false); + }); +}); + +describe("withTransientRetry", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("succeeds on first try without retrying", async () => { + const make = vi.fn(() => Future.resolve("ok")); + const result = await runFuture(withTransientRetry(make)); + expect(result).toBe("ok"); + expect(make).toHaveBeenCalledTimes(1); + }); + + it("retries transient error twice then succeeds", async () => { + let calls = 0; + const make = vi.fn((): Future => { + calls += 1; + if (calls < 3) return Future.reject(new Error("terminated")); + return Future.resolve("ok"); + }); + const promise = runFuture(withTransientRetry(make)); + await vi.runAllTimersAsync(); + const result = await promise; + expect(result).toBe("ok"); + expect(make).toHaveBeenCalledTimes(3); + }); + + it("fails immediately on non-transient error", async () => { + const make = vi.fn(() => Future.reject(new Error("401 unauthorized"))); + await expect(runFuture(withTransientRetry(make))).rejects.toThrow("401 unauthorized"); + expect(make).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/helpers/temp-git-repo.ts b/test/helpers/temp-git-repo.ts index 3a84e49..841f3bd 100644 --- a/test/helpers/temp-git-repo.ts +++ b/test/helpers/temp-git-repo.ts @@ -5,7 +5,7 @@ import { execSync } from "node:child_process"; export type TempGitRepo = { dir: string; run: (args: string) => string }; -export const createTempGitRepo = (opts?: { staged?: boolean }): TempGitRepo => { +export const createTempGitRepo = (opts?: { staged?: boolean; unstaged?: boolean; untrackedFile?: { path: string; contents: string } }): TempGitRepo => { const dir = mkdtempSync(join(tmpdir(), "commit-tools-git-")); const run = (args: string) => execSync(`git ${args}`, { cwd: dir, encoding: "utf-8" }); run("init -b main"); @@ -18,5 +18,11 @@ export const createTempGitRepo = (opts?: { staged?: boolean }): TempGitRepo => { writeFileSync(join(dir, "file.txt"), "hello world\n"); run("add file.txt"); } + if (opts?.unstaged) { + writeFileSync(join(dir, "file.txt"), "hello unstaged\n"); + } + if (opts?.untrackedFile) { + writeFileSync(join(dir, opts.untrackedFile.path), opts.untrackedFile.contents); + } return { dir, run }; }; diff --git a/test/infra/git/repo.integration.test.ts b/test/infra/git/repo.integration.test.ts index 3eb790c..fca41f0 100644 --- a/test/infra/git/repo.integration.test.ts +++ b/test/infra/git/repo.integration.test.ts @@ -1,10 +1,80 @@ import { describe, expect, it } from "vitest"; import { chdir, cwd } from "node:process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { runFuture } from "@test/helpers/run-future"; import { createTempGitRepo } from "@test/helpers/temp-git-repo"; import * as repo from "@/infra/git/repo"; describe("git repo integration", () => { + it("getLocalChangeContext includes unstaged diff", async () => { + const { dir } = createTempGitRepo({ unstaged: true }); + const prev = cwd(); + chdir(dir); + try { + const ctx = await runFuture(repo.getLocalChangeContext()); + expect(ctx).toContain("file.txt"); + expect(ctx).toContain("--- git status --porcelain ---"); + } finally { + chdir(prev); + } + }); + + it("getLocalChangeContext rejects when working tree clean", async () => { + const { dir } = createTempGitRepo({ staged: false }); + const prev = cwd(); + chdir(dir); + try { + await expect(runFuture(repo.getLocalChangeContext())).rejects.toThrow(repo.NO_LOCAL_CHANGES_MESSAGE); + } finally { + chdir(prev); + } + }); + + it("getLocalChangeContext lists untracked file path without exposing its body", async () => { + const secret = "DO-NOT-EXFILTRATE-secret-token"; + const { dir } = createTempGitRepo({ + untrackedFile: { path: "new-feature.ts", contents: `export const marker = "${secret}";\n` } + }); + const prev = cwd(); + chdir(dir); + try { + const ctx = await runFuture(repo.getLocalChangeContext()); + expect(ctx).toContain("?? new-feature.ts"); + expect(ctx).not.toContain(secret); + expect(ctx).not.toContain("--- untracked file:"); + } finally { + chdir(prev); + } + }); + + it("getLocalChangeContext enumerates files inside new untracked directories", async () => { + const { dir } = createTempGitRepo({ staged: false }); + mkdirSync(join(dir, "newdir")); + writeFileSync(join(dir, "newdir", "inner.ts"), "export const x = 1;\n"); + const prev = cwd(); + chdir(dir); + try { + const ctx = await runFuture(repo.getLocalChangeContext()); + expect(ctx).toContain("?? newdir/inner.ts"); + } finally { + chdir(prev); + } + }); + + it("createAndSwitchBranch switches HEAD", async () => { + const { dir } = createTempGitRepo({ unstaged: true }); + const prev = cwd(); + chdir(dir); + try { + await runFuture(repo.createAndSwitchBranch("my-work-branch")); + const branch = await runFuture(repo.getCurrentBranch()); + expect(branch).toBe("my-work-branch"); + } finally { + chdir(prev); + } + }); + it("getStagedDiff rejects when nothing staged", async () => { const { dir } = createTempGitRepo({ staged: false }); const prev = cwd(); diff --git a/test/infra/ui/push-note.test.ts b/test/infra/ui/push-note.test.ts new file mode 100644 index 0000000..7119853 --- /dev/null +++ b/test/infra/ui/push-note.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@clack/prompts", () => ({ + note: vi.fn() +})); + +import { renderBranchNote } from "@/infra/ui/push-note"; +import { Just, Nothing } from "@/libs/maybe"; +import * as p from "@clack/prompts"; + +describe("renderBranchNote", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("renders branch, base, and full request metadata", () => { + renderBranchNote({ + branch: "ai-branch-creator", + baseBranch: Just("main"), + request: Just({ + durationMs: 11400, + model: { provider: "anthropic", model: "claude-opus-4-7", effort: "max" }, + tokens: Just({ + input: Just(13026), + output: Just(478), + total: Just(13504) + }) + }) + }); + + expect(p.note).toHaveBeenCalledWith( + [ + "branch ai-branch-creator", + "base main", + "model claude-opus-4-7 with max effort", + "request 11.4s", + "tokens input 13,026 output 478 total 13,504" + ].join("\n"), + "Branched" + ); + }); + + it("omits base when unavailable", () => { + renderBranchNote({ + branch: "auth-wiring", + baseBranch: Nothing(), + request: Just({ + durationMs: 500, + model: { provider: "openai", model: "gpt-4.1-mini", effort: "medium" }, + tokens: Nothing() + }) + }); + + expect(p.note).toHaveBeenCalledWith( + ["branch auth-wiring", "model gpt-4.1-mini with medium effort", "request 500ms", "tokens unavailable"].join("\n"), + "Branched" + ); + }); + + it("shows branch and base when request metadata is unavailable", () => { + renderBranchNote({ + branch: "login-form-ui", + baseBranch: Just("main"), + request: Nothing() + }); + + expect(p.note).toHaveBeenCalledWith(["branch login-form-ui", "base main"].join("\n"), "Branched"); + }); +});