diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 6c98229e8a..2ac369f460 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -12,6 +12,7 @@ import { GitCoreLive } from "./GitCore.ts"; import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; import { GitCommandError } from "../Errors.ts"; import { type ProcessRunResult, runProcess } from "../../processRunner.ts"; +import { tokenizeCommitFlags } from "./GitManager.ts"; // ── Helpers ── @@ -98,7 +99,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) => status: (input) => core.status(input), statusDetails: (cwd) => core.statusDetails(cwd), prepareCommitContext: (cwd, filePaths?) => core.prepareCommitContext(cwd, filePaths), - commit: (cwd, subject, body) => core.commit(cwd, subject, body), + commit: (cwd, subject, body, extraArgs?) => core.commit(cwd, subject, body, extraArgs), pushCurrentBranch: (cwd, fallbackBranch) => core.pushCurrentBranch(cwd, fallbackBranch), pullCurrentBranch: (cwd) => core.pullCurrentBranch(cwd), readRangeContext: (cwd, baseBranch) => core.readRangeContext(cwd, baseBranch), @@ -1903,4 +1904,107 @@ it.layer(TestLayer)("git integration", (it) => { }), ); }); + + describe("tokenizeCommitFlags", () => { + it("returns empty array for undefined", () => { + expect(tokenizeCommitFlags(undefined)).toEqual([]); + }); + + it("returns empty array for empty string", () => { + expect(tokenizeCommitFlags("")).toEqual([]); + }); + + it("returns empty array for whitespace-only string", () => { + expect(tokenizeCommitFlags(" ")).toEqual([]); + }); + + it("tokenizes a single flag", () => { + expect(tokenizeCommitFlags("--no-gpg-sign")).toEqual(["--no-gpg-sign"]); + }); + + it("tokenizes multiple flags separated by spaces", () => { + expect(tokenizeCommitFlags("--no-gpg-sign --signoff")).toEqual([ + "--no-gpg-sign", + "--signoff", + ]); + }); + + it("handles extra whitespace between flags", () => { + expect(tokenizeCommitFlags(" --no-gpg-sign --signoff ")).toEqual([ + "--no-gpg-sign", + "--signoff", + ]); + }); + + it("keeps flags with = values intact", () => { + expect(tokenizeCommitFlags("--cleanup=verbatim")).toEqual(["--cleanup=verbatim"]); + }); + + it("drops tokens that do not start with a dash", () => { + expect(tokenizeCommitFlags("--author Foo")).toEqual(["--author"]); + }); + + it("drops all non-flag tokens from a mixed input", () => { + expect(tokenizeCommitFlags("HEAD --no-verify somefile.txt -S")).toEqual([ + "--no-verify", + "-S", + ]); + }); + + it("handles double-quoted values", () => { + expect(tokenizeCommitFlags('--author="Foo Bar"')).toEqual(['--author="Foo Bar"']); + }); + + it("handles double-quoted values with space separation", () => { + expect(tokenizeCommitFlags('--author "Foo Bar"')).toEqual(["--author"]); + }); + + it("handles single-quoted values", () => { + expect(tokenizeCommitFlags("--author='Foo Bar'")).toEqual(["--author='Foo Bar'"]); + }); + + it("handles mixed quoted and unquoted flags", () => { + expect(tokenizeCommitFlags('--no-gpg-sign --cleanup="strip"')).toEqual([ + "--no-gpg-sign", + '--cleanup="strip"', + ]); + }); + }); + + describe("commit with extraArgs", () => { + it.effect("passes extra flags through to git commit", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + + yield* writeTextFile(path.join(tmp, "extra.txt"), "extra args test\n"); + yield* git(tmp, ["add", "extra.txt"]); + + const { commitSha } = yield* core.commit(tmp, "test extra args", "", ["--no-gpg-sign"]); + expect(commitSha).toBeTruthy(); + + // Verify the commit was created with the right message + const logOutput = yield* git(tmp, ["log", "-1", "--pretty=%s"]); + expect(logOutput).toBe("test extra args"); + }), + ); + + it.effect("works without extra args (backward compat)", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + + yield* writeTextFile(path.join(tmp, "compat.txt"), "compat test\n"); + yield* git(tmp, ["add", "compat.txt"]); + + const { commitSha } = yield* core.commit(tmp, "no extra args", ""); + expect(commitSha).toBeTruthy(); + + const logOutput = yield* git(tmp, ["log", "-1", "--pretty=%s"]); + expect(logOutput).toBe("no extra args"); + }), + ); + }); }); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index f5b9168abb..2f86e58d8a 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -804,13 +804,16 @@ const makeGitCore = Effect.gen(function* () { }; }); - const commit: GitCoreShape["commit"] = (cwd, subject, body) => + const commit: GitCoreShape["commit"] = (cwd, subject, body, extraArgs = []) => Effect.gen(function* () { const args = ["commit", "-m", subject]; const trimmedBody = body.trim(); if (trimmedBody.length > 0) { args.push("-m", trimmedBody); } + // Append extra flags after the message args so an argument-consuming + // flag (e.g. `-C`) cannot accidentally swallow `-m` or the subject. + args.push(...extraArgs); yield* runGit("GitCore.commit.commit", cwd, args); const commitSha = yield* runGitStdout("GitCore.commit.revParseHead", cwd, [ "rev-parse", diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 1a3cf2bb35..379703a890 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -207,6 +207,29 @@ interface CommitAndBranchSuggestion { commitMessage: string; } +/** + * Tokenize and sanitise user-provided extra flags for `git commit`. + * + * Supports quoted values: `--author="Foo Bar"` or `--author "Foo Bar"`. + * Each resulting token must look like a CLI flag (start with `-`) to be + * included — bare positional arguments are silently dropped. + */ +export function tokenizeCommitFlags(rawFlags?: string): string[] { + const input = (rawFlags ?? "").trim(); + if (!input) return []; + + // Matches: unquoted runs of non-whitespace that may contain quoted segments + // e.g. --author="Foo Bar", --flag='val ue', --simple, -S + const tokens: string[] = []; + const pattern = /(?:[^\s"']+(?:(?:"[^"]*"|'[^']*')[^\s"']*)*)|"[^"]*"|'[^']*'/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(input)) !== null) { + tokens.push(match[0]); + } + + return tokens.filter((token) => token.startsWith("-")); +} + function formatCommitMessage(subject: string, body: string): string { const trimmedBody = body.trim(); if (trimmedBody.length === 0) { @@ -682,6 +705,7 @@ export const makeGitManager = Effect.gen(function* () { cwd: string, branch: string | null, commitMessage?: string, + commitFlags?: string, preResolvedSuggestion?: CommitAndBranchSuggestion, filePaths?: readonly string[], model?: string, @@ -700,7 +724,12 @@ export const makeGitManager = Effect.gen(function* () { return { status: "skipped_no_changes" as const }; } - const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body); + const { commitSha } = yield* gitCore.commit( + cwd, + suggestion.subject, + suggestion.body, + tokenizeCommitFlags(commitFlags), + ); return { status: "created" as const, commitSha, @@ -1050,6 +1079,7 @@ export const makeGitManager = Effect.gen(function* () { input.cwd, currentBranch, commitMessageForStep, + input.commitFlags, preResolvedCommitSuggestion, input.filePaths, input.textGenerationModel, diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index 879927934e..6234692dec 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -111,6 +111,7 @@ export interface GitCoreShape { cwd: string, subject: string, body: string, + extraArgs?: readonly string[], ) => Effect.Effect<{ commitSha: string }, GitCommandError>; /** diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index e4f4d8b1ca..221711e773 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -34,6 +34,7 @@ const withDefaults = export const AppSettingsSchema = Schema.Struct({ codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), + gitCommitFlags: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "local" as const satisfies EnvMode)), confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)), enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)), @@ -42,6 +43,7 @@ export const AppSettingsSchema = Schema.Struct({ customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), textGenerationModel: Schema.optional(TrimmedNonEmptyString), }); + export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { slug: string; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 0771875135..5b9d505d16 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -205,6 +205,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0; const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; const isGitActionRunning = isRunStackedActionRunning || isPullRunning; + const configuredCommitFlags = settings.gitCommitFlags.trim(); const isDefaultBranch = useMemo(() => { const branchName = gitStatusForActions?.branch; if (!branchName) return false; @@ -355,6 +356,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const promise = runImmediateGitActionMutation.mutateAsync({ action, ...(commitMessage ? { commitMessage } : {}), + ...(configuredCommitFlags ? { commitFlags: configuredCommitFlags } : {}), ...(featureBranch ? { featureBranch } : {}), ...(filePaths ? { filePaths } : {}), }); @@ -448,6 +450,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }, [ + configuredCommitFlags, isDefaultBranch, runImmediateGitActionMutation, setPendingDefaultBranchAction, diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index d520f73c1c..d39bbc122d 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -119,11 +119,13 @@ export function gitRunStackedActionMutationOptions(input: { mutationFn: async ({ action, commitMessage, + commitFlags, featureBranch, filePaths, }: { action: GitStackedAction; commitMessage?: string; + commitFlags?: string; featureBranch?: boolean; filePaths?: string[]; }) => { @@ -133,6 +135,7 @@ export function gitRunStackedActionMutationOptions(input: { cwd: input.cwd, action, ...(commitMessage ? { commitMessage } : {}), + ...(commitFlags ? { commitFlags } : {}), ...(featureBranch ? { featureBranch } : {}), ...(filePaths ? { filePaths } : {}), ...(input.model ? { model: input.model } : {}), diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index f3dee29096..e422e76b7a 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { type ProviderKind, DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { getAppModelOptions, MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; @@ -123,6 +123,22 @@ function SettingsRouteView() { const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; + const gitCommitFlags = settings.gitCommitFlags; + const gitCommitFlagsWarning = useMemo(() => { + const trimmed = gitCommitFlags.trim(); + if (!trimmed) return null; + const tokens: string[] = []; + const pattern = /(?:[^\s"']+(?:(?:"[^"]*"|'[^']*')[^\s"']*)*)|"[^"]*"|'[^']*'/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(trimmed)) !== null) { + tokens.push(match[0]); + } + const invalidTokens = tokens.filter((t) => !t.startsWith("-")); + if (invalidTokens.length > 0) { + return `Non-flag tokens will be ignored: ${invalidTokens.join(", ")}`; + } + return null; + }, [gitCommitFlags]); const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; @@ -531,59 +547,80 @@ function SettingsRouteView() {

Git

- Configure the model used for generating commit messages, PR titles, and branch - names. + Configure git-related settings for text generation and commit commands.

-
-
-

Text generation model

-

- Model used for auto-generated git content. -

-
- { + if (value) { + updateSettings({ + textGenerationModel: value, + }); + } + }} > - {selectedGitTextGenerationModelLabel} - - - {gitTextGenerationModelOptions.map((option) => ( - - {option.name} - - ))} - - -
+ + {selectedGitTextGenerationModelLabel} + + + {gitTextGenerationModelOptions.map((option) => ( + + {option.name} + + ))} + + + + Model used for auto-generated git content. + + - {settings.textGenerationModel !== defaults.textGenerationModel ? ( -
- -
- ) : null} + + + {settings.textGenerationModel !== defaults.textGenerationModel || + settings.gitCommitFlags !== defaults.gitCommitFlags ? ( +
+ +
+ ) : null} +
diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index e64ca13d72..cd7959c2e1 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -61,6 +61,7 @@ export const GitRunStackedActionInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, action: GitStackedAction, commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))), + commitFlags: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(4_096))), featureBranch: Schema.optional(Schema.Boolean), filePaths: Schema.optional( Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)),