diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 6c98229e8..c1a6aa998 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,90 @@ 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", + ]); + }); + }); + + 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 f5b9168ab..2f86e58d8 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 1a3cf2bb3..10152f535 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -207,6 +207,23 @@ interface CommitAndBranchSuggestion { commitMessage: string; } +/** + * Tokenize and sanitise user-provided extra flags for `git commit`. + * + * Each token must look like a CLI flag (start with `-`). Tokens that don't + * start with a dash are silently dropped so that a malformed value like + * `--author "Foo"` (which the naive split turns into `["--author", "Foo"]`) + * cannot inject a positional argument into the git invocation. + */ +export function tokenizeCommitFlags(rawFlags?: string): string[] { + const tokens = (rawFlags ?? "") + .trim() + .split(/\s+/g) + .filter((value) => value.length > 0); + + return tokens.filter((token) => token.startsWith("-")); +} + function formatCommitMessage(subject: string, body: string): string { const trimmedBody = body.trim(); if (trimmedBody.length === 0) { @@ -682,6 +699,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 +718,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 +1073,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 879927934..6234692de 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 d060c2ef0..b3f9f981c 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -21,6 +21,9 @@ const AppSettingsSchema = Schema.Struct({ codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe( Schema.withConstructorDefault(() => Option.some("")), ), + gitCommitFlags: Schema.String.check(Schema.isMaxLength(4096)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), defaultThreadEnvMode: Schema.Literals(["local", "worktree"]).pipe( Schema.withConstructorDefault(() => Option.some("local")), ), diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 077187513..697380ea7 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -33,6 +33,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Textarea } from "~/components/ui/textarea"; import { toastManager } from "~/components/ui/toast"; +import { useAppSettings } from "~/appSettings"; import { openInPreferredEditor } from "~/editorPreferences"; import { gitBranchesQueryOptions, @@ -160,6 +161,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions () => (activeThreadId ? { threadId: activeThreadId } : undefined), [activeThreadId], ); + const { settings } = useAppSettings(); const queryClient = useQueryClient(); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); @@ -205,6 +207,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 +358,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const promise = runImmediateGitActionMutation.mutateAsync({ action, ...(commitMessage ? { commitMessage } : {}), + ...(configuredCommitFlags ? { commitFlags: configuredCommitFlags } : {}), ...(featureBranch ? { featureBranch } : {}), ...(filePaths ? { filePaths } : {}), }); @@ -448,6 +452,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 d520f73c1..d39bbc122 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 e79592c99..708b75aeb 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -109,6 +109,7 @@ function SettingsRouteView() { const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; + const gitCommitFlags = settings.gitCommitFlags; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; @@ -615,6 +616,48 @@ function SettingsRouteView() { ) : null} +
+
+

Git

+

+ Configure extra flags for app-run git commit commands. +

+
+ +
+ + +
+ +
+
+
+

Responses

diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index e64ca13d7..cd7959c2e 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)),