Skip to content

Commit 3d35d16

Browse files
refactor(ai-commit): switch project config to json
- Tests migrated to use .ai-commit/config.json - Updated config read/write paths to JSON config files refactor ai-commit to adopt json based project config and update tests accordingly Co-Authored-By: Ai Commit <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 2610393 commit 3d35d16

7 files changed

Lines changed: 288 additions & 62 deletions

File tree

tests/cli-integration-git.test.ts

Lines changed: 190 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NodeServices } from "@effect/platform-node";
22
import { describe, expect, layer } from "@effect/vitest";
33
import { Effect } from "effect";
4+
import { execFileSync } from "node:child_process";
45
import {
56
createGitRepo,
67
fileExists,
@@ -28,7 +29,7 @@ const seedGitRepoWithScopes = Effect.fn(function* () {
2829
const repo = yield* seedGitRepo();
2930
yield* writeTextFile(
3031
repo,
31-
".ai-commit/config.yml",
32+
".ai-commit/config.json",
3233
projectScopesConfig([
3334
["api", "Backend API handlers"],
3435
["web", "Frontend pages"],
@@ -38,6 +39,19 @@ const seedGitRepoWithScopes = Effect.fn(function* () {
3839
return repo;
3940
});
4041

42+
const stagePartialHunk = (cwd: string, relativePath: string, from: string, to: string): void => {
43+
execFileSync("git", ["apply", "--cached", "--unidiff-zero", "-"], {
44+
cwd,
45+
input:
46+
`diff --git a/${relativePath} b/${relativePath}\n` +
47+
`--- a/${relativePath}\n` +
48+
`+++ b/${relativePath}\n` +
49+
"@@ -1 +1 @@\n" +
50+
`-${from}\n` +
51+
`+${to}\n`,
52+
});
53+
};
54+
4155
describe.concurrent("CLI integration (git)", () => {
4256
layer(NodeServices.layer)((it) => {
4357
it.effect(
@@ -74,10 +88,10 @@ describe.concurrent("CLI integration (git)", () => {
7488

7589
expect(result.exitCode).toBe(0);
7690
expect(result.stdout).toContain("scopes written");
77-
const config = yield* readTextFile(repo, ".ai-commit/config.yml");
78-
expect(config).toContain("name: api");
79-
expect(config).toContain("description: Backend API handlers");
80-
expect(config).toContain("name: web");
91+
const config = yield* readTextFile(repo, ".ai-commit/config.json");
92+
expect(config).toContain('"name": "api"');
93+
expect(config).toContain('"description": "Backend API handlers"');
94+
expect(config).toContain('"name": "web"');
8195
expect(llm.requests).toHaveLength(1);
8296
}),
8397
);
@@ -133,6 +147,49 @@ describe.concurrent("CLI integration (git)", () => {
133147
}),
134148
);
135149

150+
it.effect(
151+
"git init --gitignore works when project config already exists",
152+
Effect.fn(function* () {
153+
const repo = yield* seedGitRepo();
154+
yield* writeTextFile(repo, ".ai-commit/config.json", '{\n "hook": ["conventional"]\n}\n');
155+
156+
const llm = yield* startMockLlmServer([
157+
{
158+
content: {
159+
technologies: ["node"],
160+
},
161+
},
162+
]);
163+
const gitignore = yield* startMockGitignoreServer({
164+
node: "# Created by https://www.toptal.com/developers/gitignore/api/node\nnode_modules/\n",
165+
});
166+
167+
const result = yield* runCli(
168+
[
169+
"init",
170+
"--gitignore",
171+
"--api-key",
172+
"test-key",
173+
"--base-url",
174+
llm.baseUrl,
175+
"--model",
176+
"test-model",
177+
],
178+
{
179+
cwd: repo,
180+
env: {
181+
GIT_AGENT_GITIGNORE_BASE_URL: gitignore.baseUrl,
182+
},
183+
httpClientLayer: makeMockHttpClientLayer(llm.handler, gitignore.handler),
184+
},
185+
);
186+
187+
expect(result.exitCode).toBe(0);
188+
expect(result.stdout).toContain(".gitignore updated: node");
189+
expect(yield* readTextFile(repo, ".ai-commit/config.json")).toContain("conventional");
190+
}),
191+
);
192+
136193
it.effect(
137194
"git commit fails without an API key before mutating the repository",
138195
Effect.fn(function* () {
@@ -205,6 +262,66 @@ describe.concurrent("CLI integration (git)", () => {
205262
}),
206263
);
207264

265+
it.effect(
266+
"git commit --dry-run preserves a partially staged index",
267+
Effect.fn(function* () {
268+
const repo = yield* createGitRepo();
269+
yield* writeTextFile(
270+
repo,
271+
"src/app.ts",
272+
"one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\n",
273+
);
274+
yield* gitCommitAll(repo, "chore: seed repo");
275+
yield* writeTextFile(
276+
repo,
277+
".ai-commit/config.json",
278+
projectScopesConfig([["core", "Core"]]),
279+
);
280+
yield* writeTextFile(
281+
repo,
282+
"src/app.ts",
283+
"ONE\ntwo\nthree\nfour\nfive\nsix\nseven\nEIGHT\n",
284+
);
285+
stagePartialHunk(repo, "src/app.ts", "one", "ONE");
286+
287+
const llm = yield* startMockLlmServer([
288+
{
289+
content: {
290+
title: "fix(core): preserve staged patch",
291+
bullets: ["Keep the dry run read-only"],
292+
explanation: "Ensures dry-run does not rewrite the git index.",
293+
},
294+
},
295+
]);
296+
297+
const beforeCached = yield* git(repo, ["diff", "--cached", "--binary"]);
298+
const beforeUnstaged = yield* git(repo, ["diff", "--binary"]);
299+
const result = yield* runCli(
300+
[
301+
"commit",
302+
"--dry-run",
303+
"--no-stage",
304+
"--api-key",
305+
"test-key",
306+
"--base-url",
307+
llm.baseUrl,
308+
"--model",
309+
"test-model",
310+
],
311+
{
312+
cwd: repo,
313+
httpClientLayer: makeMockHttpClientLayer(llm.handler),
314+
},
315+
);
316+
const afterCached = yield* git(repo, ["diff", "--cached", "--binary"]);
317+
const afterUnstaged = yield* git(repo, ["diff", "--binary"]);
318+
319+
expect(result.exitCode).toBe(0);
320+
expect(afterCached.stdout).toBe(beforeCached.stdout);
321+
expect(afterUnstaged.stdout).toBe(beforeUnstaged.stdout);
322+
}),
323+
);
324+
208325
it.effect(
209326
"git commit rejects --amend and --no-stage together",
210327
Effect.fn(function* () {
@@ -229,6 +346,69 @@ describe.concurrent("CLI integration (git)", () => {
229346
}),
230347
);
231348

349+
it.effect(
350+
"git commit --no-stage preserves unstaged hunks in a partially staged file",
351+
Effect.fn(function* () {
352+
const repo = yield* createGitRepo();
353+
yield* writeTextFile(
354+
repo,
355+
"src/app.ts",
356+
"one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\n",
357+
);
358+
yield* gitCommitAll(repo, "chore: seed repo");
359+
yield* writeTextFile(
360+
repo,
361+
".ai-commit/config.json",
362+
projectScopesConfig([["core", "Core"]]),
363+
);
364+
yield* writeTextFile(
365+
repo,
366+
"src/app.ts",
367+
"ONE\ntwo\nthree\nfour\nfive\nsix\nseven\nEIGHT\n",
368+
);
369+
stagePartialHunk(repo, "src/app.ts", "one", "ONE");
370+
371+
const llm = yield* startMockLlmServer([
372+
{
373+
content: {
374+
title: "fix(core): preserve staged hunk",
375+
bullets: ["Commit only the staged part"],
376+
explanation: "Leaves the remaining working tree change untouched.",
377+
},
378+
},
379+
]);
380+
381+
const result = yield* runCli(
382+
[
383+
"commit",
384+
"--no-stage",
385+
"--api-key",
386+
"test-key",
387+
"--base-url",
388+
llm.baseUrl,
389+
"--model",
390+
"test-model",
391+
],
392+
{
393+
cwd: repo,
394+
httpClientLayer: makeMockHttpClientLayer(llm.handler),
395+
},
396+
);
397+
398+
const committed = yield* git(repo, ["show", "HEAD:src/app.ts"]);
399+
const unstaged = yield* git(repo, ["diff", "--", "src/app.ts"]);
400+
const cached = yield* git(repo, ["diff", "--cached", "--", "src/app.ts"]);
401+
402+
expect(result.exitCode).toBe(0);
403+
expect(committed.stdout).toBe("ONE\ntwo\nthree\nfour\nfive\nsix\nseven\neight\n");
404+
expect(unstaged.stdout).toContain("-eight");
405+
expect(unstaged.stdout).toContain("+EIGHT");
406+
expect(unstaged.stdout).not.toContain("-one");
407+
expect(unstaged.stdout).not.toContain("+ONE");
408+
expect(cached.stdout).toBe("");
409+
}),
410+
);
411+
232412
it.effect(
233413
"git commit --dry-run plans and renders split commits from real file changes",
234414
Effect.fn(function* () {
@@ -342,7 +522,7 @@ describe.concurrent("CLI integration (git)", () => {
342522

343523
expect(result.exitCode).toBe(0);
344524
expect(result.stdout).toContain("feat(api): update routes");
345-
expect(yield* fileExists(repo, ".ai-commit/config.yml")).toBe(false);
525+
expect(yield* fileExists(repo, ".ai-commit/config.json")).toBe(false);
346526
expect(llm.requests).toHaveLength(3);
347527
}),
348528
);
@@ -444,8 +624,8 @@ describe.concurrent("CLI integration (git)", () => {
444624
const repo = yield* createGitRepo();
445625
yield* writeTextFile(
446626
repo,
447-
".ai-commit/config.yml",
448-
"scopes:\n - name: core\n description: Shared application logic\nhook:\n - conventional\n",
627+
".ai-commit/config.json",
628+
'{\n "scopes": [\n {\n "name": "core",\n "description": "Shared application logic"\n }\n ],\n "hook": ["conventional"]\n}\n',
449629
);
450630
yield* writeTextFile(repo, "src/app.ts", "export const value = 'base';\n");
451631
yield* writeTextFile(repo, "src/extra.ts", "export const extra = 'base';\n");
@@ -609,8 +789,8 @@ describe.concurrent("CLI integration (git)", () => {
609789
const repo = yield* createGitRepo();
610790
yield* writeTextFile(
611791
repo,
612-
".ai-commit/config.yml",
613-
"scopes:\n - name: core\n description: Shared application logic\nhook:\n - conventional\n",
792+
".ai-commit/config.json",
793+
'{\n "scopes": [\n {\n "name": "core",\n "description": "Shared application logic"\n }\n ],\n "hook": ["conventional"]\n}\n',
614794
);
615795
yield* writeTextFile(repo, "src/app.ts", "export const value = 'base';\n");
616796
yield* writeTextFile(repo, "src/extra.ts", "export const extra = 'base';\n");

tests/cli-integration-jj.test.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,13 @@ const seedJjRepoWithScopes = Effect.fn(function* () {
2727
const repo = yield* seedJjRepo();
2828
yield* writeTextFile(
2929
repo,
30-
".ai-commit/config.yml",
30+
".ai-commit/config.json",
3131
projectScopesConfig([
3232
["cli", "Command line flows"],
3333
["core", "Shared application logic"],
3434
]),
3535
);
36-
yield* jjCommitAll(repo, "chore: add repo config", [".ai-commit/config.yml"]);
36+
yield* jjCommitAll(repo, "chore: add repo config", [".ai-commit/config.json"]);
3737
return repo;
3838
});
3939

@@ -77,11 +77,11 @@ describe.concurrent("CLI integration (jj)", () => {
7777
expect(result.stdout).toContain(".gitignore updated: node");
7878
expect(result.stdout).toContain("scopes written");
7979

80-
const config = yield* readTextFile(repo, ".ai-commit/config.yml");
80+
const config = yield* readTextFile(repo, ".ai-commit/config.json");
8181
const ignore = yield* readTextFile(repo, ".gitignore");
82-
expect(config).toContain("name: core");
83-
expect(config).toContain("hook:");
84-
expect(config).toContain("- conventional");
82+
expect(config).toContain('"name": "core"');
83+
expect(config).toContain('"hook": [');
84+
expect(config).toContain('"conventional"');
8585
expect(ignore).toContain("node_modules/");
8686
expect(llm.requests).toHaveLength(2);
8787
}),
@@ -359,11 +359,11 @@ describe.concurrent("CLI integration (jj)", () => {
359359
const repo = yield* createJjRepo();
360360
yield* writeTextFile(
361361
repo,
362-
".ai-commit/config.yml",
363-
"scopes:\n - name: core\n description: Shared application logic\nhook:\n - conventional\n",
362+
".ai-commit/config.json",
363+
'{\n "scopes": [\n {\n "name": "core",\n "description": "Shared application logic"\n }\n ],\n "hook": ["conventional"]\n}\n',
364364
);
365365
yield* writeTextFile(repo, "src/app.ts", "export const value = 'base';\n");
366-
yield* jjCommitAll(repo, "chore: seed repo", [".ai-commit/config.yml", "src/app.ts"]);
366+
yield* jjCommitAll(repo, "chore: seed repo", [".ai-commit/config.json", "src/app.ts"]);
367367
yield* writeTextFile(repo, "src/app.ts", "export const value = 'next';\n");
368368

369369
const firstTitle =
@@ -429,11 +429,11 @@ describe.concurrent("CLI integration (jj)", () => {
429429
const repo = yield* createJjRepo();
430430
yield* writeTextFile(
431431
repo,
432-
".ai-commit/config.yml",
433-
"scopes:\n - name: core\n description: Shared application logic\nhook:\n - conventional\n",
432+
".ai-commit/config.json",
433+
'{\n "scopes": [\n {\n "name": "core",\n "description": "Shared application logic"\n }\n ],\n "hook": ["conventional"]\n}\n',
434434
);
435435
yield* writeTextFile(repo, "src/app.ts", "export const value = 'base';\n");
436-
yield* jjCommitAll(repo, "chore: seed repo", [".ai-commit/config.yml", "src/app.ts"]);
436+
yield* jjCommitAll(repo, "chore: seed repo", [".ai-commit/config.json", "src/app.ts"]);
437437
yield* writeTextFile(repo, "src/app.ts", "export const value = 'next';\n");
438438

439439
const badMessage = {

tests/cli-integration.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ describe.concurrent("CLI integration", () => {
1616
"get prefers local hook over project hook",
1717
Effect.fn(function* () {
1818
const repo = yield* createGitRepo();
19-
yield* writeTextFile(repo, ".ai-commit/config.yml", "hook:\n - conventional\n");
20-
yield* writeTextFile(repo, ".ai-commit/config.local.yml", "hook:\n - empty\n");
19+
yield* writeTextFile(repo, ".ai-commit/config.json", '{\n "hook": ["conventional"]\n}\n');
20+
yield* writeTextFile(repo, ".ai-commit/config.local.json", '{\n "hook": ["empty"]\n}\n');
2121

2222
const result = yield* runCli(["config", "get", "hook"], {
2323
cwd: repo,
@@ -43,7 +43,7 @@ describe.concurrent("CLI integration", () => {
4343
expect(result.exitCode).toBe(0);
4444
expect(result.stdout).toContain(`installed hook: ${hookPath}`);
4545
expect(yield* fileExists(repo, ".ai-commit/hooks/pre-commit")).toBe(true);
46-
expect(yield* readTextFile(repo, ".ai-commit/config.yml")).toContain(hookPath);
46+
expect(yield* readTextFile(repo, ".ai-commit/config.json")).toContain(hookPath);
4747
}),
4848
);
4949

@@ -78,8 +78,8 @@ describe.concurrent("CLI integration", () => {
7878
expect(result.exitCode).toBe(0);
7979
expect(result.stdout).toContain("set model = gpt-4o-mini (user)");
8080

81-
const userConfig = yield* readTextFile(repo, "../.xdg/ai-commit/config.yml");
82-
expect(userConfig).toContain("model: gpt-4o-mini");
81+
const userConfig = yield* readTextFile(repo, "../.xdg/ai-commit/config.json");
82+
expect(userConfig).toContain('"model": "gpt-4o-mini"');
8383
}),
8484
);
8585

@@ -93,7 +93,7 @@ describe.concurrent("CLI integration", () => {
9393
);
9494

9595
expect(result.exitCode).not.toBe(0);
96-
expect(yield* fileExists(repo, ".ai-commit/config.yml")).toBe(false);
96+
expect(yield* fileExists(repo, ".ai-commit/config.json")).toBe(false);
9797
}),
9898
);
9999

0 commit comments

Comments
 (0)