11import { NodeServices } from "@effect/platform-node" ;
22import { describe , expect , layer } from "@effect/vitest" ;
33import { Effect } from "effect" ;
4+ import { execFileSync } from "node:child_process" ;
45import {
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+
4155describe . 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" ) ;
0 commit comments