From d271eb5f9aea7fceedf2ec431c6e1bd5f4948976 Mon Sep 17 00:00:00 2001
From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>
Date: Mon, 5 Oct 2026 14:02:50 -0700
Subject: [PATCH 1/2] feat(source-control): omit agent credits from PR merge
messages
---
.../SettingsServerControlsRouteScreen.tsx | 11 ++
.../pullRequest/GitHubPullRequestCli.test.ts | 164 ++++++++++++++++++
.../src/pullRequest/GitHubPullRequestCli.ts | 81 ++++++++-
.../pullRequest/GitHubPullRequestProvider.ts | 1 +
.../src/pullRequest/PullRequestProvider.ts | 2 +
.../pullRequest/PullRequestService.test.ts | 66 +++++++
.../src/pullRequest/PullRequestService.ts | 87 ++++++----
.../src/pullRequest/mergeMessage.test.ts | 64 +++++++
apps/server/src/pullRequest/mergeMessage.ts | 44 +++++
.../settings/ProjectDefaultsSettings.tsx | 27 +++
.../src/components/settings/settingsSearch.ts | 7 +
docs/user/source-control.md | 6 +
packages/contracts/src/settings.test.ts | 15 ++
packages/contracts/src/settings.ts | 4 +
14 files changed, 545 insertions(+), 34 deletions(-)
create mode 100644 apps/server/src/pullRequest/mergeMessage.test.ts
create mode 100644 apps/server/src/pullRequest/mergeMessage.ts
diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx
index 9fdee211b0a7..885f269251c8 100644
--- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx
@@ -50,6 +50,7 @@ const PAGE_PROJECT_KEYS: Record
+
+ write({ removeAgentCreditsOnMerge: value })}
+ />
+
{
}),
);
+ it.effect.each(["merge", "enable-auto-merge"] as const)(
+ "removes agent credits from the proposed message for %s",
+ (action) =>
+ Effect.gen(function* () {
+ mockedExecute
+ .mockReturnValueOnce(
+ Effect.succeed(
+ output(
+ encodeJson({
+ data: {
+ repository: {
+ pullRequest: {
+ isMergeQueueEnabled: false,
+ headRefOid: "abc123",
+ viewerMergeBodyText:
+ "Details\n\nCo-authored-by: Alice \nCo-authored-by: Claude ",
+ },
+ },
+ },
+ }),
+ ),
+ ),
+ )
+ .mockReturnValueOnce(Effect.succeed(output("")));
+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
+ yield* cli.runPullRequestAction({
+ cwd: "/w",
+ repository: "acme/web",
+ host: "github.com",
+ number: 7,
+ action,
+ mergeMethod: "squash",
+ removeAgentCreditsOnMerge: true,
+ });
+ expect(callAt(0).args).toContain("method=SQUASH");
+ expect(callAt(1).args.slice(-2)).toEqual(["--body-file", "-"]);
+ expect(callAt(1).args).toContain("--match-head-commit");
+ expect(callAt(1).args).toContain("abc123");
+ expect(callAt(1).stdin).toBe("Details\n\nCo-authored-by: Alice ");
+ expect(callAt(1).args.join(" ")).not.toContain("alice@example.com");
+ }),
+ );
+
+ it.effect.each([
+ {
+ description: "an unchanged message",
+ body: "Details\n\nCo-authored-by: Alice ",
+ queued: false,
+ },
+ {
+ description: "a merge queue",
+ body: "Co-authored-by: Claude ",
+ queued: true,
+ },
+ ] as const)("keeps GitHub's default message for $description", ({ body, queued }) =>
+ Effect.gen(function* () {
+ mockedExecute
+ .mockReturnValueOnce(
+ Effect.succeed(
+ output(
+ encodeJson({
+ data: {
+ repository: {
+ pullRequest: {
+ isMergeQueueEnabled: queued,
+ headRefOid: "abc123",
+ viewerMergeBodyText: body,
+ },
+ },
+ },
+ }),
+ ),
+ ),
+ )
+ .mockReturnValueOnce(Effect.succeed(output("")));
+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
+ yield* cli.runPullRequestAction({
+ cwd: "/w",
+ repository: "acme/web",
+ host: "github.com",
+ number: 7,
+ action: "merge",
+ removeAgentCreditsOnMerge: true,
+ });
+ expect(callAt(0).args).toContain("method=MERGE");
+ expect(callAt(1).args).not.toContain("--body-file");
+ expect(callAt(1).stdin).toBeUndefined();
+ }),
+ );
+
+ it.effect("passes an explicitly empty body when the proposed message only credits an agent", () =>
+ Effect.gen(function* () {
+ mockedExecute
+ .mockReturnValueOnce(
+ Effect.succeed(
+ output(
+ encodeJson({
+ data: {
+ repository: {
+ pullRequest: {
+ isMergeQueueEnabled: false,
+ headRefOid: "abc123",
+ viewerMergeBodyText: "Co-authored-by: Claude ",
+ },
+ },
+ },
+ }),
+ ),
+ ),
+ )
+ .mockReturnValueOnce(Effect.succeed(output("")));
+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
+ yield* cli.runPullRequestAction({
+ cwd: "/w",
+ repository: "acme/web",
+ host: "github.com",
+ number: 7,
+ action: "merge",
+ removeAgentCreditsOnMerge: true,
+ });
+ expect(callAt(1).stdin).toBe("");
+ expect(callAt(1).args).toContain("--body-file");
+ }),
+ );
+
+ it.effect("does not fetch a message for rebase merges", () =>
+ Effect.gen(function* () {
+ mockedExecute.mockReturnValue(Effect.succeed(output("")));
+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
+ yield* cli.runPullRequestAction({
+ cwd: "/w",
+ repository: "acme/web",
+ host: "github.com",
+ number: 7,
+ action: "merge",
+ mergeMethod: "rebase",
+ removeAgentCreditsOnMerge: true,
+ });
+ expect(mockedExecute).toHaveBeenCalledTimes(1);
+ expect(callAt(0).args).toContain("--rebase");
+ }),
+ );
+
+ it.effect("refuses to merge when the proposed message cannot be read", () =>
+ Effect.gen(function* () {
+ mockedExecute.mockReturnValue(
+ Effect.succeed(output('{"data":{"repository":{"pullRequest":null}}}')),
+ );
+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
+ const result = yield* Effect.result(
+ cli.runPullRequestAction({
+ cwd: "/w",
+ repository: "acme/web",
+ host: "github.com",
+ number: 7,
+ action: "merge",
+ removeAgentCreditsOnMerge: true,
+ }),
+ );
+ expect(result._tag).toBe("Failure");
+ expect(mockedExecute).toHaveBeenCalledTimes(1);
+ }),
+ );
+
it.effect("arms auto-merge with the same strategy a merge would have used", () =>
Effect.gen(function* () {
mockedExecute.mockReturnValue(Effect.succeed(output("")));
diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts
index f4f95e7afbf9..8345193bced5 100644
--- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts
+++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts
@@ -1,3 +1,4 @@
+import { removeAgentCredits } from "./mergeMessage.ts";
import { makeChecksRevalidator } from "./gitHubConditionalChecks.ts";
import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts";
import * as Cause from "effect/Cause";
@@ -737,6 +738,7 @@ export class GitHubPullRequestCli extends Context.Service<
readonly action: PullRequestAction;
readonly stackNumber?: number;
readonly expectedStackHeads?: ReadonlyArray;
+ readonly removeAgentCreditsOnMerge?: boolean;
readonly mergeMethod?: PullRequestMergeMethod;
readonly updateMethod?: PullRequestUpdateMethod;
}) => Effect.Effect;
@@ -1055,6 +1057,35 @@ function cursorVariable(cursor: string | null): readonly [string, string] {
return cursor === null ? ["-F", "cursor=null"] : ["-f", `cursor=${cursor}`];
}
+const MERGE_MESSAGE_GRAPHQL_QUERY = `
+query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $method: PullRequestMergeMethod!) {
+ repository(owner: $owner, name: $name) {
+ pullRequest(number: $number) {
+ isMergeQueueEnabled
+ headRefOid
+ viewerMergeBodyText(mergeType: $method)
+ }
+ }
+}`;
+
+const decodeMergeMessageResponse = Schema.decodeUnknownResult(
+ Schema.fromJsonString(
+ Schema.Struct({
+ data: Schema.Struct({
+ repository: Schema.Struct({
+ pullRequest: Schema.Struct({
+ isMergeQueueEnabled: Schema.Boolean,
+ headRefOid: Schema.String,
+ viewerMergeBodyText: Schema.String,
+ }),
+ }),
+ }),
+ }),
+ ),
+);
+const decodeMergeMessage = (raw: string) =>
+ Result.map(decodeMergeMessageResponse(raw), (response) => response.data.repository.pullRequest);
+
function actionArgs(
action: PullRequestAction,
mergeMethod: PullRequestMergeMethod | undefined,
@@ -2653,12 +2684,52 @@ export const make = Effect.gen(function* () {
input.mergeMethod,
input.updateMethod,
);
- return github
- .execute({
+ return Effect.gen(function* () {
+ let body: string | undefined;
+ let expectedHead: string | undefined;
+ if (
+ input.removeAgentCreditsOnMerge === true &&
+ (input.action === "merge" || input.action === "enable-auto-merge") &&
+ input.mergeMethod !== "rebase"
+ ) {
+ const { owner, name } = parseRepositorySelector(input.repository);
+ const message = yield* graphqlRead({
+ cwd: input.cwd,
+ host: input.host,
+ operation: "runPullRequestAction",
+ allowReserve: true,
+ query: MERGE_MESSAGE_GRAPHQL_QUERY,
+ variables: [
+ ["-f", `owner=${owner}`],
+ ["-f", `name=${name}`],
+ ["-F", `number=${input.number}`],
+ ["-f", `method=${input.mergeMethod === "squash" ? "SQUASH" : "MERGE"}`],
+ ],
+ decode: decodeMergeMessage,
+ });
+ // GitHub's merge queue chooses its own message and ignores custom text.
+ if (!message.isMergeQueueEnabled) {
+ const cleaned = removeAgentCredits(message.viewerMergeBodyText);
+ if (cleaned !== message.viewerMergeBodyText) {
+ body = cleaned;
+ expectedHead = message.headRefOid;
+ }
+ }
+ }
+ yield* github.execute({
cwd: input.cwd,
- args: ["pr", subcommand!, String(input.number), ...repositoryArgs(input), ...flags],
- })
- .pipe(Effect.asVoid);
+ args: [
+ "pr",
+ subcommand!,
+ String(input.number),
+ ...repositoryArgs(input),
+ ...flags,
+ ...(expectedHead === undefined ? [] : ["--match-head-commit", expectedHead]),
+ ...(body === undefined ? [] : ["--body-file", "-"]),
+ ],
+ ...(body === undefined ? {} : { stdin: body }),
+ });
+ });
},
commentOnPullRequest: (input) =>
diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts
index 7ef81691201f..3f8e9abcf8d4 100644
--- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts
+++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts
@@ -582,6 +582,7 @@ export const make = Effect.gen(function* () {
host: input.host,
number: input.number,
action: input.action,
+ ...(input.removeAgentCreditsOnMerge === true ? { removeAgentCreditsOnMerge: true } : {}),
...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }),
...(input.expectedStackHeads === undefined
? {}
diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts
index 0fe33e472fd0..c45802549919 100644
--- a/apps/server/src/pullRequest/PullRequestProvider.ts
+++ b/apps/server/src/pullRequest/PullRequestProvider.ts
@@ -543,6 +543,8 @@ export interface PullRequestProviderApi {
readonly action: PullRequestAction;
readonly stackNumber?: number;
readonly expectedStackHeads?: ReadonlyArray;
+ /** GitHub merge message cleanup; ignored by hosts without support. */
+ readonly removeAgentCreditsOnMerge?: boolean;
/** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */
readonly mergeMethod?: PullRequestMergeMethod;
/** Only meaningful for `update-branch`; absent takes the host's own default. */
diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts
index 7f62db11fec6..bf2052a13e12 100644
--- a/apps/server/src/pullRequest/PullRequestService.test.ts
+++ b/apps/server/src/pullRequest/PullRequestService.test.ts
@@ -19,6 +19,9 @@ import type {
PullRequestReviewerCapabilities,
SourceControlProviderKind,
} from "@t3tools/contracts";
+import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings";
+import type { ServerSettings } from "@t3tools/contracts";
+import * as ServerSettingsService from "../serverSettings.ts";
import { PullRequestOperationError } from "@t3tools/contracts";
import * as ProjectService from "../project/ProjectService.ts";
@@ -402,6 +405,7 @@ function fakeProvider(
}
function makeService(input: {
+ readonly settings?: ServerSettings;
readonly projects: ReadonlyArray;
readonly providers: ReadonlyArray;
readonly resolveHandle?: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]["resolveHandle"];
@@ -412,6 +416,9 @@ function makeService(input: {
return Effect.flatMap(
Layer.build(
Layer.mergeAll(
+ Layer.mock(ServerSettingsService.ServerSettingsService)({
+ getSettings: Effect.succeed(input.settings ?? DEFAULT_SERVER_SETTINGS),
+ }),
Layer.succeed(
PullRequestProviderRegistry.PullRequestProviderRegistry,
PullRequestProviderRegistry.fromProviders(input.providers),
@@ -7114,3 +7121,62 @@ it.effect("keeps Azure continuation cursors separate for repositories with the s
assert.deepStrictEqual(seen, ["/org-b"]);
}),
);
+
+it.effect.each([
+ { action: "merge", environmentValue: true, projectValue: false, expected: false },
+ { action: "merge", environmentValue: false, projectValue: true, expected: true },
+ { action: "merge", environmentValue: true, projectValue: undefined, expected: true },
+ { action: "merge", environmentValue: false, projectValue: undefined, expected: false },
+ { action: "enable-auto-merge", environmentValue: true, projectValue: false, expected: false },
+ { action: "enable-auto-merge", environmentValue: false, projectValue: true, expected: true },
+ { action: "enable-auto-merge", environmentValue: true, projectValue: undefined, expected: true },
+] as const)(
+ "resolves agent credits for $action with environment=$environmentValue and project=$projectValue",
+ ({ action, environmentValue, projectValue, expected }) =>
+ Effect.gen(function* () {
+ const calls: boolean[] = [];
+ const service = yield* makeService({
+ projects: [
+ project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" }),
+ ],
+ settings: {
+ ...DEFAULT_SERVER_SETTINGS,
+ removeAgentCreditsOnMerge: environmentValue,
+ projectSettingsOverrides:
+ projectValue === undefined
+ ? {}
+ : {
+ ["p1" as ProjectId]: { removeAgentCreditsOnMerge: projectValue },
+ },
+ },
+ providers: [
+ fakeProvider("github", {
+ capabilities: {
+ ...fakeProvider("github").capabilities,
+ actions: ["merge", "enable-auto-merge"],
+ },
+ getChangeRequestSummary: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")),
+ getViewerPermissions: () =>
+ Effect.succeed({
+ actions: ["merge", "enable-auto-merge"],
+ comment: true,
+ resolve: true,
+ verdicts: ["comment"],
+ requestReviewers: false,
+ }),
+ runAction: (input) =>
+ Effect.sync(() => {
+ calls.push(input.removeAgentCreditsOnMerge === true);
+ }),
+ }),
+ ],
+ });
+ yield* service.runAction({
+ projectId: "p1" as ProjectId,
+ repository: "acme/web",
+ number: 1,
+ action,
+ });
+ assert.deepStrictEqual(calls, [expected]);
+ }),
+);
diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts
index a5a1a5b01d62..ad38a220f228 100644
--- a/apps/server/src/pullRequest/PullRequestService.ts
+++ b/apps/server/src/pullRequest/PullRequestService.ts
@@ -72,10 +72,12 @@ import {
type SourceControlProviderInfo,
type SourceControlProviderKind,
} from "@t3tools/contracts";
+import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts";
import * as ProjectService from "../project/ProjectService.ts";
+import * as ServerSettings from "../serverSettings.ts";
import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts";
import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
@@ -635,6 +637,7 @@ export const make = Effect.gen(function* () {
const mergedPullRequests = yield* PubSub.sliding(64);
const pullRequestRefreshes = yield* SubscriptionRef.make(0);
const registry = yield* PullRequestProviderRegistry.PullRequestProviderRegistry;
+ const serverSettings = yield* ServerSettings.ServerSettingsService;
const projects = yield* ProjectService.ProjectService;
const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
@@ -1991,35 +1994,61 @@ export const make = Effect.gen(function* () {
}),
);
}
- return project.api
- .runAction({
- cwd: project.project.workspaceRoot,
- repository: project.repository,
- host: project.host,
- number: input.number,
- action: input.action,
- ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }),
- ...(input.expectedStackHeads === undefined
- ? {}
- : { expectedStackHeads: input.expectedStackHeads }),
- ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
- ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }),
- })
- .pipe(
- // Once the authorized provider action starts, a failure may leave partial
- // remote updates. Validation and permission failures above changed nothing.
- Effect.ensuring(
- input.stackNumber === undefined
- ? Effect.void
- : refreshAfterTurn(project.project.id),
- ),
- Effect.mapError(toPullRequestError("runAction")),
- Effect.as(
- project.api.kind === "azure-devops"
- ? input.repository.trim()
- : project.repository,
- ),
- );
+ const mergeSettings =
+ project.api.kind === "github" &&
+ input.stackNumber === undefined &&
+ (input.action === "merge" || input.action === "enable-auto-merge")
+ ? serverSettings.getSettings.pipe(
+ Effect.map(
+ (settings) =>
+ resolveProjectSettings(settings, project.project.id).settings
+ .removeAgentCreditsOnMerge,
+ ),
+ Effect.mapError(
+ () =>
+ new PullRequestOperationError({
+ operation: "runAction",
+ detail: "Could not read merge settings.",
+ }),
+ ),
+ )
+ : Effect.succeed(false);
+ return mergeSettings.pipe(
+ Effect.flatMap((removeAgentCreditsOnMerge) =>
+ project.api
+ .runAction({
+ cwd: project.project.workspaceRoot,
+ repository: project.repository,
+ host: project.host,
+ number: input.number,
+ action: input.action,
+ ...(removeAgentCreditsOnMerge ? { removeAgentCreditsOnMerge: true } : {}),
+ ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }),
+ ...(input.expectedStackHeads === undefined
+ ? {}
+ : { expectedStackHeads: input.expectedStackHeads }),
+ ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
+ ...(input.updateMethod === undefined
+ ? {}
+ : { updateMethod: input.updateMethod }),
+ })
+ .pipe(
+ // Once the authorized provider action starts, a failure may leave partial
+ // remote updates. Validation and permission failures above changed nothing.
+ Effect.ensuring(
+ input.stackNumber === undefined
+ ? Effect.void
+ : refreshAfterTurn(project.project.id),
+ ),
+ Effect.mapError(toPullRequestError("runAction")),
+ Effect.as(
+ project.api.kind === "azure-devops"
+ ? input.repository.trim()
+ : project.repository,
+ ),
+ ),
+ ),
+ );
}),
);
}),
diff --git a/apps/server/src/pullRequest/mergeMessage.test.ts b/apps/server/src/pullRequest/mergeMessage.test.ts
new file mode 100644
index 000000000000..b1fc0e246d02
--- /dev/null
+++ b/apps/server/src/pullRequest/mergeMessage.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from "vite-plus/test";
+import { removeAgentCredits } from "./mergeMessage.ts";
+
+describe("removeAgentCredits", () => {
+ it("removes recognized agent trailers and generated footers, keeping human co-authors", () => {
+ expect(
+ removeAgentCredits(
+ [
+ "Details about Claude support.",
+ "",
+ "",
+ "Co-authored-by: Alice ",
+ "Co-authored-by: Claude ",
+ "* Co-authored-by: Codex ",
+ "co-authored-by: Cursor ",
+ "Co-authored-by: Copilot ",
+ "",
+ "🤖 Generated with [Claude Code](https://claude.ai/code)",
+ ].join("\n"),
+ ),
+ ).toBe("Details about Claude support.\n\n\nCo-authored-by: Alice ");
+ });
+
+ it("preserves unknown identities, humans named after agents, and ordinary prose", () => {
+ const message = [
+ "Co-authored-by: Claude ",
+ "Co-authored-by: Alice ",
+ "Co-authored-by: Release Bot ",
+ "Generated by our new Claude integration.",
+ "Generated with [Claude Code](https://example.com)",
+ "",
+ ].join("\n");
+ expect(removeAgentCredits(message)).toBe(message);
+ });
+
+ it("preserves code fences and block quotes containing attribution examples", () => {
+ const example = "Co-authored-by: Claude ";
+ const message = `Example:\n\n\`\`\`text\n${example}\n\`\`\`\n\n> ${example}\n\n${example}`;
+ expect(removeAgentCredits(message)).toBe(
+ `Example:\n\n\`\`\`text\n${example}\n\`\`\`\n\n> ${example}`,
+ );
+ });
+
+ it("preserves indented examples and whitespace on surviving lines", () => {
+ const example = "Co-authored-by: Claude ";
+ expect(removeAgentCredits(`Example:\n\n ${example}\n\n${example}\nDetails \n`)).toBe(
+ `Example:\n\n ${example}\n\nDetails `,
+ );
+ });
+
+ it("preserves CRLF line endings", () => {
+ expect(
+ removeAgentCredits(
+ "Details\r\n\r\nCo-authored-by: Claude \r\nCo-authored-by: Alice ",
+ ),
+ ).toBe("Details\r\n\r\nCo-authored-by: Alice ");
+ });
+
+ it("returns an empty body when only agent credits remain", () => {
+ expect(
+ removeAgentCredits("Co-authored-by: Claude \n\nGenerated by Codex\n"),
+ ).toBe("");
+ });
+});
diff --git a/apps/server/src/pullRequest/mergeMessage.ts b/apps/server/src/pullRequest/mergeMessage.ts
new file mode 100644
index 000000000000..919383c4429f
--- /dev/null
+++ b/apps/server/src/pullRequest/mergeMessage.ts
@@ -0,0 +1,44 @@
+const AGENT_EMAILS = new Set([
+ "noreply@anthropic.com",
+ "claude@anthropic.com",
+ "noreply@openai.com",
+ "codex@openai.com",
+ "cursoragent@cursor.com",
+ "copilot@github.com",
+ "noreply@github.com",
+]);
+
+const GENERATED_BY_AGENT =
+ /^(?:🤖\s*)?generated (?:with|by)\s+(?:claude(?: code)?|codex|cursor|github copilot|copilot|opencode|grok|antigravity)[.!]?$/i;
+
+/** Remove standalone agent credits while keeping human signatures and quoted examples. */
+export function removeAgentCredits(message: string): string {
+ let fence: string | undefined;
+ let removed = false;
+ const newline = message.includes("\r\n") ? "\r\n" : "\n";
+ const lines = message.split(/\r?\n/).filter((line) => {
+ const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1];
+ if (marker !== undefined) {
+ if (fence === undefined) fence = marker;
+ else if (marker[0] === fence[0] && marker.length >= fence.length) fence = undefined;
+ return true;
+ }
+ if (fence !== undefined || /^(?: {4}|\t)/.test(line)) return true;
+ const text = line.trim().replace(/^(?:[-*]\s+)/, "");
+ const author = /^co-authored-by:\s*(.*?)\s*<([^<>]+)>\s*$/i.exec(text);
+ // GitHub's generic noreply address is also used by people; require Copilot's name.
+ const email = author?.[2]?.toLowerCase();
+ const agentAuthor =
+ email !== undefined &&
+ AGENT_EMAILS.has(email) &&
+ (email !== "noreply@github.com" || /^github copilot$|^copilot$/i.test(author![1]!));
+ const footer = text.replace(
+ /\[([^\]]+)\]\(https:\/\/(?:claude\.ai|www\.anthropic\.com|chatgpt\.com|openai\.com|cursor\.com|github\.com|opencode\.ai|x\.ai|antigravity\.google)(?:\/[^\s)]*)?\)/gi,
+ "$1",
+ );
+ if (!agentAuthor && !GENERATED_BY_AGENT.test(footer)) return true;
+ removed = true;
+ return false;
+ });
+ return removed ? lines.join(newline).replace(/(?:\r?\n[ \t]*)+$/, "") : message;
+}
diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx
index 62a58307aaf5..bfd776855307 100644
--- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx
+++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx
@@ -79,6 +79,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
const mixedSubmodules = useScopedSettingsMixed(["worktreeSubmodules"]);
const mixedBrowser = useScopedSettingsMixed(["enableAgentBrowserAccess"]);
const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]);
+ const mixedAgentCredits = useScopedSettingsMixed(["removeAgentCreditsOnMerge"]);
const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]);
const modelSource = useScopedSettingSource(["defaultModelSelection"]);
const isProjectScope = scope.kind === "project" || scope.kind === "checkout";
@@ -412,6 +413,32 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
/>
}
/>
+ updateSettings({ removeAgentCreditsOnMerge: false })}
+ />
+ ) : null
+ }
+ control={
+
+ updateSettings({ removeAgentCreditsOnMerge: enabled })
+ }
+ />
+ }
+ />
{
},
);
});
+
+describe("ServerSettings.removeAgentCreditsOnMerge", () => {
+ it("keeps agent credits by default and accepts opt-in patches", () => {
+ expect(decodeServerSettings({}).removeAgentCreditsOnMerge).toBe(false);
+ expect(
+ decodeServerSettingsPatch({ removeAgentCreditsOnMerge: true }).removeAgentCreditsOnMerge,
+ ).toBe(true);
+ expect(
+ decodeServerSettings({
+ projectSettingsOverrides: { project: { removeAgentCreditsOnMerge: true } },
+ }).projectSettingsOverrides["project" as ProjectId]?.removeAgentCreditsOnMerge,
+ ).toBe(true);
+ });
+});
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index c2720d0a371f..ff14bcaab91b 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -1137,6 +1137,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [
"textGenerationModelSelection",
"sourceControlWriterModelSelection",
"sourceControlWritingStyle",
+ "removeAgentCreditsOnMerge",
"branchNamingMode",
"branchNamePrefix",
"branchNameInstructions",
@@ -1167,6 +1168,7 @@ export const ProjectSettingsOverrides = Schema.Struct({
textGenerationModelSelection: Schema.optionalKey(ModelSelection),
sourceControlWriterModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),
sourceControlWritingStyle: Schema.optionalKey(SourceControlWritingStyleSettings),
+ removeAgentCreditsOnMerge: Schema.optionalKey(Schema.Boolean),
branchNamingMode: Schema.optionalKey(BranchNamingMode),
branchNamePrefix: Schema.optionalKey(TrimmedString),
branchNameInstructions: Schema.optionalKey(TrimmedString),
@@ -1368,6 +1370,7 @@ export const ServerSettings = Schema.Struct({
),
branchNamePrefix: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed("t3code"))),
branchNameInstructions: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
+ removeAgentCreditsOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
sourceControlWritingStyle: SourceControlWritingStyleSettings.pipe(
Schema.withDecodingDefault(Effect.succeed({})),
),
@@ -1673,6 +1676,7 @@ export const ServerSettingsPatch = Schema.Struct({
branchNamingMode: Schema.optionalKey(BranchNamingMode),
branchNamePrefix: Schema.optionalKey(TrimmedString),
branchNameInstructions: Schema.optionalKey(TrimmedString),
+ removeAgentCreditsOnMerge: Schema.optionalKey(Schema.Boolean),
sourceControlWritingStyle: Schema.optionalKey(
Schema.Struct({
mode: Schema.optionalKey(SourceControlWritingStyleMode),
From 95521dce456aa96c4ae58bdee4a9401a847175e8 Mon Sep 17 00:00:00 2001
From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>
Date: Mon, 5 Oct 2026 14:08:40 -0700
Subject: [PATCH 2/2] fix(source-control): preserve Markdown examples during
merge cleanup
---
.../src/pullRequest/mergeMessage.test.ts | 15 +++++++++++++++
apps/server/src/pullRequest/mergeMessage.ts | 18 ++++++++++++++----
2 files changed, 29 insertions(+), 4 deletions(-)
diff --git a/apps/server/src/pullRequest/mergeMessage.test.ts b/apps/server/src/pullRequest/mergeMessage.test.ts
index b1fc0e246d02..2c6247399500 100644
--- a/apps/server/src/pullRequest/mergeMessage.test.ts
+++ b/apps/server/src/pullRequest/mergeMessage.test.ts
@@ -48,6 +48,21 @@ describe("removeAgentCredits", () => {
);
});
+ it.each(["``` text ```", " ```", "\t~~~"])(
+ "removes trailers after a line that cannot open a fence: %s",
+ (line) => {
+ expect(removeAgentCredits(`${line}\n\nCo-authored-by: Claude `)).toBe(
+ line,
+ );
+ },
+ );
+
+ it.each(["```", "~~~"])("keeps examples after a false closing fence: %s", (fence) => {
+ const example = "Co-authored-by: Claude ";
+ const content = `${fence}text\n${fence} trailing text\n${example}\n ${fence}\n${example}\n${fence.slice(0, 2)}\n${example}\n ${fence}${fence[0]} \t`;
+ expect(removeAgentCredits(`${content}\n\n${example}`)).toBe(content);
+ });
+
it("preserves CRLF line endings", () => {
expect(
removeAgentCredits(
diff --git a/apps/server/src/pullRequest/mergeMessage.ts b/apps/server/src/pullRequest/mergeMessage.ts
index 919383c4429f..20083b0d8228 100644
--- a/apps/server/src/pullRequest/mergeMessage.ts
+++ b/apps/server/src/pullRequest/mergeMessage.ts
@@ -17,13 +17,23 @@ export function removeAgentCredits(message: string): string {
let removed = false;
const newline = message.includes("\r\n") ? "\r\n" : "\n";
const lines = message.split(/\r?\n/).filter((line) => {
- const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1];
+ if (/^(?: {4}|\t)/.test(line)) return true;
+ const delimiter = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
+ const marker = delimiter?.[1];
+ const remainder = delimiter?.[2] ?? "";
if (marker !== undefined) {
- if (fence === undefined) fence = marker;
- else if (marker[0] === fence[0] && marker.length >= fence.length) fence = undefined;
+ if (fence === undefined) {
+ if (marker[0] === "~" || !remainder.includes("`")) fence = marker;
+ } else if (
+ marker[0] === fence[0] &&
+ marker.length >= fence.length &&
+ /^[ \t]*$/.test(remainder)
+ ) {
+ fence = undefined;
+ }
return true;
}
- if (fence !== undefined || /^(?: {4}|\t)/.test(line)) return true;
+ if (fence !== undefined) return true;
const text = line.trim().replace(/^(?:[-*]\s+)/, "");
const author = /^co-authored-by:\s*(.*?)\s*<([^<>]+)>\s*$/i.exec(text);
// GitHub's generic noreply address is also used by people; require Copilot's name.