Skip to content

Commit 115227b

Browse files
xesrevinuGit Agent
andcommitted
feat(services): add commit, conventional, hooks, and VCS logic
- Add commit-service for commit planning and message generation - Implement conventional commit message validation - Add gitignore-service for generating and merging .gitignore files - Create hooks service for executing and installing commit hooks - Add openai-client for LLM interaction and commit message generation - Implement scope-service for auto-generating project scopes - Implement vcs service supporting Git and Jj with diff and commit operations - Add tests for conventional commit validation and hook execution This commit introduces several core service modules to support commit message automation and repository management. The commit-service handles commit planning, message generation, and performing commits with support for Git and Jj. The conventional module validates commit messages against conventional commit rules. The hooks service runs configured commit hooks including shell and conventional validation hooks. The openai-client integrates with an LLM to generate commit messages, plans, and scopes. The gitignore-service generates .gitignore files based on detected project technologies. The scope-service derives commit scopes from the project history and structure. Finally, the vcs service abstracts version control operations for Git and Jj. Tests validate commit message validation and hook execution. These additions establish a foundation for automated, consistent commit workflows. Co-Authored-By: Git Agent <noreply@git-agent.dev>
1 parent 774705d commit 115227b

9 files changed

Lines changed: 2100 additions & 0 deletions

File tree

src/services/commit-service.ts

Lines changed: 564 additions & 0 deletions
Large diffs are not rendered by default.

src/services/conventional.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { wrapExplanation } from "../shared/text";
2+
3+
export interface ValidationIssue {
4+
readonly severity: "error" | "warning";
5+
readonly message: string;
6+
}
7+
8+
export interface ValidationResult {
9+
readonly issues: ReadonlyArray<ValidationIssue>;
10+
}
11+
12+
const headerRegex =
13+
/^(feat|fix|docs|style|refactor|perf|test|chore|build|ci|revert)(\([a-z0-9_-]+\))?!?: .+/;
14+
const coAuthorRegex = /^Co-Authored-By: .+ <[^>]+@[^>]+>$/;
15+
const footerRegex = /^([A-Za-z][A-Za-z0-9-]*|BREAKING CHANGE): /;
16+
const pastVerbs = new Set([
17+
"added",
18+
"removed",
19+
"updated",
20+
"changed",
21+
"fixed",
22+
"created",
23+
"deleted",
24+
"modified",
25+
"implemented",
26+
"refactored",
27+
"renamed",
28+
"moved",
29+
"replaced",
30+
"improved",
31+
"enhanced",
32+
"upgraded",
33+
"downgraded",
34+
"reverted",
35+
"resolved",
36+
]);
37+
38+
export const validateConventional = (raw: string): ValidationResult => {
39+
const issues: Array<ValidationIssue> = [];
40+
if (raw.trim().length === 0) {
41+
return { issues: [{ severity: "error", message: "commit message is empty" }] };
42+
}
43+
44+
const lines = raw.split("\n");
45+
const header = lines[0] ?? "";
46+
47+
if (!headerRegex.test(header)) {
48+
issues.push({
49+
severity: "error",
50+
message:
51+
"header must match: <type>[(<scope>)][!]: <description> (valid types: feat fix docs style refactor perf test chore build ci revert)",
52+
});
53+
}
54+
if (header.length > 50) {
55+
issues.push({
56+
severity: "error",
57+
message: `title must be 50 characters or less (got ${header.length})`,
58+
});
59+
}
60+
if (header.endsWith(".")) {
61+
issues.push({
62+
severity: "error",
63+
message: "title must not end with a period",
64+
});
65+
}
66+
67+
const separatorIndex = header.indexOf(": ");
68+
if (separatorIndex >= 0) {
69+
const description = header.slice(separatorIndex + 2);
70+
if (description !== description.toLowerCase()) {
71+
issues.push({
72+
severity: "error",
73+
message: "description must be all lowercase",
74+
});
75+
}
76+
const firstWord = description.trim().split(/\s+/)[0] ?? "";
77+
if (pastVerbs.has(firstWord)) {
78+
issues.push({
79+
severity: "warning",
80+
message: `description starts with past-tense verb "${firstWord}" - prefer imperative mood`,
81+
});
82+
}
83+
}
84+
85+
if (lines.length < 3) {
86+
issues.push({
87+
severity: "error",
88+
message: "body is required: add bullet points followed by an explanation paragraph",
89+
});
90+
return { issues };
91+
}
92+
if ((lines[1] ?? "") !== "") {
93+
issues.push({
94+
severity: "error",
95+
message: "blank line required between header and body",
96+
});
97+
}
98+
99+
const bodyLines = lines.slice(2);
100+
let lastBulletIndex = -1;
101+
const bulletFirstWords: Array<string> = [];
102+
103+
for (const [index, line] of bodyLines.entries()) {
104+
if (line.startsWith("- ")) {
105+
lastBulletIndex = index;
106+
const firstWord = line.slice(2).trim().split(/\s+/)[0] ?? "";
107+
if (firstWord.length > 0) {
108+
bulletFirstWords.push(firstWord.toLowerCase());
109+
}
110+
}
111+
}
112+
113+
if (lastBulletIndex === -1) {
114+
issues.push({
115+
severity: "error",
116+
message: "body must contain at least one bullet point starting with '- '",
117+
});
118+
} else {
119+
const hasExplanation = bodyLines.slice(lastBulletIndex + 1).some((line) => {
120+
const trimmed = line.trim();
121+
return trimmed.length > 0 && !footerRegex.test(trimmed) && !trimmed.startsWith("- ");
122+
});
123+
if (!hasExplanation) {
124+
issues.push({
125+
severity: "error",
126+
message: "explanation paragraph required after bullet points",
127+
});
128+
}
129+
}
130+
131+
for (const line of bodyLines) {
132+
if (footerRegex.test(line)) {
133+
continue;
134+
}
135+
if (line.length > 72) {
136+
issues.push({
137+
severity: "error",
138+
message: `body line exceeds 72 characters: ${line}`,
139+
});
140+
}
141+
if (line.startsWith("Co-Authored-By: ") && !coAuthorRegex.test(line)) {
142+
issues.push({
143+
severity: "error",
144+
message: "Co-Authored-By footer must match 'Co-Authored-By: Name <email@example.com>'",
145+
});
146+
}
147+
}
148+
149+
for (const word of bulletFirstWords) {
150+
if (pastVerbs.has(word)) {
151+
issues.push({
152+
severity: "warning",
153+
message: `bullet starts with past-tense verb "${word}" - prefer imperative mood`,
154+
});
155+
}
156+
}
157+
158+
return {
159+
issues,
160+
};
161+
};
162+
163+
export const hasErrors = (result: ValidationResult): boolean =>
164+
result.issues.some((issue) => issue.severity === "error");
165+
166+
export const validationErrors = (result: ValidationResult): Array<string> =>
167+
result.issues.filter((issue) => issue.severity === "error").map((issue) => issue.message);
168+
169+
export const validationWarnings = (result: ValidationResult): Array<string> =>
170+
result.issues.filter((issue) => issue.severity === "warning").map((issue) => issue.message);
171+
172+
export const normalizeExplanation = (text: string): string => wrapExplanation(text, 72);

src/services/gitignore-service.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { platform } from "node:os";
2+
import { Effect, FileSystem, Path } from "effect";
3+
import { ApiError } from "../shared/errors";
4+
import { buildEnvironment } from "../config/env";
5+
import { detectTechnologies, type ProviderConfig } from "./openai-client";
6+
import type { VcsClient } from "./vcs";
7+
8+
const autoGenStart = "### git-agent auto-generated — DO NOT EDIT this block ###";
9+
const legacyAutoGenStart = "### git-agent auto-generated - DO NOT EDIT this block ###";
10+
const autoGenEnd = "### end git-agent ###";
11+
const customSection = "### custom rules ###";
12+
13+
const runtimeOs = (): string => {
14+
switch (platform()) {
15+
case "darwin":
16+
return "macos";
17+
case "win32":
18+
return "windows";
19+
default:
20+
return "linux";
21+
}
22+
};
23+
24+
const wrapGenerated = (content: string, technologies: ReadonlyArray<string>): string =>
25+
`${autoGenStart}\n# Technologies: ${technologies.join(", ")}\n${content.trimEnd()}\n${autoGenEnd}\n`;
26+
27+
const toptalTechs = (content: string): Array<string> => {
28+
for (const line of content.split("\n").slice(0, 10)) {
29+
const trimmed = line.trim();
30+
if (!trimmed.startsWith("# Created by")) {
31+
continue;
32+
}
33+
const fields = trimmed.split(/\s+/);
34+
const url = fields[fields.length - 1];
35+
if (url == null) {
36+
break;
37+
}
38+
const marker = "/api/";
39+
const index = url.lastIndexOf(marker);
40+
if (index !== -1) {
41+
return url
42+
.slice(index + marker.length)
43+
.split(",")
44+
.map((item) => item.trim())
45+
.filter((item) => item.length > 0);
46+
}
47+
break;
48+
}
49+
return [];
50+
};
51+
52+
const mergeGitignore = (existing: string, generated: string): string => {
53+
const userLines: Array<string> = [];
54+
let insideBlock = false;
55+
56+
for (const line of existing.split("\n")) {
57+
const trimmed = line.trim();
58+
if (trimmed === autoGenStart || trimmed === legacyAutoGenStart) {
59+
insideBlock = true;
60+
continue;
61+
}
62+
if (trimmed === autoGenEnd) {
63+
insideBlock = false;
64+
continue;
65+
}
66+
if (trimmed === customSection) {
67+
continue;
68+
}
69+
if (!insideBlock) {
70+
userLines.push(line);
71+
}
72+
}
73+
74+
const covered = new Set(
75+
generated
76+
.split("\n")
77+
.map((line) => line.trim())
78+
.filter((line) => line.length > 0 && !line.startsWith("#")),
79+
);
80+
81+
const unique = userLines.filter((line) => {
82+
const trimmed = line.trim();
83+
return trimmed.length === 0 || trimmed.startsWith("#") || !covered.has(trimmed);
84+
});
85+
86+
while (unique.length > 0 && unique[0]?.trim().length === 0) {
87+
unique.shift();
88+
}
89+
while (unique.length > 0 && unique[unique.length - 1]?.trim().length === 0) {
90+
unique.pop();
91+
}
92+
93+
if (unique.length === 0) {
94+
return generated;
95+
}
96+
return `${generated.trimEnd()}\n\n${customSection}\n${unique.join("\n")}\n`;
97+
};
98+
99+
const fetchGitignore = (technologies: ReadonlyArray<string>) =>
100+
Effect.gen(function* () {
101+
const env = yield* buildEnvironment;
102+
const baseUrl = env.gitignoreBaseUrl.replace(/\/$/, "");
103+
return yield* Effect.tryPromise({
104+
try: async () => {
105+
const response = await fetch(
106+
`${baseUrl}/${technologies.map((item) => encodeURIComponent(item)).join(",")}`,
107+
);
108+
const text = await response.text();
109+
if (!response.ok) {
110+
throw new ApiError({
111+
message: `failed to fetch gitignore template (${response.status})`,
112+
status: response.status,
113+
body: text,
114+
});
115+
}
116+
return text;
117+
},
118+
catch: (cause) =>
119+
cause instanceof ApiError
120+
? cause
121+
: new ApiError({
122+
message: cause instanceof Error ? cause.message : String(cause),
123+
}),
124+
});
125+
});
126+
127+
export const generateGitignore = (provider: ProviderConfig, vcs: VcsClient, cwd: string) =>
128+
Effect.gen(function* () {
129+
const fs = yield* FileSystem.FileSystem;
130+
const path = yield* Path.Path;
131+
const [dirs, files] = yield* Effect.all([vcs.topLevelDirs(cwd), vcs.projectFiles(cwd)]);
132+
let technologies = yield* detectTechnologies(provider, runtimeOs(), dirs, files);
133+
const fetched = yield* fetchGitignore(technologies);
134+
const actualTechnologies = toptalTechs(fetched);
135+
if (actualTechnologies.length > 0) {
136+
technologies = actualTechnologies;
137+
}
138+
139+
const gitignorePath = path.join(cwd, ".gitignore");
140+
const existing = yield* fs.exists(gitignorePath).pipe(
141+
Effect.flatMap((exists) =>
142+
exists ? fs.readFileString(gitignorePath, "utf8") : Effect.succeed(""),
143+
),
144+
Effect.mapError(
145+
(cause) =>
146+
new ApiError({
147+
message: `failed to write .gitignore: ${cause.message}`,
148+
}),
149+
),
150+
);
151+
const generated = wrapGenerated(fetched, technologies);
152+
const content = existing.length === 0 ? generated : mergeGitignore(existing, generated);
153+
154+
yield* fs.writeFileString(gitignorePath, content, { mode: 0o644 }).pipe(
155+
Effect.mapError(
156+
(cause) =>
157+
new ApiError({
158+
message: `failed to write .gitignore: ${cause.message}`,
159+
}),
160+
),
161+
);
162+
return technologies;
163+
});

0 commit comments

Comments
 (0)