Skip to content

Commit b87ae3e

Browse files
skulidropekclaude
andcommitted
fix(tests): replace node:child_process and node:path with @effect/platform
Use Command/CommandExecutor and Path from @effect/platform instead of direct node imports to satisfy the Effect-TS lint profile. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3aca85b commit b87ae3e

File tree

1 file changed

+126
-79
lines changed

1 file changed

+126
-79
lines changed

packages/lib/tests/usecases/state-repo-init.test.ts

Lines changed: 126 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -5,53 +5,125 @@
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"
810
import * as FileSystem from "@effect/platform/FileSystem"
911
import * as Path from "@effect/platform/Path"
1012
import { NodeContext } from "@effect/platform-node"
1113
import { 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

1618
import { 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
*/
61133
const 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

Comments
 (0)