Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 105 additions & 1 deletion apps/server/src/git/Layers/GitCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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");
}),
);
});
});
5 changes: 4 additions & 1 deletion apps/server/src/git/Layers/GitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 31 additions & 1 deletion apps/server/src/git/Layers/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -1050,6 +1079,7 @@ export const makeGitManager = Effect.gen(function* () {
input.cwd,
currentBranch,
commitMessageForStep,
input.commitFlags,
preResolvedCommitSuggestion,
input.filePaths,
input.textGenerationModel,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/git/Services/GitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export interface GitCoreShape {
cwd: string,
subject: string,
body: string,
extraArgs?: readonly string[],
) => Effect.Effect<{ commitSha: string }, GitCommandError>;

/**
Expand Down
36 changes: 21 additions & 15 deletions apps/web/src/appSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,27 +14,33 @@ const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>
codex: new Set(getModelOptions("codex").map((option) => option.slug)),
};

const withDefaults =
<
S extends Schema.Top & Schema.WithoutConstructorDefault,
D extends S["~type.make.in"] & S["Encoded"],
>(
fallback: () => D,
) =>
(schema: S) =>
schema.pipe(
Schema.withConstructorDefault(() => Option.some(fallback())),
Schema.withDecodingDefault(() => fallback()),
);

const AppSettingsSchema = Schema.Struct({
codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(
Schema.withConstructorDefault(() => Option.some("")),
),
codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(
Schema.withConstructorDefault(() => Option.some("")),
),
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: Schema.Literals(["local", "worktree"]).pipe(
Schema.withConstructorDefault(() => Option.some("local")),
),
confirmThreadDelete: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))),
enableAssistantStreaming: Schema.Boolean.pipe(
Schema.withConstructorDefault(() => Option.some(false)),
withDefaults(() => "local" as const),
),
confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)),
enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)),
timestampFormat: Schema.Literals(["locale", "12-hour", "24-hour"]).pipe(
Schema.withConstructorDefault(() => Option.some(DEFAULT_TIMESTAMP_FORMAT)),
),
customCodexModels: Schema.Array(Schema.String).pipe(
Schema.withConstructorDefault(() => Option.some([])),
withDefaults(() => DEFAULT_TIMESTAMP_FORMAT),
),
textGenerationModel: Schema.optional(TrimmedNonEmptyString),
customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])),
});
export type AppSettings = typeof AppSettingsSchema.Type;
export interface AppModelOption {
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/GitActionsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 } : {}),
});
Expand Down Expand Up @@ -448,6 +450,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
},

[
configuredCommitFlags,
isDefaultBranch,
runImmediateGitActionMutation,
setPendingDefaultBranchAction,
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/lib/gitReactQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}) => {
Expand All @@ -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 } : {}),
Expand Down
Loading