55// PURITY: SHELL (integration tests using real git)
66// INVARIANT: each test uses an isolated temp dir and a local bare repo as fake remote
77
8+ import * as Command from "@effect/platform/Command"
9+ import * as CommandExecutor from "@effect/platform/CommandExecutor"
810import * as FileSystem from "@effect/platform/FileSystem"
911import * as Path from "@effect/platform/Path"
1012import { NodeContext } from "@effect/platform-node"
1113import { describe , expect , it } from "@effect/vitest"
12- import { Effect } from "effect"
13- import { execSync } from "node:child_process "
14- import * as nodePath from "node:path "
14+ import { Effect , pipe } from "effect"
15+ import * as Chunk from "effect/Chunk "
16+ import * as Stream from "effect/Stream "
1517
1618import { stateInit } from "../../src/usecases/state-repo.js"
1719
1820// ---------------------------------------------------------------------------
1921// Helpers
2022// ---------------------------------------------------------------------------
2123
24+ // GIT_CONFIG_NOSYSTEM=1 bypasses system-level git hooks (e.g. the docker-git
25+ // pre-push hook that blocks pushes to `main`). Only used in test seeding, not
26+ // in the code-under-test.
27+ const seedEnv : Record < string , string > = { GIT_CONFIG_NOSYSTEM : "1" }
28+
29+ const collectUint8Array = ( chunks : Chunk . Chunk < Uint8Array > ) : Uint8Array =>
30+ Chunk . reduce ( chunks , new Uint8Array ( ) , ( acc , curr ) => {
31+ const next = new Uint8Array ( acc . length + curr . length )
32+ next . set ( acc )
33+ next . set ( curr , acc . length )
34+ return next
35+ } )
36+
37+ const captureGit = (
38+ args : ReadonlyArray < string > ,
39+ cwd : string
40+ ) : Effect . Effect < string , Error , CommandExecutor . CommandExecutor > =>
41+ Effect . scoped (
42+ Effect . gen ( function * ( _ ) {
43+ const executor = yield * _ ( CommandExecutor . CommandExecutor )
44+ const cmd = pipe (
45+ Command . make ( "git" , ...args ) ,
46+ Command . workingDirectory ( cwd ) ,
47+ Command . env ( seedEnv ) ,
48+ Command . stdout ( "pipe" ) ,
49+ Command . stderr ( "pipe" ) ,
50+ Command . stdin ( "pipe" )
51+ )
52+ const proc = yield * _ ( executor . start ( cmd ) )
53+ const bytes = yield * _ (
54+ pipe ( proc . stdout , Stream . runCollect , Effect . map ( ( c ) => collectUint8Array ( c ) ) )
55+ )
56+ const exitCode = yield * _ ( proc . exitCode )
57+ if ( Number ( exitCode ) !== 0 ) {
58+ return yield * _ ( Effect . fail ( new Error ( `git ${ args . join ( " " ) } exited with ${ String ( exitCode ) } ` ) ) )
59+ }
60+ return new TextDecoder ( "utf-8" ) . decode ( bytes ) . trim ( )
61+ } )
62+ )
63+
64+ const runShell = (
65+ script : string ,
66+ cwd : string
67+ ) : Effect . Effect < string , Error , CommandExecutor . CommandExecutor > =>
68+ Effect . scoped (
69+ Effect . gen ( function * ( _ ) {
70+ const executor = yield * _ ( CommandExecutor . CommandExecutor )
71+ const cmd = pipe (
72+ Command . make ( "sh" , "-c" , script ) ,
73+ Command . workingDirectory ( cwd ) ,
74+ Command . env ( seedEnv ) ,
75+ Command . stdout ( "pipe" ) ,
76+ Command . stderr ( "pipe" ) ,
77+ Command . stdin ( "pipe" )
78+ )
79+ const proc = yield * _ ( executor . start ( cmd ) )
80+ const bytes = yield * _ (
81+ pipe ( proc . stdout , Stream . runCollect , Effect . map ( ( c ) => collectUint8Array ( c ) ) )
82+ )
83+ const exitCode = yield * _ ( proc . exitCode )
84+ if ( Number ( exitCode ) !== 0 ) {
85+ return yield * _ ( Effect . fail ( new Error ( `sh -c '${ script } ' exited with ${ String ( exitCode ) } ` ) ) )
86+ }
87+ return new TextDecoder ( "utf-8" ) . decode ( bytes ) . trim ( )
88+ } )
89+ )
90+
2291/**
2392 * Create a local bare git repository that can act as a remote for tests.
2493 * Optionally seeds it with an initial commit so that `git fetch` has history.
2594 *
26- * @pure false (filesystem + process spawn)
95+ * @pure false
2796 * @invariant returned path is always an absolute path to a bare repo
2897 */
29- // GIT_CONFIG_NOSYSTEM=1 bypasses system-level git hooks (e.g. the docker-git
30- // pre-push hook that blocks pushes to `main`). Only used in test seeding, not
31- // in the code-under-test.
32- const seedEnv = { ...process . env , GIT_CONFIG_NOSYSTEM : "1" }
33-
34- const makeFakeRemote = ( baseDir : string , withInitialCommit : boolean ) : string => {
35- const remotePath = nodePath . join ( baseDir , "remote.git" )
36- execSync ( `git init --bare --initial-branch=main "${ remotePath } " 2>/dev/null || git init --bare "${ remotePath } "` , { env : seedEnv } )
37-
38- if ( withInitialCommit ) {
39- // Seed the bare repo by creating a local repo and pushing to it
40- const seedDir = nodePath . join ( baseDir , "seed" )
41- execSync ( `git init --initial-branch=main "${ seedDir } " 2>/dev/null || git init "${ seedDir } "` , { env : seedEnv } )
42- execSync ( `git -C "${ seedDir } " config user.email "test@example.com"` )
43- execSync ( `git -C "${ seedDir } " config user.name "Test"` )
44- execSync ( `git -C "${ seedDir } " remote add origin "${ remotePath } "` )
45- execSync ( `echo "# .docker-git" > "${ seedDir } /README.md"` )
46- execSync ( `git -C "${ seedDir } " add -A` , { env : seedEnv } )
47- execSync ( `git -C "${ seedDir } " commit -m "initial"` , { env : seedEnv } )
48- // Push explicitly to main regardless of local default branch name.
49- // GIT_CONFIG_NOSYSTEM bypasses the docker-git system pre-push hook.
50- execSync ( `git -C "${ seedDir } " push origin HEAD:refs/heads/main` , { env : seedEnv } )
51- }
52-
53- return remotePath
54- }
98+ const makeFakeRemote = (
99+ p : Path . Path ,
100+ baseDir : string ,
101+ withInitialCommit : boolean
102+ ) : Effect . Effect < string , Error , CommandExecutor . CommandExecutor > =>
103+ Effect . gen ( function * ( _ ) {
104+ const remotePath = p . join ( baseDir , "remote.git" )
105+ yield * _ ( runShell (
106+ `git init --bare --initial-branch=main "${ remotePath } " 2>/dev/null || git init --bare "${ remotePath } "` ,
107+ baseDir
108+ ) )
109+
110+ if ( withInitialCommit ) {
111+ const seedDir = p . join ( baseDir , "seed" )
112+ yield * _ ( runShell (
113+ `git init --initial-branch=main "${ seedDir } " 2>/dev/null || git init "${ seedDir } "` ,
114+ baseDir
115+ ) )
116+ yield * _ ( captureGit ( [ "config" , "user.email" , "test@example.com" ] , seedDir ) )
117+ yield * _ ( captureGit ( [ "config" , "user.name" , "Test" ] , seedDir ) )
118+ yield * _ ( captureGit ( [ "remote" , "add" , "origin" , remotePath ] , seedDir ) )
119+ yield * _ ( runShell ( `echo "# .docker-git" > "${ seedDir } /README.md"` , seedDir ) )
120+ yield * _ ( captureGit ( [ "add" , "-A" ] , seedDir ) )
121+ yield * _ ( captureGit ( [ "commit" , "-m" , "initial" ] , seedDir ) )
122+ yield * _ ( captureGit ( [ "push" , "origin" , "HEAD:refs/heads/main" ] , seedDir ) )
123+ }
124+
125+ return remotePath
126+ } )
55127
56128/**
57129 * Run an Effect inside a freshly created temp directory, cleaning up after.
@@ -60,14 +132,15 @@ const makeFakeRemote = (baseDir: string, withInitialCommit: boolean): string =>
60132 */
61133const withTempStateRoot = < A , E , R > (
62134 use : ( opts : { tempBase : string ; stateRoot : string } ) => Effect . Effect < A , E , R >
63- ) : Effect . Effect < A , E , R | FileSystem . FileSystem > =>
135+ ) : Effect . Effect < A , E , R | FileSystem . FileSystem | Path . Path > =>
64136 Effect . scoped (
65137 Effect . gen ( function * ( _ ) {
66138 const fs = yield * _ ( FileSystem . FileSystem )
139+ const p = yield * _ ( Path . Path )
67140 const tempBase = yield * _ (
68141 fs . makeTempDirectoryScoped ( { prefix : "docker-git-state-init-" } )
69142 )
70- const stateRoot = nodePath . join ( tempBase , "state" )
143+ const stateRoot = p . join ( tempBase , "state" )
71144
72145 const previous = process . env [ "DOCKER_GIT_PROJECTS_ROOT" ]
73146 yield * _ (
@@ -95,94 +168,68 @@ describe("stateInit", () => {
95168 it . effect ( "clones an empty remote into an empty local directory" , ( ) =>
96169 withTempStateRoot ( ( { tempBase, stateRoot } ) =>
97170 Effect . gen ( function * ( _ ) {
98- const remoteUrl = makeFakeRemote ( tempBase , true )
171+ const p = yield * _ ( Path . Path )
172+ const remoteUrl = yield * _ ( makeFakeRemote ( p , tempBase , true ) )
99173
100174 yield * _ ( stateInit ( { repoUrl : remoteUrl , repoRef : "main" } ) )
101175
102- // .git directory must exist
103176 const fs = yield * _ ( FileSystem . FileSystem )
104- const hasGit = yield * _ ( fs . exists ( nodePath . join ( stateRoot , ".git" ) ) )
177+ const hasGit = yield * _ ( fs . exists ( p . join ( stateRoot , ".git" ) ) )
105178 expect ( hasGit ) . toBe ( true )
106179
107- // origin remote must point to remoteUrl
108- const originOut = execSync (
109- `git -C "${ stateRoot } " remote get-url origin`
110- ) . toString ( ) . trim ( )
180+ const originOut = yield * _ ( captureGit ( [ "remote" , "get-url" , "origin" ] , stateRoot ) )
111181 expect ( originOut ) . toBe ( remoteUrl )
112182
113- // HEAD must be on main branch with at least one commit
114- const branch = execSync (
115- `git -C "${ stateRoot } " rev-parse --abbrev-ref HEAD`
116- ) . toString ( ) . trim ( )
183+ const branch = yield * _ ( captureGit ( [ "rev-parse" , "--abbrev-ref" , "HEAD" ] , stateRoot ) )
117184 expect ( branch ) . toBe ( "main" )
118185
119- const log = execSync (
120- `git -C "${ stateRoot } " log --oneline`
121- ) . toString ( ) . trim ( )
186+ const log = yield * _ ( captureGit ( [ "log" , "--oneline" ] , stateRoot ) )
122187 expect ( log . length ) . toBeGreaterThan ( 0 )
123188 } )
124189 ) . pipe ( Effect . provide ( NodeContext . layer ) ) )
125190
126191 it . effect ( "adopts remote history when local dir has files but no .git (the bug fix)" , ( ) =>
127192 withTempStateRoot ( ( { tempBase, stateRoot } ) =>
128193 Effect . gen ( function * ( _ ) {
129- const remoteUrl = makeFakeRemote ( tempBase , true )
194+ const p = yield * _ ( Path . Path )
195+ const remoteUrl = yield * _ ( makeFakeRemote ( p , tempBase , true ) )
130196
131- // Simulate the bug scenario: stateRoot exists with files but no .git
132197 const fs = yield * _ ( FileSystem . FileSystem )
133- const orchAuthDir = nodePath . join ( stateRoot , ".orch" , "auth" )
198+ const orchAuthDir = p . join ( stateRoot , ".orch" , "auth" )
134199 yield * _ ( fs . makeDirectory ( orchAuthDir , { recursive : true } ) )
135- yield * _ ( fs . writeFileString ( nodePath . join ( orchAuthDir , "github.env" ) , "GH_TOKEN=test\n" ) )
200+ yield * _ ( fs . writeFileString ( p . join ( orchAuthDir , "github.env" ) , "GH_TOKEN=test\n" ) )
136201
137- // Run stateInit — must NOT create a divergent root commit
138202 yield * _ ( stateInit ( { repoUrl : remoteUrl , repoRef : "main" } ) )
139203
140- // .git directory must exist after init
141- const hasGit = yield * _ ( fs . exists ( nodePath . join ( stateRoot , ".git" ) ) )
204+ const hasGit = yield * _ ( fs . exists ( p . join ( stateRoot , ".git" ) ) )
142205 expect ( hasGit ) . toBe ( true )
143206
144- // origin remote must be configured
145- const originOut = execSync (
146- `git -C "${ stateRoot } " remote get-url origin`
147- ) . toString ( ) . trim ( )
207+ const originOut = yield * _ ( captureGit ( [ "remote" , "get-url" , "origin" ] , stateRoot ) )
148208 expect ( originOut ) . toBe ( remoteUrl )
149209
150- // HEAD must point to main
151- const branch = execSync (
152- `git -C "${ stateRoot } " rev-parse --abbrev-ref HEAD`
153- ) . toString ( ) . trim ( )
210+ const branch = yield * _ ( captureGit ( [ "rev-parse" , "--abbrev-ref" , "HEAD" ] , stateRoot ) )
154211 expect ( branch ) . toBe ( "main" )
155212
156213 // INVARIANT: no divergent root commit — the repo must share history with remote
157- // Verify by checking that local HEAD includes the remote initial commit
158- const remoteHead = execSync (
159- `git -C "${ stateRoot } " rev-parse origin/main`
160- ) . toString ( ) . trim ( )
161- const mergeBase = execSync (
162- `git -C "${ stateRoot } " merge-base HEAD origin/main || git -C "${ stateRoot } " rev-parse origin/main`
163- ) . toString ( ) . trim ( )
214+ const remoteHead = yield * _ ( captureGit ( [ "rev-parse" , "origin/main" ] , stateRoot ) )
215+ const mergeBase = yield * _ (
216+ runShell ( `git merge-base HEAD origin/main || git rev-parse origin/main` , stateRoot )
217+ )
164218 expect ( mergeBase ) . toBe ( remoteHead )
165219 } )
166220 ) . pipe ( Effect . provide ( NodeContext . layer ) ) )
167221
168222 it . effect ( "is idempotent when .git already exists" , ( ) =>
169223 withTempStateRoot ( ( { tempBase, stateRoot } ) =>
170224 Effect . gen ( function * ( _ ) {
171- const remoteUrl = makeFakeRemote ( tempBase , true )
225+ const p = yield * _ ( Path . Path )
226+ const remoteUrl = yield * _ ( makeFakeRemote ( p , tempBase , true ) )
172227
173- // First call — sets up the repository
174228 yield * _ ( stateInit ( { repoUrl : remoteUrl , repoRef : "main" } ) )
229+ const firstCommit = yield * _ ( captureGit ( [ "rev-parse" , "HEAD" ] , stateRoot ) )
175230
176- const firstCommit = execSync (
177- `git -C "${ stateRoot } " rev-parse HEAD`
178- ) . toString ( ) . trim ( )
179-
180- // Second call — must be a no-op (same HEAD, no extra commits)
181231 yield * _ ( stateInit ( { repoUrl : remoteUrl , repoRef : "main" } ) )
182-
183- const secondCommit = execSync (
184- `git -C "${ stateRoot } " rev-parse HEAD`
185- ) . toString ( ) . trim ( )
232+ const secondCommit = yield * _ ( captureGit ( [ "rev-parse" , "HEAD" ] , stateRoot ) )
186233
187234 // INVARIANT: idempotent — HEAD does not change on repeated calls
188235 expect ( secondCommit ) . toBe ( firstCommit )
0 commit comments