33// REF: issue-201
44// PURITY: SHELL (executes generated bash scripts in isolated temp directories)
55
6- import { execFileSync } from "node:child_process"
7- import fs from "node:fs"
8- import os from "node:os"
9- import path from "node:path"
10- import { afterEach , describe , expect , it } from "vitest"
6+ import * as Command from "@effect/platform/Command"
7+ import * as CommandExecutor from "@effect/platform/CommandExecutor"
8+ import * as FileSystem from "@effect/platform/FileSystem"
9+ import * as Path from "@effect/platform/Path"
10+ import { NodeContext } from "@effect/platform-node"
11+ import { describe , expect , it } from "@effect/vitest"
12+ import { Effect , pipe } from "effect"
13+ import * as Chunk from "effect/Chunk"
14+ import * as Stream from "effect/Stream"
1115
1216import { renderEntrypointGitHooks } from "../../src/core/templates-entrypoint/git.js"
1317import { renderEntrypointGitPostPushWrapperInstall } from "../../src/core/templates-entrypoint/git-post-push-wrapper.js"
1418
1519type WrapperHarness = {
16- readonly rootDir : string
1720 readonly repoDir : string
1821 readonly externalDir : string
1922 readonly binDir : string
@@ -24,8 +27,6 @@ type WrapperHarness = {
2427 readonly nodeScriptLogPath : string
2528}
2629
27- const tempRoots : string [ ] = [ ]
28-
2930const fakeGitScript = `#!/usr/bin/env bash
3031set -euo pipefail
3132
@@ -100,11 +101,13 @@ set -euo pipefail
100101exit 0
101102`
102103
103- const writeExecutable = ( filePath : string , content : string ) : void => {
104- fs . mkdirSync ( path . dirname ( filePath ) , { recursive : true } )
105- fs . writeFileSync ( filePath , content )
106- fs . chmodSync ( filePath , 0o755 )
107- }
104+ const collectUint8Array = ( chunks : Chunk . Chunk < Uint8Array > ) : Uint8Array =>
105+ Chunk . reduce ( chunks , new Uint8Array ( ) , ( acc , curr ) => {
106+ const next = new Uint8Array ( acc . length + curr . length )
107+ next . set ( acc )
108+ next . set ( curr , acc . length )
109+ return next
110+ } )
108111
109112const extractEmbeddedScript = ( template : string , target : string ) : string => {
110113 const marker = `cat <<'EOF' > "${ target } "\n`
@@ -122,68 +125,65 @@ const extractEmbeddedScript = (template: string, target: string): string => {
122125 return template . slice ( bodyStart , bodyEnd )
123126}
124127
125- const readLogLines = ( filePath : string ) : ReadonlyArray < string > => {
126- if ( ! fs . existsSync ( filePath ) ) {
127- return [ ]
128- }
128+ const writeExecutable = (
129+ filePath : string ,
130+ content : string
131+ ) : Effect . Effect < void , Error , FileSystem . FileSystem | Path . Path > =>
132+ Effect . gen ( function * ( _ ) {
133+ const fs = yield * _ ( FileSystem . FileSystem )
134+ const path = yield * _ ( Path . Path )
135+ yield * _ ( fs . makeDirectory ( path . dirname ( filePath ) , { recursive : true } ) )
136+ yield * _ ( fs . writeFileString ( filePath , content ) )
137+ yield * _ ( fs . chmod ( filePath , 0o755 ) )
138+ } )
129139
130- const contents = fs . readFileSync ( filePath , "utf8" ) . trim ( )
131- return contents . length === 0 ? [ ] : contents . split ( "\n" )
132- }
140+ const readLogLines = (
141+ filePath : string
142+ ) : Effect . Effect < ReadonlyArray < string > , Error , FileSystem . FileSystem > =>
143+ Effect . gen ( function * ( _ ) {
144+ const fs = yield * _ ( FileSystem . FileSystem )
145+ const exists = yield * _ ( fs . exists ( filePath ) )
146+ if ( ! exists ) {
147+ return [ ]
148+ }
149+
150+ const contents = yield * _ ( fs . readFileString ( filePath ) )
151+ const trimmed = contents . trim ( )
152+ return trimmed . length === 0 ? [ ] : trimmed . split ( "\n" )
153+ } )
154+
155+ const runCommand = (
156+ command : string ,
157+ args : ReadonlyArray < string > ,
158+ cwd : string ,
159+ env ?: Readonly < Record < string , string | undefined > >
160+ ) : Effect . Effect < string , Error , CommandExecutor . CommandExecutor > =>
161+ Effect . scoped (
162+ Effect . gen ( function * ( _ ) {
163+ const executor = yield * _ ( CommandExecutor . CommandExecutor )
164+ const cmd = pipe (
165+ Command . make ( command , ...args ) ,
166+ Command . workingDirectory ( cwd ) ,
167+ env ? Command . env ( env ) : ( value ) => value ,
168+ Command . stdout ( "pipe" ) ,
169+ Command . stderr ( "pipe" ) ,
170+ Command . stdin ( "pipe" )
171+ )
172+ const proc = yield * _ ( executor . start ( cmd ) )
173+ yield * _ ( Effect . forkDaemon ( Stream . runDrain ( proc . stderr ) ) )
174+ const stdoutBytes = yield * _ (
175+ pipe ( proc . stdout , Stream . runCollect , Effect . map ( ( chunks ) => collectUint8Array ( chunks ) ) )
176+ )
177+ const exitCode = yield * _ ( proc . exitCode )
178+ if ( Number ( exitCode ) !== 0 ) {
179+ return yield * _ ( Effect . fail ( new Error ( `${ command } ${ args . join ( " " ) } exited with ${ String ( exitCode ) } ` ) ) )
180+ }
133181
134- const makeHarness = ( ) : WrapperHarness => {
135- const rootDir = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , "docker-git-post-push-" ) )
136- tempRoots . push ( rootDir )
137-
138- const repoDir = path . join ( rootDir , "repo" )
139- const externalDir = path . join ( rootDir , "external" )
140- const binDir = path . join ( rootDir , "bin" )
141- const hooksDir = path . join ( rootDir , "hooks" )
142- const gitLogPath = path . join ( rootDir , "git.log" )
143- const nodeCwdLogPath = path . join ( rootDir , "node-cwd.log" )
144- const nodeRepoRootLogPath = path . join ( rootDir , "node-repo-root.log" )
145- const nodeScriptLogPath = path . join ( rootDir , "node-script.log" )
146-
147- fs . mkdirSync ( path . join ( repoDir , ".git" ) , { recursive : true } )
148- fs . mkdirSync ( path . join ( repoDir , "scripts" ) , { recursive : true } )
149- fs . mkdirSync ( externalDir , { recursive : true } )
150- fs . mkdirSync ( binDir , { recursive : true } )
151- fs . mkdirSync ( hooksDir , { recursive : true } )
152- fs . writeFileSync ( path . join ( repoDir , "scripts" , "session-backup-gist.js" ) , "// test placeholder\n" )
153-
154- writeExecutable ( path . join ( binDir , "git" ) , fakeGitScript )
155- writeExecutable ( path . join ( binDir , "git-real" ) , fakeGitScript )
156- writeExecutable ( path . join ( binDir , "gh" ) , fakeGhScript )
157- writeExecutable ( path . join ( binDir , "node" ) , fakeNodeScript )
158-
159- const postPushScript = extractEmbeddedScript ( renderEntrypointGitHooks ( ) , "$POST_PUSH_ACTION" )
160- const postPushPath = path . join ( hooksDir , "post-push" )
161- writeExecutable ( postPushPath , postPushScript )
162-
163- const wrapperTemplate = extractEmbeddedScript (
164- renderEntrypointGitPostPushWrapperInstall ( ) ,
165- "$GIT_WRAPPER_BIN"
182+ return new TextDecoder ( "utf-8" ) . decode ( stdoutBytes ) . trim ( )
183+ } )
166184 )
167- const wrapperPath = path . join ( rootDir , "git-wrapper" )
168- const wrapperScript = wrapperTemplate
169- . replace ( "__DOCKER_GIT_REAL_BIN__" , path . join ( binDir , "git-real" ) )
170- . replace ( "/opt/docker-git/hooks/post-push" , postPushPath )
171- writeExecutable ( wrapperPath , wrapperScript )
172-
173- return {
174- rootDir,
175- repoDir,
176- externalDir,
177- binDir,
178- wrapperPath,
179- gitLogPath,
180- nodeCwdLogPath,
181- nodeRepoRootLogPath,
182- nodeScriptLogPath
183- }
184- }
185185
186- const makeHarnessEnv = ( harness : WrapperHarness ) : NodeJS . ProcessEnv => ( {
186+ const makeHarnessEnv = ( harness : WrapperHarness ) : Readonly < Record < string , string | undefined > > => ( {
187187 ...process . env ,
188188 PATH : `${ harness . binDir } :${ process . env [ "PATH" ] ?? "" } ` ,
189189 FAKE_GIT_LOG_PATH : harness . gitLogPath ,
@@ -196,62 +196,118 @@ const runWrapper = (
196196 harness : WrapperHarness ,
197197 cwd : string ,
198198 args : ReadonlyArray < string >
199- ) : void => {
200- execFileSync ( harness . wrapperPath , args , {
201- cwd,
202- env : makeHarnessEnv ( harness ) ,
203- encoding : "utf8" ,
204- stdio : "pipe"
205- } )
206- }
199+ ) : Effect . Effect < void , Error , CommandExecutor . CommandExecutor > =>
200+ runCommand ( harness . wrapperPath , args , cwd , makeHarnessEnv ( harness ) ) . pipe ( Effect . asVoid )
201+
202+ const withHarness = < A , E , R > (
203+ use : ( harness : WrapperHarness ) => Effect . Effect < A , E , R >
204+ ) : Effect . Effect < A , E , R | FileSystem . FileSystem | Path . Path > =>
205+ Effect . scoped (
206+ Effect . gen ( function * ( _ ) {
207+ const fs = yield * _ ( FileSystem . FileSystem )
208+ const path = yield * _ ( Path . Path )
209+ const rootDir = yield * _ (
210+ fs . makeTempDirectoryScoped ( {
211+ prefix : "docker-git-post-push-"
212+ } )
213+ )
214+
215+ const repoDir = path . join ( rootDir , "repo" )
216+ const externalDir = path . join ( rootDir , "external" )
217+ const binDir = path . join ( rootDir , "bin" )
218+ const hooksDir = path . join ( rootDir , "hooks" )
219+ const gitLogPath = path . join ( rootDir , "git.log" )
220+ const nodeCwdLogPath = path . join ( rootDir , "node-cwd.log" )
221+ const nodeRepoRootLogPath = path . join ( rootDir , "node-repo-root.log" )
222+ const nodeScriptLogPath = path . join ( rootDir , "node-script.log" )
223+
224+ yield * _ ( fs . makeDirectory ( path . join ( repoDir , ".git" ) , { recursive : true } ) )
225+ yield * _ ( fs . makeDirectory ( path . join ( repoDir , "scripts" ) , { recursive : true } ) )
226+ yield * _ ( fs . makeDirectory ( externalDir , { recursive : true } ) )
227+ yield * _ ( fs . makeDirectory ( binDir , { recursive : true } ) )
228+ yield * _ ( fs . makeDirectory ( hooksDir , { recursive : true } ) )
229+ yield * _ ( fs . writeFileString ( path . join ( repoDir , "scripts" , "session-backup-gist.js" ) , "// test placeholder\n" ) )
230+
231+ yield * _ ( writeExecutable ( path . join ( binDir , "git" ) , fakeGitScript ) )
232+ yield * _ ( writeExecutable ( path . join ( binDir , "git-real" ) , fakeGitScript ) )
233+ yield * _ ( writeExecutable ( path . join ( binDir , "gh" ) , fakeGhScript ) )
234+ yield * _ ( writeExecutable ( path . join ( binDir , "node" ) , fakeNodeScript ) )
235+
236+ const postPushScript = extractEmbeddedScript ( renderEntrypointGitHooks ( ) , "$POST_PUSH_ACTION" )
237+ const postPushPath = path . join ( hooksDir , "post-push" )
238+ yield * _ ( writeExecutable ( postPushPath , postPushScript ) )
239+
240+ const wrapperTemplate = extractEmbeddedScript (
241+ renderEntrypointGitPostPushWrapperInstall ( ) ,
242+ "$GIT_WRAPPER_BIN"
243+ )
244+ const wrapperPath = path . join ( rootDir , "git-wrapper" )
245+ const wrapperScript = wrapperTemplate
246+ . replace ( "__DOCKER_GIT_REAL_BIN__" , path . join ( binDir , "git-real" ) )
247+ . replace ( "/opt/docker-git/hooks/post-push" , postPushPath )
248+ yield * _ ( writeExecutable ( wrapperPath , wrapperScript ) )
249+
250+ return yield * _ (
251+ use ( {
252+ repoDir,
253+ externalDir,
254+ binDir,
255+ wrapperPath,
256+ gitLogPath,
257+ nodeCwdLogPath,
258+ nodeRepoRootLogPath,
259+ nodeScriptLogPath
260+ } )
261+ )
262+ } )
263+ )
207264
208265describe ( "git post-push wrapper" , ( ) => {
209- afterEach ( ( ) => {
210- while ( tempRoots . length > 0 ) {
211- const root = tempRoots . pop ( )
212- if ( root !== undefined ) {
213- fs . rmSync ( root , { recursive : true , force : true } )
214- }
215- }
216- } )
217-
218- it ( "runs session backup from the repository root for a normal push" , ( ) => {
219- const harness = makeHarness ( )
220-
221- runWrapper ( harness , harness . repoDir , [ "push" , "origin" , "HEAD" ] )
222-
223- expect ( readLogLines ( harness . nodeCwdLogPath ) ) . toEqual ( [ harness . repoDir ] )
224- expect ( readLogLines ( harness . nodeRepoRootLogPath ) ) . toEqual ( [ harness . repoDir ] )
225- expect ( readLogLines ( harness . nodeScriptLogPath ) ) . toEqual ( [
226- path . join ( harness . repoDir , "scripts" , "session-backup-gist.js" )
227- ] )
228- } )
229-
230- it ( "preserves the pushed repository context for git -C push invocations" , ( ) => {
231- const harness = makeHarness ( )
232-
233- runWrapper ( harness , harness . externalDir , [ "-C" , harness . repoDir , "push" , "origin" , "HEAD" ] )
234-
235- expect ( readLogLines ( harness . nodeCwdLogPath ) ) . toEqual ( [ harness . repoDir ] )
236- expect ( readLogLines ( harness . nodeRepoRootLogPath ) ) . toEqual ( [ harness . repoDir ] )
237- expect ( readLogLines ( harness . nodeScriptLogPath ) ) . toEqual ( [
238- path . join ( harness . repoDir , "scripts" , "session-backup-gist.js" )
239- ] )
240- expect ( readLogLines ( harness . gitLogPath ) . some ( ( line ) => line . startsWith ( `${ harness . externalDir } \t-C ${ harness . repoDir } push` ) ) ) . toBe (
241- true
242- )
243- } )
244-
245- it . each ( [
246- [ "--dry-run" ] ,
247- [ "-n" ]
248- ] ) ( "does not run session backup for dry-run push (%s)" , ( dryRunFlag ) => {
249- const harness = makeHarness ( )
250-
251- runWrapper ( harness , harness . externalDir , [ "-C" , harness . repoDir , "push" , dryRunFlag , "origin" , "HEAD" ] )
252-
253- expect ( readLogLines ( harness . nodeCwdLogPath ) ) . toEqual ( [ ] )
254- expect ( readLogLines ( harness . nodeRepoRootLogPath ) ) . toEqual ( [ ] )
255- expect ( readLogLines ( harness . nodeScriptLogPath ) ) . toEqual ( [ ] )
256- } )
266+ it . effect ( "runs session backup from the repository root for a normal push" , ( ) =>
267+ withHarness ( ( harness ) =>
268+ Effect . gen ( function * ( _ ) {
269+ yield * _ ( runWrapper ( harness , harness . repoDir , [ "push" , "origin" , "HEAD" ] ) )
270+
271+ const nodeCwd = yield * _ ( readLogLines ( harness . nodeCwdLogPath ) )
272+ const nodeRepoRoot = yield * _ ( readLogLines ( harness . nodeRepoRootLogPath ) )
273+ const nodeScript = yield * _ ( readLogLines ( harness . nodeScriptLogPath ) )
274+
275+ expect ( nodeCwd ) . toEqual ( [ harness . repoDir ] )
276+ expect ( nodeRepoRoot ) . toEqual ( [ harness . repoDir ] )
277+ expect ( nodeScript ) . toEqual ( [ `${ harness . repoDir } /scripts/session-backup-gist.js` ] )
278+ } )
279+ ) . pipe ( Effect . provide ( NodeContext . layer ) ) )
280+
281+ it . effect ( "preserves the pushed repository context for git -C push invocations" , ( ) =>
282+ withHarness ( ( harness ) =>
283+ Effect . gen ( function * ( _ ) {
284+ yield * _ ( runWrapper ( harness , harness . externalDir , [ "-C" , harness . repoDir , "push" , "origin" , "HEAD" ] ) )
285+
286+ const nodeCwd = yield * _ ( readLogLines ( harness . nodeCwdLogPath ) )
287+ const nodeRepoRoot = yield * _ ( readLogLines ( harness . nodeRepoRootLogPath ) )
288+ const nodeScript = yield * _ ( readLogLines ( harness . nodeScriptLogPath ) )
289+ const gitLog = yield * _ ( readLogLines ( harness . gitLogPath ) )
290+
291+ expect ( nodeCwd ) . toEqual ( [ harness . repoDir ] )
292+ expect ( nodeRepoRoot ) . toEqual ( [ harness . repoDir ] )
293+ expect ( nodeScript ) . toEqual ( [ `${ harness . repoDir } /scripts/session-backup-gist.js` ] )
294+ expect ( gitLog . some ( ( line ) => line . startsWith ( `${ harness . externalDir } \t-C ${ harness . repoDir } push` ) ) ) . toBe ( true )
295+ } )
296+ ) . pipe ( Effect . provide ( NodeContext . layer ) ) )
297+
298+ it . effect ( "does not run session backup for dry-run push variants" , ( ) =>
299+ withHarness ( ( harness ) =>
300+ Effect . gen ( function * ( _ ) {
301+ yield * _ ( runWrapper ( harness , harness . externalDir , [ "-C" , harness . repoDir , "push" , "--dry-run" , "origin" , "HEAD" ] ) )
302+ yield * _ ( runWrapper ( harness , harness . externalDir , [ "-C" , harness . repoDir , "push" , "-n" , "origin" , "HEAD" ] ) )
303+
304+ const nodeCwd = yield * _ ( readLogLines ( harness . nodeCwdLogPath ) )
305+ const nodeRepoRoot = yield * _ ( readLogLines ( harness . nodeRepoRootLogPath ) )
306+ const nodeScript = yield * _ ( readLogLines ( harness . nodeScriptLogPath ) )
307+
308+ expect ( nodeCwd ) . toEqual ( [ ] )
309+ expect ( nodeRepoRoot ) . toEqual ( [ ] )
310+ expect ( nodeScript ) . toEqual ( [ ] )
311+ } )
312+ ) . pipe ( Effect . provide ( NodeContext . layer ) ) )
257313} )
0 commit comments