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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1826,6 +1826,109 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
}),
);

it.effect("uses structured recent commits for repository commit conventions", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
yield* runGit(repoDir, ["checkout", "-b", "style-example"]);
NodeFS.writeFileSync(NodePath.join(repoDir, "style.txt"), "example\n");
yield* runGit(repoDir, ["add", "style.txt"]);
yield* runGit(repoDir, [
"commit",
"-m",
"feat(server): add style example",
"-m",
"- Explain the reason\n- Preserve body formatting",
]);
yield* runGit(repoDir, ["checkout", "main"]);
yield* runGit(repoDir, [
"merge",
"--no-ff",
"style-example",
"-m",
"Merge style example",
"-m",
"Keep merge body style",
]);
NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n");
let commitInstructions: string | undefined;
let changeRequestInstructions: string | undefined;

const { manager } = yield* makeManager({
serverSettings: {
sourceControlWritingStyle: {
mode: "repo_conventions" as const,
},
},
textGeneration: {
generateCommitMessage: (input) => {
commitInstructions = input.policy?.commitInstructions;
changeRequestInstructions = input.policy?.changeRequestInstructions;
return Effect.succeed({ subject: "Match repository commit style", body: "" });
},
},
});
yield* runStackedAction(manager, {
cwd: repoDir,
action: "commit",
});

expect(commitInstructions).toContain("Recent commits from this repository:");
expect(commitInstructions).toContain("Subject: Merge style example");
expect(commitInstructions).toContain("Body: Keep merge body style");
expect(commitInstructions).toContain("Subject: feat(server): add style example");
expect(commitInstructions).toContain(
"Body: - Explain the reason\n- Preserve body formatting",
);
expect(changeRequestInstructions).toBe(
"Follow the repository's established change request title and body style when examples are available.",
);
}),
);

it.effect("limits repository commit conventions to 10 recent commits", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
for (let index = 1; index <= 10; index += 1) {
yield* runGit(repoDir, [
"commit",
"--allow-empty",
"-m",
`chore: history ${index}`,
"-m",
`Explain history ${index}\n${"x".repeat(500)}`,
]);
}
NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n");
let commitInstructions: string | undefined;

const { manager } = yield* makeManager({
serverSettings: {
sourceControlWritingStyle: {
mode: "repo_conventions" as const,
},
},
textGeneration: {
generateCommitMessage: (input) => {
commitInstructions = input.policy?.commitInstructions;
return Effect.succeed({ subject: "Limit commit history", body: "" });
},
},
});
yield* runStackedAction(manager, {
cwd: repoDir,
action: "commit",
});

expect(commitInstructions?.match(/^Subject: /gm)).toHaveLength(10);
expect(commitInstructions).toContain("Subject: chore: history 10");
expect(commitInstructions).not.toContain("Subject: Initial commit");
expect(commitInstructions).toContain("[truncated]");
expect(commitInstructions?.length).toBeLessThanOrEqual(4_000);
}),
);

it.effect("uses custom commit message when provided", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down Expand Up @@ -2834,6 +2937,90 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
}),
);

it.effect("uses subjects only for repository PR conventions", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
yield* runGit(repoDir, ["checkout", "-b", "feature-pr-style"]);
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n");
yield* runGit(repoDir, ["add", "changes.txt"]);
yield* runGit(repoDir, [
"commit",
"-m",
"feat(server): add PR style example",
"-m",
"Commit body must not reach PR instructions",
]);
yield* runGit(repoDir, ["checkout", "-b", "pr-style-example"]);
yield* runGit(repoDir, [
"commit",
"--allow-empty",
"-m",
"fix(server): preserve authored PR style",
]);
yield* runGit(repoDir, ["checkout", "feature-pr-style"]);
yield* runGit(repoDir, [
"merge",
"--no-ff",
"pr-style-example",
"-m",
"Merge noisy PR style example",
]);
yield* runGit(repoDir, ["push", "-u", "origin", "feature-pr-style"]);
yield* runGit(repoDir, ["config", "branch.feature-pr-style.gh-merge-base", "main"]);
let commitInstructions: string | undefined;
let changeRequestInstructions: string | undefined;

const { manager } = yield* makeManager({
serverSettings: {
sourceControlWritingStyle: {
mode: "repo_conventions" as const,
},
},
textGeneration: {
generatePrContent: (input) => {
commitInstructions = input.policy?.commitInstructions;
changeRequestInstructions = input.policy?.changeRequestInstructions;
return Effect.succeed({
title: "Match repository PR style",
body: "## Summary\n- Match repository PR style\n\n## Testing\n- Covered by tests",
});
},
},
ghScenario: {
prListSequence: [
"[]",
// @effect-diagnostics-next-line preferSchemaOverJson:off
JSON.stringify([
{
number: 89,
title: "Match repository PR style",
url: "https://github.com/pingdotgg/codething-mvp/pull/89",
baseRefName: "main",
headRefName: "feature-pr-style",
},
]),
],
},
});
yield* runStackedAction(manager, {
cwd: repoDir,
action: "create_pr",
});

expect(changeRequestInstructions).toContain("Recent commit subjects from this repository:");
expect(changeRequestInstructions).toContain("feat(server): add PR style example");
expect(changeRequestInstructions).toContain("fix(server): preserve authored PR style");
expect(changeRequestInstructions).not.toContain("Merge noisy PR style example");
expect(changeRequestInstructions).not.toContain("Commit body must not reach PR instructions");
expect(commitInstructions).toBe(
"Follow the repository's established commit message style when examples are available.",
);
}),
);

it.effect("generates PR content against the remote base when the local base is stale", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down
80 changes: 70 additions & 10 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -605,24 +605,62 @@ export const make = Effect.gen(function* () {
const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd });
const serverSettingsService = yield* ServerSettings.ServerSettingsService;

interface RecentCommit {
readonly hash: string;
readonly subject: string;
readonly body: string;
}

const recentCommitCount = 10;
const commitSubjectExampleMaxChars = 120;
const commitBodyExampleMaxChars = 160;

const readRecentCommits = (cwd: string) =>
gitCore
.execute({
operation: "GitManager.readRecentCommits",
cwd,
args: ["log", "-n", String(recentCommitCount), "--pretty=format:%h%x00%s%x00%b%x00"],
})
.pipe(
Effect.map((result) => {
const fields = result.stdout.split("\0");
const commits: RecentCommit[] = [];
for (let index = 0; index + 2 < fields.length; index += 3) {
const hash = fields[index]?.trim() ?? "";
const subject = fields[index + 1]?.trim() ?? "";
const body = fields[index + 2]?.trim() ?? "";
if (hash && subject) {
commits.push({ hash, subject, body });
}
}
return commits;
}),
Effect.orElseSucceed(() => []),
);

const readRecentCommitSubjects = (cwd: string) =>
gitCore
.execute({
operation: "GitManager.readRecentCommitSubjects",
cwd,
args: ["log", "-n", "20", "--no-merges", "--pretty=format:%s"],
args: ["log", "-n", String(recentCommitCount), "--no-merges", "--pretty=format:%s"],
})
.pipe(
Effect.map((result) =>
result.stdout
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0),
.map((subject) => subject.trim())
.filter(Boolean),
),
Effect.orElseSucceed(() => []),
);

const resolveStylePolicy = (cwd: string, style: SourceControlWritingStyleSettings) =>
const resolveStylePolicy = (
cwd: string,
style: SourceControlWritingStyleSettings,
consumer: "commit" | "change_request",
) =>
Effect.gen(function* () {
switch (style.mode) {
case "conventional_commits":
Expand All @@ -637,15 +675,37 @@ export const make = Effect.gen(function* () {
: {},
);
case "repo_conventions": {
const subjects = yield* readRecentCommitSubjects(cwd);
if (subjects.length === 0) {
if (consumer === "change_request") {
const subjects = yield* readRecentCommitSubjects(cwd);
if (subjects.length === 0) {
return repositoryConventionsTextGenerationPolicy;
}
const examples = ["Recent commit subjects from this repository:", ...subjects].join(
"\n",
);
return {
...repositoryConventionsTextGenerationPolicy,
changeRequestInstructions: `${repositoryConventionsTextGenerationPolicy.changeRequestInstructions}\n\n${examples}`,
};
}

const commits = yield* readRecentCommits(cwd);
if (commits.length === 0) {
return repositoryConventionsTextGenerationPolicy;
}
const examples = ["Recent commit subjects from this repository:", ...subjects].join("\n");
const examples = [
"Recent commits from this repository:",
...commits.map((commit) =>
[
`Commit ${commit.hash}`,
`Subject: ${limitContext(commit.subject, commitSubjectExampleMaxChars)}`,
`Body: ${commit.body ? limitContext(commit.body, commitBodyExampleMaxChars) : "(empty)"}`,
].join("\n"),
),
].join("\n\n");
return {
...repositoryConventionsTextGenerationPolicy,
commitInstructions: `${repositoryConventionsTextGenerationPolicy.commitInstructions}\n\n${examples}`,
changeRequestInstructions: `${repositoryConventionsTextGenerationPolicy.changeRequestInstructions}\n\n${examples}`,
};
}
}
Expand Down Expand Up @@ -1500,7 +1560,7 @@ export const make = Effect.gen(function* () {
};
}

const policy = yield* resolveStylePolicy(input.cwd, input.settings.style);
const policy = yield* resolveStylePolicy(input.cwd, input.settings.style, "commit");

const generated = yield* textGeneration
.generateCommitMessage({
Expand Down Expand Up @@ -1686,7 +1746,7 @@ export const make = Effect.gen(function* () {
});
const baseRangeRef = yield* resolveBaseRangeRef(cwd, baseBranch);
const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef);
const policy = yield* resolveStylePolicy(cwd, settings.style);
const policy = yield* resolveStylePolicy(cwd, settings.style, "change_request");
const changeRequestTemplate =
settings.style.followChangeRequestTemplates && provider.kind === "github"
? Option.getOrUndefined(yield* detectPrTemplate(cwd, baseRangeRef, gitCore.execute))
Expand Down
Loading