Skip to content

Commit d939c74

Browse files
authored
Merge pull request #206 from pylon-code/upstream/2026-08-31-repo-commit-conventions
fix(git): follow repository instructions in generated source control text
2 parents 97dbece + fbd4240 commit d939c74

5 files changed

Lines changed: 207 additions & 15 deletions

File tree

apps/server/src/git/GitManager.test.ts

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1951,18 +1951,26 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
19511951
}),
19521952
);
19531953

1954-
it.effect("preserves repository conventions style when recent history is empty", () =>
1954+
it.effect("includes local agent instructions when recent history is empty", () =>
19551955
Effect.gen(function* () {
19561956
const repoDir = yield* makeTempDir("t3code-git-manager-");
19571957
yield* runGit(repoDir, ["init", "--initial-branch=main"]);
19581958
yield* runGit(repoDir, ["config", "user.email", "test@example.com"]);
19591959
yield* runGit(repoDir, ["config", "user.name", "Test User"]);
1960+
const agentInstructions = "Use lowercase source control text.";
1961+
const claudeInstructions = "Keep pull request bodies brief.";
1962+
NodeFS.writeFileSync(NodePath.join(repoDir, "AGENTS.md"), agentInstructions);
1963+
NodeFS.writeFileSync(NodePath.join(repoDir, "CLAUDE.md"), claudeInstructions);
19601964
NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\n");
19611965
yield* runGit(repoDir, ["add", "README.md"]);
19621966
let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined;
19631967

19641968
const { manager } = yield* makeManager({
19651969
serverSettings: {
1970+
textGenerationModelSelection: {
1971+
instanceId: ProviderInstanceId.make("claudeAgent"),
1972+
model: "claude-sonnet-4-6",
1973+
},
19661974
sourceControlWritingStyle: {
19671975
mode: "repo_conventions" as const,
19681976
},
@@ -1979,6 +1987,42 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
19791987
action: "commit",
19801988
});
19811989

1990+
expect(generatedPolicy).toEqual({
1991+
kind: "repo_conventions",
1992+
commitInstructions: `Follow the repository's established commit message style when examples are available.\n\nLocal AGENTS.md:\n${agentInstructions}\n\nLocal CLAUDE.md:\n${claudeInstructions}`,
1993+
changeRequestInstructions: `Follow the repository's established change request title and body style when examples are available.\n\nLocal AGENTS.md:\n${agentInstructions}\n\nLocal CLAUDE.md:\n${claudeInstructions}`,
1994+
inferRepositoryConventions: true,
1995+
});
1996+
}),
1997+
);
1998+
1999+
it.effect("leaves the policy unmodified when the repository offers no examples", () =>
2000+
Effect.gen(function* () {
2001+
const repoDir = yield* makeTempDir("t3code-git-manager-");
2002+
yield* runGit(repoDir, ["init", "--initial-branch=main"]);
2003+
yield* runGit(repoDir, ["config", "user.email", "test@example.com"]);
2004+
yield* runGit(repoDir, ["config", "user.name", "Test User"]);
2005+
NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\n");
2006+
yield* runGit(repoDir, ["add", "README.md"]);
2007+
let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined;
2008+
2009+
const { manager } = yield* makeManager({
2010+
serverSettings: {
2011+
textGenerationModelSelection: {
2012+
instanceId: ProviderInstanceId.make("claudeAgent"),
2013+
model: "claude-sonnet-4-6",
2014+
},
2015+
sourceControlWritingStyle: { mode: "repo_conventions" as const },
2016+
},
2017+
textGeneration: {
2018+
generateCommitMessage: (input) => {
2019+
generatedPolicy = input.policy;
2020+
return Effect.succeed({ subject: "Create initial commit", body: "" });
2021+
},
2022+
},
2023+
});
2024+
yield* runStackedAction(manager, { cwd: repoDir, action: "commit" });
2025+
19822026
expect(generatedPolicy).toEqual({
19832027
kind: "repo_conventions",
19842028
commitInstructions:
@@ -1990,6 +2034,91 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
19902034
}),
19912035
);
19922036

2037+
it.effect("truncates oversized instructions instead of dropping them", () =>
2038+
Effect.gen(function* () {
2039+
const repoDir = yield* makeTempDir("t3code-git-manager-");
2040+
yield* runGit(repoDir, ["init", "--initial-branch=main"]);
2041+
yield* runGit(repoDir, ["config", "user.email", "test@example.com"]);
2042+
yield* runGit(repoDir, ["config", "user.name", "Test User"]);
2043+
// This repository's own AGENTS.md is past 20 KB, which is what made the
2044+
// original drop-on-oversize guard a silent no-op.
2045+
const oversized = `Use lowercase source control text.\n${"x".repeat(30_000)}`;
2046+
NodeFS.writeFileSync(NodePath.join(repoDir, "AGENTS.md"), oversized);
2047+
NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\n");
2048+
yield* runGit(repoDir, ["add", "README.md"]);
2049+
let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined;
2050+
2051+
const { manager } = yield* makeManager({
2052+
serverSettings: {
2053+
textGenerationModelSelection: {
2054+
instanceId: ProviderInstanceId.make("claudeAgent"),
2055+
model: "claude-sonnet-4-6",
2056+
},
2057+
sourceControlWritingStyle: { mode: "repo_conventions" as const },
2058+
},
2059+
textGeneration: {
2060+
generateCommitMessage: (input) => {
2061+
generatedPolicy = input.policy;
2062+
return Effect.succeed({ subject: "Create initial commit", body: "" });
2063+
},
2064+
},
2065+
});
2066+
yield* runStackedAction(manager, { cwd: repoDir, action: "commit" });
2067+
2068+
expect(generatedPolicy).toMatchObject({
2069+
commitInstructions: expect.stringContaining("Use lowercase source control text."),
2070+
});
2071+
expect(generatedPolicy).toMatchObject({
2072+
commitInstructions: expect.stringContaining("[Truncated]"),
2073+
});
2074+
}),
2075+
);
2076+
2077+
it.effect("skips a CLAUDE.md that only imports another file", () =>
2078+
Effect.gen(function* () {
2079+
const repoDir = yield* makeTempDir("t3code-git-manager-");
2080+
yield* runGit(repoDir, ["init", "--initial-branch=main"]);
2081+
yield* runGit(repoDir, ["config", "user.email", "test@example.com"]);
2082+
yield* runGit(repoDir, ["config", "user.name", "Test User"]);
2083+
NodeFS.writeFileSync(
2084+
NodePath.join(repoDir, "AGENTS.md"),
2085+
"Use lowercase source control text.",
2086+
);
2087+
// Exactly this repository's CLAUDE.md.
2088+
NodeFS.writeFileSync(NodePath.join(repoDir, "CLAUDE.md"), "@AGENTS.md\n");
2089+
NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\n");
2090+
yield* runGit(repoDir, ["add", "README.md"]);
2091+
let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined;
2092+
2093+
const { manager } = yield* makeManager({
2094+
serverSettings: {
2095+
textGenerationModelSelection: {
2096+
instanceId: ProviderInstanceId.make("claudeAgent"),
2097+
model: "claude-sonnet-4-6",
2098+
},
2099+
sourceControlWritingStyle: { mode: "repo_conventions" as const },
2100+
},
2101+
textGeneration: {
2102+
generateCommitMessage: (input) => {
2103+
generatedPolicy = input.policy;
2104+
return Effect.succeed({ subject: "Create initial commit", body: "" });
2105+
},
2106+
},
2107+
});
2108+
yield* runStackedAction(manager, { cwd: repoDir, action: "commit" });
2109+
2110+
expect(generatedPolicy).toMatchObject({
2111+
commitInstructions: expect.stringContaining("Local AGENTS.md:"),
2112+
});
2113+
expect(generatedPolicy).toMatchObject({
2114+
commitInstructions: expect.not.stringContaining("Local CLAUDE.md:"),
2115+
});
2116+
expect(generatedPolicy).toMatchObject({
2117+
commitInstructions: expect.not.stringContaining("@AGENTS.md"),
2118+
});
2119+
}),
2120+
);
2121+
19932122
it.effect("uses custom commit message when provided", () =>
19942123
Effect.gen(function* () {
19952124
const repoDir = yield* makeTempDir("t3code-git-manager-");

apps/server/src/git/GitManager.ts

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -606,9 +606,33 @@ export const make = Effect.gen(function* () {
606606
const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
607607
const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
608608
const crypto = yield* Crypto.Crypto;
609+
const fileSystem = yield* FileSystem.FileSystem;
610+
const path = yield* Path.Path;
609611

610612
const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd });
611613
const serverSettingsService = yield* ServerSettings.ServerSettingsService;
614+
const readRepositoryInstructions = (cwd: string, fileName: string) =>
615+
Effect.gen(function* () {
616+
const root = yield* fileSystem.realPath(cwd);
617+
const instructionPath = yield* fileSystem.realPath(path.join(root, fileName));
618+
if (!instructionPath.startsWith(`${root}${path.sep}`)) {
619+
return "";
620+
}
621+
const info = yield* fileSystem.stat(instructionPath);
622+
if (
623+
info.type !== "File" ||
624+
info.size > FileSystem.Size(MAX_REPOSITORY_INSTRUCTIONS_READ_BYTES)
625+
) {
626+
return "";
627+
}
628+
const contents = (yield* fileSystem.readFileString(instructionPath)).trim();
629+
// Conventions live near the top of these files, so keep the head. Dropping
630+
// the file outright would silently disable the feature for any repository
631+
// whose instructions grew past the budget — including this one.
632+
return contents.length > MAX_REPOSITORY_INSTRUCTIONS_CHARS
633+
? `${contents.slice(0, MAX_REPOSITORY_INSTRUCTIONS_CHARS)}\n[Truncated]`
634+
: contents;
635+
}).pipe(Effect.orElseSucceed(() => ""));
612636

613637
const readRecentCommitSubjects = (cwd: string) =>
614638
gitCore
@@ -627,26 +651,65 @@ export const make = Effect.gen(function* () {
627651
Effect.orElseSucceed(() => []),
628652
);
629653

630-
const resolveStylePolicy = (cwd: string, style: SourceControlWritingStyleSettings) =>
654+
/**
655+
* Instruction files are read whole up to this size and then head-truncated, so a
656+
* repository whose AGENTS.md grows does not silently lose the feature.
657+
*/
658+
const MAX_REPOSITORY_INSTRUCTIONS_READ_BYTES = 128_000;
659+
const MAX_REPOSITORY_INSTRUCTIONS_CHARS = 8_000;
660+
661+
/**
662+
* `CLAUDE.md` commonly consists only of `@path` imports — this repository's is
663+
* exactly `@AGENTS.md`. Sending the directive verbatim tells the model nothing,
664+
* and resolving it would duplicate a file already included, so a CLAUDE.md that
665+
* carries no prose of its own is skipped.
666+
*/
667+
function repositoryInstructionsProse(contents: string): string {
668+
const prose = contents
669+
.split("\n")
670+
.filter((line) => !/^\s*@\S+\s*$/.test(line))
671+
.join("\n")
672+
.trim();
673+
return prose;
674+
}
675+
676+
const resolveStylePolicy = (cwd: string, settings: SourceControlTextGenerationSettings) =>
631677
Effect.gen(function* () {
632-
switch (style.mode) {
678+
switch (settings.style.mode) {
633679
case "conventional_commits":
634680
return conventionalCommitsTextGenerationPolicy;
635681
case "custom":
636682
return customTextGenerationPolicy(
637-
style.customInstructions
683+
settings.style.customInstructions
638684
? {
639-
commitInstructions: style.customInstructions,
640-
changeRequestInstructions: style.customInstructions,
685+
commitInstructions: settings.style.customInstructions,
686+
changeRequestInstructions: settings.style.customInstructions,
641687
}
642688
: {},
643689
);
644690
case "repo_conventions": {
645691
const subjects = yield* readRecentCommitSubjects(cwd);
646-
if (subjects.length === 0) {
692+
const agentInstructions = yield* readRepositoryInstructions(cwd, "AGENTS.md");
693+
const isClaudeWriter =
694+
settings.modelSelection.instanceId === "claudeAgent" ||
695+
(yield* providerRegistry.getProviders).some(
696+
(provider) =>
697+
provider.instanceId === settings.modelSelection.instanceId &&
698+
provider.driver === "claudeAgent",
699+
);
700+
const claudeInstructions = isClaudeWriter
701+
? repositoryInstructionsProse(yield* readRepositoryInstructions(cwd, "CLAUDE.md"))
702+
: "";
703+
const examples = [
704+
...(subjects.length > 0
705+
? [["Recent commit subjects from this repository:", ...subjects].join("\n")]
706+
: []),
707+
...(agentInstructions ? [`Local AGENTS.md:\n${agentInstructions}`] : []),
708+
...(claudeInstructions ? [`Local CLAUDE.md:\n${claudeInstructions}`] : []),
709+
].join("\n\n");
710+
if (!examples) {
647711
return repositoryConventionsTextGenerationPolicy;
648712
}
649-
const examples = ["Recent commit subjects from this repository:", ...subjects].join("\n");
650713
return {
651714
...repositoryConventionsTextGenerationPolicy,
652715
commitInstructions: `${repositoryConventionsTextGenerationPolicy.commitInstructions}\n\n${examples}`,
@@ -848,9 +911,6 @@ export const make = Effect.gen(function* () {
848911
),
849912
),
850913
);
851-
const fileSystem = yield* FileSystem.FileSystem;
852-
const path = yield* Path.Path;
853-
854914
const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";
855915
const canonicalizeExistingPath = (value: string) =>
856916
fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value));
@@ -1574,7 +1634,7 @@ export const make = Effect.gen(function* () {
15741634
};
15751635
}
15761636

1577-
const policy = yield* resolveStylePolicy(input.cwd, input.settings.style);
1637+
const policy = yield* resolveStylePolicy(input.cwd, input.settings);
15781638

15791639
const generated = yield* textGeneration
15801640
.generateCommitMessage({
@@ -1760,7 +1820,7 @@ export const make = Effect.gen(function* () {
17601820
});
17611821
const baseRangeRef = yield* resolveBaseRangeRef(cwd, baseBranch);
17621822
const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef);
1763-
const policy = yield* resolveStylePolicy(cwd, settings.style);
1823+
const policy = yield* resolveStylePolicy(cwd, settings);
17641824
const changeRequestTemplate =
17651825
settings.style.followChangeRequestTemplates && provider.kind === "github"
17661826
? Option.getOrUndefined(yield* detectPrTemplate(cwd, baseRangeRef, gitCore.execute))

apps/server/src/textGeneration/TextGenerationPrompts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const EARLIER_CONTENT_TRUNCATION_MARKER = "[Earlier content truncated]\n\n";
1616

1717
function policyInstruction(instruction: string | undefined): ReadonlyArray<string> {
1818
const trimmed = instruction?.trim();
19-
return trimmed ? ["", "Additional instructions:", limitSection(trimmed, 4_000)] : [];
19+
return trimmed ? ["", "Additional instructions:", limitSection(trimmed, 20_000)] : [];
2020
}
2121

2222
// ---------------------------------------------------------------------------

apps/web/src/components/settings/SourceControlWritingSettings.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ const MODE_OPTIONS: Record<SourceControlWritingStyleMode, { label: string; descr
2828
{
2929
repo_conventions: {
3030
label: "Repository conventions",
31-
description: "In each project, matches recent change descriptions and change request titles.",
31+
description:
32+
"In each project, matches recent change descriptions and change request titles. Also follows the project's AGENTS.md, and its CLAUDE.md when a Claude model writes.",
3233
},
3334
conventional_commits: {
3435
label: "Conventional Commits",

docs/user/source-control.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ Pylon works with the platforms your team already uses:
3636

3737
- Push a branch and create a pull request from the Git actions controls in the toolbar
3838
- Pylon can suggest titles and descriptions based on your commits
39+
- With **Repository conventions** selected, generated source control text follows the project's
40+
`AGENTS.md` along with recent commit subjects. Claude writers also follow `CLAUDE.md`
3941
- Supports GitHub Pull Requests, GitLab Merge Requests, Bitbucket Pull Requests, and Azure DevOps Pull Requests
4042

4143
**Stay on top of open reviews**

0 commit comments

Comments
 (0)