authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-07-27 16:02:15-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-27 22:02:15+02:00
log10bca3f44e1be6a874438fe80badbad45cd00279
tree1d1e2ee61ddcd78f891ba45969d39fa94018ea94
parente77f42c111dc40e0b208393957ebb23863330e2e
signature Signed by PGP key B5690EEEBB952194

feat: add configurable source control writing settings (#4204)

Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>

27 files changed, 1335 insertions(+), 125 deletions(-)

apps/server/src/git/GitManager.test.ts+171-36
......@@ -16,11 +16,16 @@ import { expect } from "vite-plus/test";
1616import type {
1717 GitActionProgressEvent,
1818 GitPreparePullRequestThreadInput,
19 ModelSelection,
2019 ThreadId,
2120} from "@t3tools/contracts";
2221
23import { GitCommandError, TextGenerationError } from "@t3tools/contracts";
22import {
23 DEFAULT_SERVER_SETTINGS,
24 GitCommandError,
25 ProviderDriverKind,
26 ProviderInstanceId,
27 TextGenerationError,
28} from "@t3tools/contracts";
2429import * as GitHubCli from "../sourceControl/GitHubCli.ts";
2530import * as TextGeneration from "../textGeneration/TextGeneration.ts";
2631import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
......@@ -29,6 +34,7 @@ import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceContr
2934import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
3035import * as ServerConfig from "../config.ts";
3136import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
37import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";
3238import * as ServerSettings from "../serverSettings.ts";
3339import * as GitManager from "./GitManager.ts";
3440
......@@ -65,38 +71,7 @@ function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput {
6571 };
6672}
6773
68interface FakeGitTextGeneration {
69 generateCommitMessage: (input: {
70 cwd: string;
71 branch: string | null;
72 stagedSummary: string;
73 stagedPatch: string;
74 includeBranch?: boolean;
75 modelSelection: ModelSelection;
76 }) => Effect.Effect<
77 { subject: string; body: string; branch?: string | undefined },
78 TextGenerationError
79 >;
80 generatePrContent: (input: {
81 cwd: string;
82 baseBranch: string;
83 headBranch: string;
84 commitSummary: string;
85 diffSummary: string;
86 diffPatch: string;
87 modelSelection: ModelSelection;
88 }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>;
89 generateBranchName: (input: {
90 cwd: string;
91 message: string;
92 modelSelection: ModelSelection;
93 }) => Effect.Effect<{ branch: string }, TextGenerationError>;
94 generateThreadTitle: (input: {
95 cwd: string;
96 message: string;
97 modelSelection: ModelSelection;
98 }) => Effect.Effect<{ title: string }, TextGenerationError>;
99}
74type FakeGitTextGeneration = TextGeneration.TextGeneration["Service"];
10075
10176type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>;
10277
......@@ -640,6 +615,7 @@ function preparePullRequestThread(
640615function makeManager(input?: {
641616 ghScenario?: FakeGhScenario;
642617 textGeneration?: Partial<FakeGitTextGeneration>;
618 serverSettings?: Parameters<typeof ServerSettings.layerTest>[0];
643619 setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"];
644620}) {
645621 const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario);
......@@ -648,7 +624,7 @@ function makeManager(input?: {
648624 prefix: "t3-git-manager-test-",
649625 });
650626
651 const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest();
627 const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(input?.serverSettings);
652628
653629 const vcsDriverLayer = GitVcsDriver.layer.pipe(
654630 Layer.provideMerge(VcsProcess.layer),
......@@ -672,6 +648,9 @@ function makeManager(input?: {
672648
673649 const managerLayer = Layer.mergeAll(
674650 Layer.succeed(TextGeneration.TextGeneration, textGeneration),
651 Layer.mock(ProviderRegistry.ProviderRegistry)({
652 getProviders: Effect.succeed([]),
653 }),
675654 Layer.succeed(
676655 ProjectSetupScriptRunner.ProjectSetupScriptRunner,
677656 input?.setupScriptRunner ?? {
......@@ -1568,8 +1547,22 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
15681547 const repoDir = yield* makeTempDir("t3code-git-manager-");
15691548 yield* initRepo(repoDir);
15701549 NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n");
1550 let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined;
15711551
1572 const { manager } = yield* makeManager();
1552 const { manager } = yield* makeManager({
1553 serverSettings: {
1554 sourceControlWritingStyle: {
1555 mode: "custom" as const,
1556 customInstructions: "Use a direct tone.",
1557 },
1558 },
1559 textGeneration: {
1560 generateCommitMessage: (input) => {
1561 generatedPolicy = input.policy;
1562 return Effect.succeed({ subject: "Implement stacked git actions", body: "" });
1563 },
1564 },
1565 });
15731566 const result = yield* runStackedAction(manager, {
15741567 cwd: repoDir,
15751568 action: "commit",
......@@ -1579,6 +1572,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
15791572 expect(result.commit.status).toBe("created");
15801573 expect(result.push.status).toBe("skipped_not_requested");
15811574 expect(result.pr.status).toBe("skipped_not_requested");
1575 expect(generatedPolicy).toMatchObject({ commitInstructions: "Use a direct tone." });
15821576 expect(result.toast).toMatchObject({
15831577 description: "Implement stacked git actions",
15841578 cta: {
......@@ -1598,6 +1592,118 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
15981592 }),
15991593 );
16001594
1595 it.effect("preserves custom style when instructions are empty", () =>
1596 Effect.gen(function* () {
1597 const repoDir = yield* makeTempDir("t3code-git-manager-");
1598 yield* initRepo(repoDir);
1599 NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n");
1600 let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined;
1601
1602 const { manager } = yield* makeManager({
1603 serverSettings: {
1604 sourceControlWritingStyle: {
1605 mode: "custom" as const,
1606 customInstructions: "",
1607 },
1608 },
1609 textGeneration: {
1610 generateCommitMessage: (input) => {
1611 generatedPolicy = input.policy;
1612 return Effect.succeed({ subject: "Preserve custom style", body: "" });
1613 },
1614 },
1615 });
1616 yield* runStackedAction(manager, {
1617 cwd: repoDir,
1618 action: "commit",
1619 });
1620
1621 expect(generatedPolicy).toEqual({
1622 kind: "custom",
1623 inferRepositoryConventions: false,
1624 });
1625 }),
1626 );
1627
1628 it.effect("falls back when the dedicated source control writer is unavailable", () =>
1629 Effect.gen(function* () {
1630 const repoDir = yield* makeTempDir("t3code-git-manager-");
1631 yield* initRepo(repoDir);
1632 NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n");
1633 const missingInstanceId = ProviderInstanceId.make("missing_writer");
1634 let generatedModelSelection:
1635 | TextGeneration.CommitMessageGenerationInput["modelSelection"]
1636 | undefined;
1637
1638 const { manager } = yield* makeManager({
1639 serverSettings: {
1640 providerInstances: {
1641 [missingInstanceId]: {
1642 driver: ProviderDriverKind.make("missing-driver"),
1643 config: {},
1644 },
1645 },
1646 sourceControlWriterModelSelection: {
1647 instanceId: missingInstanceId,
1648 model: "missing-model",
1649 },
1650 },
1651 textGeneration: {
1652 generateCommitMessage: (input) => {
1653 generatedModelSelection = input.modelSelection;
1654 return Effect.succeed({ subject: "Use the available writer", body: "" });
1655 },
1656 },
1657 });
1658
1659 yield* runStackedAction(manager, {
1660 cwd: repoDir,
1661 action: "commit",
1662 });
1663
1664 expect(generatedModelSelection).toEqual(DEFAULT_SERVER_SETTINGS.textGenerationModelSelection);
1665 }),
1666 );
1667
1668 it.effect("preserves repository conventions style when recent history is empty", () =>
1669 Effect.gen(function* () {
1670 const repoDir = yield* makeTempDir("t3code-git-manager-");
1671 yield* runGit(repoDir, ["init", "--initial-branch=main"]);
1672 yield* runGit(repoDir, ["config", "user.email", "test@example.com"]);
1673 yield* runGit(repoDir, ["config", "user.name", "Test User"]);
1674 NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\n");
1675 yield* runGit(repoDir, ["add", "README.md"]);
1676 let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined;
1677
1678 const { manager } = yield* makeManager({
1679 serverSettings: {
1680 sourceControlWritingStyle: {
1681 mode: "repo_conventions" as const,
1682 },
1683 },
1684 textGeneration: {
1685 generateCommitMessage: (input) => {
1686 generatedPolicy = input.policy;
1687 return Effect.succeed({ subject: "Create initial commit", body: "" });
1688 },
1689 },
1690 });
1691 yield* runStackedAction(manager, {
1692 cwd: repoDir,
1693 action: "commit",
1694 });
1695
1696 expect(generatedPolicy).toEqual({
1697 kind: "repo_conventions",
1698 commitInstructions:
1699 "Follow the repository's established commit message style when examples are available.",
1700 changeRequestInstructions:
1701 "Follow the repository's established change request title and body style when examples are available.",
1702 inferRepositoryConventions: true,
1703 });
1704 }),
1705 );
1706
16011707 it.effect("uses custom commit message when provided", () =>
16021708 Effect.gen(function* () {
16031709 const repoDir = yield* makeTempDir("t3code-git-manager-");
......@@ -2535,6 +2641,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
25352641 Effect.gen(function* () {
25362642 const repoDir = yield* makeTempDir("t3code-git-manager-");
25372643 yield* initRepo(repoDir);
2644 NodeFS.mkdirSync(NodePath.join(repoDir, ".github"));
2645 NodeFS.writeFileSync(
2646 NodePath.join(repoDir, ".github", "pull_request_template.md"),
2647 "## What changed?\n\n## Verification",
2648 );
2649 yield* runGit(repoDir, ["add", ".github/pull_request_template.md"]);
2650 yield* runGit(repoDir, ["commit", "-m", "Add pull request template"]);
25382651 yield* runGit(repoDir, ["checkout", "-b", "feature-create-pr"]);
25392652 const remoteDir = yield* createBareRemote();
25402653 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
......@@ -2543,8 +2656,26 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
25432656 yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);
25442657 yield* runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]);
25452658 yield* runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]);
2659 let generatedPolicy: TextGeneration.PrContentGenerationInput["policy"] = undefined;
2660 let generatedChangeRequestTemplate: string | undefined;
25462661
25472662 const { manager, ghCalls } = yield* makeManager({
2663 serverSettings: {
2664 sourceControlWritingStyle: {
2665 mode: "custom" as const,
2666 customInstructions: "Lead with user impact.",
2667 },
2668 },
2669 textGeneration: {
2670 generatePrContent: (input) => {
2671 generatedPolicy = input.policy;
2672 generatedChangeRequestTemplate = input.changeRequestTemplate;
2673 return Effect.succeed({
2674 title: "Add stacked git actions",
2675 body: "## What changed?\nAdded stacked git actions.",
2676 });
2677 },
2678 },
25482679 ghScenario: {
25492680 prListSequence: [
25502681 "[]",
......@@ -2569,6 +2700,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
25692700 expect(result.branch.status).toBe("skipped_not_requested");
25702701 expect(result.pr.status).toBe("created");
25712702 expect(result.pr.number).toBe(88);
2703 expect(generatedPolicy).toMatchObject({
2704 changeRequestInstructions: "Lead with user impact.",
2705 });
2706 expect(generatedChangeRequestTemplate).toBe("## What changed?\n\n## Verification");
25722707 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2);
25732708 expect(
25742709 ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")),
apps/server/src/git/GitManager.ts+98-13
......@@ -28,6 +28,7 @@ import {
2828 type VcsStatusRemoteResult,
2929 VcsStatusResult,
3030 ModelSelection,
31 type SourceControlWritingStyleSettings,
3132} from "@t3tools/contracts";
3233import {
3334 detectSourceControlProviderFromGitRemoteUrl,
......@@ -44,12 +45,19 @@ import {
4445
4546import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts";
4647import * as TextGeneration from "../textGeneration/TextGeneration.ts";
48import {
49 conventionalCommitsTextGenerationPolicy,
50 customTextGenerationPolicy,
51 repositoryConventionsTextGenerationPolicy,
52} from "../textGeneration/TextGenerationPresets.ts";
4753import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
54import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";
4855import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts";
4956import * as ServerSettings from "../serverSettings.ts";
5057import type { GitManagerServiceError } from "@t3tools/contracts";
5158import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
5259import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
60import { detectPrTemplate } from "../sourceControl/PrTemplateDetection.ts";
5361import type { ChangeRequest } from "@t3tools/contracts";
5462
5563export interface GitActionProgressReporter {
......@@ -61,6 +69,11 @@ export interface GitRunStackedActionOptions {
6169 readonly progressReporter?: GitActionProgressReporter;
6270}
6371
72interface SourceControlTextGenerationSettings {
73 readonly modelSelection: ModelSelection;
74 readonly style: SourceControlWritingStyleSettings;
75}
76
6477export class GitManager extends Context.Service<
6578 GitManager,
6679 {
......@@ -565,11 +578,58 @@ export const make = Effect.gen(function* () {
565578 const gitCore = yield* GitVcsDriver.GitVcsDriver;
566579 const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
567580 const textGeneration = yield* TextGeneration.TextGeneration;
581 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
568582 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
569583 const crypto = yield* Crypto.Crypto;
570584
571585 const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd });
572586 const serverSettingsService = yield* ServerSettings.ServerSettingsService;
587
588 const readRecentCommitSubjects = (cwd: string) =>
589 gitCore
590 .execute({
591 operation: "GitManager.readRecentCommitSubjects",
592 cwd,
593 args: ["log", "-n", "20", "--no-merges", "--pretty=format:%s"],
594 })
595 .pipe(
596 Effect.map((result) =>
597 result.stdout
598 .split("\n")
599 .map((line) => line.trim())
600 .filter((line) => line.length > 0),
601 ),
602 Effect.orElseSucceed(() => []),
603 );
604
605 const resolveStylePolicy = (cwd: string, style: SourceControlWritingStyleSettings) =>
606 Effect.gen(function* () {
607 switch (style.mode) {
608 case "conventional_commits":
609 return conventionalCommitsTextGenerationPolicy;
610 case "custom":
611 return customTextGenerationPolicy(
612 style.customInstructions
613 ? {
614 commitInstructions: style.customInstructions,
615 changeRequestInstructions: style.customInstructions,
616 }
617 : {},
618 );
619 case "repo_conventions": {
620 const subjects = yield* readRecentCommitSubjects(cwd);
621 if (subjects.length === 0) {
622 return repositoryConventionsTextGenerationPolicy;
623 }
624 const examples = ["Recent commit subjects from this repository:", ...subjects].join("\n");
625 return {
626 ...repositoryConventionsTextGenerationPolicy,
627 commitInstructions: `${repositoryConventionsTextGenerationPolicy.commitInstructions}\n\n${examples}`,
628 changeRequestInstructions: `${repositoryConventionsTextGenerationPolicy.changeRequestInstructions}\n\n${examples}`,
629 };
630 }
631 }
632 });
573633 const randomUUIDv4 = (cwd: string) =>
574634 crypto.randomUUIDv4.pipe(
575635 Effect.mapError(
......@@ -1330,7 +1390,7 @@ export const make = Effect.gen(function* () {
13301390 /** When true, also produce a semantic feature branch name. */
13311391 includeBranch?: boolean;
13321392 filePaths?: readonly string[];
1333 modelSelection: ModelSelection;
1393 settings: SourceControlTextGenerationSettings;
13341394 }) {
13351395 const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths);
13361396 if (!context) {
......@@ -1349,6 +1409,8 @@ export const make = Effect.gen(function* () {
13491409 };
13501410 }
13511411
1412 const policy = yield* resolveStylePolicy(input.cwd, input.settings.style);
1413
13521414 const generated = yield* textGeneration
13531415 .generateCommitMessage({
13541416 cwd: input.cwd,
......@@ -1356,7 +1418,8 @@ export const make = Effect.gen(function* () {
13561418 stagedSummary: limitContext(context.stagedSummary, 8_000),
13571419 stagedPatch: limitContext(context.stagedPatch, 50_000),
13581420 ...(input.includeBranch ? { includeBranch: true } : {}),
1359 modelSelection: input.modelSelection,
1421 ...(policy ? { policy } : {}),
1422 modelSelection: input.settings.modelSelection,
13601423 })
13611424 .pipe(Effect.map((result) => sanitizeCommitMessage(result)));
13621425
......@@ -1370,7 +1433,7 @@ export const make = Effect.gen(function* () {
13701433 );
13711434
13721435 const runCommitStep = Effect.fn("runCommitStep")(function* (
1373 modelSelection: ModelSelection,
1436 settings: SourceControlTextGenerationSettings,
13741437 cwd: string,
13751438 action: "commit" | "commit_push" | "commit_push_pr",
13761439 branch: string | null,
......@@ -1405,7 +1468,7 @@ export const make = Effect.gen(function* () {
14051468 branch,
14061469 ...(commitMessage ? { commitMessage } : {}),
14071470 ...(filePaths ? { filePaths } : {}),
1408 modelSelection,
1471 settings,
14091472 });
14101473 }
14111474 if (!suggestion) {
......@@ -1483,7 +1546,7 @@ export const make = Effect.gen(function* () {
14831546 });
14841547
14851548 const runPrStep = Effect.fn("runPrStep")(function* (
1486 modelSelection: ModelSelection,
1549 settings: SourceControlTextGenerationSettings,
14871550 cwd: string,
14881551 fallbackBranch: string | null,
14891552 emit: GitActionProgressEmitter,
......@@ -1532,6 +1595,11 @@ export const make = Effect.gen(function* () {
15321595 });
15331596 const baseRangeRef = yield* resolveBaseRangeRef(cwd, baseBranch);
15341597 const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef);
1598 const policy = yield* resolveStylePolicy(cwd, settings.style);
1599 const changeRequestTemplate =
1600 settings.style.followChangeRequestTemplates && provider.kind === "github"
1601 ? Option.getOrUndefined(yield* detectPrTemplate(cwd, baseRangeRef, gitCore.execute))
1602 : undefined;
15351603
15361604 const generated = yield* textGeneration.generatePrContent({
15371605 cwd,
......@@ -1540,7 +1608,9 @@ export const make = Effect.gen(function* () {
15401608 commitSummary: limitContext(rangeContext.commitSummary, 20_000),
15411609 diffSummary: limitContext(rangeContext.diffSummary, 20_000),
15421610 diffPatch: limitContext(rangeContext.diffPatch, 60_000),
1543 modelSelection,
1611 ...(changeRequestTemplate ? { changeRequestTemplate } : {}),
1612 ...(policy ? { policy } : {}),
1613 modelSelection: settings.modelSelection,
15441614 });
15451615
15461616 const bodyFile = path.join(
......@@ -1820,7 +1890,7 @@ export const make = Effect.gen(function* () {
18201890 });
18211891
18221892 const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* (
1823 modelSelection: ModelSelection,
1893 settings: SourceControlTextGenerationSettings,
18241894 cwd: string,
18251895 branch: string | null,
18261896 commitMessage?: string,
......@@ -1832,7 +1902,7 @@ export const make = Effect.gen(function* () {
18321902 ...(commitMessage ? { commitMessage } : {}),
18331903 ...(filePaths ? { filePaths } : {}),
18341904 includeBranch: true,
1835 modelSelection,
1905 settings,
18361906 });
18371907 if (!suggestion) {
18381908 return yield* new GitManagerError({
......@@ -1921,8 +1991,23 @@ export const make = Effect.gen(function* () {
19211991 let commitMessageForStep = input.commitMessage;
19221992 let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined;
19231993
1924 const modelSelection = yield* serverSettingsService.getSettings.pipe(
1925 Effect.map((settings) => settings.textGenerationModelSelection),
1994 const textGenerationSettings = yield* serverSettingsService.getSettings.pipe(
1995 Effect.flatMap((settings) =>
1996 settings.sourceControlWriterModelSelection === null
1997 ? Effect.succeed({
1998 modelSelection: settings.textGenerationModelSelection,
1999 style: settings.sourceControlWritingStyle,
2000 })
2001 : providerRegistry.getProviders.pipe(
2002 Effect.map((providers) => ({
2003 modelSelection: ServerSettings.resolveSourceControlWriterModelSelection(
2004 settings,
2005 providers,
2006 ),
2007 style: settings.sourceControlWritingStyle,
2008 })),
2009 ),
2010 ),
19262011 Effect.mapError(
19272012 (cause) =>
19282013 new GitManagerError({
......@@ -1942,7 +2027,7 @@ export const make = Effect.gen(function* () {
19422027 label: "Preparing feature branch...",
19432028 });
19442029 const result = yield* runFeatureBranchStep(
1945 modelSelection,
2030 textGenerationSettings,
19462031 input.cwd,
19472032 initialStatus.branch,
19482033 input.commitMessage,
......@@ -1968,7 +2053,7 @@ export const make = Effect.gen(function* () {
19682053 ? yield* Ref.set(currentPhase, Option.some("commit")).pipe(
19692054 Effect.flatMap(() =>
19702055 runCommitStep(
1971 modelSelection,
2056 textGenerationSettings,
19722057 input.cwd,
19732058 commitAction,
19742059 currentBranch,
......@@ -2005,7 +2090,7 @@ export const make = Effect.gen(function* () {
20052090 .pipe(
20062091 Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))),
20072092 Effect.flatMap(() =>
2008 runPrStep(modelSelection, input.cwd, currentBranch, progress.emit),
2093 runPrStep(textGenerationSettings, input.cwd, currentBranch, progress.emit),
20092094 ),
20102095 )
20112096 : { status: "skipped_not_requested" as const };
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+12-3
......@@ -38,7 +38,10 @@ import {
3838 ProviderCommandReactor,
3939 type ProviderCommandReactorShape,
4040} from "../Services/ProviderCommandReactor.ts";
41import { ServerSettingsService } from "../../serverSettings.ts";
41import {
42 resolveSourceControlWriterModelSelection,
43 ServerSettingsService,
44} from "../../serverSettings.ts";
4245import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
4346import { GitWorkflowService } from "../../git/GitWorkflowService.ts";
4447const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);
......@@ -688,8 +691,14 @@ const make = Effect.gen(function* () {
688691 const cwd = input.worktreePath;
689692 const attachments = input.attachments ?? [];
690693 yield* Effect.gen(function* () {
691 const { textGenerationModelSelection: modelSelection } =
692 yield* serverSettingsService.getSettings;
694 const settings = yield* serverSettingsService.getSettings;
695 const modelSelection =
696 settings.sourceControlWriterModelSelection === null
697 ? settings.textGenerationModelSelection
698 : resolveSourceControlWriterModelSelection(
699 settings,
700 yield* providerRegistry.getProviders,
701 );
693702
694703 const generated = yield* textGeneration.generateBranchName({
695704 cwd,
apps/server/src/serverSettings.test.ts+67
......@@ -325,6 +325,73 @@ it.layer(NodeServices.layer)("server settings", (it) => {
325325 }).pipe(Effect.provide(makeServerSettingsLayer())),
326326 );
327327
328 it.effect(
329 "preserves the source control writer selection when its provider instance is disabled",
330 () =>
331 Effect.gen(function* () {
332 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
333 const serverConfig = yield* ServerConfig.ServerConfig;
334 const fileSystem = yield* FileSystem.FileSystem;
335 const instanceId = ProviderInstanceId.make("codex_writer");
336 const sourceControlWriterModelSelection = {
337 instanceId,
338 model: "gpt-5.4-mini",
339 };
340
341 yield* serverSettings.updateSettings({
342 providerInstances: {
343 [instanceId]: {
344 driver: ProviderDriverKind.make("codex"),
345 enabled: true,
346 config: {},
347 },
348 },
349 sourceControlWriterModelSelection,
350 });
351
352 const next = yield* serverSettings.updateSettings({
353 providerInstances: {
354 [instanceId]: {
355 driver: ProviderDriverKind.make("codex"),
356 enabled: false,
357 config: {},
358 },
359 },
360 });
361
362 assert.deepEqual(next.sourceControlWriterModelSelection, sourceControlWriterModelSelection);
363 assert.deepEqual(
364 ServerSettingsModule.resolveSourceControlWriterModelSelection(next),
365 next.textGenerationModelSelection,
366 );
367 assert.deepEqual(
368 (yield* serverSettings.getSettings).sourceControlWriterModelSelection,
369 sourceControlWriterModelSelection,
370 );
371
372 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
373 assert.deepEqual(
374 // @effect-diagnostics-next-line preferSchemaOverJson:off
375 JSON.parse(raw).sourceControlWriterModelSelection,
376 sourceControlWriterModelSelection,
377 );
378
379 const restored = yield* serverSettings.updateSettings({
380 providerInstances: {
381 [instanceId]: {
382 driver: ProviderDriverKind.make("codex"),
383 enabled: true,
384 config: {},
385 },
386 },
387 });
388 assert.deepEqual(
389 ServerSettingsModule.resolveSourceControlWriterModelSelection(restored),
390 sourceControlWriterModelSelection,
391 );
392 }).pipe(Effect.provide(makeServerSettingsLayer())),
393 );
394
328395 it.effect("drops stale text generation options when resetting model selection", () =>
329396 Effect.gen(function* () {
330397 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
apps/server/src/serverSettings.ts+16-35
......@@ -11,11 +11,10 @@
1111 * @module ServerSettings
1212 */
1313import {
14 DEFAULT_GIT_TEXT_GENERATION_MODEL,
15 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
14 DEFAULT_TEXT_GENERATION_MODEL,
15 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER,
1616 DEFAULT_MODEL_BY_PROVIDER,
1717 DEFAULT_SERVER_SETTINGS,
18 isProviderDriverKind,
1918 type ModelSelection,
2019 type ProviderInstanceConfig,
2120 type ProviderInstanceEnvironmentVariable,
......@@ -47,9 +46,14 @@ import { writeFileStringAtomically } from "./atomicWrite.ts";
4746import * as ServerConfig from "./config.ts";
4847import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
4948import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";
50import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
49import {
50 applyServerSettingsPatch,
51 isModelSelectionProviderEnabled,
52} from "@t3tools/shared/serverSettings";
5153import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
5254
55export { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings";
56
5357const encodeServerSettings = Schema.encodeEffect(ServerSettings);
5458const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings));
5559const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings);
......@@ -148,12 +152,13 @@ const makeTest = (overrides: DeepPartial<ServerSettings> = {}) =>
148152 return {
149153 start: Effect.void,
150154 ready: Effect.void,
151 getSettings: Ref.get(currentSettingsRef),
155 getSettings: Ref.get(currentSettingsRef).pipe(Effect.map(resolveTextGenerationProvider)),
152156 updateSettings: (patch) =>
153157 Ref.get(currentSettingsRef).pipe(
154158 Effect.map((currentSettings) => applyServerSettingsPatch(currentSettings, patch)),
155159 Effect.flatMap(normalizeServerSettings),
156160 Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)),
161 Effect.map(resolveTextGenerationProvider),
157162 ),
158163 streamChanges: Stream.empty,
159164 } satisfies ServerSettingsService["Service"];
......@@ -165,35 +170,10 @@ export const layerTest = (overrides: DeepPartial<ServerSettings> = {}) =>
165170const ServerSettingsJson = fromLenientJson(ServerSettings);
166171const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson);
167172
168type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]];
169
170const getLegacyProviderSettings = (
171 settings: ServerSettings,
172 provider: ProviderDriverKind,
173): LegacyProviderSettings | undefined =>
174 (settings.providers as Record<string, LegacyProviderSettings | undefined>)[provider];
175
176/**
177 * Ensure the `textGenerationModelSelection` points to an enabled provider.
178 * If the selected provider is disabled, fall back to the first enabled
179 * provider with its default model. This is applied at read-time so the
180 * persisted preference is preserved for when a provider is re-enabled.
181 */
182173function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings {
183 const selection = settings.textGenerationModelSelection;
184 const instanceConfig = settings.providerInstances[selection.instanceId];
185 if (instanceConfig !== undefined) {
186 return (instanceConfig.enabled ?? true) ? settings : fallbackTextGenerationProvider(settings);
187 }
188
189 if (
190 isProviderDriverKind(selection.instanceId) &&
191 getLegacyProviderSettings(settings, selection.instanceId)?.enabled
192 ) {
193 return settings;
194 }
195
196 return fallbackTextGenerationProvider(settings);
174 return isModelSelectionProviderEnabled(settings, settings.textGenerationModelSelection)
175 ? settings
176 : fallbackTextGenerationProvider(settings);
197177}
198178
199179function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings {
......@@ -208,9 +188,9 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting
208188 textGenerationModelSelection: {
209189 instanceId: ProviderInstanceId.make(fallback),
210190 model:
211 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ??
191 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ??
212192 DEFAULT_MODEL_BY_PROVIDER[fallback] ??
213 DEFAULT_GIT_TEXT_GENERATION_MODEL,
193 DEFAULT_TEXT_GENERATION_MODEL,
214194 } satisfies ModelSelection,
215195 };
216196}
......@@ -218,6 +198,7 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting
218198// Values under these keys are compared as a whole — never stripped field-by-field.
219199const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([
220200 "automaticGitFetchInterval",
201 "sourceControlWriterModelSelection",
221202 "textGenerationModelSelection",
222203]);
223204
apps/server/src/sourceControl/PrTemplateDetection.test.ts created+248
......@@ -0,0 +1,248 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8
9import { ServerConfig } from "../config.ts";
10import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
11import * as VcsProcess from "../vcs/VcsProcess.ts";
12import { detectPrTemplate } from "./PrTemplateDetection.ts";
13
14const SINGLE_TEMPLATE_PATHS = [
15 ".github/pull_request_template.md",
16 ".github/PULL_REQUEST_TEMPLATE.md",
17 "pull_request_template.md",
18 "PULL_REQUEST_TEMPLATE.md",
19 "docs/pull_request_template.md",
20 "docs/PULL_REQUEST_TEMPLATE.md",
21] as const;
22
23const TEMPLATE_DIRECTORIES = [
24 ".github/PULL_REQUEST_TEMPLATE",
25 "PULL_REQUEST_TEMPLATE",
26 "docs/PULL_REQUEST_TEMPLATE",
27] as const;
28
29const PrTemplateDetectionTestLayer = GitVcsDriver.layer.pipe(
30 Layer.provide(
31 ServerConfig.layerTest(process.cwd(), {
32 prefix: "t3-pr-template-test-",
33 }),
34 ),
35 Layer.provideMerge(VcsProcess.layer),
36 Layer.provideMerge(NodeServices.layer),
37);
38
39const runGit = (cwd: string, args: ReadonlyArray<string>) =>
40 Effect.gen(function* () {
41 const git = yield* GitVcsDriver.GitVcsDriver;
42 return yield* git.execute({
43 operation: "PrTemplateDetection.test.runGit",
44 cwd,
45 args,
46 });
47 });
48
49const runWithTempDirectory = <A, E, R>(
50 test: (cwd: string) => Effect.Effect<A, E, R | FileSystem.FileSystem | Path.Path>,
51) =>
52 Effect.scoped(
53 Effect.gen(function* () {
54 const fileSystem = yield* FileSystem.FileSystem;
55 const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-pr-template-" });
56 yield* runGit(cwd, ["init", "--initial-branch=main"]);
57 yield* runGit(cwd, ["config", "user.email", "test@example.com"]);
58 yield* runGit(cwd, ["config", "user.name", "Test User"]);
59 return yield* test(cwd);
60 }),
61 ).pipe(Effect.provide(PrTemplateDetectionTestLayer));
62
63const writeTemplate = (cwd: string, relativePath: string, contents: string) =>
64 Effect.gen(function* () {
65 const fileSystem = yield* FileSystem.FileSystem;
66 const path = yield* Path.Path;
67 const templatePath = path.join(cwd, relativePath);
68 yield* fileSystem.makeDirectory(path.dirname(templatePath), { recursive: true });
69 yield* fileSystem.writeFileString(templatePath, contents);
70 return templatePath;
71 });
72
73const commitTemplates = (cwd: string) =>
74 Effect.gen(function* () {
75 yield* runGit(cwd, ["add", "-A"]);
76 yield* runGit(cwd, ["commit", "--allow-empty", "-m", "Add pull request templates"]);
77 });
78
79const detectTemplate = (cwd: string, treeish = "HEAD") =>
80 Effect.gen(function* () {
81 const git = yield* GitVcsDriver.GitVcsDriver;
82 return yield* detectPrTemplate(cwd, treeish, git.execute);
83 });
84
85it.effect.each(SINGLE_TEMPLATE_PATHS)("recognizes $0", (relativePath) =>
86 runWithTempDirectory((cwd) =>
87 Effect.gen(function* () {
88 yield* writeTemplate(cwd, relativePath, `template from ${relativePath}`);
89 yield* commitTemplates(cwd);
90
91 const template = yield* detectTemplate(cwd);
92 assert.strictEqual(Option.getOrUndefined(template), `template from ${relativePath}`);
93 }),
94 ),
95);
96
97it.effect("reads templates from the requested base tree", () =>
98 runWithTempDirectory((cwd) =>
99 Effect.gen(function* () {
100 yield* writeTemplate(cwd, "README.md", "initial\n");
101 yield* commitTemplates(cwd);
102 yield* runGit(cwd, ["branch", "feature"]);
103 yield* writeTemplate(cwd, ".github/pull_request_template.md", "base template");
104 yield* commitTemplates(cwd);
105 yield* runGit(cwd, ["checkout", "feature"]);
106
107 assert.isTrue(Option.isNone(yield* detectTemplate(cwd)));
108 assert.strictEqual(
109 Option.getOrUndefined(yield* detectTemplate(cwd, "main")),
110 "base template",
111 );
112 }),
113 ),
114);
115
116it.effect("uses the first non-empty template in the configured path order", () =>
117 runWithTempDirectory((cwd) =>
118 Effect.gen(function* () {
119 yield* writeTemplate(cwd, ".github/pull_request_template.md", " \n");
120 yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE.md", " ## Preferred template \n");
121 yield* writeTemplate(cwd, "pull_request_template.md", "## Later template");
122 yield* commitTemplates(cwd);
123
124 const template = yield* detectTemplate(cwd);
125 assert.strictEqual(Option.getOrUndefined(template), "## Preferred template");
126 }),
127 ),
128);
129
130it.effect.each(TEMPLATE_DIRECTORIES)("recognizes the $0 directory", (relativeDirectory) =>
131 runWithTempDirectory((cwd) =>
132 Effect.gen(function* () {
133 yield* writeTemplate(cwd, `${relativeDirectory}/template.MD`, "directory template");
134 yield* commitTemplates(cwd);
135
136 const template = yield* detectTemplate(cwd);
137 assert.strictEqual(Option.getOrUndefined(template), "directory template");
138 }),
139 ),
140);
141
142it.effect("skips unusable directory entries and uses the one valid template", () =>
143 runWithTempDirectory((cwd) =>
144 Effect.gen(function* () {
145 const fileSystem = yield* FileSystem.FileSystem;
146 const path = yield* Path.Path;
147 const templateDirectory = path.join(cwd, ".github", "PULL_REQUEST_TEMPLATE");
148 yield* fileSystem.makeDirectory(path.join(templateDirectory, "b-directory.md"), {
149 recursive: true,
150 });
151 yield* fileSystem.writeFileString(path.join(templateDirectory, "a-empty.md"), " \n");
152 yield* fileSystem.symlink(
153 path.join(templateDirectory, "missing.md"),
154 path.join(templateDirectory, "c-broken.md"),
155 );
156 yield* fileSystem.writeFileString(path.join(templateDirectory, "z-valid.md"), "valid");
157 yield* commitTemplates(cwd);
158
159 const template = yield* detectTemplate(cwd);
160 assert.strictEqual(Option.getOrUndefined(template), "valid");
161 }),
162 ),
163);
164
165it.effect("does not guess between multiple directory templates", () =>
166 runWithTempDirectory((cwd) =>
167 Effect.gen(function* () {
168 yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE/a.md", "first");
169 yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE/b.md", "second");
170 yield* writeTemplate(cwd, "PULL_REQUEST_TEMPLATE/fallback.md", "fallback");
171 yield* commitTemplates(cwd);
172
173 const template = yield* detectTemplate(cwd);
174 assert.isTrue(Option.isNone(template));
175 }),
176 ),
177);
178
179it.effect("rejects a committed template symlink escaping the repository", () =>
180 runWithTempDirectory((cwd) =>
181 Effect.gen(function* () {
182 const fileSystem = yield* FileSystem.FileSystem;
183 const path = yield* Path.Path;
184 const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({
185 prefix: "t3-pr-template-outside-",
186 });
187 const outsideTemplate = path.join(outsideDirectory, "secret.md");
188 yield* fileSystem.writeFileString(outsideTemplate, "LOCAL_SECRET_SENTINEL");
189 const escapedTemplatePath = path.join(cwd, ".github", "pull_request_template.md");
190 yield* fileSystem.makeDirectory(path.dirname(escapedTemplatePath), { recursive: true });
191 yield* fileSystem.symlink(outsideTemplate, escapedTemplatePath);
192 yield* writeTemplate(cwd, "pull_request_template.md", "safe template");
193 yield* commitTemplates(cwd);
194
195 const template = yield* detectTemplate(cwd);
196 assert.strictEqual(Option.getOrUndefined(template), "safe template");
197 assert.notInclude(
198 Option.getOrElse(template, () => ""),
199 "LOCAL_SECRET_SENTINEL",
200 );
201 }),
202 ),
203);
204
205it.effect("reads the committed template when a worktree parent is replaced", () =>
206 runWithTempDirectory((cwd) =>
207 Effect.gen(function* () {
208 const fileSystem = yield* FileSystem.FileSystem;
209 const path = yield* Path.Path;
210 const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({
211 prefix: "t3-pr-template-outside-",
212 });
213 const templatePath = yield* writeTemplate(
214 cwd,
215 ".github/pull_request_template.md",
216 "committed template",
217 );
218 yield* commitTemplates(cwd);
219 yield* writeTemplate(outsideDirectory, "pull_request_template.md", "LOCAL_SECRET_SENTINEL");
220
221 const templateDirectory = path.dirname(templatePath);
222 yield* fileSystem.rename(templateDirectory, path.join(cwd, ".github-original"));
223 yield* fileSystem.symlink(outsideDirectory, templateDirectory);
224
225 const template = yield* detectTemplate(cwd);
226 assert.strictEqual(Option.getOrUndefined(template), "committed template");
227 assert.notInclude(
228 Option.getOrElse(template, () => ""),
229 "LOCAL_SECRET_SENTINEL",
230 );
231 }),
232 ),
233);
234
235it.effect("bounds template reads and marks truncated content", () =>
236 runWithTempDirectory((cwd) =>
237 Effect.gen(function* () {
238 const prefix = "a".repeat(8_000);
239 yield* writeTemplate(cwd, ".github/pull_request_template.md", `${prefix}SECRET_SENTINEL`);
240 yield* commitTemplates(cwd);
241
242 const template = Option.getOrThrow(yield* detectTemplate(cwd));
243 assert.strictEqual(template, `${prefix}\n\n[truncated]`);
244 assert.lengthOf(template.match(/\[truncated\]/g) ?? [], 1);
245 assert.notInclude(template, "SECRET_SENTINEL");
246 }),
247 ),
248);
apps/server/src/sourceControl/PrTemplateDetection.ts created+177
......@@ -0,0 +1,177 @@
1import type { GitCommandError } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4
5import type * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
6
7const TEMPLATE_MAX_BYTES = 8_000;
8const TREE_LIST_MAX_BYTES = 100_000;
9const TRUNCATION_MARKER = "[truncated]";
10
11const TEMPLATE_PATHS = [
12 ".github/pull_request_template.md",
13 ".github/PULL_REQUEST_TEMPLATE.md",
14 "pull_request_template.md",
15 "PULL_REQUEST_TEMPLATE.md",
16 "docs/pull_request_template.md",
17 "docs/PULL_REQUEST_TEMPLATE.md",
18] as const;
19
20const TEMPLATE_DIRECTORIES = [
21 ".github/PULL_REQUEST_TEMPLATE",
22 "PULL_REQUEST_TEMPLATE",
23 "docs/PULL_REQUEST_TEMPLATE",
24] as const;
25
26const TREE_PATHS = [...TEMPLATE_PATHS, ...TEMPLATE_DIRECTORIES] as const;
27
28type ExecuteGit = GitVcsDriver.GitVcsDriver["Service"]["execute"];
29
30interface TemplateTreeEntry {
31 readonly objectId: string;
32 readonly path: string;
33}
34
35function parseTemplateTreeEntries(output: string): ReadonlyArray<TemplateTreeEntry> {
36 const entries: TemplateTreeEntry[] = [];
37 for (const record of output.split("\0")) {
38 if (record.length === 0) {
39 continue;
40 }
41
42 const separator = record.indexOf("\t");
43 if (separator < 0) {
44 continue;
45 }
46
47 const [mode, type, objectId] = record.slice(0, separator).split(" ");
48 if (
49 type !== "blob" ||
50 (mode !== "100644" && mode !== "100755") ||
51 !objectId ||
52 !/^[0-9a-f]{40,64}$/.test(objectId)
53 ) {
54 continue;
55 }
56
57 entries.push({ objectId, path: record.slice(separator + 1) });
58 }
59 return entries;
60}
61
62function readTemplateBlob(input: {
63 readonly cwd: string;
64 readonly executeGit: ExecuteGit;
65 readonly entry: TemplateTreeEntry;
66}): Effect.Effect<Option.Option<string>, GitCommandError> {
67 return input
68 .executeGit({
69 operation: "PrTemplateDetection.readTemplateBlob",
70 cwd: input.cwd,
71 args: ["cat-file", "blob", input.entry.objectId],
72 maxOutputBytes: TEMPLATE_MAX_BYTES,
73 appendTruncationMarker: true,
74 })
75 .pipe(
76 Effect.map((result) => {
77 const template = result.stdout.trim();
78 if (template.length === 0) {
79 return Option.none();
80 }
81 return Option.some(
82 result.stdoutTruncated && !template.endsWith(TRUNCATION_MARKER)
83 ? `${template}\n\n${TRUNCATION_MARKER}`
84 : template,
85 );
86 }),
87 );
88}
89
90type DirectoryTemplateResult =
91 | { readonly _tag: "None" }
92 | { readonly _tag: "Ambiguous" }
93 | { readonly _tag: "Template"; readonly template: string };
94
95function readTemplateDirectory(input: {
96 readonly cwd: string;
97 readonly executeGit: ExecuteGit;
98 readonly entries: ReadonlyArray<TemplateTreeEntry>;
99 readonly directory: string;
100}): Effect.Effect<DirectoryTemplateResult, GitCommandError> {
101 return Effect.gen(function* () {
102 const prefix = `${input.directory}/`;
103 const candidates = input.entries.filter((entry) => {
104 if (!entry.path.startsWith(prefix)) {
105 return false;
106 }
107 const relativePath = entry.path.slice(prefix.length);
108 return !relativePath.includes("/") && relativePath.toLowerCase().endsWith(".md");
109 });
110
111 const templates: string[] = [];
112 for (const entry of candidates) {
113 const template = yield* readTemplateBlob({ ...input, entry });
114 if (Option.isSome(template)) {
115 templates.push(template.value);
116 if (templates.length > 1) {
117 return { _tag: "Ambiguous" } as const;
118 }
119 }
120 }
121
122 return templates[0]
123 ? ({ _tag: "Template", template: templates[0] } as const)
124 : ({ _tag: "None" } as const);
125 });
126}
127
128export const detectPrTemplate = Effect.fn("detectPrTemplate")(function* (
129 cwd: string,
130 treeish: string,
131 executeGit: ExecuteGit,
132) {
133 return yield* Effect.gen(function* () {
134 // Worktree paths can be replaced between validation and open. Read regular blobs from the
135 // committed base tree so repository-controlled symlinks and path races never reach the host filesystem.
136 const result = yield* executeGit({
137 operation: "PrTemplateDetection.listTemplates",
138 cwd,
139 args: ["ls-tree", "-r", "-z", "--full-tree", treeish, "--", ...TREE_PATHS],
140 maxOutputBytes: TREE_LIST_MAX_BYTES,
141 appendTruncationMarker: true,
142 });
143 if (result.stdoutTruncated) {
144 return Option.none();
145 }
146
147 const entries = parseTemplateTreeEntries(result.stdout);
148 const entriesByPath = new Map(entries.map((entry) => [entry.path, entry]));
149 for (const templatePath of TEMPLATE_PATHS) {
150 const entry = entriesByPath.get(templatePath);
151 if (!entry) {
152 continue;
153 }
154 const template = yield* readTemplateBlob({ cwd, executeGit, entry });
155 if (Option.isSome(template)) {
156 return template;
157 }
158 }
159
160 for (const directory of TEMPLATE_DIRECTORIES) {
161 const directoryTemplate = yield* readTemplateDirectory({
162 cwd,
163 executeGit,
164 entries,
165 directory,
166 });
167 if (directoryTemplate._tag === "Template") {
168 return Option.some(directoryTemplate.template);
169 }
170 if (directoryTemplate._tag === "Ambiguous") {
171 return Option.none();
172 }
173 }
174
175 return Option.none();
176 }).pipe(Effect.orElseSucceed(() => Option.none()));
177});
apps/server/src/textGeneration/ClaudeTextGeneration.ts+3
......@@ -272,6 +272,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
272272 stagedSummary: input.stagedSummary,
273273 stagedPatch: input.stagedPatch,
274274 includeBranch: input.includeBranch === true,
275 policy: input.policy,
275276 });
276277
277278 const generated = yield* runClaudeJson({
......@@ -299,6 +300,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
299300 commitSummary: input.commitSummary,
300301 diffSummary: input.diffSummary,
301302 diffPatch: input.diffPatch,
303 policy: input.policy,
304 changeRequestTemplate: input.changeRequestTemplate,
302305 });
303306
304307 const generated = yield* runClaudeJson({
apps/server/src/textGeneration/CodexTextGeneration.ts+3
......@@ -305,6 +305,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
305305 stagedSummary: input.stagedSummary,
306306 stagedPatch: input.stagedPatch,
307307 includeBranch: input.includeBranch === true,
308 policy: input.policy,
308309 });
309310
310311 const generated = yield* runCodexJson({
......@@ -332,6 +333,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
332333 commitSummary: input.commitSummary,
333334 diffSummary: input.diffSummary,
334335 diffPatch: input.diffPatch,
336 policy: input.policy,
337 changeRequestTemplate: input.changeRequestTemplate,
335338 });
336339
337340 const generated = yield* runCodexJson({
apps/server/src/textGeneration/CursorTextGeneration.ts+3
......@@ -172,6 +172,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu
172172 stagedSummary: input.stagedSummary,
173173 stagedPatch: input.stagedPatch,
174174 includeBranch: input.includeBranch === true,
175 policy: input.policy,
175176 });
176177
177178 const generated = yield* runCursorJson({
......@@ -199,6 +200,8 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu
199200 commitSummary: input.commitSummary,
200201 diffSummary: input.diffSummary,
201202 diffPatch: input.diffPatch,
203 policy: input.policy,
204 changeRequestTemplate: input.changeRequestTemplate,
202205 });
203206
204207 const generated = yield* runCursorJson({
apps/server/src/textGeneration/GrokTextGeneration.ts+3
......@@ -164,6 +164,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi
164164 stagedSummary: input.stagedSummary,
165165 stagedPatch: input.stagedPatch,
166166 includeBranch: input.includeBranch === true,
167 policy: input.policy,
167168 });
168169
169170 const generated = yield* runGrokJson({
......@@ -191,6 +192,8 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi
191192 commitSummary: input.commitSummary,
192193 diffSummary: input.diffSummary,
193194 diffPatch: input.diffPatch,
195 policy: input.policy,
196 changeRequestTemplate: input.changeRequestTemplate,
194197 });
195198
196199 const generated = yield* runGrokJson({
apps/server/src/textGeneration/OpenCodeTextGeneration.ts+3
......@@ -530,6 +530,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"
530530 stagedSummary: input.stagedSummary,
531531 stagedPatch: input.stagedPatch,
532532 includeBranch: input.includeBranch === true,
533 policy: input.policy,
533534 });
534535 const generated = yield* runOpenCodeJson({
535536 operation: "generateCommitMessage",
......@@ -556,6 +557,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"
556557 commitSummary: input.commitSummary,
557558 diffSummary: input.diffSummary,
558559 diffPatch: input.diffPatch,
560 policy: input.policy,
561 changeRequestTemplate: input.changeRequestTemplate,
559562 });
560563 const generated = yield* runOpenCodeJson({
561564 operation: "generatePrContent",
apps/server/src/textGeneration/TextGeneration.ts+6-2
......@@ -6,6 +6,7 @@ import { TextGenerationError } from "@t3tools/contracts";
66
77import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";
88import type { ProviderInstance } from "../provider/ProviderDriver.ts";
9import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts";
910
1011export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode";
1112
......@@ -16,6 +17,7 @@ export interface CommitMessageGenerationInput {
1617 stagedPatch: string;
1718 /** When true, the model also returns a semantic branch name for the change. */
1819 includeBranch?: boolean;
20 policy?: TextGenerationPolicy | undefined;
1921 /** What model and provider to use for generation. */
2022 modelSelection: ModelSelection;
2123}
......@@ -34,6 +36,8 @@ export interface PrContentGenerationInput {
3436 commitSummary: string;
3537 diffSummary: string;
3638 diffPatch: string;
39 changeRequestTemplate?: string | undefined;
40 policy?: TextGenerationPolicy | undefined;
3741 /** What model and provider to use for generation. */
3842 modelSelection: ModelSelection;
3943}
......@@ -77,7 +81,7 @@ export interface TextGenerationService {
7781}
7882
7983/**
80 * TextGeneration - Service tag for commit and PR text generation.
84 * TextGeneration - Service tag for commit and change request text generation.
8185 */
8286export class TextGeneration extends Context.Service<
8387 TextGeneration,
......@@ -90,7 +94,7 @@ export class TextGeneration extends Context.Service<
9094 ) => Effect.Effect<CommitMessageGenerationResult, TextGenerationError>;
9195
9296 /**
93 * Generate pull request title/body from branch and diff context.
97 * Generate change request title/body from branch and diff context.
9498 */
9599 readonly generatePrContent: (
96100 input: PrContentGenerationInput,
apps/server/src/textGeneration/TextGenerationPrompts.test.ts+41
......@@ -49,6 +49,23 @@ describe("buildCommitMessagePrompt", () => {
4949
5050 expect(result.prompt).toContain("Branch: (detached)");
5151 });
52
53 it("includes policy instructions", () => {
54 const result = buildCommitMessagePrompt({
55 branch: "main",
56 stagedSummary: "M a.ts",
57 stagedPatch: "diff",
58 includeBranch: false,
59 policy: {
60 kind: "custom",
61 commitInstructions: "Use a terse repository-specific subject.",
62 inferRepositoryConventions: false,
63 },
64 });
65
66 expect(result.prompt).toContain("Additional instructions:");
67 expect(result.prompt).toContain("Use a terse repository-specific subject.");
68 });
5269});
5370
5471describe("buildPrContentPrompt", () => {
......@@ -69,6 +86,30 @@ describe("buildPrContentPrompt", () => {
6986 expect(result.prompt).toContain("3 files changed");
7087 expect(result.prompt).toContain("Diff patch:");
7188 expect(result.prompt).toContain("export function login()");
89 expect(result.prompt).toContain("include headings '## Summary' and '## Testing'");
90 });
91
92 it("follows a repository PR template instead of the default body headings", () => {
93 const result = buildPrContentPrompt({
94 baseBranch: "main",
95 headBranch: "feature/auth",
96 commitSummary: "feat: add login page",
97 diffSummary: "3 files changed",
98 diffPatch: "diff",
99 changeRequestTemplate: "<!-- remove me -->\n## What changed\n\n## Verification",
100 policy: {
101 kind: "custom",
102 changeRequestInstructions: "Keep the title in sentence case.",
103 inferRepositoryConventions: false,
104 },
105 });
106
107 expect(result.prompt).toContain("Keep the title in sentence case.");
108 expect(result.prompt).toContain("follow the repository change request template structure");
109 expect(result.prompt).toContain("drop HTML comments from the template");
110 expect(result.prompt).toContain("Repository change request template:");
111 expect(result.prompt).toContain("<!-- remove me -->\n## What changed\n\n## Verification");
112 expect(result.prompt).not.toContain("include headings '## Summary' and '## Testing'");
72113 });
73114});
74115
apps/server/src/textGeneration/TextGenerationPrompts.ts+22-7
......@@ -25,12 +25,12 @@ export interface CommitMessagePromptInput {
2525 branch: string | null;
2626 stagedSummary: string;
2727 stagedPatch: string;
28 includeBranch: boolean;
28 includeBranch?: boolean;
2929 policy?: TextGenerationPolicy | undefined;
3030}
3131
3232export function buildCommitMessagePrompt(input: CommitMessagePromptInput) {
33 const wantsBranch = input.includeBranch;
33 const wantsBranch = input.includeBranch === true;
3434
3535 const prompt = [
3636 "You write concise git commit messages.",
......@@ -76,7 +76,7 @@ export function buildCommitMessagePrompt(input: CommitMessagePromptInput) {
7676}
7777
7878// ---------------------------------------------------------------------------
79// PR content
79// Change request content
8080// ---------------------------------------------------------------------------
8181
8282export interface PrContentPromptInput {
......@@ -85,19 +85,34 @@ export interface PrContentPromptInput {
8585 commitSummary: string;
8686 diffSummary: string;
8787 diffPatch: string;
88 changeRequestTemplate?: string | undefined;
8889 policy?: TextGenerationPolicy | undefined;
8990}
9091
9192export function buildPrContentPrompt(input: PrContentPromptInput) {
93 const changeRequestTemplate = input.changeRequestTemplate?.trim();
94 const bodyRules = changeRequestTemplate
95 ? [
96 "- body must be markdown and follow the repository change request template structure",
97 "- fill in the template sections appropriately for this change",
98 "- drop HTML comments from the template in the generated body",
99 "- keep the template's markdown structure",
100 ]
101 : [
102 "- body must be markdown and include headings '## Summary' and '## Testing'",
103 "- under Summary, provide short bullet points",
104 "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate",
105 ];
92106 const prompt = [
93 "You write GitHub pull request content.",
107 "You write source control change request content.",
94108 "Return a JSON object with keys: title, body.",
95109 "Rules:",
96110 "- title should be concise and specific",
97 "- body must be markdown and include headings '## Summary' and '## Testing'",
98 "- under Summary, provide short bullet points",
99 "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate",
111 ...bodyRules,
100112 ...policyInstruction(input.policy?.changeRequestInstructions),
113 ...(changeRequestTemplate
114 ? ["", "Repository change request template:", limitSection(changeRequestTemplate, 8_000)]
115 : []),
101116 "",
102117 `Base branch: ${input.baseBranch}`,
103118 `Head branch: ${input.headBranch}`,
apps/web/src/components/chat/ProviderModelPicker.tsx+2
......@@ -39,6 +39,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
3939 open?: boolean;
4040 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
4141 triggerClassName?: string;
42 triggerAriaLabel?: string;
4243 onOpenChange?: (open: boolean) => void;
4344 getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null;
4445 onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void;
......@@ -146,6 +147,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
146147 <PopoverTrigger
147148 render={
148149 <Button
150 aria-label={props.triggerAriaLabel}
149151 size="sm"
150152 variant={props.triggerVariant ?? "ghost"}
151153 data-chat-provider-model-picker="true"
apps/web/src/components/settings/SettingsPanels.tsx+11-11
......@@ -392,7 +392,7 @@ export function useSettingsRestore(onRestored?: () => void) {
392392 const settings = usePrimarySettings();
393393 const updateSettings = useUpdatePrimarySettings();
394394
395 const isGitWritingModelDirty = !Equal.equals(
395 const isTextGenerationModelDirty = !Equal.equals(
396396 settings.textGenerationModelSelection ?? null,
397397 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
398398 );
......@@ -445,10 +445,10 @@ export function useSettingsRestore(onRestored?: () => void) {
445445 ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete
446446 ? ["Delete confirmation"]
447447 : []),
448 ...(isGitWritingModelDirty ? ["Git writing model"] : []),
448 ...(isTextGenerationModelDirty ? ["Text generation model"] : []),
449449 ],
450450 [
451 isGitWritingModelDirty,
451 isTextGenerationModelDirty,
452452 settings.autoOpenPlanSidebar,
453453 settings.confirmThreadArchive,
454454 settings.confirmThreadDelete,
......@@ -533,21 +533,21 @@ export function GeneralSettingsPanel() {
533533 const textGenInstanceId = textGenerationModelSelection.instanceId;
534534 const textGenModel = textGenerationModelSelection.model;
535535 const textGenModelOptions = textGenerationModelSelection.options;
536 const gitModelInstanceEntries = sortProviderInstanceEntries(
536 const textGenerationModelInstanceEntries = sortProviderInstanceEntries(
537537 applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings),
538538 );
539 const textGenInstanceEntry = gitModelInstanceEntries.find(
539 const textGenInstanceEntry = textGenerationModelInstanceEntries.find(
540540 (entry) => entry.instanceId === textGenInstanceId,
541541 );
542542 const textGenProvider: ProviderDriverKind =
543543 textGenInstanceEntry?.driverKind ?? DEFAULT_DRIVER_KIND;
544 const gitModelOptionsByInstance = getCustomModelOptionsByInstance(
544 const textGenerationModelOptionsByInstance = getCustomModelOptionsByInstance(
545545 settings,
546546 serverProviders,
547547 textGenInstanceId,
548548 textGenModel,
549549 );
550 const isGitWritingModelDirty = !Equal.equals(
550 const isTextGenerationModelDirty = !Equal.equals(
551551 settings.textGenerationModelSelection ?? null,
552552 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
553553 );
......@@ -1000,9 +1000,9 @@ export function GeneralSettingsPanel() {
10001000
10011001 <SettingsRow
10021002 title="Text generation model"
1003 description="Configure the model used for generated commit messages, PR titles, and similar Git text."
1003 description="Default model for generated text like thread titles and source control content. Source control settings can override it with a dedicated source control writer model."
10041004 resetAction={
1005 isGitWritingModelDirty ? (
1005 isTextGenerationModelDirty ? (
10061006 <SettingResetButton
10071007 label="text generation model"
10081008 onClick={() =>
......@@ -1020,8 +1020,8 @@ export function GeneralSettingsPanel() {
10201020 activeInstanceId={textGenInstanceId}
10211021 model={textGenModel}
10221022 lockedProvider={null}
1023 instanceEntries={gitModelInstanceEntries}
1024 modelOptionsByInstance={gitModelOptionsByInstance}
1023 instanceEntries={textGenerationModelInstanceEntries}
1024 modelOptionsByInstance={textGenerationModelOptionsByInstance}
10251025 triggerVariant="outline"
10261026 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
10271027 onInstanceModelChange={(instanceId, model) => {
apps/web/src/components/settings/SourceControlSettings.tsx+4-1
......@@ -48,6 +48,7 @@ import {
4848 type Icon,
4949} from "../Icons";
5050import { RedactedSensitiveText } from "./RedactedSensitiveText";
51import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings";
5152import { SettingResetButton, SettingsPageContainer, SettingsSection } from "./settingsLayout";
5253
5354const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {
......@@ -195,7 +196,7 @@ function itemSummary({
195196 <span>
196197 {item.label} is not authenticated on this server. Sign in or configure credentials using
197198 the <code className="rounded bg-muted px-1 py-px text-[11px]">{item.executable}</code>{" "}
198 tool on the server host to enable pull request features.
199 tool on the server host to enable change request features.
199200 </span>
200201 );
201202 }
......@@ -513,6 +514,8 @@ export function SourceControlSettingsPanel() {
513514 onScan={handleScan}
514515 />
515516 )}
517
518 {environmentId !== null ? <SourceControlWritingSettingsSection /> : null}
516519 </SettingsPageContainer>
517520 );
518521}
apps/web/src/components/settings/SourceControlWritingSettings.tsx created+213
......@@ -0,0 +1,213 @@
1import { useAtomValue } from "@effect/atom-react";
2import { useRef } from "react";
3import type { SourceControlWritingStyleMode } from "@t3tools/contracts";
4import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
5import { createModelSelection } from "@t3tools/shared/model";
6import { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings";
7
8import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
9import {
10 applyProviderInstanceSettings,
11 deriveProviderInstanceEntries,
12 sortProviderInstanceEntries,
13} from "../../providerInstances";
14import {
15 getCustomModelOptionsByInstance,
16 resolveAppModelSelectionState,
17} from "../../modelSelection";
18import { primaryServerProvidersAtom } from "../../state/server";
19import { ProviderModelPicker } from "../chat/ProviderModelPicker";
20import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
21import { Switch } from "../ui/switch";
22import { Textarea } from "../ui/textarea";
23import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout";
24
25const MODE_OPTIONS: Record<SourceControlWritingStyleMode, { label: string; description: string }> =
26 {
27 repo_conventions: {
28 label: "Repository conventions",
29 description: "In each project, matches recent change descriptions and change request titles.",
30 },
31 conventional_commits: {
32 label: "Conventional Commits",
33 description:
34 "Uses Conventional Commit prefixes for change descriptions; change request titles and descriptions stay concise.",
35 },
36 custom: {
37 label: "Custom instructions",
38 description:
39 "Applies your instructions to change descriptions and change request titles and descriptions in every project.",
40 },
41 };
42
43export function SourceControlWritingSettingsSection() {
44 const settings = usePrimarySettings();
45 const updateSettings = useUpdatePrimarySettings();
46 const serverProviders = useAtomValue(primaryServerProvidersAtom);
47 const customInstructionsRef = useRef<HTMLTextAreaElement>(null);
48 const style = settings.sourceControlWritingStyle;
49 const defaults = DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle;
50 const isSourceControlWritingStyleDirty =
51 style.mode !== defaults.mode || style.customInstructions !== defaults.customInstructions;
52
53 const defaultModelSelection = resolveAppModelSelectionState(settings, serverProviders);
54 const usesDedicatedModel = settings.sourceControlWriterModelSelection !== null;
55 const resolvedSourceControlWriterSelection = resolveSourceControlWriterModelSelection(
56 settings,
57 serverProviders,
58 );
59 const activeSelection =
60 resolvedSourceControlWriterSelection === settings.textGenerationModelSelection
61 ? defaultModelSelection
62 : resolvedSourceControlWriterSelection;
63 const instanceEntries = sortProviderInstanceEntries(
64 applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings),
65 );
66 const modelOptionsByInstance = getCustomModelOptionsByInstance(
67 settings,
68 serverProviders,
69 activeSelection.instanceId,
70 activeSelection.model,
71 );
72
73 return (
74 <SettingsSection title="Text generation">
75 <SettingsRow
76 title="Source control writing style"
77 description={MODE_OPTIONS[style.mode].description}
78 resetAction={
79 isSourceControlWritingStyleDirty ? (
80 <SettingResetButton
81 label="source control writing style"
82 onClick={() =>
83 updateSettings({
84 sourceControlWritingStyle: {
85 mode: defaults.mode,
86 customInstructions: defaults.customInstructions,
87 },
88 })
89 }
90 />
91 ) : null
92 }
93 control={
94 <Select
95 value={style.mode}
96 onValueChange={(value) => {
97 const customInstructions = customInstructionsRef.current?.value.trim();
98 updateSettings({
99 sourceControlWritingStyle: {
100 mode: value as SourceControlWritingStyleMode,
101 ...(customInstructions !== undefined ? { customInstructions } : {}),
102 },
103 });
104 }}
105 >
106 <SelectTrigger className="w-full sm:w-56" aria-label="Source control writing style">
107 <SelectValue>{MODE_OPTIONS[style.mode].label}</SelectValue>
108 </SelectTrigger>
109 <SelectPopup align="end" alignItemWithTrigger={false}>
110 {(Object.keys(MODE_OPTIONS) as SourceControlWritingStyleMode[]).map((mode) => (
111 <SelectItem key={mode} hideIndicator value={mode}>
112 {MODE_OPTIONS[mode].label}
113 </SelectItem>
114 ))}
115 </SelectPopup>
116 </Select>
117 }
118 >
119 {style.mode === "custom" ? (
120 <div className="mt-3 max-w-2xl pb-3.5">
121 <Textarea
122 key={style.customInstructions}
123 ref={customInstructionsRef}
124 defaultValue={style.customInstructions}
125 onBlur={(event) => {
126 const customInstructions = event.target.value.trim();
127 if (customInstructions !== style.customInstructions) {
128 updateSettings({ sourceControlWritingStyle: { customInstructions } });
129 }
130 }}
131 rows={4}
132 placeholder="Keep titles concise. Use short bullet points in descriptions."
133 aria-label="Custom source control writing instructions"
134 />
135 </div>
136 ) : null}
137 </SettingsRow>
138
139 <SettingsRow
140 title="Follow change request templates"
141 description="Structures change request descriptions using the current repository's template when one is available."
142 resetAction={
143 style.followChangeRequestTemplates !== defaults.followChangeRequestTemplates ? (
144 <SettingResetButton
145 label="change request templates"
146 onClick={() =>
147 updateSettings({
148 sourceControlWritingStyle: {
149 followChangeRequestTemplates: defaults.followChangeRequestTemplates,
150 },
151 })
152 }
153 />
154 ) : null
155 }
156 control={
157 <Switch
158 checked={style.followChangeRequestTemplates}
159 onCheckedChange={(checked) =>
160 updateSettings({
161 sourceControlWritingStyle: {
162 followChangeRequestTemplates: Boolean(checked),
163 },
164 })
165 }
166 aria-label="Follow change request templates"
167 />
168 }
169 />
170
171 <SettingsRow
172 title="Source control writer model"
173 description="Optional model override for change descriptions, change request titles and descriptions, and branch or bookmark names. Off uses the global text generation model."
174 control={
175 <div className="flex flex-wrap items-center justify-end gap-2">
176 {usesDedicatedModel ? (
177 <ProviderModelPicker
178 activeInstanceId={activeSelection.instanceId}
179 model={activeSelection.model}
180 lockedProvider={null}
181 instanceEntries={instanceEntries}
182 modelOptionsByInstance={modelOptionsByInstance}
183 triggerVariant="outline"
184 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
185 triggerAriaLabel="Source control writer model"
186 onInstanceModelChange={(instanceId, model) => {
187 updateSettings({
188 sourceControlWriterModelSelection: createModelSelection(instanceId, model),
189 });
190 }}
191 />
192 ) : null}
193 <Switch
194 checked={usesDedicatedModel}
195 onCheckedChange={(checked) =>
196 updateSettings({
197 sourceControlWriterModelSelection: checked
198 ? createModelSelection(
199 defaultModelSelection.instanceId,
200 defaultModelSelection.model,
201 defaultModelSelection.options,
202 )
203 : null,
204 })
205 }
206 aria-label="Use a separate source control writer model"
207 />
208 </div>
209 }
210 />
211 </SettingsSection>
212 );
213}
apps/web/src/hooks/useSettings.ts+4-2
......@@ -32,6 +32,8 @@ import { useAtomCommand } from "~/state/use-atom-command";
3232
3333const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";
3434
35type UnifiedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch;
36
3537const clientSettingsListeners = new Set<() => void>();
3638const clientSettingsHydrationListeners = new Set<() => void>();
3739let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;
......@@ -144,7 +146,7 @@ function persistClientSettings(settings: ClientSettings): void {
144146
145147const SERVER_SETTINGS_KEYS = new Set<string>(Struct.keys(ServerSettings.fields));
146148
147function splitPatch(patch: Partial<UnifiedSettings>): {
149function splitPatch(patch: UnifiedSettingsPatch): {
148150 serverPatch: ServerSettingsPatch;
149151 clientPatch: ClientSettingsPatch;
150152} {
......@@ -246,7 +248,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
246248 "server settings update",
247249 );
248250 const updateSettings = useCallback(
249 (patch: Partial<UnifiedSettings>) => {
251 (patch: UnifiedSettingsPatch) => {
250252 const { serverPatch, clientPatch } = splitPatch(patch);
251253
252254 if (Object.keys(serverPatch).length > 0) {
apps/web/src/modelSelection.ts+4-4
......@@ -1,6 +1,6 @@
11import {
2 DEFAULT_GIT_TEXT_GENERATION_MODEL,
3 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
2 DEFAULT_TEXT_GENERATION_MODEL,
3 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER,
44 defaultInstanceIdForDriver,
55 type ModelSelection,
66 ProviderDriverKind,
......@@ -281,7 +281,7 @@ export function resolveAppModelSelectionState(
281281): ModelSelection {
282282 const selection = settings.textGenerationModelSelection ?? {
283283 instanceId: DEFAULT_TEXT_GENERATION_INSTANCE_ID,
284 model: DEFAULT_GIT_TEXT_GENERATION_MODEL,
284 model: DEFAULT_TEXT_GENERATION_MODEL,
285285 };
286286 const entries = deriveProviderInstanceEntries(providers);
287287 const selectedEntry = entries.find(
......@@ -296,7 +296,7 @@ export function resolveAppModelSelectionState(
296296 const model =
297297 resolveAppModelSelectionForInstance(entry.instanceId, settings, providers, selectedModel) ??
298298 entry.models[0]?.slug ??
299 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind];
299 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind];
300300 if (!model) {
301301 return createModelSelection(entry.instanceId, "", []);
302302 }
apps/web/src/routes/settings.tsx+6
......@@ -52,6 +52,12 @@ function SettingsContentLayout() {
5252 if (event.defaultPrevented) return;
5353 if (event.key === "Escape") {
5454 event.preventDefault();
55
56 const activeElement = document.activeElement;
57 if (activeElement instanceof HTMLElement) {
58 activeElement.blur();
59 }
60
5561 navigateBackWithinApp();
5662 }
5763 };
packages/contracts/src/model.ts+3-3
......@@ -144,7 +144,7 @@ export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray<string> = [
144144 "gpt-5.6-sol",
145145 "gpt-5.6-terra",
146146];
147export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.6-luna";
147export const DEFAULT_TEXT_GENERATION_MODEL = "gpt-5.6-luna";
148148
149149export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, string>> = {
150150 [CODEX_DRIVER_KIND]: DEFAULT_MODEL,
......@@ -155,10 +155,10 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, strin
155155};
156156
157157/** Per-provider text generation model defaults. */
158export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Partial<
158export const DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER: Partial<
159159 Record<ProviderDriverKind, string>
160160> = {
161 [CODEX_DRIVER_KIND]: DEFAULT_GIT_TEXT_GENERATION_MODEL,
161 [CODEX_DRIVER_KIND]: DEFAULT_TEXT_GENERATION_MODEL,
162162 [CLAUDE_DRIVER_KIND]: "claude-haiku-4-5",
163163 [CURSOR_DRIVER_KIND]: "composer-2",
164164 [OPENCODE_DRIVER_KIND]: "openai/gpt-5",
packages/contracts/src/settings.test.ts+27
......@@ -136,6 +136,33 @@ describe("ServerSettings worktree defaults", () => {
136136 });
137137});
138138
139describe("ServerSettings.sourceControlWritingStyle", () => {
140 it("defaults all style settings for legacy configs", () => {
141 const settings = decodeServerSettings({});
142
143 expect(settings.sourceControlWritingStyle).toEqual({
144 mode: "repo_conventions",
145 customInstructions: "",
146 followChangeRequestTemplates: true,
147 });
148 expect(settings.sourceControlWriterModelSelection).toBeNull();
149 });
150
151 it("trims partial style updates", () => {
152 const patch = decodeServerSettingsPatch({
153 sourceControlWritingStyle: {
154 mode: "custom",
155 customInstructions: " Prefer concise wording. ",
156 },
157 });
158
159 expect(patch.sourceControlWritingStyle).toEqual({
160 mode: "custom",
161 customInstructions: "Prefer concise wording.",
162 });
163 });
164});
165
139166describe("ServerSettingsPatch.providerInstances", () => {
140167 it("treats providerInstances as an optional whole-map replacement", () => {
141168 const patch = decodeServerSettingsPatch({});
packages/contracts/src/settings.ts+34-2
......@@ -3,7 +3,7 @@ import * as Duration from "effect/Duration";
33import * as Schema from "effect/Schema";
44import * as SchemaTransformation from "effect/SchemaTransformation";
55import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";
6import { DEFAULT_GIT_TEXT_GENERATION_MODEL, ProviderOptionSelections } from "./model.ts";
6import { DEFAULT_TEXT_GENERATION_MODEL, ProviderOptionSelections } from "./model.ts";
77import { ModelSelection } from "./orchestration.ts";
88import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts";
99
......@@ -395,6 +395,24 @@ export const ObservabilitySettings = Schema.Struct({
395395});
396396export type ObservabilitySettings = typeof ObservabilitySettings.Type;
397397
398export const SourceControlWritingStyleMode = Schema.Literals([
399 "repo_conventions",
400 "conventional_commits",
401 "custom",
402]);
403export type SourceControlWritingStyleMode = typeof SourceControlWritingStyleMode.Type;
404
405export const SourceControlWritingStyleSettings = Schema.Struct({
406 mode: SourceControlWritingStyleMode.pipe(
407 Schema.withDecodingDefault(Effect.succeed("repo_conventions" as const)),
408 ),
409 customInstructions: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
410 followChangeRequestTemplates: Schema.Boolean.pipe(
411 Schema.withDecodingDefault(Effect.succeed(true)),
412 ),
413});
414export type SourceControlWritingStyleSettings = typeof SourceControlWritingStyleSettings.Type;
415
398416export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30);
399417
400418export const ServerSettings = Schema.Struct({
......@@ -416,10 +434,16 @@ export const ServerSettings = Schema.Struct({
416434 Schema.withDecodingDefault(
417435 Effect.succeed({
418436 instanceId: ProviderInstanceId.make("codex"),
419 model: DEFAULT_GIT_TEXT_GENERATION_MODEL,
437 model: DEFAULT_TEXT_GENERATION_MODEL,
420438 }),
421439 ),
422440 ),
441 sourceControlWritingStyle: SourceControlWritingStyleSettings.pipe(
442 Schema.withDecodingDefault(Effect.succeed({})),
443 ),
444 sourceControlWriterModelSelection: Schema.NullOr(ModelSelection).pipe(
445 Schema.withDecodingDefault(Effect.succeed(null)),
446 ),
423447
424448 // Legacy single-instance-per-driver settings. Continues to be the source
425449 // of truth until `providerInstances` (below) lands per-driver migration
......@@ -545,6 +569,14 @@ export const ServerSettingsPatch = Schema.Struct({
545569 newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),
546570 addProjectBaseDirectory: Schema.optionalKey(TrimmedString),
547571 textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),
572 sourceControlWritingStyle: Schema.optionalKey(
573 Schema.Struct({
574 mode: Schema.optionalKey(SourceControlWritingStyleMode),
575 customInstructions: Schema.optionalKey(TrimmedString),
576 followChangeRequestTemplates: Schema.optionalKey(Schema.Boolean),
577 }),
578 ),
579 sourceControlWriterModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),
548580 observability: Schema.optionalKey(
549581 Schema.Struct({
550582 otlpTracesUrl: Schema.optionalKey(TrimmedString),
packages/shared/src/serverSettings.test.ts+101
......@@ -2,14 +2,17 @@ import {
22 DEFAULT_SERVER_SETTINGS,
33 ProviderDriverKind,
44 ProviderInstanceId,
5 type ServerProvider,
56} from "@t3tools/contracts";
67import { describe, expect, it } from "vite-plus/test";
78import { createModelSelection } from "./model.ts";
89import {
910 applyServerSettingsPatch,
1011 extractPersistedServerObservabilitySettings,
12 isModelSelectionProviderEnabled,
1113 normalizePersistedServerSettingString,
1214 parsePersistedServerObservabilitySettings,
15 resolveSourceControlWriterModelSelection,
1316} from "./serverSettings.ts";
1417
1518describe("serverSettings helpers", () => {
......@@ -161,6 +164,104 @@ describe("serverSettings helpers", () => {
161164 });
162165 });
163166
167 it("replaces source control writer selection without retaining stale options", () => {
168 const current = {
169 ...DEFAULT_SERVER_SETTINGS,
170 sourceControlWriterModelSelection: createModelSelection(
171 ProviderInstanceId.make("codex"),
172 "gpt-5.4-mini",
173 [{ id: "reasoningEffort", value: "high" }],
174 ),
175 };
176
177 expect(
178 applyServerSettingsPatch(current, {
179 sourceControlWriterModelSelection: {
180 instanceId: ProviderInstanceId.make("opencode"),
181 model: "openai/gpt-5",
182 },
183 }).sourceControlWriterModelSelection,
184 ).toEqual({
185 instanceId: "opencode",
186 model: "openai/gpt-5",
187 });
188 });
189
190 it("clears source control writer selection with null", () => {
191 const current = {
192 ...DEFAULT_SERVER_SETTINGS,
193 sourceControlWriterModelSelection: createModelSelection(
194 ProviderInstanceId.make("codex"),
195 "gpt-5.4-mini",
196 ),
197 };
198
199 expect(
200 applyServerSettingsPatch(current, {
201 sourceControlWriterModelSelection: null,
202 }).sourceControlWriterModelSelection,
203 ).toBeNull();
204 });
205
206 it("falls back from a disabled source control writer provider without clearing its selection", () => {
207 const instanceId = ProviderInstanceId.make("codex_writer");
208 const sourceControlWriterModelSelection = createModelSelection(instanceId, "gpt-5.4-mini");
209 const settings = {
210 ...DEFAULT_SERVER_SETTINGS,
211 providerInstances: {
212 [instanceId]: {
213 driver: ProviderDriverKind.make("codex"),
214 enabled: false,
215 config: {},
216 },
217 },
218 sourceControlWriterModelSelection,
219 };
220
221 expect(isModelSelectionProviderEnabled(settings, sourceControlWriterModelSelection)).toBe(
222 false,
223 );
224 expect(resolveSourceControlWriterModelSelection(settings)).toBe(
225 settings.textGenerationModelSelection,
226 );
227 expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection);
228 });
229
230 it("falls back from an unavailable source control writer provider", () => {
231 const instanceId = ProviderInstanceId.make("missing_writer");
232 const sourceControlWriterModelSelection = createModelSelection(instanceId, "missing-model");
233 const settings = {
234 ...DEFAULT_SERVER_SETTINGS,
235 providerInstances: {
236 [instanceId]: {
237 driver: ProviderDriverKind.make("missing-driver"),
238 config: {},
239 },
240 },
241 sourceControlWriterModelSelection,
242 };
243 const unavailableProvider = {
244 instanceId,
245 driver: ProviderDriverKind.make("missing-driver"),
246 enabled: false,
247 installed: false,
248 version: null,
249 status: "disabled",
250 auth: { status: "unknown" },
251 checkedAt: "2026-07-27T00:00:00.000Z",
252 availability: "unavailable",
253 unavailableReason: "This provider driver is not available in this build.",
254 models: [],
255 slashCommands: [],
256 skills: [],
257 } satisfies ServerProvider;
258
259 expect(resolveSourceControlWriterModelSelection(settings, [unavailableProvider])).toBe(
260 settings.textGenerationModelSelection,
261 );
262 expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection);
263 });
264
164265 it("replaces providerInstances maps so omitted instance fields are cleared", () => {
165266 const codexId = ProviderInstanceId.make("codex");
166267 const current = {
packages/shared/src/serverSettings.ts+53-6
......@@ -1,4 +1,12 @@
1import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts";
1import {
2 isProviderDriverKind,
3 isProviderAvailable,
4 type ModelSelection,
5 type ProviderDriverKind,
6 type ServerProvider,
7 ServerSettings,
8 type ServerSettingsPatch,
9} from "@t3tools/contracts";
210import * as Option from "effect/Option";
311import * as Schema from "effect/Schema";
412import { deepMerge } from "./Struct.ts";
......@@ -8,6 +16,47 @@ import { createModelSelection } from "./model.ts";
816const ServerSettingsJson = fromLenientJson(ServerSettings);
917const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson);
1018
19type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]];
20
21const getLegacyProviderSettings = (
22 settings: ServerSettings,
23 provider: ProviderDriverKind,
24): LegacyProviderSettings | undefined =>
25 (settings.providers as Record<string, LegacyProviderSettings | undefined>)[provider];
26
27export function isModelSelectionProviderEnabled(
28 settings: ServerSettings,
29 selection: ModelSelection,
30): boolean {
31 const instanceConfig = settings.providerInstances[selection.instanceId];
32 if (instanceConfig !== undefined) {
33 return instanceConfig.enabled ?? true;
34 }
35
36 return (
37 isProviderDriverKind(selection.instanceId) &&
38 getLegacyProviderSettings(settings, selection.instanceId)?.enabled === true
39 );
40}
41
42export function resolveSourceControlWriterModelSelection(
43 settings: ServerSettings,
44 providers?: ReadonlyArray<ServerProvider>,
45): ModelSelection {
46 const selection = settings.sourceControlWriterModelSelection;
47 if (!selection || !isModelSelectionProviderEnabled(settings, selection)) {
48 return settings.textGenerationModelSelection;
49 }
50 if (providers === undefined) {
51 return selection;
52 }
53
54 const provider = providers.find((candidate) => candidate.instanceId === selection.instanceId);
55 return provider?.enabled === true && isProviderAvailable(provider)
56 ? selection
57 : settings.textGenerationModelSelection;
58}
59
1160export interface PersistedServerObservabilitySettings {
1261 readonly otlpTracesUrl: string | undefined;
1362 readonly otlpMetricsUrl: string | undefined;
......@@ -66,11 +115,6 @@ function mergeModelSelectionOptionsById(input: {
66115 return [...merged.entries()].map(([id, value]) => ({ id, value }));
67116}
68117
69/**
70 * Applies a server settings patch while treating textGenerationModelSelection as
71 * replace-on-provider/model updates. This prevents stale nested options from
72 * surviving a reset patch that intentionally omits options.
73 */
74118export function applyServerSettingsPatch(
75119 current: ServerSettings,
76120 patch: ServerSettingsPatch,
......@@ -83,6 +127,9 @@ export function applyServerSettingsPatch(
83127 ...(patch.providerInstances !== undefined
84128 ? { providerInstances: patch.providerInstances }
85129 : {}),
130 ...(patch.sourceControlWriterModelSelection !== undefined
131 ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection }
132 : {}),
86133 ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}),
87134 };
88135 if (!selectionPatch) {