From 5a57ada95bfe71553495556aced11c5e67b46516 Mon Sep 17 00:00:00 2001 From: Niklas Wojtkowiak Date: Thu, 12 Mar 2026 03:31:09 -0400 Subject: [PATCH 1/2] feat(git): support custom commit flags for app-run commits --- apps/server/src/git/Layers/GitCore.test.ts | 2 +- apps/server/src/git/Layers/GitCore.ts | 4 +- apps/server/src/git/Layers/GitManager.ts | 16 ++++++- apps/server/src/git/Services/GitCore.ts | 1 + apps/web/public/mockServiceWorker.js | 2 +- apps/web/src/appSettings.ts | 3 ++ apps/web/src/components/GitActionsControl.tsx | 5 +++ apps/web/src/lib/gitReactQuery.ts | 3 ++ apps/web/src/routes/_chat.settings.tsx | 43 +++++++++++++++++++ packages/contracts/src/git.ts | 1 + 10 files changed, 75 insertions(+), 5 deletions(-) diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 6c98229e8a..6b5bc0232f 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -98,7 +98,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), diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index f5b9168abb..9070d295f3 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -804,9 +804,9 @@ 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 args = ["commit", ...extraArgs, "-m", subject]; const trimmedBody = body.trim(); if (trimmedBody.length > 0) { args.push("-m", trimmedBody); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 8357795173..2b248d3c64 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -207,6 +207,13 @@ interface CommitAndBranchSuggestion { commitMessage: string; } +function tokenizeCommitFlags(rawFlags?: string): string[] { + return (rawFlags ?? "") + .trim() + .split(/\s+/g) + .filter((value) => value.length > 0); +} + function formatCommitMessage(subject: string, body: string): string { const trimmedBody = body.trim(); if (trimmedBody.length === 0) { @@ -680,6 +687,7 @@ export const makeGitManager = Effect.gen(function* () { cwd: string, branch: string | null, commitMessage?: string, + commitFlags?: string, preResolvedSuggestion?: CommitAndBranchSuggestion, filePaths?: readonly string[], ) => @@ -696,7 +704,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, @@ -1042,6 +1055,7 @@ export const makeGitManager = Effect.gen(function* () { input.cwd, currentBranch, commitMessageForStep, + input.commitFlags, preResolvedCommitSuggestion, input.filePaths, ); 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/public/mockServiceWorker.js b/apps/web/public/mockServiceWorker.js index 85e9010123..daa58d0f12 100644 --- a/apps/web/public/mockServiceWorker.js +++ b/apps/web/public/mockServiceWorker.js @@ -7,7 +7,7 @@ * - Please do NOT modify this file. */ -const PACKAGE_VERSION = '2.12.9' +const PACKAGE_VERSION = '2.12.10' const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 18e76d2f92..208d59773e 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 e4fad02af2..d86c607a87 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -32,6 +32,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, @@ -158,6 +159,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(""); @@ -199,6 +201,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; @@ -349,6 +352,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const promise = runImmediateGitActionMutation.mutateAsync({ action, ...(commitMessage ? { commitMessage } : {}), + ...(configuredCommitFlags ? { commitFlags: configuredCommitFlags } : {}), ...(featureBranch ? { featureBranch } : {}), ...(filePaths ? { filePaths } : {}), }); @@ -442,6 +446,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 9b5fe7731f..180b170a1c 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -118,11 +118,13 @@ export function gitRunStackedActionMutationOptions(input: { mutationFn: async ({ action, commitMessage, + commitFlags, featureBranch, filePaths, }: { action: GitStackedAction; commitMessage?: string; + commitFlags?: string; featureBranch?: boolean; filePaths?: string[]; }) => { @@ -132,6 +134,7 @@ export function gitRunStackedActionMutationOptions(input: { cwd: input.cwd, action, ...(commitMessage ? { commitMessage } : {}), + ...(commitFlags ? { commitFlags } : {}), ...(featureBranch ? { featureBranch } : {}), ...(filePaths ? { filePaths } : {}), }); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index b4afcdefa1..e2a43e59ff 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; @@ -545,6 +546,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 081b4d0d82..4c06d8a737 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -60,6 +60,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)), From 77a64493bbdc664609f4b55d1f041ff441550d03 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 19 Mar 2026 12:05:40 -0700 Subject: [PATCH 2/2] Harden git commit extra flags - Move extra commit args after message flags to avoid swallowing `-m` - Drop non-flag tokens when tokenizing user-supplied commit flags - Add tests and clarify the settings helper text --- apps/server/src/git/Layers/GitCore.test.ts | 87 ++++++++++++++++++++++ apps/server/src/git/Layers/GitCore.ts | 5 +- apps/server/src/git/Layers/GitManager.ts | 14 +++- apps/web/src/routes/_chat.settings.tsx | 4 +- 4 files changed, 105 insertions(+), 5 deletions(-) diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 6b5bc0232f..c1a6aa9984 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 ── @@ -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 9070d295f3..2f86e58d8a 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -806,11 +806,14 @@ const makeGitCore = Effect.gen(function* () { const commit: GitCoreShape["commit"] = (cwd, subject, body, extraArgs = []) => Effect.gen(function* () { - const args = ["commit", ...extraArgs, "-m", subject]; + 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 2b248d3c64..9ed2add0f1 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -207,11 +207,21 @@ interface CommitAndBranchSuggestion { commitMessage: string; } -function tokenizeCommitFlags(rawFlags?: string): string[] { - return (rawFlags ?? "") +/** + * 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 { diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index e2a43e59ff..50537c94f7 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -567,8 +567,8 @@ function SettingsRouteView() { spellCheck={false} /> - Applied to app-run git commit commands only. Example: --no-gpg-sign - . Quoted arguments are not supported yet. + Applied to app-run git commit commands only. Example:{" "} + --no-gpg-sign. Quoted arguments are not supported yet.