diff --git a/README.md b/README.md index 9d67899..5cd5f0b 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ You will be prompted to choose: - Anthropic: Claude setup-token or API key - xAI: Sign in with Grok or API key - **Commit convention**: Conventional, Imperative, or Custom +- **Split commits**: whether `commit` should analyze staged files and open a multi-commit plan when they look independent If you want to use your claude.ai subscription with Anthropic, run `claude setup-token` in another terminal first, then paste the generated setup-token during `commit setup`. @@ -160,6 +161,8 @@ Or explicitly: commit generate ``` +With split enabled in setup, `commit` analyzes staged files and opens a multi-commit plan when they look independent. + ### System Checks Verify your installation, environment, and configuration: diff --git a/src/cli/commit.ts b/src/cli/commit.ts index 54dcd9d..8904049 100644 --- a/src/cli/commit.ts +++ b/src/cli/commit.ts @@ -1,4 +1,4 @@ -export { Commit }; +export { Commit, routeAnalysis, type AnalysisRoute }; import * as p from "@clack/prompts"; import * as pr from "@/infra/github/pr"; @@ -7,10 +7,21 @@ import * as repo from "@/infra/git/repo"; import { Future } from "@/libs/future"; import { loadConfig } from "@/infra/storage/config"; import { Setup } from "@/cli/setup"; +import { Split } from "@/cli/split"; import { type CommitConvention, type Config, type ProviderConfig } from "@/domain/config/config"; import { resolveProvider } from "@/domain/llm/auth-resolver"; -import { generateCommitMessage, refineCommitMessage, type GeneratedContent, type LlmRequestMetadata } from "@/domain/llm/router"; -import { Nothing, type Maybe, Just } from "@/libs/maybe"; +import { + generateCommitMessage, + generateSplitPlan, + refineCommitMessage, + type GeneratedContent, + type LlmRequestMetadata, + type SplitPlanContent +} from "@/domain/llm/router"; +import { type SplitPlan } from "@/domain/split/plan"; +import { Nothing, type Maybe, Just, fromOptional } from "@/libs/maybe"; +import { Failure, Success, type Result } from "@/libs/result"; +import { absurd } from "@/libs/types"; import { loading } from "@/infra/ui/spinner"; import { renderCommitNote, renderPushNote } from "@/infra/ui/push-note"; @@ -19,6 +30,16 @@ import color from "picocolors"; const USER_ACTIONS = ["commit_push", "commit", "regenerate", "adjust", "cancel"] as const; type UserAction = (typeof USER_ACTIONS)[number]; +type AnalysisRoute = { tag: "split"; plan: SplitPlan } | { tag: "single"; message: string }; + +const routeAnalysis = (plan: SplitPlan): Result => + plan.shouldSplit && plan.commits.length >= 2 ? + Success({ tag: "split", plan }) + : fromOptional(plan.commits[0]).unwrap>( + () => Failure(new Error("Split plan: expected at least 1 commit")), + (first) => Success({ tag: "single", message: first.message }) + ); + class Commit { private constructor( private readonly config: Config, @@ -39,14 +60,41 @@ class Commit { run(): Future { return repo .checkIsGitRepo() - .chain(() => this.diff()) - .chain((diff) => this.generate(diff, this.config.commit_convention, this.config.custom_template).chain((message) => this.interact(diff, message))) + .chain(() => Future.concurrently({ diff: repo.getStagedDiff(), files: repo.listStagedPaths() })) + .chain(({ diff, files }) => this.route(diff, files)) .mapRej((e) => { p.log.error(color.red(e.message)); return e; }); } + private route(diff: string, files: readonly string[]): Future { + return this.config.split_commits && files.length >= 2 ? + loading( + "Analyzing staged changes...", + "Ready!", + generateSplitPlan(this.providerConfig, diff, files, this.config.commit_convention, this.config.custom_template) + ).chain((content) => this.followAnalysis(diff, files, content)) + : this.generate(diff, this.config.commit_convention, this.config.custom_template).chain((message) => this.interact(diff, message)); + } + + private followAnalysis(diff: string, files: readonly string[], content: SplitPlanContent): Future { + const { plan, metadata } = content; + return routeAnalysis(plan).either( + (e) => Future.reject(e), + (route) => { + switch (route.tag) { + case "split": + return Split.fromResolved(this.config, this.providerConfig).runPlan(diff, files, route.plan, metadata); + case "single": + return this.interact(diff, { text: route.message, metadata }); + default: + return absurd(route, "AnalysisRoute"); + } + } + ); + } + diff(): Future { return repo.getStagedDiff(); } @@ -123,11 +171,11 @@ class Commit { const action = await p.select({ message: "What would you like to do?", options: [ - { value: "commit_push", label: "Commit & Push" }, - { value: "commit", label: "Commit" }, - { value: "regenerate", label: "Regenerate" }, - { value: "adjust", label: "Adjust" }, - { value: "cancel", label: "Cancel" } + { value: "commit_push" as const, label: "Commit & Push" }, + { value: "commit" as const, label: "Commit" }, + { value: "regenerate" as const, label: "Regenerate" }, + { value: "adjust" as const, label: "Adjust" }, + { value: "cancel" as const, label: "Cancel" } ] }); diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 4473421..74a433e 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -23,6 +23,7 @@ type SetupPreferences = { readonly customTemplate: string | undefined; readonly provider: ProviderConfig["provider"]; readonly authMethod: "google_oauth" | "openai_oauth" | "xai_oauth" | "api_key" | "anthropic_setup_token"; + readonly splitCommits: boolean; }; class Setup { @@ -67,6 +68,16 @@ class Setup { customTemplate = template; } + const splitCommits = await p.select({ + message: "Analyze staged files and split them when they look independent?", + options: [ + { value: true, label: "Auto — split when needed (recommended)" }, + { value: false, label: "No — always one commit" } + ], + initialValue: true as const + }); + if (p.isCancel(splitCommits)) throw new Error("Setup cancelled"); + const authMethod = await p.select({ message: "Select authentication method:", options: getAuthMethodOptions(provider), @@ -79,7 +90,8 @@ class Setup { convention: convention, customTemplate, provider: provider, - authMethod: authMethod + authMethod: authMethod, + splitCommits }); }); } @@ -103,7 +115,8 @@ class Setup { return { ai, commit_convention: this.preferences.convention, - custom_template: this.preferences.customTemplate ? Just(this.preferences.customTemplate) : Nothing() + custom_template: this.preferences.customTemplate ? Just(this.preferences.customTemplate) : Nothing(), + split_commits: this.preferences.splitCommits }; } diff --git a/src/cli/split.ts b/src/cli/split.ts new file mode 100644 index 0000000..7e6246d --- /dev/null +++ b/src/cli/split.ts @@ -0,0 +1,280 @@ +export { Split }; + +import * as p from "@clack/prompts"; +import * as pr from "@/infra/github/pr"; +import * as repo from "@/infra/git/repo"; + +import { Future } from "@/libs/future"; +import { Commit } from "@/cli/commit"; +import { type Config, type ProviderConfig } from "@/domain/config/config"; +import { generateSplitPlan, type LlmRequestMetadata, type SplitPlanContent } from "@/domain/llm/router"; +import { type SplitPlan } from "@/domain/split/plan"; +import { Just, type Maybe } from "@/libs/maybe"; +import { loading } from "@/infra/ui/spinner"; +import { renderCommitNote, renderPushNote } from "@/infra/ui/push-note"; +import { absurd } from "@/libs/types"; + +import color from "picocolors"; + +const SPLIT_ACTIONS = ["apply_push", "apply", "edit", "move", "reorder", "regenerate", "cancel"] as const; +type SplitAction = (typeof SPLIT_ACTIONS)[number]; + +const formatPlanNote = (plan: SplitPlan, stagedCount: number): string => { + const groups = plan.commits.map((commit, index) => `${index + 1}. ${commit.message}\n ${commit.files.join(" ")}`).join("\n\n"); + return `${plan.commits.length} commits · ${stagedCount} staged files\n\n${groups}`; +}; + +const commitOptions = (plan: SplitPlan): { value: number; label: string }[] => + plan.commits.map((commit, index) => ({ value: index, label: `${index + 1}. ${commit.message}` })); + +const withEditedMessage = (plan: SplitPlan, index: number, message: string): SplitPlan => ({ + ...plan, + commits: plan.commits.map((commit, i) => (i === index ? { message, files: commit.files } : commit)) +}); + +const withMovedFile = (plan: SplitPlan, file: string, destIndex: number): SplitPlan => { + const sourceIndex = plan.commits.findIndex((commit) => commit.files.includes(file)); + if (sourceIndex === -1 || sourceIndex === destIndex) { + return plan; + } + const moved = plan.commits.map((commit, i) => { + if (i === sourceIndex) { + return { message: commit.message, files: commit.files.filter((path) => path !== file) }; + } + if (i === destIndex) { + return { message: commit.message, files: [...commit.files, file] }; + } + return commit; + }); + return { commits: moved.filter((commit) => commit.files.length > 0), shouldSplit: plan.shouldSplit }; +}; + +const withReorderedCommit = (plan: SplitPlan, fromIndex: number, toIndex: number): SplitPlan => { + if (fromIndex === toIndex) { + return plan; + } + const commits = [...plan.commits]; + const [item] = commits.splice(fromIndex, 1); + if (item === undefined) { + return plan; + } + commits.splice(toIndex, 0, item); + return { commits, shouldSplit: plan.shouldSplit }; +}; + +class Split { + private constructor( + private readonly config: Config, + private readonly providerConfig: ProviderConfig + ) {} + + static fromResolved(config: Config, providerConfig: ProviderConfig): Split { + return new Split(config, providerConfig); + } + + runPlan(diff: string, files: readonly string[], plan: SplitPlan, meta: LlmRequestMetadata): Future { + return this.interact(diff, files, plan, meta); + } + + private generate(diff: string, files: readonly string[]): Future { + return loading( + "Generating split plan...", + "Split plan generated!", + generateSplitPlan(this.providerConfig, diff, files, this.config.commit_convention, this.config.custom_template) + ); + } + + private interact(diff: string, files: readonly string[], plan: SplitPlan, meta: LlmRequestMetadata): Future { + return this.promptAction(plan, files.length).chain((action) => { + switch (action) { + case "apply_push": + return this.apply(plan, meta, true); + case "apply": + return this.apply(plan, meta, false); + case "edit": + return this.editMessage(plan).chain((next) => this.interact(diff, files, next, meta)); + case "move": + return this.moveFile(plan).chain((next) => this.interact(diff, files, next, meta)); + case "reorder": + return this.reorder(plan).chain((next) => this.interact(diff, files, next, meta)); + case "regenerate": + return this.generate(diff, files).chain((c) => this.interact(diff, files, c.plan, c.metadata)); + case "cancel": + return Future.resolve(undefined); + default: + return absurd(action, "SplitAction"); + } + }); + } + + private promptAction(plan: SplitPlan, stagedCount: number): Future { + return Future.attemptP(async () => { + p.note(formatPlanNote(plan, stagedCount), "Split Plan"); + + const action = await p.select({ + message: "What would you like to do?", + options: [ + { value: "apply_push", label: "Apply & Push" }, + { value: "apply", label: "Apply" }, + { value: "edit", label: "Edit message" }, + { value: "move", label: "Move file" }, + { value: "reorder", label: "Reorder" }, + { value: "regenerate", label: "Regenerate" }, + { value: "cancel", label: "Cancel" } + ] + }); + + if (p.isCancel(action) || action === "cancel") { + p.outro("Operation cancelled."); + return "cancel"; + } + + return action; + }); + } + + private editMessage(plan: SplitPlan): Future { + return Future.attemptP(async () => { + const picked = await p.select({ + message: "Which commit?", + options: commitOptions(plan) + }); + if (p.isCancel(picked)) { + return plan; + } + const current = plan.commits[picked]; + if (current === undefined) { + return plan; + } + const next = await p.text({ + message: "New message", + initialValue: current.message + }); + if (p.isCancel(next)) { + return plan; + } + const message = next.trim(); + return message.length === 0 ? plan : withEditedMessage(plan, picked, message); + }); + } + + private moveFile(plan: SplitPlan): Future { + return Future.attemptP(async () => { + const file = await p.select({ + message: "Move which file?", + options: plan.commits.flatMap((commit) => commit.files.map((path) => ({ value: path, label: path }))) + }); + if (p.isCancel(file)) { + return plan; + } + const dest = await p.select({ + message: "Move to which commit?", + options: commitOptions(plan) + }); + if (p.isCancel(dest)) { + return plan; + } + return withMovedFile(plan, file, dest); + }); + } + + private reorder(plan: SplitPlan): Future { + return Future.attemptP(async () => { + const from = await p.select({ + message: "Which commit?", + options: commitOptions(plan) + }); + if (p.isCancel(from)) { + return plan; + } + const position = await p.select({ + message: "New position", + options: plan.commits.map((_, index) => ({ value: index + 1, label: String(index + 1) })) + }); + if (p.isCancel(position)) { + return plan; + } + return withReorderedCommit(plan, from, position - 1); + }); + } + + private apply(plan: SplitPlan, meta: LlmRequestMetadata, shouldPush: boolean): Future { + return Future.traverse( + (group) => + repo.performCommit(group.message, group.files).map((stats) => { + process.stdout.write(stats); + }), + [...plan.commits] + ).chain(() => + shouldPush ? + this.pushAfterCommit(Just(meta)).map(() => { + p.outro(color.green("Done!")); + }) + : repo.findCommitMetadata().map((commit) => { + renderCommitNote({ commit, request: Just(meta) }); + p.outro(color.green("Committed successfully!")); + }) + ); + } + + private push(request: Maybe, branch?: string, publish = false, forceWithLease = false): Future { + const startMsg = + forceWithLease ? "Force pushing with lease..." + : publish ? `Publishing '${branch}'...` + : "Pushing..."; + + const endMsg = + forceWithLease ? "Force pushed successfully!" + : publish ? "Published successfully!" + : "Pushed successfully!"; + + return loading(startMsg, endMsg, repo.performPush(branch, publish, forceWithLease)).chain((result) => + Future.concurrently< + Error, + { + commit: Maybe; + localBranch: Maybe; + baseBranch: Maybe; + remoteUrl: Maybe; + pr: pr.PrLookup; + } + >({ + commit: repo.findCommitMetadata(), + localBranch: repo.findCurrentBranch(), + baseBranch: repo.findBaseBranch(), + remoteUrl: repo.findTrackingRemoteUrl(), + pr: pr.getOpenPullRequest() + }).map((parts) => renderPushNote({ ...parts, range: result.range, request })) + ); + } + + private pushAfterCommit(request: Maybe): Future { + return repo + .hasUpstream() + .chain((exists) => + exists ? + this.push(request).chainRej((err) => (Commit.isNonFastForwardError(err) ? this.promptForceWithLease(request) : Future.reject(err))) + : this.promptPublishBranch(request) + ); + } + + private promptPublishBranch(request: Maybe): Future { + return repo.getCurrentBranch().chain((branch) => + Future.attemptP(async () => { + const publish = await p.confirm({ + message: `Branch '${branch}' has no upstream. Publish to origin?` + }); + return !(p.isCancel(publish) || !publish); + }).chain((shouldPublish) => (shouldPublish ? this.push(request, branch, true) : Future.resolve(undefined))) + ); + } + + private promptForceWithLease(request: Maybe): Future { + return Future.attemptP(async () => { + const force = await p.confirm({ + message: "Push was rejected (branch is behind remote). Force push with lease?" + }); + return !(p.isCancel(force) || !force); + }).chain((shouldForce) => (shouldForce ? this.push(request, undefined, false, true) : Future.resolve(undefined))); + } +} diff --git a/src/domain/commit/prompts.ts b/src/domain/commit/prompts.ts index 4b988be..7a09fea 100644 --- a/src/domain/commit/prompts.ts +++ b/src/domain/commit/prompts.ts @@ -1,4 +1,4 @@ -export { getPrompt, getRefinePrompt, getBranchNamePrompt }; +export { getPrompt, getRefinePrompt, getBranchNamePrompt, getSplitPrompt }; import { CommitConvention } from "@/domain/config/config"; import { Just, Nothing, type Maybe } from "@/libs/maybe"; @@ -275,6 +275,53 @@ function promptCustom(gitDiff: string, template: Maybe): string { } } +function getSplitPrompt(diff: string, files: readonly string[], convention: CommitConvention, customTemplate: Maybe = Nothing()): string { + const basePrompt = getPrompt(diff, convention, customTemplate); + const outputInstructionsStart = basePrompt.lastIndexOf(""); + const conventionPrompt = outputInstructionsStart >= 0 ? basePrompt.slice(0, outputInstructionsStart) : basePrompt; + return ` + + Partition staged files into reviewable commits. Do not write one message for the whole diff. + A feature that touches unrelated layers is several commits. + + ${conventionPrompt} + + + ${files.join("\n")} + + + + Return ONE JSON object. First character "{", last "}". + {"should_split":,"commits":[{"message":"","files":["",...]}]} + + + - Use only paths from . Exact strings. + - Every staged path in exactly one commit. + - Prefer should_split=true when files (or groups) do not share one concern. Each group is its own commit. + - should_split=false only when every file is the same concern (implementation + its test, rename + callers). Then exactly 1 commit covering every staged path. + - Same feature across unrelated layers (CLI, domain, docs, unrelated tests) is still should_split=true. + - When unsure across 2+ areas, should_split=true with 2+ commits. + - Each message follows the active convention (SMALL/MEDIUM/LARGE shape) for that commit only. + + + + src/cli/setup.ts +test/cli/setup.test.ts + {"should_split":false,"commits":[{"message":"Add split option to setup","files":["src/cli/setup.ts","test/cli/setup.test.ts"]}]} + + + src/cli/setup.ts +src/domain/split/plan.ts +test/domain/split/plan.test.ts + {"should_split":true,"commits":[{"message":"Add split option to setup","files":["src/cli/setup.ts"]},{"message":"Parse should_split in split plans","files":["src/domain/split/plan.ts","test/domain/split/plan.test.ts"]}]} + + + + Emit ONLY the JSON object. No prose, no markdown fences, no commentary. + + `; +} + function getBranchNamePrompt(context: string): string { return ` diff --git a/src/domain/config/config.ts b/src/domain/config/config.ts index 429d7c3..b7b9bcb 100644 --- a/src/domain/config/config.ts +++ b/src/domain/config/config.ts @@ -138,7 +138,8 @@ const resolveAuthMethod = (ai: ProviderConfig, auth_method: ProviderConfig["auth const Config = s.object({ ai: schema_ProviderConfig, commit_convention: s.stringEnum([...COMMIT_CONVENTIONS]), - custom_template: s.optionalMaybe(s.string) + custom_template: s.optionalMaybe(s.string), + split_commits: s.optionalDefault(false, s.boolean) }); type Config = s.Infer; diff --git a/src/domain/llm/effort.ts b/src/domain/llm/effort.ts index cf82871..0709764 100644 --- a/src/domain/llm/effort.ts +++ b/src/domain/llm/effort.ts @@ -1,4 +1,4 @@ -export { seedProviderConfig, withModel, selectEffortForProvider }; +export { seedProviderConfig, withModel, withMinEffort, selectEffortForProvider }; import { type Future } from "@/libs/future"; import { @@ -7,9 +7,10 @@ import { type OpenAIModelEffort, type XaiEffort, type AnthropicEffort, - type GeminiEffort + type GeminiEffort, + GEMINI_EFFORTS } from "@/domain/config/config"; -import { Nothing, type Maybe } from "@/libs/maybe"; +import { Just, Nothing, type Maybe } from "@/libs/maybe"; import { selectOpenAIEffort, selectAnthropicEffort, selectGeminiEffort, selectXaiEffort } from "@/infra/ui/effort-picker"; import { absurd } from "@/libs/types"; @@ -43,6 +44,21 @@ const withModel = (ai: ProviderConfig, model: string): ProviderConfig => { } }; +const withMinEffort = (config: ProviderConfig): ProviderConfig => { + switch (config.provider) { + case "openai": + return { provider: "openai", model: config.model, auth_method: config.auth_method, effort: Just("low") }; + case "anthropic": + return { provider: "anthropic", model: config.model, auth_method: config.auth_method, effort: Just("low") }; + case "gemini": + return { provider: "gemini", model: config.model, auth_method: config.auth_method, effort: Just(GEMINI_EFFORTS[0]) }; + case "xai": + return { provider: "xai", model: config.model, auth_method: config.auth_method, effort: Just("low") }; + default: + return absurd(config, "ProviderConfig"); + } +}; + const selectEffortForProvider = (current: ProviderConfig, modelEffort: Maybe = Nothing()): Future => { switch (current.provider) { case "openai": diff --git a/src/domain/llm/router.ts b/src/domain/llm/router.ts index 0bbafb2..f944b33 100644 --- a/src/domain/llm/router.ts +++ b/src/domain/llm/router.ts @@ -9,7 +9,9 @@ export { type BranchSuggestion, generateCommitMessage, refineCommitMessage, - generateBranchNameSuggestions + generateBranchNameSuggestions, + generateSplitPlan, + type SplitPlanContent }; import { Future } from "@/libs/future"; @@ -19,9 +21,11 @@ import { generateContentWithGemini } from "@/infra/llm/gemini"; import { generateContentWithOpenAI } from "@/infra/llm/openai"; import { generateContentWithAnthropic } from "@/infra/llm/anthropic"; import { generateContentWithXai } from "@/infra/llm/xai"; -import { getPrompt, getRefinePrompt, getBranchNamePrompt } from "@/domain/commit/prompts"; +import { getPrompt, getRefinePrompt, getBranchNamePrompt, getSplitPrompt } from "@/domain/commit/prompts"; import { parseAndValidateBranchSuggestions, type BranchSuggestion } from "@/domain/branch/suggestions"; +import { parseAndValidateSplitPlan, type SplitPlan } from "@/domain/split/plan"; import { withTransientRetry } from "@/domain/llm/retry"; +import { withMinEffort } from "@/domain/llm/effort"; import { Maybe, Nothing } from "@/libs/maybe"; type GenerateContentParams = { @@ -57,6 +61,8 @@ type BranchNameSuggestions = { readonly metadata: LlmRequestMetadata; }; +type SplitPlanContent = { readonly plan: SplitPlan; readonly metadata: LlmRequestMetadata }; + type ProviderGeneratedContent = { readonly text: string; readonly tokens: Maybe; @@ -132,3 +138,16 @@ const generateBranchNameSuggestions = (config: ProviderConfig, context: string): })) ) ); + +const generateSplitPlan = ( + config: ProviderConfig, + diff: string, + files: readonly string[], + convention: CommitConvention, + customTemplate: Maybe +): Future => + withTransientRetry(() => + generateContent(withMinEffort(config), { prompt: getSplitPrompt(diff, files, convention, customTemplate) }).chain((gc) => + resultToFuture(parseAndValidateSplitPlan(gc.text, files)).map((plan) => ({ plan, metadata: gc.metadata })) + ) + ); diff --git a/src/domain/split/plan.ts b/src/domain/split/plan.ts new file mode 100644 index 0000000..7cab464 --- /dev/null +++ b/src/domain/split/plan.ts @@ -0,0 +1,121 @@ +export { parseAndValidateSplitPlan, type SplitCommit, type SplitPlan }; + +import * as D from "@/libs/json/decoder"; +import { Failure, Success, type Result } from "@/libs/result"; + +type SplitCommit = { readonly message: string; readonly files: readonly string[] }; +type SplitPlan = { readonly commits: readonly SplitCommit[]; readonly shouldSplit: boolean }; + +const REMAINING_STAGED_MESSAGE = "Commit remaining staged changes"; + +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 nonEmptyFiles: D.Decoder = D.array(nonEmptyString("file")).chain((xs) => + xs.length === 0 ? D.fail("files must be non-empty") : D.succeed(xs) +); + +const splitCommitDecoder: D.Decoder = D.object({ + message: nonEmptyString("message"), + files: nonEmptyFiles +}); + +const nonEmptyCommits: D.Decoder = D.array(splitCommitDecoder).chain((xs) => + xs.length === 0 ? D.fail("expected at least 1 commit") : D.succeed(xs) +); + +const splitPlanDecoder: D.Decoder = D.object({ + should_split: D.boolean, + commits: nonEmptyCommits +}).map(({ should_split, commits }) => ({ shouldSplit: should_split, commits })); + +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 extractJsonObject = (s: string): string => { + const match = s.match(/\{\s*"should_split"\s*:/); + const start = match?.index ?? s.indexOf("{"); + const end = s.lastIndexOf("}"); + if (start === -1 || end <= start) { + return s; + } + return s.slice(start, end + 1); +}; + +const parseSplitPlan = (raw: string): Result => { + const trimmed = extractJsonObject(stripOptionalJsonFence(raw.trim())); + let json: unknown; + try { + json = JSON.parse(trimmed); + } catch { + return Failure(new Error("Split plan: invalid JSON")); + } + return D.decode(json, splitPlanDecoder).mapFailure((msg) => new Error(`Split plan: ${msg}`)); +}; + +const collapseToSingleCommit = (commits: readonly SplitCommit[], leftover: readonly string[]): Result => { + const [first, ...rest] = commits; + if (first === undefined) { + return Failure(new Error("Split plan: expected at least 1 commit")); + } + return Success({ + shouldSplit: false, + commits: [{ message: first.message, files: [...first.files, ...rest.flatMap((commit) => commit.files), ...leftover] }] + }); +}; + +const takePath = (file: string, staged: Set, seen: Set): Result => { + if (seen.has(file)) { + return Failure(new Error(`Split plan: duplicate path: ${file}`)); + } + if (!staged.has(file)) { + return Failure(new Error(`Split plan: unknown path: ${file}`)); + } + seen.add(file); + return Success(undefined); +}; + +const leftoverStagedPaths = (plan: SplitPlan, stagedFiles: readonly string[]): Result => { + const staged = new Set(stagedFiles); + const seen = new Set(); + for (const commit of plan.commits) { + for (const file of commit.files) { + const taken = takePath(file, staged, seen); + if (taken instanceof Failure) { + return Failure(taken.error); + } + } + } + return Success(stagedFiles.filter((file) => !seen.has(file))); +}; + +const validateSplitPlan = (plan: SplitPlan, stagedFiles: readonly string[]): Result => + leftoverStagedPaths(plan, stagedFiles).chain((leftover) => { + if (!plan.shouldSplit) { + return collapseToSingleCommit(plan.commits, leftover); + } + if (leftover.length === 0) { + return Success(plan); + } + return Success({ + shouldSplit: true, + commits: [...plan.commits, { message: REMAINING_STAGED_MESSAGE, files: leftover }] + }); + }); + +const parseAndValidateSplitPlan = (raw: string, stagedFiles: readonly string[]): Result => + parseSplitPlan(raw).chain((plan) => validateSplitPlan(plan, stagedFiles)); diff --git a/src/infra/git/parsers.ts b/src/infra/git/parsers.ts index 2276a70..e6a11a3 100644 --- a/src/infra/git/parsers.ts +++ b/src/infra/git/parsers.ts @@ -8,6 +8,7 @@ export { parseRemoteFromUpstream, splitCommitFields, commandFailureMessage, + parseHookInterpreter, CREATED_FROM_RE, COMMIT_KEYS, type BaseLookupError @@ -28,6 +29,16 @@ const COMMIT_KEYS = ["hash", "short", "subject", "authorName", "authorEmail", "d const commandFailureMessage = (failure: CommandFailure, fallbackMsg: string): string => failure.output.stderr.trim() || failure.output.stdout.trim() || `${failure.error.message}: ${fallbackMsg}`; +const lastPathSegment = (path: string): string => path.split(/[/\\]/).filter(Boolean).at(-1) ?? path; + +const parseHookInterpreter = (shebangLine: string): string => { + const line = shebangLine.trim(); + const env = /^#!\s*\/usr\/bin\/env(?:\s+(\S+))?/.exec(line); + if (env) return env[1] === undefined || env[1] === "" ? "sh" : lastPathSegment(env[1]); + const interp = /^#!\s*(\S+)/.exec(line); + return interp?.[1] === undefined ? "sh" : lastPathSegment(interp[1]); +}; + const formatCommitOutput = (stdout: string): string => "\n" + stdout diff --git a/src/infra/git/repo.ts b/src/infra/git/repo.ts index 47ac8bc..655394f 100644 --- a/src/infra/git/repo.ts +++ b/src/infra/git/repo.ts @@ -1,6 +1,7 @@ export { checkIsGitRepo, getStagedDiff, + listStagedPaths, getLocalChangeContext, createAndSwitchBranch, performCommit, @@ -25,21 +26,24 @@ export { import { Future } from "@/libs/future"; import { Just, Nothing, type Maybe } from "@/libs/maybe"; -import { type Result, Failure } from "@/libs/result"; +import { type Result, Failure, Success } from "@/libs/result"; import { absurd } from "@/libs/types"; import { type BaseLookupError } from "@/infra/git/parsers"; -import { execBin } from "@/infra/shell"; +import { execBin, type CommandFailure, type ExecResult } from "@/infra/shell"; +import { spawn } from "node:child_process"; import * as Decoder from "@/libs/json/decoder"; -import { unlink, writeFile } from "node:fs/promises"; +import { constants as fsConstants } from "node:fs"; +import { access, chmod, copyFile, cp, lstat, mkdir, readdir, readFile, readlink, rm, symlink, unlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, isAbsolute, join } from "node:path"; import { parsePushRange, formatCommitOutput, parseBaseFromReflog, parseRemoteFromUpstream, splitCommitFields, - commandFailureMessage + commandFailureMessage, + parseHookInterpreter } from "@/infra/git/parsers"; type CommitMetadata = { @@ -58,14 +62,16 @@ type PushResult = { range: Maybe; }; -const execGitChecked = (args: string[], fallbackMsg: string): Future => - execBin("git", args).chain((result) => +const execGitChecked = (args: string[], fallbackMsg: string, env?: NodeJS.ProcessEnv): Future => + execBin("git", args, { GIT_LITERAL_PATHSPECS: "1", ...env }).chain((result) => result.either( (failure) => Future.reject(new Error(commandFailureMessage(failure, fallbackMsg))), ({ stdout }) => Future.resolve(stdout) ) ); +const splitNulPaths = (stdout: string): readonly string[] => stdout.split("\0").filter((p) => p.length > 0); + const checkIsGitRepo = (): Future => execGitChecked(["rev-parse", "--is-inside-work-tree"], "Not a git repository").map(() => {}); const getStagedDiff = (): Future => @@ -73,6 +79,14 @@ const getStagedDiff = (): Future => stdout.trim() ? Future.resolve(stdout) : Future.reject(new Error("No staged changes found")) ); +const listStagedPaths = (): Future => + execGitChecked(["diff", "--staged", "--name-only", "--no-renames", "-z"], "Failed to list staged files").chain((stdout) => { + const files = splitNulPaths(stdout); + return files.length > 0 ? + Future.resolve(files) + : 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; @@ -94,18 +108,452 @@ const getLocalChangeContext = (): Future => 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( - Future.attemptP(() => writeFile(tmpPath, message, "utf-8")), - () => Future.attemptP(() => unlink(tmpPath).catch(() => {})), - () => execBin("git", ["commit", "-F", tmpPath]) - ).chain((result) => +const getWorkTreeRoot = (): Future => + execGitChecked(["rev-parse", "--show-toplevel"], "Failed to resolve git directory").map((s) => s.trim()); + +const indexEnv = (indexFile: string, extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv => ({ + GIT_INDEX_FILE: indexFile, + ...extra +}); + +type HookIsolation = { hooksDir: string; origPreCommit: string }; + +const resolveHooksDir = (root: string): Future => + execGitChecked(["-C", root, "rev-parse", "--git-path", "hooks"], "Failed to resolve git hooks").map((p) => { + const trimmed = p.trim(); + return isAbsolute(trimmed) ? trimmed : join(root, trimmed); + }); + +const shellSingleQuote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`; + +const copyHookEntry = async (src: string, dest: string): Promise => { + const st = await lstat(src); + if (st.isDirectory()) { + await mkdir(dest, { recursive: true }); + const names = await readdir(src); + await Promise.all(names.map((name) => copyHookEntry(join(src, name), join(dest, name)))); + return; + } + await writeFile(dest, `#!/bin/sh\nexec ${shellSingleQuote(src)} "$@"\n`); + await chmod(dest, st.mode); +}; + +const acquireHookIsolation = (root: string): Future => { + const hooksDir = join(tmpdir(), `commit-hooks-${Date.now()}`); + return resolveHooksDir(root).chain((origHooks) => + Future.attemptP(async () => { + await mkdir(hooksDir); + const names = await readdir(origHooks).catch(() => [] as string[]); + await Promise.all(names.filter((name) => name !== "pre-commit").map((name) => copyHookEntry(join(origHooks, name), join(hooksDir, name)))); + return { hooksDir, origPreCommit: join(origHooks, "pre-commit") }; + }) + ); +}; + +const releaseHookIsolation = (iso: HookIsolation): Future => + Future.attemptP(async () => { + await rm(iso.hooksDir, { recursive: true, force: true }); + }); + +const hasExecutablePreCommit = (root: string): Future => + resolveHooksDir(root).chain((hooks) => + Future.attemptP(() => + access(join(hooks, "pre-commit"), fsConstants.X_OK) + .then(() => true) + .catch(() => false) + ) + ); + +type WorktreeSnapshot = { dir: string; paths: readonly string[]; checkout: readonly string[] }; + +const nulPathSet = (stdout: string): ReadonlySet => new Set(splitNulPaths(stdout)); + +const indexPathSet = (root: string, tmpIndex: string, paths: readonly string[]): Future> => + paths.length === 0 ? + Future.resolve(new Set()) + : execGitChecked(["-C", root, "ls-files", "-z", "--", ...paths], "Failed to list index paths", indexEnv(tmpIndex)).map(nulPathSet); + +const dirtyPathSet = (root: string, tmpIndex: string, paths: readonly string[]): Future> => + paths.length === 0 ? + Future.resolve(new Set()) + : execGitChecked(["-C", root, "diff", "--name-only", "-z", "--", ...paths], "Failed to list dirty worktree paths", indexEnv(tmpIndex)).map(nulPathSet); + +const hasIndexedDescendant = (path: string, inIndex: ReadonlySet): boolean => [...inIndex].some((indexed) => indexed.startsWith(`${path}/`)); + +const hasIndexedCaseAlias = (path: string, inIndex: ReadonlySet): boolean => { + const folded = path.toLowerCase(); + return [...inIndex].some((indexed) => indexed.toLowerCase() === folded); +}; + +const planWorktreeHide = ( + selected: readonly string[], + inIndex: ReadonlySet, + dirty: ReadonlySet, + ignoreCase: boolean +): { checkout: readonly string[]; hide: readonly string[] } => { + const checkout = selected.filter((path) => inIndex.has(path) && dirty.has(path)); + const deleted = selected.filter( + (path) => !inIndex.has(path) && !hasIndexedDescendant(path, inIndex) && !(ignoreCase && hasIndexedCaseAlias(path, inIndex)) + ); + return { checkout, hide: [...checkout, ...deleted] }; +}; + +const repoIgnoresCase = (root: string): Future => + execBin("git", ["-C", root, "config", "--bool", "core.ignorecase"]).map((result) => result.either( - (failure) => Future.reject(new Error(commandFailureMessage(failure, "Commit failed"))), - ({ stdout }) => Future.resolve(formatCommitOutput(stdout)) + () => false, + ({ stdout }) => stdout.trim() === "true" ) ); + +const errnoCode = (err: unknown): string | undefined => (err instanceof Error && "code" in err ? (err as NodeJS.ErrnoException).code : undefined); + +const isStructuralPathErr = (err: unknown): boolean => { + const code = errnoCode(err); + return code === "ENOENT" || code === "ENOTDIR" || code === "EISDIR"; +}; + +const removeWorktreePath = async (path: string): Promise => { + let st; + try { + st = await lstat(path); + } catch (err) { + if (isStructuralPathErr(err)) { + return; + } + throw err; + } + if (st.isDirectory()) { + await rm(path, { recursive: true, force: true }); + return; + } + await unlink(path); +}; + +const snapshotWorktreeEntry = async (src: string, dest: string): Promise => { + const st = await lstat(src); + await mkdir(dirname(dest), { recursive: true }); + if (st.isSymbolicLink()) { + await symlink(await readlink(src), dest); + return; + } + if (st.isDirectory()) { + await cp(src, dest, { recursive: true }); + return; + } + await copyFile(src, dest); +}; + +const restoreWorktreeEntry = async (backup: string, dest: string): Promise => { + const st = await lstat(backup); + await removeWorktreePath(dest); + await mkdir(dirname(dest), { recursive: true }); + if (st.isSymbolicLink()) { + await symlink(await readlink(backup), dest); + return; + } + if (st.isDirectory()) { + await cp(backup, dest, { recursive: true }); + return; + } + await copyFile(backup, dest); +}; + +const backupWorktreeFiles = (root: string, dir: string, paths: readonly string[]): Promise => + Promise.all( + paths.map(async (rel) => { + try { + await snapshotWorktreeEntry(join(root, rel), join(dir, rel)); + } catch (err) { + if (!isStructuralPathErr(err)) { + throw err; + } + } + }) + ).then(() => {}); + +const acquireWorktreeSnapshot = (root: string, tmpIndex: string, selected: readonly string[]): Future => { + const dir = join(tmpdir(), `commit-wt-${Date.now()}`); + return Future.both(Future.both(indexPathSet(root, tmpIndex, selected), dirtyPathSet(root, tmpIndex, selected)), repoIgnoresCase(root)).chain( + ([[inIndex, dirty], ignoreCase]) => { + const { checkout, hide } = planWorktreeHide(selected, inIndex, dirty, ignoreCase); + const snap: WorktreeSnapshot = { dir, paths: hide, checkout }; + if (hide.length === 0) { + return Future.resolve(snap); + } + return Future.attemptP(async () => { + await mkdir(dir); + await backupWorktreeFiles(root, dir, hide); + await Promise.all(hide.filter((path) => !checkout.includes(path)).map((rel) => removeWorktreePath(join(root, rel)))); + }) + .chain(() => + checkout.length === 0 ? + Future.resolve(snap) + : execGitChecked(["-C", root, "checkout-index", "-f", "--", ...checkout], "Failed to isolate worktree for hooks", indexEnv(tmpIndex)).map( + () => snap + ) + ) + .chainRej((err) => releaseWorktreeSnapshot(root, snap).chain(() => Future.reject(err))); + } + ); +}; + +const releaseWorktreeSnapshot = (root: string, snap: WorktreeSnapshot): Future => + Future.attemptP(async () => { + try { + await access(snap.dir); + } catch { + return; + } + await Promise.all( + snap.paths.map(async (rel) => { + const backup = join(snap.dir, rel); + const dest = join(root, rel); + try { + await lstat(backup); + } catch { + await removeWorktreePath(dest); + return; + } + await restoreWorktreeEntry(backup, dest); + }) + ); + await rm(snap.dir, { recursive: true, force: true }); + }); + +const isMissingGitHookCommand = (failure: CommandFailure): boolean => { + const text = `${failure.output.stderr}\n${failure.output.stdout}`.toLowerCase(); + return text.includes("is not a git command") && text.includes("hook"); +}; + +const runWindowsPreCommitFile = (hook: string, env: NodeJS.ProcessEnv, root: string): Future => + Future.attemptP(() => readFile(hook, "utf8")) + .chainRej(() => Future.resolve("")) + .chain((text) => execBin(parseHookInterpreter(text.split(/\r?\n/, 1)[0] ?? ""), [hook], env, root)); + +const runPreCommitFile = (root: string, tmpIndex: string): Future => + resolveHooksDir(root).chain((hooks) => { + const hook = join(hooks, "pre-commit"); + const env = indexEnv(tmpIndex); + return process.platform === "win32" ? runWindowsPreCommitFile(hook, env, root) : execBin(hook, [], env, root); + }); + +const runUserPreCommit = (root: string, tmpIndex: string): Future => + execBin("git", ["-C", root, "hook", "run", "pre-commit"], indexEnv(tmpIndex)).chain((result) => + result.either( + (failure) => (isMissingGitHookCommand(failure) ? runPreCommitFile(root, tmpIndex) : Future.resolve(result)), + (ok) => Future.resolve(Success(ok)) + ) + ); + +const listAllIndexPaths = (root: string, tmpIndex: string, failMsg: string): Future => + execGitChecked(["-C", root, "ls-files", "-z"], failMsg, indexEnv(tmpIndex)).map(splitNulPaths); + +const foreignIndexPaths = (selected: readonly string[], preHook: readonly string[], postHook: readonly string[]): readonly string[] => { + const keep = new Set(selected); + return [...new Set([...preHook, ...postHook])].filter((path) => !keep.has(path)); +}; + +const resetForeignIndexPaths = (root: string, tmpIndex: string, selected: readonly string[], preHook: readonly string[]): Future => + listAllIndexPaths(root, tmpIndex, "Failed to list index after hook").chain((postHook) => + resetIndexPaths(root, tmpIndex, foreignIndexPaths(selected, preHook, postHook), new Set(selected)) + ); + +const commitIsolatedAfterHook = (root: string, messageFile: string, tmpIndex: string): Future => + Future.bracket(acquireHookIsolation(root), releaseHookIsolation, (iso) => + execBin("git", ["-C", root, "-c", `core.hooksPath=${iso.hooksDir}`, "commit", "-F", messageFile], indexEnv(tmpIndex)) + ); + +const commitAfterHook = (root: string, messageFile: string, tmpIndex: string, selected: readonly string[]): Future => + listAllIndexPaths(root, tmpIndex, "Failed to list index before hook").chain((preHook) => + runUserPreCommit(root, tmpIndex).chain((hookResult) => + hookResult.either( + () => Future.resolve(hookResult), + () => resetForeignIndexPaths(root, tmpIndex, selected, preHook).chain(() => commitIsolatedAfterHook(root, messageFile, tmpIndex)) + ) + ) + ); + +const commitIsolatedIndex = (root: string, messageFile: string, tmpIndex: string, selected: readonly string[]): Future => + hasExecutablePreCommit(root).chain((hasHook) => { + const commit = () => + hasHook ? commitAfterHook(root, messageFile, tmpIndex, selected) : execBin("git", ["-C", root, "commit", "-F", messageFile], indexEnv(tmpIndex)); + return hasHook ? Future.bracket(acquireWorktreeSnapshot(root, tmpIndex, selected), (snap) => releaseWorktreeSnapshot(root, snap), commit) : commit(); + }); + +const copyIndexFile = (root: string, dest: string): Future => + execGitChecked(["-C", root, "rev-parse", "--absolute-git-dir"], "Failed to resolve git directory").chain((gitDir) => + Future.attemptP(() => copyFile(join(gitDir.trim(), "index"), dest)) + ); + +const listStagedPathsNoRenames = (root: string): Future => + execGitChecked(["-C", root, "diff", "--staged", "--name-only", "--no-renames", "-z"], "Failed to list staged files").map(splitNulPaths); + +const execGitStdin = (args: string[], stdin: string, fallbackMsg: string, env?: NodeJS.ProcessEnv): Future => + Future.create((reject, resolve) => { + const proc = spawn("git", args, { + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, GIT_LITERAL_PATHSPECS: "1", ...env } + }); + let stdout = ""; + let stderr = ""; + proc.stdout.on("data", (d: Buffer) => (stdout += d.toString())); + proc.stderr.on("data", (d: Buffer) => (stderr += d.toString())); + proc.on("error", (err) => reject(new Error(`Failed to start process: ${err.message}`))); + proc.on("close", (exitCode) => { + if (exitCode === 0) { + resolve(stdout); + return; + } + reject(new Error(stderr.trim() || stdout.trim() || `Command failed with exit code ${exitCode}: ${fallbackMsg}`)); + }); + proc.stdin.end(stdin); + return () => proc.kill(); + }); + +type HeadBlob = { mode: string; sha: string }; + +const parseHeadBlobMap = (stdout: string): ReadonlyMap => { + const map = new Map(); + for (const rec of splitNulPaths(stdout)) { + const tab = rec.indexOf("\t"); + if (tab < 0) { + continue; + } + const [mode, kind, sha] = rec.slice(0, tab).split(" "); + const path = rec.slice(tab + 1); + if ((kind === "blob" || kind === "commit") && mode && sha && path) { + map.set(path, { mode, sha }); + } + } + return map; +}; + +const listHeadBlobMap = (root: string): Future> => + execGitChecked(["-C", root, "ls-tree", "-r", "-z", "HEAD"], "Failed to isolate staged paths").map(parseHeadBlobMap); + +const hasKeptDescendant = (path: string, keep: ReadonlySet): boolean => [...keep].some((kept) => kept.startsWith(`${path}/`)); + +const restoreExactHeadBlobs = (root: string, indexFile: string, entries: readonly (HeadBlob & { path: string })[]): Future => + entries.length === 0 ? + Future.resolve(undefined) + : execGitStdin( + ["-C", root, "update-index", "-z", "--index-info"], + `${entries.map((entry) => `${entry.mode} ${entry.sha}\t${entry.path}`).join("\0")}\0`, + "Failed to isolate staged paths", + indexEnv(indexFile) + ).map(() => {}); + +const removeExactIndexPaths = (root: string, indexFile: string, paths: readonly string[]): Future => + paths.length === 0 ? + Future.resolve(undefined) + : execGitStdin( + ["-C", root, "update-index", "--force-remove", "-z", "--stdin"], + `${paths.join("\0")}\0`, + "Failed to isolate staged paths", + indexEnv(indexFile) + ).map(() => {}); + +const applyHeadIndexPartition = ( + root: string, + indexFile: string, + paths: readonly string[], + keep: ReadonlySet, + head: ReadonlyMap +): Future => { + const restore = paths.flatMap((path) => { + const blob = head.get(path); + return blob === undefined || hasKeptDescendant(path, keep) ? [] : [{ path, ...blob }]; + }); + const remove = paths.filter((path) => head.get(path) === undefined); + return removeExactIndexPaths(root, indexFile, remove).chain(() => restoreExactHeadBlobs(root, indexFile, restore)); +}; + +const resetIndexPaths = (root: string, indexFile: string, paths: readonly string[], keep: ReadonlySet): Future => + paths.length === 0 ? + Future.resolve(undefined) + : execBin("git", ["-C", root, "rev-parse", "-q", "--verify", "HEAD"]).chain((head) => + head.either( + () => removeExactIndexPaths(root, indexFile, paths), + () => listHeadBlobMap(root).chain((blobs) => applyHeadIndexPartition(root, indexFile, paths, keep, blobs)) + ) + ); + +const reconcileCommittedIndex = (root: string, paths: readonly string[]): Future => + execGitChecked(["-C", root, "rev-parse", "--absolute-git-dir"], "Failed to reconcile index after commit").chain((gitDir) => + resetIndexPaths(root, join(gitDir.trim(), "index"), paths, new Set()) + ); + +const finishIsolatedCommit = (root: string, paths: readonly string[], result: ExecResult): Future => + result.either( + () => Future.resolve(result), + () => reconcileCommittedIndex(root, paths).map(() => result) + ); + +const isolateAndCommit = (root: string, messageFile: string, tmpIndex: string, paths: readonly string[]): Future => + listStagedPathsNoRenames(root).chain((staged) => { + const stagedSet = new Set(staged); + const keep = new Set(paths.filter((path) => stagedSet.has(path))); + if (keep.size === 0) { + return Future.resolve(Success({ stdout: "", stderr: "" })); + } + const unselected = staged.filter((path) => !keep.has(path)); + return resetIndexPaths(root, tmpIndex, unselected, keep).chain(() => + commitIsolatedIndex(root, messageFile, tmpIndex, [...keep]).chain((result) => finishIsolatedCommit(root, [...keep], result)) + ); + }); + +const SEQUENCER_REFS = ["MERGE_HEAD", "REBASE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD"] as const; + +const refExists = (root: string, ref: string): Future => + execBin("git", ["-C", root, "rev-parse", "-q", "--verify", ref]).map((result) => + result.either( + () => false, + () => true + ) + ); + +const rejectIfUnmergedPaths = (root: string): Future => + execGitChecked(["-C", root, "ls-files", "-u", "-z"], "Failed to list unmerged files").chain((stdout) => + splitNulPaths(stdout).length === 0 ? + Future.resolve(undefined) + : Future.reject(new Error("Cannot isolate a commit while the index has unmerged paths")) + ); + +const rejectIfSequencerInProgress = (root: string): Future => + Future.traverse((ref) => refExists(root, ref), [...SEQUENCER_REFS]).chain((present) => + present.some(Boolean) ? + Future.reject(new Error("Cannot isolate a commit while a merge, rebase, cherry-pick, or revert is in progress")) + : rejectIfUnmergedPaths(root) + ); + +const commitIsolatedPaths = (root: string, messageFile: string, paths: readonly string[]): Future => { + const tmpIndex = join(tmpdir(), `commit-index-${Date.now()}`); + return rejectIfSequencerInProgress(root).chain(() => + Future.bracket( + copyIndexFile(root, tmpIndex), + () => Future.attemptP(() => unlink(tmpIndex).catch(() => {})), + () => isolateAndCommit(root, messageFile, tmpIndex, paths) + ) + ); +}; + +const performCommit = (message: string, paths: readonly string[] = []): Future => { + const tmpPath = join(tmpdir(), `commit-msg-${Date.now()}.txt`); + return getWorkTreeRoot() + .chain((root) => + Future.bracket( + Future.attemptP(() => writeFile(tmpPath, message, "utf-8")), + () => Future.attemptP(() => unlink(tmpPath).catch(() => {})), + () => (paths.length > 0 ? commitIsolatedPaths(root, tmpPath, paths) : execBin("git", ["-C", root, "commit", "-F", tmpPath])) + ) + ) + .chain((result) => + result.either( + (failure) => Future.reject(new Error(commandFailureMessage(failure, "Commit failed"))), + ({ stdout }) => Future.resolve(formatCommitOutput(stdout)) + ) + ); }; const performPush = (branch?: string, publish = false, forceWithLease = false): Future => { diff --git a/src/infra/shell.ts b/src/infra/shell.ts index c6c937d..3a6f7c8 100644 --- a/src/infra/shell.ts +++ b/src/infra/shell.ts @@ -16,9 +16,13 @@ const commandResult = (output: CommandOutput, exitCode: number | null, signal: N Success(output) : Failure({ output, error: exitCodeError(exitCode, signal) }); -const execBin = (bin: string, args: string[]): Future => +const execBin = (bin: string, args: string[], env?: NodeJS.ProcessEnv, cwd?: string): Future => Future.create((reject, resolve) => { - const proc = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"] }); + const proc = spawn(bin, args, { + stdio: ["pipe", "pipe", "pipe"], + cwd, + env: env === undefined ? undefined : { ...process.env, ...env } + }); let stdout = ""; let stderr = ""; diff --git a/test/cli/branch.test.ts b/test/cli/branch.test.ts index 1353288..6d89371 100644 --- a/test/cli/branch.test.ts +++ b/test/cli/branch.test.ts @@ -65,6 +65,7 @@ vi.mock("@/infra/ui/spinner", () => ({ const config = (): ConfigValue => ({ commit_convention: "conventional", custom_template: Nothing(), + split_commits: false, ai: { provider: "openai", model: "gpt-4.1-mini", effort: Nothing(), auth_method: { type: "api_key", content: "sk" } } }); diff --git a/test/cli/commit.test.ts b/test/cli/commit.test.ts index 712146e..aa92170 100644 --- a/test/cli/commit.test.ts +++ b/test/cli/commit.test.ts @@ -4,9 +4,10 @@ vi.mock("@/infra/env", () => ({ environment: { GOOGLE_CLIENT_ID: "test", GOOGLE_CLIENT_SECRET: "test" } })); -import { Commit } from "@/cli/commit"; +import { Commit, routeAnalysis } from "@/cli/commit"; import { Future } from "@/libs/future"; import { Nothing, Just } from "@/libs/maybe"; +import { Failure, Success } from "@/libs/result"; import { runFuture } from "@test/helpers/run-future"; import * as s from "@/libs/json/schema"; import { Config } from "@/domain/config/config"; @@ -22,6 +23,7 @@ vi.mock("@/domain/llm/auth-resolver", () => ({ vi.mock("@/infra/git/repo", () => ({ checkIsGitRepo: vi.fn(() => Future.resolve(undefined)), getStagedDiff: vi.fn(() => Future.resolve("staged diff")), + listStagedPaths: vi.fn(() => Future.resolve(["a.ts"])), performCommit: vi.fn(() => Future.resolve("\n 1 file changed\n")), findCommitMetadata: vi.fn() })); @@ -32,6 +34,15 @@ vi.mock("@/domain/llm/router", () => ({ metadata: { durationMs: 1, model: { provider: "openai", model: "m", effort: "medium" }, tokens: Nothing() } }) ), + generateSplitPlan: vi.fn(() => + Future.resolve({ + plan: { + shouldSplit: false, + commits: [{ message: "feat: one", files: ["a.ts", "b.ts"] }] + }, + metadata: { durationMs: 1, model: { provider: "openai", model: "m", effort: "medium" }, tokens: Nothing() } + }) + ), refineCommitMessage: vi.fn() })); vi.mock("@clack/prompts", () => ({ @@ -51,9 +62,10 @@ vi.mock("@/infra/ui/spinner", () => ({ loading: vi.fn((_a: string, _b: string, f: Future) => f as Future) })); -const config = (): ConfigValue => ({ +const config = (split_commits = false): ConfigValue => ({ commit_convention: "conventional", custom_template: Nothing(), + split_commits, ai: { provider: "openai", model: "gpt-4.1-mini", effort: Nothing(), auth_method: { type: "api_key", content: "sk" } } }); @@ -63,9 +75,22 @@ describe("Commit.run", () => { 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.listStagedPaths).mockReturnValue(Future.resolve(["a.ts"])); vi.mocked(repo.findCommitMetadata).mockReturnValue( Future.resolve(Just({ hash: "h", short: "h", subject: "feat: generated", authorName: "t", authorEmail: "t@t.com", date: new Date() })) ); + const router = await import("@/domain/llm/router"); + vi.mocked(router.generateSplitPlan).mockReturnValue( + Future.resolve({ + plan: { + shouldSplit: false, + commits: [{ message: "feat: one", files: ["a.ts", "b.ts"] }] + }, + metadata: { durationMs: 1, model: { provider: "openai", model: "m", effort: "medium" }, tokens: Nothing() } + }) + ); + const prompts = await import("@clack/prompts"); + vi.mocked(prompts.select).mockResolvedValue("commit"); }); it("commits when user selects commit", async () => { @@ -73,4 +98,123 @@ describe("Commit.run", () => { const repo = await import("@/infra/git/repo"); expect(repo.performCommit).toHaveBeenCalledWith("feat: generated"); }); + + it("does not analyze when split is disabled and two files are staged", async () => { + const repo = await import("@/infra/git/repo"); + vi.mocked(repo.listStagedPaths).mockReturnValue(Future.resolve(["a.ts", "b.ts"])); + + await runFuture(Commit.create().chain((c) => c.run())); + + const router = await import("@/domain/llm/router"); + expect(router.generateCommitMessage).toHaveBeenCalled(); + expect(router.generateSplitPlan).not.toHaveBeenCalled(); + expect(repo.performCommit).toHaveBeenCalledWith("feat: generated"); + }); + + it("applies a split plan when auto-analysis says to split", async () => { + const storage = await import("@/infra/storage/config"); + vi.mocked(storage.loadConfig).mockReturnValue(Future.resolve(config(true))); + const repo = await import("@/infra/git/repo"); + vi.mocked(repo.listStagedPaths).mockReturnValue(Future.resolve(["a.ts", "b.ts"])); + const router = await import("@/domain/llm/router"); + vi.mocked(router.generateSplitPlan).mockReturnValue( + Future.resolve({ + plan: { + shouldSplit: true, + commits: [ + { message: "msg one", files: ["a.ts"] }, + { message: "msg two", files: ["b.ts"] } + ] + }, + metadata: { durationMs: 1, model: { provider: "openai", model: "m", effort: "medium" }, tokens: Nothing() } + }) + ); + const prompts = await import("@clack/prompts"); + vi.mocked(prompts.select).mockResolvedValue("apply"); + + await runFuture(Commit.create().chain((c) => c.run())); + + expect(router.generateSplitPlan).toHaveBeenCalled(); + expect(router.generateCommitMessage).not.toHaveBeenCalled(); + expect(repo.performCommit).toHaveBeenNthCalledWith(1, "msg one", ["a.ts"]); + expect(repo.performCommit).toHaveBeenNthCalledWith(2, "msg two", ["b.ts"]); + }); + + it("commits the single analysis message when shouldSplit is false", async () => { + const storage = await import("@/infra/storage/config"); + vi.mocked(storage.loadConfig).mockReturnValue(Future.resolve(config(true))); + const repo = await import("@/infra/git/repo"); + vi.mocked(repo.listStagedPaths).mockReturnValue(Future.resolve(["a.ts", "b.ts"])); + + await runFuture(Commit.create().chain((c) => c.run())); + + const router = await import("@/domain/llm/router"); + expect(router.generateSplitPlan).toHaveBeenCalled(); + expect(router.generateCommitMessage).not.toHaveBeenCalled(); + expect(repo.performCommit).toHaveBeenCalledWith("feat: one"); + }); + + it("regenerates the single message instead of re-routing into split", async () => { + const storage = await import("@/infra/storage/config"); + vi.mocked(storage.loadConfig).mockReturnValue(Future.resolve(config(true))); + const repo = await import("@/infra/git/repo"); + vi.mocked(repo.listStagedPaths).mockReturnValue(Future.resolve(["a.ts", "b.ts"])); + const prompts = await import("@clack/prompts"); + vi.mocked(prompts.select).mockResolvedValueOnce("regenerate").mockResolvedValueOnce("commit"); + + await runFuture(Commit.create().chain((c) => c.run())); + + const router = await import("@/domain/llm/router"); + expect(router.generateSplitPlan).toHaveBeenCalledTimes(1); + expect(router.generateCommitMessage).toHaveBeenCalled(); + expect(repo.performCommit).toHaveBeenCalledWith("feat: generated"); + }); +}); + +describe("routeAnalysis", () => { + const twoCommits = [ + { message: "msg one", files: ["a.ts"] }, + { message: "msg two", files: ["b.ts"] } + ] as const; + + it("routes a multi-commit split plan to split", () => { + const plan = { shouldSplit: true, commits: twoCommits }; + const r = routeAnalysis(plan); + expect(r instanceof Success).toBe(true); + if (r instanceof Success) { + expect(r.value).toEqual({ tag: "split", plan }); + } + }); + + it("routes a single-commit plan to that message", () => { + const r = routeAnalysis({ shouldSplit: false, commits: [{ message: "feat: one", files: ["a.ts"] }] }); + expect(r instanceof Success).toBe(true); + if (r instanceof Success) { + expect(r.value).toEqual({ tag: "single", message: "feat: one" }); + } + }); + + it("does not split when shouldSplit is true but only one commit exists", () => { + const r = routeAnalysis({ shouldSplit: true, commits: [{ message: "feat: one", files: ["a.ts"] }] }); + expect(r instanceof Success).toBe(true); + if (r instanceof Success) { + expect(r.value).toEqual({ tag: "single", message: "feat: one" }); + } + }); + + it("uses the first message when shouldSplit is false with multiple commits", () => { + const r = routeAnalysis({ shouldSplit: false, commits: twoCommits }); + expect(r instanceof Success).toBe(true); + if (r instanceof Success) { + expect(r.value).toEqual({ tag: "single", message: "msg one" }); + } + }); + + it("fails when the plan has no commits", () => { + const r = routeAnalysis({ shouldSplit: false, commits: [] }); + expect(r instanceof Failure).toBe(true); + if (r instanceof Failure) { + expect(r.error.message).toBe("Split plan: expected at least 1 commit"); + } + }); }); diff --git a/test/cli/effort.test.ts b/test/cli/effort.test.ts index 9ffee0d..e6260d4 100644 --- a/test/cli/effort.test.ts +++ b/test/cli/effort.test.ts @@ -38,6 +38,7 @@ describe("EffortCommand", () => { Future.resolve({ commit_convention: "conventional", custom_template: Nothing(), + split_commits: false, ai: { provider: "openai", model: "gpt-4.1-mini", effort: Nothing(), auth_method: { type: "api_key", content: "sk" } } } satisfies ConfigValue) ); @@ -53,6 +54,7 @@ describe("EffortCommand", () => { const config: ConfigValue = { commit_convention: "conventional", custom_template: Nothing(), + split_commits: false, ai: { provider: "openai", model: "gpt-5.6-sol", diff --git a/test/cli/model.test.ts b/test/cli/model.test.ts index 77ee9b0..17464fd 100644 --- a/test/cli/model.test.ts +++ b/test/cli/model.test.ts @@ -11,6 +11,7 @@ type ConfigValue = s.Infer; const config = (): ConfigValue => ({ commit_convention: "conventional", custom_template: Nothing(), + split_commits: false, ai: { provider: "openai", model: "old", effort: Nothing(), auth_method: { type: "api_key", content: "sk" } } }); diff --git a/test/cli/setup.test.ts b/test/cli/setup.test.ts index ac746c3..b9567b0 100644 --- a/test/cli/setup.test.ts +++ b/test/cli/setup.test.ts @@ -62,18 +62,18 @@ vi.mock("@/infra/auth/anthropic", () => ({ validateAnthropicSetupToken: vi.fn() })); -/** The wizard asks provider, then convention, then auth method — in that order. */ -const scriptWizard = async (provider: string, convention: string, authMethod: string) => { +/** The wizard asks provider, then convention, then split, then auth method — in that order. */ +const scriptWizard = async (provider: string, convention: string, split: boolean, authMethod: string) => { const p = await import("@clack/prompts"); vi.mocked(p.select).mockReset(); - vi.mocked(p.select).mockResolvedValueOnce(provider).mockResolvedValueOnce(convention).mockResolvedValueOnce(authMethod); + vi.mocked(p.select).mockResolvedValueOnce(provider).mockResolvedValueOnce(convention).mockResolvedValueOnce(split).mockResolvedValueOnce(authMethod); }; describe("Setup.run", () => { beforeEach(() => vi.clearAllMocks()); it("saves config after wizard", async () => { - await scriptWizard("openai", "conventional", "api_key"); + await scriptWizard("openai", "conventional", false, "api_key"); const { saveConfig } = await import("@/infra/storage/config"); await runFuture(Setup.create().chain((s) => s.run())); @@ -82,7 +82,7 @@ describe("Setup.run", () => { }); it("saves an xai api_key config", async () => { - await scriptWizard("xai", "conventional", "api_key"); + await scriptWizard("xai", "conventional", false, "api_key"); const { saveConfig } = await import("@/infra/storage/config"); await runFuture(Setup.create().chain((s) => s.run())); diff --git a/test/cli/split.test.ts b/test/cli/split.test.ts new file mode 100644 index 0000000..c3638c7 --- /dev/null +++ b/test/cli/split.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/infra/env", () => ({ + environment: { GOOGLE_CLIENT_ID: "test", GOOGLE_CLIENT_SECRET: "test" } +})); + +import { Split } from "@/cli/split"; +import { Future } from "@/libs/future"; +import { Nothing, Just } from "@/libs/maybe"; +import { runFuture } from "@test/helpers/run-future"; +import * as s from "@/libs/json/schema"; +import { Config } from "@/domain/config/config"; +import { type LlmRequestMetadata } from "@/domain/llm/router"; + +type ConfigValue = s.Infer; + +vi.mock("@/infra/git/repo", () => ({ + performCommit: vi.fn(() => Future.resolve("\n 1 file changed\n")), + findCommitMetadata: vi.fn() +})); +vi.mock("@clack/prompts", () => ({ + note: vi.fn(), + select: vi.fn(async () => "apply"), + text: vi.fn(), + confirm: vi.fn(), + isCancel: vi.fn(() => false), + outro: vi.fn(), + log: { warn: vi.fn(), error: vi.fn() } +})); +vi.mock("@/infra/ui/push-note", () => ({ + renderCommitNote: vi.fn(), + renderPushNote: 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(), + split_commits: false, + ai: { provider: "openai", model: "gpt-4.1-mini", effort: Nothing(), auth_method: { type: "api_key", content: "sk" } } +}); + +const plan = { + shouldSplit: true, + commits: [ + { message: "msg one", files: ["a.ts"] }, + { message: "msg two", files: ["b.ts"] } + ] +}; + +const meta: LlmRequestMetadata = { + durationMs: 1, + model: { provider: "openai", model: "m", effort: "medium" }, + tokens: Nothing() +}; + +const runPlan = () => { + const cfg = config(); + return Split.fromResolved(cfg, cfg.ai).runPlan("staged diff", ["a.ts", "b.ts"], plan, meta); +}; + +describe("Split.runPlan", () => { + beforeEach(async () => { + vi.clearAllMocks(); + const repo = await import("@/infra/git/repo"); + vi.mocked(repo.findCommitMetadata).mockReturnValue( + Future.resolve(Just({ hash: "h", short: "h", subject: "msg two", authorName: "t", authorEmail: "t@t.com", date: new Date() })) + ); + const prompts = await import("@clack/prompts"); + vi.mocked(prompts.select).mockResolvedValue("apply"); + vi.mocked(prompts.isCancel).mockReturnValue(false); + }); + + it("applies each commit group with pathspecs when user selects apply", async () => { + await runFuture(runPlan()); + const repo = await import("@/infra/git/repo"); + expect(repo.performCommit).toHaveBeenNthCalledWith(1, "msg one", ["a.ts"]); + expect(repo.performCommit).toHaveBeenNthCalledWith(2, "msg two", ["b.ts"]); + }); + + it("does not commit when user selects cancel", async () => { + const prompts = await import("@clack/prompts"); + vi.mocked(prompts.select).mockResolvedValue("cancel"); + + await runFuture(runPlan()); + const repo = await import("@/infra/git/repo"); + expect(repo.performCommit).not.toHaveBeenCalled(); + }); +}); diff --git a/test/domain/commit/prompts.test.ts b/test/domain/commit/prompts.test.ts index ab3eaa8..3de1039 100644 --- a/test/domain/commit/prompts.test.ts +++ b/test/domain/commit/prompts.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { getPrompt, getRefinePrompt } from "@/domain/commit/prompts"; +import { getPrompt, getRefinePrompt, getSplitPrompt } from "@/domain/commit/prompts"; import { Just, Nothing } from "@/libs/maybe"; const DIFF = "diff --git a/foo.ts b/foo.ts\n+console.log(1)"; @@ -30,6 +30,38 @@ describe("getPrompt", () => { }); }); +describe("getSplitPrompt", () => { + it("embeds diff, file list, and commits", () => { + const prompt = getSplitPrompt(DIFF, ["foo.ts", "bar.ts"], "conventional"); + expect(prompt).toContain(DIFF); + expect(prompt).toContain("foo.ts"); + expect(prompt).toContain("bar.ts"); + expect(prompt).toContain("commits"); + expect(prompt).toContain("should_split"); + }); + + it("forbids commit-message-only output and requires JSON-only output", () => { + const prompt = getSplitPrompt(DIFF, ["foo.ts", "bar.ts"], "conventional"); + expect(prompt).not.toMatch(/output ONLY the final commit message/i); + expect(prompt).toContain("Emit ONLY the JSON object"); + }); + + it("keeps a diff that contains output_instructions tags", () => { + const diff = "diff --git a/x b/x\n+\n+keep this hunk\n+"; + const prompt = getSplitPrompt(diff, ["x"], "conventional"); + expect(prompt).toContain("keep this hunk"); + expect(prompt).not.toMatch(/output ONLY the final commit message/i); + expect(prompt).toContain("Emit ONLY the JSON object"); + }); + + it("prefers split across unrelated layers", () => { + const prompt = getSplitPrompt(DIFF, ["foo.ts", "bar.ts"], "conventional"); + expect(prompt).toContain("Prefer should_split=true"); + expect(prompt).toContain("unrelated layers"); + expect(prompt).not.toContain("should_split=true only when"); + }); +}); + describe("getRefinePrompt", () => { it("wraps diff, current message, and adjustment", () => { const { prompt, systemInstruction } = getRefinePrompt({ diff --git a/test/domain/config/config.test.ts b/test/domain/config/config.test.ts index 844f161..108cabb 100644 --- a/test/domain/config/config.test.ts +++ b/test/domain/config/config.test.ts @@ -9,6 +9,7 @@ type ConfigValue = s.Infer; const sampleConfig = (): ConfigValue => ({ commit_convention: "conventional", custom_template: Nothing(), + split_commits: false, ai: { provider: "openai", model: "gpt-4.1-mini", @@ -80,4 +81,12 @@ describe("Config schema", () => { expect(s.decode(Config, encoded).isSuccess()).toBe(true); }); + + it("defaults missing split_commits to false", () => { + const encoded = s.encode(Config, sampleConfig()) as Record; + delete encoded["split_commits"]; + const decoded = s.decode(Config, encoded); + expect(decoded.isSuccess()).toBe(true); + if (decoded instanceof Success) expect(decoded.value.split_commits).toBe(false); + }); }); diff --git a/test/domain/llm/auth-resolver.test.ts b/test/domain/llm/auth-resolver.test.ts index 79d8225..e8f9e04 100644 --- a/test/domain/llm/auth-resolver.test.ts +++ b/test/domain/llm/auth-resolver.test.ts @@ -19,6 +19,7 @@ type ConfigValue = s.Infer; const googleConfig = (): ConfigValue => ({ commit_convention: "conventional", custom_template: Nothing(), + split_commits: false, ai: { provider: "gemini", model: "gemini-2.0", @@ -58,6 +59,7 @@ describe("resolveProvider", () => { const config: ConfigValue = { commit_convention: "imperative", custom_template: Nothing(), + split_commits: false, ai: { provider: "openai", model: "gpt-4.1-mini", effort: Nothing(), auth_method: { type: "api_key", content: "sk-x" } } }; const ai = await runFuture(resolveProvider(config)); diff --git a/test/domain/llm/effort.test.ts b/test/domain/llm/effort.test.ts new file mode 100644 index 0000000..25e35cf --- /dev/null +++ b/test/domain/llm/effort.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { withMinEffort } from "@/domain/llm/effort"; +import { GEMINI_EFFORTS, type ProviderConfig } from "@/domain/config/config"; +import { Just, Nothing } from "@/libs/maybe"; + +const base = (provider: ProviderConfig["provider"]): ProviderConfig => + ({ + provider, + model: "m", + effort: Nothing(), + auth_method: { type: "api_key", content: "sk" } + }) as ProviderConfig; + +describe("withMinEffort", () => { + it("forces openai low", () => { + expect(withMinEffort(base("openai")).effort).toEqual(Just("low")); + }); + it("forces anthropic low", () => { + expect(withMinEffort(base("anthropic")).effort).toEqual(Just("low")); + }); + it("forces gemini MINIMAL", () => { + expect(withMinEffort(base("gemini")).effort).toEqual(Just(GEMINI_EFFORTS[0])); + }); + it("forces xai low", () => { + expect(withMinEffort(base("xai")).effort).toEqual(Just("low")); + }); +}); diff --git a/test/domain/llm/router.test.ts b/test/domain/llm/router.test.ts index a004326..eb8a466 100644 --- a/test/domain/llm/router.test.ts +++ b/test/domain/llm/router.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; -import { generateCommitMessage, refineCommitMessage } from "@/domain/llm/router"; +import { generateCommitMessage, refineCommitMessage, generateSplitPlan } from "@/domain/llm/router"; import { Future } from "@/libs/future"; import { Just, Nothing } from "@/libs/maybe"; import { runFuture } from "@test/helpers/run-future"; @@ -54,3 +54,38 @@ describe("refineCommitMessage", () => { expect(result.metadata.model.effort).toBe("provider default"); }); }); + +describe("generateSplitPlan", () => { + it("parses provider JSON into commits", async () => { + const { generateContentWithOpenAI } = await import("@/infra/llm/openai"); + const json = JSON.stringify({ + should_split: true, + commits: [ + { message: "feat: a", files: ["a.ts"] }, + { message: "feat: b", files: ["b.ts"] } + ] + }); + vi.mocked(generateContentWithOpenAI).mockReturnValue(Future.resolve({ text: json, tokens: Nothing(), effectiveEffort: Nothing() })); + + const result = await runFuture(generateSplitPlan(mockProvider("openai"), "diff", ["a.ts", "b.ts"], "conventional", Nothing())); + expect(result.plan.shouldSplit).toBe(true); + expect(result.plan.commits).toEqual([ + { message: "feat: a", files: ["a.ts"] }, + { message: "feat: b", files: ["b.ts"] } + ]); + expect(result.metadata.model.provider).toBe("openai"); + }); + + it("calls the provider with minimum effort", async () => { + const { generateContentWithOpenAI } = await import("@/infra/llm/openai"); + vi.mocked(generateContentWithOpenAI).mockClear(); + const json = JSON.stringify({ + should_split: false, + commits: [{ message: "feat: a", files: ["a.ts"] }] + }); + vi.mocked(generateContentWithOpenAI).mockReturnValue(Future.resolve({ text: json, tokens: Nothing(), effectiveEffort: Just("low") })); + const config = { ...mockProvider("openai"), effort: Just("high") } as ProviderConfig; + await runFuture(generateSplitPlan(config, "diff", ["a.ts"], "conventional", Nothing())); + expect(vi.mocked(generateContentWithOpenAI).mock.calls[0]?.[0].effort).toEqual(Just("low")); + }); +}); diff --git a/test/domain/split/plan.test.ts b/test/domain/split/plan.test.ts new file mode 100644 index 0000000..a05c08b --- /dev/null +++ b/test/domain/split/plan.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { Failure, Success } from "@/libs/result"; +import { parseAndValidateSplitPlan } from "@/domain/split/plan"; + +const staged = ["a.ts", "b.ts", "c.ts"] as const; + +const threeCommits = JSON.stringify({ + should_split: true, + commits: [ + { message: "feat: a", files: ["a.ts"] }, + { message: "feat: b", files: ["b.ts"] }, + { message: "feat: c", files: ["c.ts"] } + ] +}); + +describe("parseAndValidateSplitPlan", () => { + it("parses valid 3-commit JSON", () => { + const r = parseAndValidateSplitPlan(threeCommits, staged); + expect(r instanceof Success).toBe(true); + if (r instanceof Success) { + expect(r.value.commits).toEqual([ + { message: "feat: a", files: ["a.ts"] }, + { message: "feat: b", files: ["b.ts"] }, + { message: "feat: c", files: ["c.ts"] } + ]); + } + }); + + it("parses fenced JSON", () => { + const r = parseAndValidateSplitPlan("```json\n" + threeCommits + "\n```", staged); + expect(r instanceof Success).toBe(true); + if (r instanceof Success) { + expect(r.value.commits.map((c) => c.message)).toEqual(["feat: a", "feat: b", "feat: c"]); + } + }); + + it("parses JSON wrapped in prose", () => { + const r = parseAndValidateSplitPlan("Here is the plan:\n" + threeCommits + "\nDone.", staged); + expect(r instanceof Success).toBe(true); + }); + + it("skips a preview brace before the plan object", () => { + const r = parseAndValidateSplitPlan('First character "{", last "}".\n' + threeCommits, staged); + expect(r instanceof Success).toBe(true); + }); + + it("rejects invalid JSON", () => { + const r = parseAndValidateSplitPlan("feat: add foo", staged); + expect(r instanceof Failure).toBe(true); + }); + + it("rejects empty commits", () => { + const r = parseAndValidateSplitPlan('{"should_split":true,"commits":[]}', staged); + expect(r instanceof Failure).toBe(true); + }); + + it("rejects empty message", () => { + const r = parseAndValidateSplitPlan('{"should_split":true,"commits":[{"message":"","files":["a.ts"]}]}', staged); + expect(r instanceof Failure).toBe(true); + }); + + it("rejects empty files", () => { + const r = parseAndValidateSplitPlan('{"should_split":true,"commits":[{"message":"feat: a","files":[]}]}', staged); + expect(r instanceof Failure).toBe(true); + }); + + it("rejects duplicate paths", () => { + const r = parseAndValidateSplitPlan( + '{"should_split":true,"commits":[{"message":"feat: a","files":["a.ts"]},{"message":"feat: again","files":["a.ts"]}]}', + staged + ); + expect(r instanceof Failure).toBe(true); + }); + + it("rejects unknown path", () => { + const r = parseAndValidateSplitPlan('{"should_split":true,"commits":[{"message":"feat: x","files":["missing.ts"]}]}', staged); + expect(r instanceof Failure).toBe(true); + }); + + it("appends leftover staged paths", () => { + const r = parseAndValidateSplitPlan('{"should_split":true,"commits":[{"message":"feat: a","files":["a.ts"]}]}', staged); + expect(r instanceof Success).toBe(true); + if (r instanceof Success) { + expect(r.value.commits).toEqual([ + { message: "feat: a", files: ["a.ts"] }, + { message: "Commit remaining staged changes", files: ["b.ts", "c.ts"] } + ]); + } + }); + + it("collapses extra commits when should_split is false", () => { + const raw = JSON.stringify({ + should_split: false, + commits: [ + { message: "feat: a", files: ["a.ts"] }, + { message: "feat: b", files: ["b.ts", "c.ts"] } + ] + }); + const r = parseAndValidateSplitPlan(raw, staged); + expect(r instanceof Success).toBe(true); + if (r instanceof Success) { + expect(r.value.shouldSplit).toBe(false); + expect(r.value.commits).toEqual([{ message: "feat: a", files: ["a.ts", "b.ts", "c.ts"] }]); + } + }); + + it("folds leftover into first commit when should_split is false", () => { + const r = parseAndValidateSplitPlan('{"should_split":false,"commits":[{"message":"feat: a","files":["a.ts"]}]}', staged); + expect(r instanceof Success).toBe(true); + if (r instanceof Success) { + expect(r.value.shouldSplit).toBe(false); + expect(r.value.commits).toEqual([{ message: "feat: a", files: ["a.ts", "b.ts", "c.ts"] }]); + } + }); + + it("rejects missing should_split", () => { + const r = parseAndValidateSplitPlan('{"commits":[{"message":"feat: a","files":["a.ts"]}]}', staged); + expect(r instanceof Failure).toBe(true); + }); +}); diff --git a/test/infra/git/parsers.test.ts b/test/infra/git/parsers.test.ts index c56a172..b47a436 100644 --- a/test/infra/git/parsers.test.ts +++ b/test/infra/git/parsers.test.ts @@ -5,7 +5,8 @@ import { parseBaseFromReflog, splitCommitFields, parseRemoteFromUpstream, - commandFailureMessage + commandFailureMessage, + parseHookInterpreter } from "@/infra/git/parsers"; import { Just, Nothing } from "@/libs/maybe"; import { Success } from "@/libs/result"; @@ -66,3 +67,21 @@ describe("commandFailureMessage", () => { expect(msg).toBe("fatal: no repo"); }); }); + +describe("parseHookInterpreter", () => { + it("defaults to sh without a shebang", () => { + expect(parseHookInterpreter("exit 0")).toBe("sh"); + }); + + it("uses env program and path basename", () => { + expect(parseHookInterpreter("#!/usr/bin/env python3")).toBe("python3"); + expect(parseHookInterpreter("#! /usr/bin/env node")).toBe("node"); + expect(parseHookInterpreter("#!/usr/bin/env")).toBe("sh"); + }); + + it("uses the interpreter basename for direct shebangs", () => { + expect(parseHookInterpreter("#!/bin/sh")).toBe("sh"); + expect(parseHookInterpreter("#!/bin/bash")).toBe("bash"); + expect(parseHookInterpreter("#!/usr/bin/python")).toBe("python"); + }); +}); diff --git a/test/infra/git/repo.integration.test.ts b/test/infra/git/repo.integration.test.ts index fca41f0..4c6425f 100644 --- a/test/infra/git/repo.integration.test.ts +++ b/test/infra/git/repo.integration.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { chdir, cwd } from "node:process"; -import { mkdirSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { runFuture } from "@test/helpers/run-future"; import { createTempGitRepo } from "@test/helpers/temp-git-repo"; @@ -110,4 +112,586 @@ describe("git repo integration", () => { chdir(prev); } }); + + it("performCommit with pathspecs leaves other staged files", async () => { + const { dir, run } = createTempGitRepo({ staged: true }); + writeFileSync(join(dir, "other.txt"), "other\n"); + run("add other.txt"); + const prev = cwd(); + chdir(dir); + try { + await runFuture(repo.performCommit("feat: one file", ["file.txt"])); + const stillStaged = run("diff --staged --name-only").trim(); + expect(stillStaged).toBe("other.txt"); + } finally { + chdir(prev); + } + }); + + it("listStagedPaths includes both sides of a rename and apply leaves no staged deletion", async () => { + const { dir, run } = createTempGitRepo({ staged: false }); + run("mv file.txt renamed.txt"); + const prev = cwd(); + chdir(dir); + try { + const staged = await runFuture(repo.listStagedPaths()); + expect([...staged].sort()).toEqual(["file.txt", "renamed.txt"]); + await runFuture(repo.performCommit("feat: rename file", staged)); + expect(run("diff --staged --name-only").trim()).toBe(""); + expect(run("ls-files").trim()).toBe("renamed.txt"); + } finally { + chdir(prev); + } + }); + + it("performCommit with pathspecs isolates a rename when a hook runs git add -A", async () => { + const { dir, run } = createTempGitRepo({ staged: false }); + writeFileSync(join(dir, "other.txt"), "other\n"); + run("add other.txt"); + run("mv file.txt renamed.txt"); + const hookPath = join(dir, ".git", "hooks", "pre-commit"); + writeFileSync( + hookPath, + `#!/bin/sh +git add -A +` + ); + chmodSync(hookPath, 0o755); + const prev = cwd(); + chdir(dir); + try { + const staged = await runFuture(repo.listStagedPaths()); + expect([...staged].sort()).toEqual(["file.txt", "other.txt", "renamed.txt"]); + await runFuture(repo.performCommit("feat: rename file", ["file.txt", "renamed.txt"])); + expect(run("diff --staged --name-only").trim()).toBe("other.txt"); + expect(run("ls-files").trim().split("\n").sort()).toEqual(["other.txt", "renamed.txt"]); + } finally { + chdir(prev); + } + }); + + it("performCommit with pathspecs keeps a case-only rename when a hook runs git add -A", async () => { + const { dir, run } = createTempGitRepo({ staged: false }); + writeFileSync(join(dir, "other.txt"), "other\n"); + run("add other.txt"); + run("mv -f file.txt File.txt"); + const hookPath = join(dir, ".git", "hooks", "pre-commit"); + writeFileSync( + hookPath, + `#!/bin/sh +git add -A +` + ); + chmodSync(hookPath, 0o755); + const prev = cwd(); + chdir(dir); + try { + const staged = await runFuture(repo.listStagedPaths()); + expect([...staged].sort()).toEqual(["File.txt", "file.txt", "other.txt"]); + await runFuture(repo.performCommit("feat: case rename", ["file.txt", "File.txt"])); + expect(run("ls-tree -r --name-only HEAD").trim()).toBe("File.txt"); + expect(run("show HEAD:File.txt")).toBe("hello\n"); + expect(run("diff --staged --name-only").trim()).toBe("other.txt"); + } finally { + chdir(prev); + } + }); + + it("performCommit with paths records the staged blob not the worktree", async () => { + const { dir, run } = createTempGitRepo({ staged: true }); + writeFileSync(join(dir, "file.txt"), "hello unstaged secret\n"); + writeFileSync(join(dir, "other.txt"), "other\n"); + run("add other.txt"); + const prev = cwd(); + chdir(dir); + try { + await runFuture(repo.performCommit("feat: staged only", ["file.txt"])); + expect(run("show HEAD:file.txt")).toBe("hello world\n"); + expect(run("diff --staged --name-only").trim()).toBe("other.txt"); + expect(run("diff -- file.txt")).toContain("hello unstaged secret"); + } finally { + chdir(prev); + } + }); + + it("performCommit with pathspecs isolates files on an unborn branch", async () => { + const dir = mkdtempSync(join(tmpdir(), "commit-tools-git-")); + const run = (args: string) => execSync(`git ${args}`, { cwd: dir, encoding: "utf-8" }); + run("init -b main"); + run('config user.email "test@example.com"'); + run('config user.name "Test"'); + writeFileSync(join(dir, "a.txt"), "a\n"); + writeFileSync(join(dir, "b.txt"), "b\n"); + run("add a.txt b.txt"); + const prev = cwd(); + chdir(dir); + try { + await runFuture(repo.performCommit("feat: first", ["a.txt"])); + expect(run("diff --staged --name-only").trim()).toBe("b.txt"); + expect((await runFuture(repo.getCommitMetadata())).subject).toBe("feat: first"); + expect(run("show HEAD:a.txt")).toBe("a\n"); + } finally { + chdir(prev); + } + }); + + it("performCommit with pathspecs isolates unborn leftovers when worktree is dirty", async () => { + const dir = mkdtempSync(join(tmpdir(), "commit-tools-git-")); + const run = (args: string) => execSync(`git ${args}`, { cwd: dir, encoding: "utf-8" }); + run("init -b main"); + run('config user.email "test@example.com"'); + run('config user.name "Test"'); + writeFileSync(join(dir, "a.txt"), "a\n"); + writeFileSync(join(dir, "b.txt"), "b\n"); + run("add a.txt b.txt"); + writeFileSync(join(dir, "b.txt"), "b dirty\n"); + const prev = cwd(); + chdir(dir); + try { + await runFuture(repo.performCommit("feat: first", ["a.txt"])); + expect(run("diff --staged --name-only").trim()).toBe("b.txt"); + expect(run("show :b.txt")).toBe("b\n"); + expect(run("show HEAD:a.txt")).toBe("a\n"); + } finally { + chdir(prev); + } + }); + + it("performCommit with pathspecs ignores hook git add of later groups", async () => { + const { dir, run } = createTempGitRepo({ staged: false }); + writeFileSync(join(dir, "a.txt"), "a\n"); + writeFileSync(join(dir, "b.txt"), "b\n"); + run("add a.txt b.txt"); + const hookPath = join(dir, ".git", "hooks", "pre-commit"); + writeFileSync( + hookPath, + `#!/bin/sh +printf 'fmt-a\\n' > a.txt +printf 'fmt-b\\n' > b.txt +git add -A +` + ); + chmodSync(hookPath, 0o755); + const prev = cwd(); + chdir(dir); + try { + await runFuture(repo.performCommit("feat: first", ["a.txt"])); + expect(run("show HEAD:a.txt")).toBe("fmt-a\n"); + expect(() => run("show HEAD:b.txt")).toThrow(); + expect(run("diff --staged --name-only").trim()).toBe("b.txt"); + expect(run("show :b.txt")).toBe("b\n"); + await runFuture(repo.performCommit("feat: second", ["b.txt"])); + expect(run("show HEAD:b.txt")).toBe("fmt-b\n"); + expect(run("diff --staged --name-only").trim()).toBe(""); + } finally { + chdir(prev); + } + }); + + it("performCommit with pathspecs does not commit unstaged selected edits via hook git add -A", async () => { + const { dir, run } = createTempGitRepo({ staged: false }); + writeFileSync(join(dir, "a.txt"), "staged safe\n"); + writeFileSync(join(dir, "b.txt"), "b\n"); + run("add a.txt b.txt"); + writeFileSync(join(dir, "a.txt"), "unstaged SECRET\n"); + const hookPath = join(dir, ".git", "hooks", "pre-commit"); + writeFileSync( + hookPath, + `#!/bin/sh +git add -A +` + ); + chmodSync(hookPath, 0o755); + const prev = cwd(); + chdir(dir); + try { + await runFuture(repo.performCommit("feat: first", ["a.txt"])); + expect(run("show HEAD:a.txt")).toBe("staged safe\n"); + expect(run("diff -- a.txt")).toContain("unstaged SECRET"); + expect(() => run("show HEAD:b.txt")).toThrow(); + } finally { + chdir(prev); + } + }); + + it("performCommit with pathspecs restores an unstaged symlink retarget after a hook", async () => { + const { dir, run } = createTempGitRepo({ staged: false }); + writeFileSync(join(dir, "target.txt"), "staged-target\n"); + writeFileSync(join(dir, "link-dest.txt"), "unstaged-dest\n"); + symlinkSync("target.txt", join(dir, "link")); + run("add link"); + unlinkSync(join(dir, "link")); + symlinkSync("link-dest.txt", join(dir, "link")); + const hookPath = join(dir, ".git", "hooks", "pre-commit"); + writeFileSync( + hookPath, + `#!/bin/sh +git add -A +` + ); + chmodSync(hookPath, 0o755); + const prev = cwd(); + chdir(dir); + try { + await runFuture(repo.performCommit("feat: link", ["link"])); + expect(run("show HEAD:link").trim()).toBe("target.txt"); + expect(readlinkSync(join(dir, "link"))).toBe("link-dest.txt"); + expect(readFileSync(join(dir, "target.txt"), "utf-8")).toBe("staged-target\n"); + } finally { + chdir(prev); + } + }); + + it("performCommit with pathspecs ignores hook git add of unstaged unrelated deletions", async () => { + const { dir, run } = createTempGitRepo({ staged: false }); + writeFileSync(join(dir, "gone.txt"), "tracked\n"); + run("add gone.txt"); + run('commit -m "track gone"'); + writeFileSync(join(dir, "file.txt"), "hello staged\n"); + run("add file.txt"); + unlinkSync(join(dir, "gone.txt")); + const hookPath = join(dir, ".git", "hooks", "pre-commit"); + writeFileSync( + hookPath, + `#!/bin/sh +git add -A +` + ); + chmodSync(hookPath, 0o755); + const prev = cwd(); + chdir(dir); + try { + await runFuture(repo.performCommit("feat: keep", ["file.txt"])); + expect(run("show HEAD:file.txt")).toBe("hello staged\n"); + expect(run("show HEAD:gone.txt")).toBe("tracked\n"); + expect(run("ls-files").trim().split("\n").sort()).toEqual(["file.txt", "gone.txt"]); + expect(run("diff --staged --name-only").trim()).toBe(""); + expect(run("status --porcelain")).toContain("gone.txt"); + } finally { + chdir(prev); + } + }); + + it("performCommit with pathspecs ignores untracked files added by hook git add -A", async () => { + const { dir, run } = createTempGitRepo({ staged: false }); + writeFileSync(join(dir, "selected.txt"), "keep\n"); + writeFileSync(join(dir, "secret.txt"), "UNTRACKED SECRET\n"); + run("add selected.txt"); + const hookPath = join(dir, ".git", "hooks", "pre-commit"); + writeFileSync( + hookPath, + `#!/bin/sh +git add -A +` + ); + chmodSync(hookPath, 0o755); + const prev = cwd(); + chdir(dir); + try { + await runFuture(repo.performCommit("feat: selected", ["selected.txt"])); + expect(run("show HEAD:selected.txt")).toBe("keep\n"); + expect(() => run("show HEAD:secret.txt")).toThrow(); + expect(run("status --porcelain").trim()).toBe("?? secret.txt"); + } finally { + chdir(prev); + } + }); + + it("performCommit with pathspecs lets hooks see glob pathspecs", async () => { + const { dir, run } = createTempGitRepo({ staged: false }); + writeFileSync(join(dir, "a.txt"), "a\n"); + run("add a.txt"); + const hookPath = join(dir, ".git", "hooks", "pre-commit"); + writeFileSync( + hookPath, + `#!/bin/sh +files=$(git diff --cached --name-only -- '*.txt') +test -n "$files" +` + ); + chmodSync(hookPath, 0o755); + const prev = cwd(); + chdir(dir); + try { + await runFuture(repo.performCommit("feat: glob hook", ["a.txt"])); + expect(run("show HEAD:a.txt")).toBe("a\n"); + } finally { + chdir(prev); + } + }); + + it("performCommit with pathspecs runs commit-msg from its original hook path", async () => { + const { dir, run } = createTempGitRepo({ staged: false }); + writeFileSync(join(dir, "a.txt"), "a\n"); + writeFileSync(join(dir, "b.txt"), "b\n"); + run("add a.txt b.txt"); + mkdirSync(join(dir, ".git", "hook-tools")); + writeFileSync(join(dir, ".git", "hook-tools", "check-msg"), "#!/bin/sh\nexit 0\n"); + chmodSync(join(dir, ".git", "hook-tools", "check-msg"), 0o755); + writeFileSync(join(dir, ".git", "hooks", "pre-commit"), "#!/bin/sh\nexit 0\n"); + chmodSync(join(dir, ".git", "hooks", "pre-commit"), 0o755); + writeFileSync( + join(dir, ".git", "hooks", "commit-msg"), + `#!/bin/sh +helper=$(dirname "$0")/../hook-tools/check-msg +test -x "$helper" && exec "$helper" +exit 1 +` + ); + chmodSync(join(dir, ".git", "hooks", "commit-msg"), 0o755); + const prev = cwd(); + chdir(dir); + try { + await runFuture(repo.performCommit("feat: original hook path", ["a.txt"])); + expect(run("show HEAD:a.txt")).toBe("a\n"); + expect(run("diff --staged --name-only").trim()).toBe("b.txt"); + } finally { + chdir(prev); + } + }); + + it("performCommit with pathspecs isolates a file-to-directory replacement when a hook exists", async () => { + const { dir, run } = createTempGitRepo({ staged: false }); + writeFileSync(join(dir, "thing"), "file\n"); + run("add thing"); + run('commit -m "add thing"'); + unlinkSync(join(dir, "thing")); + mkdirSync(join(dir, "thing")); + writeFileSync(join(dir, "thing", "child"), "child\n"); + run("add -A"); + const hookPath = join(dir, ".git", "hooks", "pre-commit"); + writeFileSync(hookPath, "#!/bin/sh\nexit 0\n"); + chmodSync(hookPath, 0o755); + const prev = cwd(); + chdir(dir); + try { + await runFuture(repo.performCommit("feat: replace file", ["thing", "thing/child"])); + expect(run("show HEAD:thing/child")).toBe("child\n"); + expect(run("cat-file -t HEAD:thing").trim()).toBe("tree"); + } finally { + chdir(prev); + } + }); + + it("performCommit with pathspecs isolates a file-to-directory replacement when a hook runs git add -A", async () => { + const { dir, run } = createTempGitRepo({ staged: false }); + writeFileSync(join(dir, "thing"), "file\n"); + run("add thing"); + run('commit -m "add thing"'); + unlinkSync(join(dir, "thing")); + mkdirSync(join(dir, "thing")); + writeFileSync(join(dir, "thing", "child"), "child\n"); + writeFileSync(join(dir, "other.txt"), "other\n"); + run("add -A"); + const hookPath = join(dir, ".git", "hooks", "pre-commit"); + writeFileSync( + hookPath, + `#!/bin/sh +git add -A +` + ); + chmodSync(hookPath, 0o755); + const prev = cwd(); + chdir(dir); + try { + await runFuture(repo.performCommit("feat: replace file", ["thing", "thing/child"])); + expect(run("show HEAD:thing/child")).toBe("child\n"); + expect(run("cat-file -t HEAD:thing").trim()).toBe("tree"); + expect(run("diff --staged --name-only").trim()).toBe("other.txt"); + } finally { + chdir(prev); + } + }); + + it("performCommit with pathspecs commits a file-to-directory child when the ancestor is unselected", async () => { + const { dir, run } = createTempGitRepo({ staged: false }); + writeFileSync(join(dir, "thing"), "file\n"); + run("add thing"); + run('commit -m "add thing"'); + unlinkSync(join(dir, "thing")); + mkdirSync(join(dir, "thing")); + writeFileSync(join(dir, "thing", "a"), "a\n"); + writeFileSync(join(dir, "thing", "b"), "b\n"); + run("add -A"); + const prev = cwd(); + chdir(dir); + try { + await runFuture(repo.performCommit("feat: add a", ["thing/a"])); + expect(run("show HEAD:thing/a")).toBe("a\n"); + expect(run("cat-file -t HEAD:thing").trim()).toBe("tree"); + expect(run("diff --staged --name-only").trim()).toBe("thing/b"); + await runFuture(repo.performCommit("feat: delete thing", ["thing"])); + expect(run("diff --staged --name-only").trim()).toBe("thing/b"); + await runFuture(repo.performCommit("feat: add b", ["thing/b"])); + expect(run("show HEAD:thing/b")).toBe("b\n"); + expect(run("diff --staged --name-only").trim()).toBe(""); + } finally { + chdir(prev); + } + }); + + it("performCommit with pathspecs keeps later descendants staged after an ancestor file-to-directory commit", async () => { + const { dir, run } = createTempGitRepo({ staged: false }); + writeFileSync(join(dir, "thing"), "file\n"); + run("add thing"); + run('commit -m "add thing"'); + unlinkSync(join(dir, "thing")); + mkdirSync(join(dir, "thing")); + writeFileSync(join(dir, "thing", "a"), "a\n"); + writeFileSync(join(dir, "thing", "b"), "b\n"); + run("add -A"); + const prev = cwd(); + chdir(dir); + try { + await runFuture(repo.performCommit("feat: replace file", ["thing", "thing/a"])); + expect(run("show HEAD:thing/a")).toBe("a\n"); + expect(run("cat-file -t HEAD:thing").trim()).toBe("tree"); + expect(() => run("show HEAD:thing/b")).toThrow(); + expect(run("diff --staged --name-only").trim()).toBe("thing/b"); + await runFuture(repo.performCommit("feat: add b", ["thing/b"])); + expect(run("show HEAD:thing/b")).toBe("b\n"); + expect(run("diff --staged --name-only").trim()).toBe(""); + } finally { + chdir(prev); + } + }); + + it("performCommit with pathspecs keeps a later ancestor file after committing a directory deletion", async () => { + const { dir, run } = createTempGitRepo({ staged: false }); + mkdirSync(join(dir, "thing")); + writeFileSync(join(dir, "thing", "a"), "a\n"); + run("add thing"); + run('commit -m "add dir"'); + run("rm -rf thing"); + writeFileSync(join(dir, "thing"), "file\n"); + run("add -A"); + const prev = cwd(); + chdir(dir); + try { + await runFuture(repo.performCommit("feat: delete child", ["thing/a"])); + expect(() => run("show HEAD:thing/a")).toThrow(); + expect(run("diff --staged --name-only").trim()).toBe("thing"); + await runFuture(repo.performCommit("feat: add file", ["thing"])); + expect(run("show HEAD:thing")).toBe("file\n"); + expect(run("diff --staged --name-only").trim()).toBe(""); + } finally { + chdir(prev); + } + }); + + it("performCommit with pathspecs isolates a directory-to-file replacement when a hook exists", async () => { + const { dir, run } = createTempGitRepo({ staged: false }); + mkdirSync(join(dir, "thing")); + writeFileSync(join(dir, "thing", "child"), "child\n"); + run("add thing"); + run('commit -m "add dir"'); + run("rm -rf thing"); + writeFileSync(join(dir, "thing"), "file\n"); + run("add -A"); + const hookPath = join(dir, ".git", "hooks", "pre-commit"); + writeFileSync(hookPath, "#!/bin/sh\nexit 0\n"); + chmodSync(hookPath, 0o755); + const prev = cwd(); + chdir(dir); + try { + const staged = await runFuture(repo.listStagedPaths()); + await runFuture(repo.performCommit("feat: replace dir", staged)); + expect(run("show HEAD:thing")).toBe("file\n"); + } finally { + chdir(prev); + } + }); + + it("performCommit with pathspecs treats bracket filenames as literal pathspecs", async () => { + const { dir, run } = createTempGitRepo({ staged: false }); + mkdirSync(join(dir, "app", "[id]"), { recursive: true }); + mkdirSync(join(dir, "app", "i"), { recursive: true }); + writeFileSync(join(dir, "app", "[id]", "page.tsx"), "bracket\n"); + writeFileSync(join(dir, "app", "i", "page.tsx"), "plain\n"); + run("add app"); + const prev = cwd(); + chdir(dir); + try { + await runFuture(repo.performCommit("feat: bracket route", ["app/[id]/page.tsx"])); + expect(run("show HEAD:app/[id]/page.tsx")).toBe("bracket\n"); + expect(run("diff --staged --name-only").trim()).toBe("app/i/page.tsx"); + } finally { + chdir(prev); + } + }); + + it("performCommit with pathspecs copies hook-updated blobs into the real index", async () => { + const { dir, run } = createTempGitRepo({ staged: false }); + writeFileSync(join(dir, "file.txt"), "unformatted\n"); + writeFileSync(join(dir, "other.txt"), "other\n"); + run("add file.txt other.txt"); + const hookPath = join(dir, ".git", "hooks", "pre-commit"); + writeFileSync( + hookPath, + `#!/bin/sh +printf 'formatted\\n' > file.txt +git add file.txt +` + ); + chmodSync(hookPath, 0o755); + const prev = cwd(); + chdir(dir); + try { + await runFuture(repo.performCommit("feat: formatted", ["file.txt"])); + expect(run("show HEAD:file.txt")).toBe("formatted\n"); + expect(run("show :file.txt")).toBe("formatted\n"); + expect(readFileSync(join(dir, "file.txt"), "utf-8")).toBe("formatted\n"); + expect(run("diff --staged --name-only").trim()).toBe("other.txt"); + } finally { + chdir(prev); + } + }); + + it("performCommit with pathspecs rejects during a merge", async () => { + const { dir, run } = createTempGitRepo({ staged: false }); + writeFileSync(join(dir, "a.txt"), "a\n"); + writeFileSync(join(dir, "b.txt"), "b\n"); + run("add a.txt b.txt"); + run('commit -m "base"'); + run("checkout -b other"); + writeFileSync(join(dir, "b.txt"), "other-b\n"); + run("add b.txt"); + run('commit -m "other"'); + run("checkout main"); + writeFileSync(join(dir, "a.txt"), "main-a\n"); + writeFileSync(join(dir, "b.txt"), "main-b\n"); + run("add a.txt b.txt"); + run('commit -m "main"'); + try { + run("merge other"); + } catch { + // conflict on b.txt + } + const prev = cwd(); + chdir(dir); + try { + await expect(runFuture(repo.performCommit("feat: split merge", ["a.txt"]))).rejects.toThrow(/merge, rebase, cherry-pick, or revert/); + expect(run("rev-parse -q --verify MERGE_HEAD").trim().length).toBeGreaterThan(0); + } finally { + chdir(prev); + } + }); + + it("performCommit pathspecs resolve from worktree root when cwd is a subdirectory", async () => { + const { dir, run } = createTempGitRepo({ staged: false }); + mkdirSync(join(dir, "app")); + writeFileSync(join(dir, "app", "nested.ts"), "export const n = 1;\n"); + run("add app/nested.ts"); + const prev = cwd(); + chdir(join(dir, "app")); + try { + const staged = await runFuture(repo.listStagedPaths()); + expect([...staged]).toEqual(["app/nested.ts"]); + await runFuture(repo.performCommit("feat: nested", staged)); + expect(run("diff --staged --name-only").trim()).toBe(""); + expect((await runFuture(repo.getCommitMetadata())).subject).toBe("feat: nested"); + } finally { + chdir(prev); + } + }); }); diff --git a/test/infra/storage/config.test.ts b/test/infra/storage/config.test.ts index 7f01b03..6bc2dae 100644 --- a/test/infra/storage/config.test.ts +++ b/test/infra/storage/config.test.ts @@ -11,6 +11,7 @@ type ConfigValue = s.Infer; const sampleConfig = (): ConfigValue => ({ commit_convention: "conventional", custom_template: Nothing(), + split_commits: false, ai: { provider: "openai", model: "gpt-4.1-mini", @@ -53,6 +54,7 @@ describe("config storage", () => { saveConfig({ commit_convention: "imperative", custom_template: Just("tpl"), + split_commits: false, ai: { ...sampleConfig().ai, auth_method: { type: "openai_oauth", content: staleTokens() } } }) );