From f9b1b4009ceb58e55d15522e715aaf463a746e78 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 25 Mar 2026 23:08:33 +0000 Subject: [PATCH 1/3] fix(core): preserve post-push repo context --- .../git-post-push-wrapper.ts | 47 +++- .../lib/src/core/templates-entrypoint/git.ts | 5 +- .../tests/core/git-post-push-wrapper.test.ts | 257 ++++++++++++++++++ packages/lib/tests/core/templates.test.ts | 3 + 4 files changed, 309 insertions(+), 3 deletions(-) create mode 100644 packages/lib/tests/core/git-post-push-wrapper.test.ts diff --git a/packages/lib/src/core/templates-entrypoint/git-post-push-wrapper.ts b/packages/lib/src/core/templates-entrypoint/git-post-push-wrapper.ts index e0a5f8c..2572d27 100644 --- a/packages/lib/src/core/templates-entrypoint/git-post-push-wrapper.ts +++ b/packages/lib/src/core/templates-entrypoint/git-post-push-wrapper.ts @@ -48,6 +48,43 @@ docker_git_git_subcommand() { return 1 } +docker_git_git_resolve_repo_root() { + local -a git_context=() + local expect_value="0" + local arg="" + + for arg in "$@"; do + if [[ "$expect_value" == "1" ]]; then + git_context+=("$arg") + expect_value="0" + continue + fi + + case "$arg" in + -c|-C|--git-dir|--work-tree|--namespace|--exec-path|--super-prefix|--config-env) + git_context+=("$arg") + expect_value="1" + continue + ;; + --git-dir=*|--work-tree=*|--namespace=*|--exec-path=*|--super-prefix=*|--config-env=*|--bare|--no-pager|--paginate|--literal-pathspecs|--no-literal-pathspecs|--glob-pathspecs|--noglob-pathspecs|--icase-pathspecs|--no-optional-locks|--no-lazy-fetch) + git_context+=("$arg") + continue + ;; + --) + break + ;; + -*) + continue + ;; + *) + break + ;; + esac + done + + "$DOCKER_GIT_REAL_GIT_BIN" "${"${"}git_context[@]}" rev-parse --show-toplevel 2>/dev/null +} + docker_git_git_push_is_dry_run() { local expect_value="0" local parsing_push_args="0" @@ -91,12 +128,18 @@ docker_git_git_push_is_dry_run() { } docker_git_post_push_action() { + local repo_root="" + if [[ "${"${"}DOCKER_GIT_SKIP_POST_PUSH_ACTION:-}" == "1" ]]; then return 0 fi if [[ -x "$DOCKER_GIT_POST_PUSH_ACTION" ]]; then - DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 "$DOCKER_GIT_POST_PUSH_ACTION" || true + if repo_root="$(docker_git_git_resolve_repo_root "$@")" && [[ -n "$repo_root" ]]; then + DOCKER_GIT_POST_PUSH_REPO_ROOT="$repo_root" DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 "$DOCKER_GIT_POST_PUSH_ACTION" || true + else + DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 "$DOCKER_GIT_POST_PUSH_ACTION" || true + fi fi } @@ -109,7 +152,7 @@ if subcommand="$(docker_git_git_subcommand "$@")" && [[ "$subcommand" == "push" fi if [[ "$status" -eq 0 ]] && ! docker_git_git_push_is_dry_run "$@"; then - docker_git_post_push_action + docker_git_post_push_action "$@" fi exit "$status" diff --git a/packages/lib/src/core/templates-entrypoint/git.ts b/packages/lib/src/core/templates-entrypoint/git.ts index 700d6c0..4083b70 100644 --- a/packages/lib/src/core/templates-entrypoint/git.ts +++ b/packages/lib/src/core/templates-entrypoint/git.ts @@ -263,7 +263,10 @@ cat <<'EOF' > "$POST_PUSH_ACTION" set -euo pipefail # 5) Run session backup after successful push -REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +REPO_ROOT="${"${"}DOCKER_GIT_POST_PUSH_REPO_ROOT:-}" +if [[ -z "$REPO_ROOT" || ! -d "$REPO_ROOT" ]]; then + REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +fi cd "$REPO_ROOT" # CHANGE: keep post-push backup logic in a reusable action script diff --git a/packages/lib/tests/core/git-post-push-wrapper.test.ts b/packages/lib/tests/core/git-post-push-wrapper.test.ts new file mode 100644 index 0000000..6e943b3 --- /dev/null +++ b/packages/lib/tests/core/git-post-push-wrapper.test.ts @@ -0,0 +1,257 @@ +// CHANGE: cover git wrapper post-push repo-context propagation +// WHY: `git -C push` must run session backup in the pushed repository, not the caller cwd +// REF: issue-201 +// PURITY: SHELL (executes generated bash scripts in isolated temp directories) + +import { execFileSync } from "node:child_process" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { afterEach, describe, expect, it } from "vitest" + +import { renderEntrypointGitHooks } from "../../src/core/templates-entrypoint/git.js" +import { renderEntrypointGitPostPushWrapperInstall } from "../../src/core/templates-entrypoint/git-post-push-wrapper.js" + +type WrapperHarness = { + readonly rootDir: string + readonly repoDir: string + readonly externalDir: string + readonly binDir: string + readonly wrapperPath: string + readonly gitLogPath: string + readonly nodeCwdLogPath: string + readonly nodeRepoRootLogPath: string + readonly nodeScriptLogPath: string +} + +const tempRoots: string[] = [] + +const fakeGitScript = `#!/usr/bin/env bash +set -euo pipefail + +if [[ -n "\${FAKE_GIT_LOG_PATH:-}" ]]; then + printf '%s\\t%s\\n' "$PWD" "$*" >> "$FAKE_GIT_LOG_PATH" +fi + +repo_dir="$PWD" +subcommand="" +args=("$@") +index=0 + +while [[ "$index" -lt "$#" ]]; do + arg="\${args[$index]}" + case "$arg" in + -C) + index=$((index + 1)) + if [[ "$index" -lt "$#" ]]; then + repo_dir="\${args[$index]}" + fi + ;; + -c|--git-dir|--work-tree|--namespace|--exec-path|--super-prefix|--config-env) + index=$((index + 1)) + ;; + --git-dir=*|--work-tree=*|--namespace=*|--exec-path=*|--super-prefix=*|--config-env=*|--bare|--no-pager|--paginate|--literal-pathspecs|--no-literal-pathspecs|--glob-pathspecs|--noglob-pathspecs|--icase-pathspecs|--no-optional-locks|--no-lazy-fetch) + ;; + --) + break + ;; + -*) + ;; + *) + subcommand="$arg" + break + ;; + esac + index=$((index + 1)) +done + +if [[ "$subcommand" == "rev-parse" ]]; then + next_index=$((index + 1)) + if [[ "$next_index" -lt "$#" && "\${args[$next_index]}" == "--show-toplevel" ]]; then + if [[ -d "$repo_dir/.git" || -f "$repo_dir/.git" ]]; then + printf '%s\\n' "$repo_dir" + exit 0 + fi + exit 128 + fi +fi + +exit 0 +` + +const fakeNodeScript = `#!/usr/bin/env bash +set -euo pipefail + +if [[ -n "\${FAKE_NODE_CWD_LOG_PATH:-}" ]]; then + printf '%s\\n' "$PWD" >> "$FAKE_NODE_CWD_LOG_PATH" +fi +if [[ -n "\${FAKE_NODE_REPO_ROOT_LOG_PATH:-}" ]]; then + printf '%s\\n' "\${DOCKER_GIT_POST_PUSH_REPO_ROOT:-}" >> "$FAKE_NODE_REPO_ROOT_LOG_PATH" +fi +if [[ -n "\${FAKE_NODE_SCRIPT_LOG_PATH:-}" ]]; then + printf '%s\\n' "$1" >> "$FAKE_NODE_SCRIPT_LOG_PATH" +fi + +exit 0 +` + +const fakeGhScript = `#!/usr/bin/env bash +set -euo pipefail +exit 0 +` + +const writeExecutable = (filePath: string, content: string): void => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, content) + fs.chmodSync(filePath, 0o755) +} + +const extractEmbeddedScript = (template: string, target: string): string => { + const marker = `cat <<'EOF' > "${target}"\n` + const start = template.indexOf(marker) + if (start < 0) { + throw new Error(`script marker not found: ${target}`) + } + + const bodyStart = start + marker.length + const bodyEnd = template.indexOf("\nEOF", bodyStart) + if (bodyEnd < 0) { + throw new Error(`script terminator not found: ${target}`) + } + + return template.slice(bodyStart, bodyEnd) +} + +const readLogLines = (filePath: string): ReadonlyArray => { + if (!fs.existsSync(filePath)) { + return [] + } + + const contents = fs.readFileSync(filePath, "utf8").trim() + return contents.length === 0 ? [] : contents.split("\n") +} + +const makeHarness = (): WrapperHarness => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "docker-git-post-push-")) + tempRoots.push(rootDir) + + const repoDir = path.join(rootDir, "repo") + const externalDir = path.join(rootDir, "external") + const binDir = path.join(rootDir, "bin") + const hooksDir = path.join(rootDir, "hooks") + const gitLogPath = path.join(rootDir, "git.log") + const nodeCwdLogPath = path.join(rootDir, "node-cwd.log") + const nodeRepoRootLogPath = path.join(rootDir, "node-repo-root.log") + const nodeScriptLogPath = path.join(rootDir, "node-script.log") + + fs.mkdirSync(path.join(repoDir, ".git"), { recursive: true }) + fs.mkdirSync(path.join(repoDir, "scripts"), { recursive: true }) + fs.mkdirSync(externalDir, { recursive: true }) + fs.mkdirSync(binDir, { recursive: true }) + fs.mkdirSync(hooksDir, { recursive: true }) + fs.writeFileSync(path.join(repoDir, "scripts", "session-backup-gist.js"), "// test placeholder\n") + + writeExecutable(path.join(binDir, "git"), fakeGitScript) + writeExecutable(path.join(binDir, "git-real"), fakeGitScript) + writeExecutable(path.join(binDir, "gh"), fakeGhScript) + writeExecutable(path.join(binDir, "node"), fakeNodeScript) + + const postPushScript = extractEmbeddedScript(renderEntrypointGitHooks(), "$POST_PUSH_ACTION") + const postPushPath = path.join(hooksDir, "post-push") + writeExecutable(postPushPath, postPushScript) + + const wrapperTemplate = extractEmbeddedScript( + renderEntrypointGitPostPushWrapperInstall(), + "$GIT_WRAPPER_BIN" + ) + const wrapperPath = path.join(rootDir, "git-wrapper") + const wrapperScript = wrapperTemplate + .replace("__DOCKER_GIT_REAL_BIN__", path.join(binDir, "git-real")) + .replace("/opt/docker-git/hooks/post-push", postPushPath) + writeExecutable(wrapperPath, wrapperScript) + + return { + rootDir, + repoDir, + externalDir, + binDir, + wrapperPath, + gitLogPath, + nodeCwdLogPath, + nodeRepoRootLogPath, + nodeScriptLogPath + } +} + +const makeHarnessEnv = (harness: WrapperHarness): NodeJS.ProcessEnv => ({ + ...process.env, + PATH: `${harness.binDir}:${process.env["PATH"] ?? ""}`, + FAKE_GIT_LOG_PATH: harness.gitLogPath, + FAKE_NODE_CWD_LOG_PATH: harness.nodeCwdLogPath, + FAKE_NODE_REPO_ROOT_LOG_PATH: harness.nodeRepoRootLogPath, + FAKE_NODE_SCRIPT_LOG_PATH: harness.nodeScriptLogPath +}) + +const runWrapper = ( + harness: WrapperHarness, + cwd: string, + args: ReadonlyArray +): void => { + execFileSync(harness.wrapperPath, args, { + cwd, + env: makeHarnessEnv(harness), + encoding: "utf8", + stdio: "pipe" + }) +} + +describe("git post-push wrapper", () => { + afterEach(() => { + while (tempRoots.length > 0) { + const root = tempRoots.pop() + if (root !== undefined) { + fs.rmSync(root, { recursive: true, force: true }) + } + } + }) + + it("runs session backup from the repository root for a normal push", () => { + const harness = makeHarness() + + runWrapper(harness, harness.repoDir, ["push", "origin", "HEAD"]) + + expect(readLogLines(harness.nodeCwdLogPath)).toEqual([harness.repoDir]) + expect(readLogLines(harness.nodeRepoRootLogPath)).toEqual([harness.repoDir]) + expect(readLogLines(harness.nodeScriptLogPath)).toEqual([ + path.join(harness.repoDir, "scripts", "session-backup-gist.js") + ]) + }) + + it("preserves the pushed repository context for git -C push invocations", () => { + const harness = makeHarness() + + runWrapper(harness, harness.externalDir, ["-C", harness.repoDir, "push", "origin", "HEAD"]) + + expect(readLogLines(harness.nodeCwdLogPath)).toEqual([harness.repoDir]) + expect(readLogLines(harness.nodeRepoRootLogPath)).toEqual([harness.repoDir]) + expect(readLogLines(harness.nodeScriptLogPath)).toEqual([ + path.join(harness.repoDir, "scripts", "session-backup-gist.js") + ]) + expect(readLogLines(harness.gitLogPath).some((line) => line.startsWith(`${harness.externalDir}\t-C ${harness.repoDir} push`))).toBe( + true + ) + }) + + it.each([ + ["--dry-run"], + ["-n"] + ])("does not run session backup for dry-run push (%s)", (dryRunFlag) => { + const harness = makeHarness() + + runWrapper(harness, harness.externalDir, ["-C", harness.repoDir, "push", dryRunFlag, "origin", "HEAD"]) + + expect(readLogLines(harness.nodeCwdLogPath)).toEqual([]) + expect(readLogLines(harness.nodeRepoRootLogPath)).toEqual([]) + expect(readLogLines(harness.nodeScriptLogPath)).toEqual([]) + }) +}) diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index b50d7c5..892cf52 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -64,11 +64,14 @@ describe("renderEntrypointGitHooks", () => { expect(hooks).toContain("git has no client-side post-push hook") expect(hooks).toContain("docker-git managed git wrapper") expect(hooks).toContain("DOCKER_GIT_SKIP_POST_PUSH_ACTION=1") + expect(hooks).toContain("DOCKER_GIT_POST_PUSH_REPO_ROOT") expect(hooks).toContain("docker_git_git_push_is_dry_run") + expect(hooks).toContain("docker_git_git_resolve_repo_root") expect(hooks).toContain("--dry-run|-n") expect(hooks).toContain("--help|-h|--version|--html-path|--man-path|--info-path|--list-cmds|--list-cmds=*") expect(hooks).not.toContain('POST_PUSH_RUNTIME="/etc/profile.d/zz-git-post-push.sh"') expect(hooks).not.toContain("source /etc/profile.d/zz-git-post-push.sh") + expect(hooks).toContain('REPO_ROOT="${DOCKER_GIT_POST_PUSH_REPO_ROOT:-}"') expect(hooks).toContain("node \"$BACKUP_SCRIPT\"") expect(hooks).not.toContain("node \"$BACKUP_SCRIPT\" --verbose") expect(hooks.indexOf('$REPO_ROOT/scripts/session-backup-gist.js')).toBeLessThan( From 4608f8f100ec7f7112ca5b5fb51cc09712cde4b7 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 25 Mar 2026 23:13:38 +0000 Subject: [PATCH 2/3] test(core): align post-push wrapper coverage with effect lint --- .../tests/core/git-post-push-wrapper.test.ts | 310 +++++++++++------- 1 file changed, 183 insertions(+), 127 deletions(-) diff --git a/packages/lib/tests/core/git-post-push-wrapper.test.ts b/packages/lib/tests/core/git-post-push-wrapper.test.ts index 6e943b3..98a68a4 100644 --- a/packages/lib/tests/core/git-post-push-wrapper.test.ts +++ b/packages/lib/tests/core/git-post-push-wrapper.test.ts @@ -3,17 +3,20 @@ // REF: issue-201 // PURITY: SHELL (executes generated bash scripts in isolated temp directories) -import { execFileSync } from "node:child_process" -import fs from "node:fs" -import os from "node:os" -import path from "node:path" -import { afterEach, describe, expect, it } from "vitest" +import * as Command from "@effect/platform/Command" +import * as CommandExecutor from "@effect/platform/CommandExecutor" +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { NodeContext } from "@effect/platform-node" +import { describe, expect, it } from "@effect/vitest" +import { Effect, pipe } from "effect" +import * as Chunk from "effect/Chunk" +import * as Stream from "effect/Stream" import { renderEntrypointGitHooks } from "../../src/core/templates-entrypoint/git.js" import { renderEntrypointGitPostPushWrapperInstall } from "../../src/core/templates-entrypoint/git-post-push-wrapper.js" type WrapperHarness = { - readonly rootDir: string readonly repoDir: string readonly externalDir: string readonly binDir: string @@ -24,8 +27,6 @@ type WrapperHarness = { readonly nodeScriptLogPath: string } -const tempRoots: string[] = [] - const fakeGitScript = `#!/usr/bin/env bash set -euo pipefail @@ -100,11 +101,13 @@ set -euo pipefail exit 0 ` -const writeExecutable = (filePath: string, content: string): void => { - fs.mkdirSync(path.dirname(filePath), { recursive: true }) - fs.writeFileSync(filePath, content) - fs.chmodSync(filePath, 0o755) -} +const collectUint8Array = (chunks: Chunk.Chunk): Uint8Array => + Chunk.reduce(chunks, new Uint8Array(), (acc, curr) => { + const next = new Uint8Array(acc.length + curr.length) + next.set(acc) + next.set(curr, acc.length) + return next + }) const extractEmbeddedScript = (template: string, target: string): string => { const marker = `cat <<'EOF' > "${target}"\n` @@ -122,68 +125,65 @@ const extractEmbeddedScript = (template: string, target: string): string => { return template.slice(bodyStart, bodyEnd) } -const readLogLines = (filePath: string): ReadonlyArray => { - if (!fs.existsSync(filePath)) { - return [] - } +const writeExecutable = ( + filePath: string, + content: string +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + yield* _(fs.makeDirectory(path.dirname(filePath), { recursive: true })) + yield* _(fs.writeFileString(filePath, content)) + yield* _(fs.chmod(filePath, 0o755)) + }) - const contents = fs.readFileSync(filePath, "utf8").trim() - return contents.length === 0 ? [] : contents.split("\n") -} +const readLogLines = ( + filePath: string +): Effect.Effect, Error, FileSystem.FileSystem> => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const exists = yield* _(fs.exists(filePath)) + if (!exists) { + return [] + } + + const contents = yield* _(fs.readFileString(filePath)) + const trimmed = contents.trim() + return trimmed.length === 0 ? [] : trimmed.split("\n") + }) + +const runCommand = ( + command: string, + args: ReadonlyArray, + cwd: string, + env?: Readonly> +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const executor = yield* _(CommandExecutor.CommandExecutor) + const cmd = pipe( + Command.make(command, ...args), + Command.workingDirectory(cwd), + env ? Command.env(env) : (value) => value, + Command.stdout("pipe"), + Command.stderr("pipe"), + Command.stdin("pipe") + ) + const proc = yield* _(executor.start(cmd)) + yield* _(Effect.forkDaemon(Stream.runDrain(proc.stderr))) + const stdoutBytes = yield* _( + pipe(proc.stdout, Stream.runCollect, Effect.map((chunks) => collectUint8Array(chunks))) + ) + const exitCode = yield* _(proc.exitCode) + if (Number(exitCode) !== 0) { + return yield* _(Effect.fail(new Error(`${command} ${args.join(" ")} exited with ${String(exitCode)}`))) + } -const makeHarness = (): WrapperHarness => { - const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "docker-git-post-push-")) - tempRoots.push(rootDir) - - const repoDir = path.join(rootDir, "repo") - const externalDir = path.join(rootDir, "external") - const binDir = path.join(rootDir, "bin") - const hooksDir = path.join(rootDir, "hooks") - const gitLogPath = path.join(rootDir, "git.log") - const nodeCwdLogPath = path.join(rootDir, "node-cwd.log") - const nodeRepoRootLogPath = path.join(rootDir, "node-repo-root.log") - const nodeScriptLogPath = path.join(rootDir, "node-script.log") - - fs.mkdirSync(path.join(repoDir, ".git"), { recursive: true }) - fs.mkdirSync(path.join(repoDir, "scripts"), { recursive: true }) - fs.mkdirSync(externalDir, { recursive: true }) - fs.mkdirSync(binDir, { recursive: true }) - fs.mkdirSync(hooksDir, { recursive: true }) - fs.writeFileSync(path.join(repoDir, "scripts", "session-backup-gist.js"), "// test placeholder\n") - - writeExecutable(path.join(binDir, "git"), fakeGitScript) - writeExecutable(path.join(binDir, "git-real"), fakeGitScript) - writeExecutable(path.join(binDir, "gh"), fakeGhScript) - writeExecutable(path.join(binDir, "node"), fakeNodeScript) - - const postPushScript = extractEmbeddedScript(renderEntrypointGitHooks(), "$POST_PUSH_ACTION") - const postPushPath = path.join(hooksDir, "post-push") - writeExecutable(postPushPath, postPushScript) - - const wrapperTemplate = extractEmbeddedScript( - renderEntrypointGitPostPushWrapperInstall(), - "$GIT_WRAPPER_BIN" + return new TextDecoder("utf-8").decode(stdoutBytes).trim() + }) ) - const wrapperPath = path.join(rootDir, "git-wrapper") - const wrapperScript = wrapperTemplate - .replace("__DOCKER_GIT_REAL_BIN__", path.join(binDir, "git-real")) - .replace("/opt/docker-git/hooks/post-push", postPushPath) - writeExecutable(wrapperPath, wrapperScript) - - return { - rootDir, - repoDir, - externalDir, - binDir, - wrapperPath, - gitLogPath, - nodeCwdLogPath, - nodeRepoRootLogPath, - nodeScriptLogPath - } -} -const makeHarnessEnv = (harness: WrapperHarness): NodeJS.ProcessEnv => ({ +const makeHarnessEnv = (harness: WrapperHarness): Readonly> => ({ ...process.env, PATH: `${harness.binDir}:${process.env["PATH"] ?? ""}`, FAKE_GIT_LOG_PATH: harness.gitLogPath, @@ -196,62 +196,118 @@ const runWrapper = ( harness: WrapperHarness, cwd: string, args: ReadonlyArray -): void => { - execFileSync(harness.wrapperPath, args, { - cwd, - env: makeHarnessEnv(harness), - encoding: "utf8", - stdio: "pipe" - }) -} +): Effect.Effect => + runCommand(harness.wrapperPath, args, cwd, makeHarnessEnv(harness)).pipe(Effect.asVoid) + +const withHarness = ( + use: (harness: WrapperHarness) => Effect.Effect +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const rootDir = yield* _( + fs.makeTempDirectoryScoped({ + prefix: "docker-git-post-push-" + }) + ) + + const repoDir = path.join(rootDir, "repo") + const externalDir = path.join(rootDir, "external") + const binDir = path.join(rootDir, "bin") + const hooksDir = path.join(rootDir, "hooks") + const gitLogPath = path.join(rootDir, "git.log") + const nodeCwdLogPath = path.join(rootDir, "node-cwd.log") + const nodeRepoRootLogPath = path.join(rootDir, "node-repo-root.log") + const nodeScriptLogPath = path.join(rootDir, "node-script.log") + + yield* _(fs.makeDirectory(path.join(repoDir, ".git"), { recursive: true })) + yield* _(fs.makeDirectory(path.join(repoDir, "scripts"), { recursive: true })) + yield* _(fs.makeDirectory(externalDir, { recursive: true })) + yield* _(fs.makeDirectory(binDir, { recursive: true })) + yield* _(fs.makeDirectory(hooksDir, { recursive: true })) + yield* _(fs.writeFileString(path.join(repoDir, "scripts", "session-backup-gist.js"), "// test placeholder\n")) + + yield* _(writeExecutable(path.join(binDir, "git"), fakeGitScript)) + yield* _(writeExecutable(path.join(binDir, "git-real"), fakeGitScript)) + yield* _(writeExecutable(path.join(binDir, "gh"), fakeGhScript)) + yield* _(writeExecutable(path.join(binDir, "node"), fakeNodeScript)) + + const postPushScript = extractEmbeddedScript(renderEntrypointGitHooks(), "$POST_PUSH_ACTION") + const postPushPath = path.join(hooksDir, "post-push") + yield* _(writeExecutable(postPushPath, postPushScript)) + + const wrapperTemplate = extractEmbeddedScript( + renderEntrypointGitPostPushWrapperInstall(), + "$GIT_WRAPPER_BIN" + ) + const wrapperPath = path.join(rootDir, "git-wrapper") + const wrapperScript = wrapperTemplate + .replace("__DOCKER_GIT_REAL_BIN__", path.join(binDir, "git-real")) + .replace("/opt/docker-git/hooks/post-push", postPushPath) + yield* _(writeExecutable(wrapperPath, wrapperScript)) + + return yield* _( + use({ + repoDir, + externalDir, + binDir, + wrapperPath, + gitLogPath, + nodeCwdLogPath, + nodeRepoRootLogPath, + nodeScriptLogPath + }) + ) + }) + ) describe("git post-push wrapper", () => { - afterEach(() => { - while (tempRoots.length > 0) { - const root = tempRoots.pop() - if (root !== undefined) { - fs.rmSync(root, { recursive: true, force: true }) - } - } - }) - - it("runs session backup from the repository root for a normal push", () => { - const harness = makeHarness() - - runWrapper(harness, harness.repoDir, ["push", "origin", "HEAD"]) - - expect(readLogLines(harness.nodeCwdLogPath)).toEqual([harness.repoDir]) - expect(readLogLines(harness.nodeRepoRootLogPath)).toEqual([harness.repoDir]) - expect(readLogLines(harness.nodeScriptLogPath)).toEqual([ - path.join(harness.repoDir, "scripts", "session-backup-gist.js") - ]) - }) - - it("preserves the pushed repository context for git -C push invocations", () => { - const harness = makeHarness() - - runWrapper(harness, harness.externalDir, ["-C", harness.repoDir, "push", "origin", "HEAD"]) - - expect(readLogLines(harness.nodeCwdLogPath)).toEqual([harness.repoDir]) - expect(readLogLines(harness.nodeRepoRootLogPath)).toEqual([harness.repoDir]) - expect(readLogLines(harness.nodeScriptLogPath)).toEqual([ - path.join(harness.repoDir, "scripts", "session-backup-gist.js") - ]) - expect(readLogLines(harness.gitLogPath).some((line) => line.startsWith(`${harness.externalDir}\t-C ${harness.repoDir} push`))).toBe( - true - ) - }) - - it.each([ - ["--dry-run"], - ["-n"] - ])("does not run session backup for dry-run push (%s)", (dryRunFlag) => { - const harness = makeHarness() - - runWrapper(harness, harness.externalDir, ["-C", harness.repoDir, "push", dryRunFlag, "origin", "HEAD"]) - - expect(readLogLines(harness.nodeCwdLogPath)).toEqual([]) - expect(readLogLines(harness.nodeRepoRootLogPath)).toEqual([]) - expect(readLogLines(harness.nodeScriptLogPath)).toEqual([]) - }) + it.effect("runs session backup from the repository root for a normal push", () => + withHarness((harness) => + Effect.gen(function*(_) { + yield* _(runWrapper(harness, harness.repoDir, ["push", "origin", "HEAD"])) + + const nodeCwd = yield* _(readLogLines(harness.nodeCwdLogPath)) + const nodeRepoRoot = yield* _(readLogLines(harness.nodeRepoRootLogPath)) + const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath)) + + expect(nodeCwd).toEqual([harness.repoDir]) + expect(nodeRepoRoot).toEqual([harness.repoDir]) + expect(nodeScript).toEqual([`${harness.repoDir}/scripts/session-backup-gist.js`]) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("preserves the pushed repository context for git -C push invocations", () => + withHarness((harness) => + Effect.gen(function*(_) { + yield* _(runWrapper(harness, harness.externalDir, ["-C", harness.repoDir, "push", "origin", "HEAD"])) + + const nodeCwd = yield* _(readLogLines(harness.nodeCwdLogPath)) + const nodeRepoRoot = yield* _(readLogLines(harness.nodeRepoRootLogPath)) + const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath)) + const gitLog = yield* _(readLogLines(harness.gitLogPath)) + + expect(nodeCwd).toEqual([harness.repoDir]) + expect(nodeRepoRoot).toEqual([harness.repoDir]) + expect(nodeScript).toEqual([`${harness.repoDir}/scripts/session-backup-gist.js`]) + expect(gitLog.some((line) => line.startsWith(`${harness.externalDir}\t-C ${harness.repoDir} push`))).toBe(true) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("does not run session backup for dry-run push variants", () => + withHarness((harness) => + Effect.gen(function*(_) { + yield* _(runWrapper(harness, harness.externalDir, ["-C", harness.repoDir, "push", "--dry-run", "origin", "HEAD"])) + yield* _(runWrapper(harness, harness.externalDir, ["-C", harness.repoDir, "push", "-n", "origin", "HEAD"])) + + const nodeCwd = yield* _(readLogLines(harness.nodeCwdLogPath)) + const nodeRepoRoot = yield* _(readLogLines(harness.nodeRepoRootLogPath)) + const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath)) + + expect(nodeCwd).toEqual([]) + expect(nodeRepoRoot).toEqual([]) + expect(nodeScript).toEqual([]) + }) + ).pipe(Effect.provide(NodeContext.layer))) }) From af088b86c0f654c153e279f20c2f5f9a8ecc0ab0 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 25 Mar 2026 23:41:07 +0000 Subject: [PATCH 3/3] test(core): cover failed post-push wrapper flow --- .../tests/core/git-post-push-wrapper.test.ts | 57 +++++++++++++++++-- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/packages/lib/tests/core/git-post-push-wrapper.test.ts b/packages/lib/tests/core/git-post-push-wrapper.test.ts index 98a68a4..421761d 100644 --- a/packages/lib/tests/core/git-post-push-wrapper.test.ts +++ b/packages/lib/tests/core/git-post-push-wrapper.test.ts @@ -77,6 +77,10 @@ if [[ "$subcommand" == "rev-parse" ]]; then fi fi +if [[ "$subcommand" == "push" && -n "\${FAKE_GIT_PUSH_EXIT_CODE:-}" ]]; then + exit "$FAKE_GIT_PUSH_EXIT_CODE" +fi + exit 0 ` @@ -156,7 +160,8 @@ const runCommand = ( command: string, args: ReadonlyArray, cwd: string, - env?: Readonly> + env?: Readonly>, + okExitCodes: ReadonlyArray = [0] ): Effect.Effect => Effect.scoped( Effect.gen(function*(_) { @@ -175,7 +180,8 @@ const runCommand = ( pipe(proc.stdout, Stream.runCollect, Effect.map((chunks) => collectUint8Array(chunks))) ) const exitCode = yield* _(proc.exitCode) - if (Number(exitCode) !== 0) { + const numericExitCode = Number(exitCode) + if (!okExitCodes.includes(numericExitCode)) { return yield* _(Effect.fail(new Error(`${command} ${args.join(" ")} exited with ${String(exitCode)}`))) } @@ -183,21 +189,35 @@ const runCommand = ( }) ) -const makeHarnessEnv = (harness: WrapperHarness): Readonly> => ({ +const makeHarnessEnv = ( + harness: WrapperHarness, + overrides: Readonly> = {} +): Readonly> => ({ ...process.env, PATH: `${harness.binDir}:${process.env["PATH"] ?? ""}`, FAKE_GIT_LOG_PATH: harness.gitLogPath, FAKE_NODE_CWD_LOG_PATH: harness.nodeCwdLogPath, FAKE_NODE_REPO_ROOT_LOG_PATH: harness.nodeRepoRootLogPath, - FAKE_NODE_SCRIPT_LOG_PATH: harness.nodeScriptLogPath + FAKE_NODE_SCRIPT_LOG_PATH: harness.nodeScriptLogPath, + ...overrides }) const runWrapper = ( harness: WrapperHarness, cwd: string, - args: ReadonlyArray + args: ReadonlyArray, + options: { + readonly env?: Readonly> + readonly okExitCodes?: ReadonlyArray + } = {} ): Effect.Effect => - runCommand(harness.wrapperPath, args, cwd, makeHarnessEnv(harness)).pipe(Effect.asVoid) + runCommand( + harness.wrapperPath, + args, + cwd, + makeHarnessEnv(harness, options.env), + options.okExitCodes + ).pipe(Effect.asVoid) const withHarness = ( use: (harness: WrapperHarness) => Effect.Effect @@ -305,6 +325,31 @@ describe("git post-push wrapper", () => { const nodeRepoRoot = yield* _(readLogLines(harness.nodeRepoRootLogPath)) const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath)) + expect(nodeCwd).toEqual([]) + expect(nodeRepoRoot).toEqual([]) + expect(nodeScript).toEqual([]) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("does not run session backup when git push fails", () => + withHarness((harness) => + Effect.gen(function*(_) { + yield* _( + runWrapper( + harness, + harness.externalDir, + ["-C", harness.repoDir, "push", "origin", "HEAD"], + { + env: { FAKE_GIT_PUSH_EXIT_CODE: "1" }, + okExitCodes: [1] + } + ) + ) + + const nodeCwd = yield* _(readLogLines(harness.nodeCwdLogPath)) + const nodeRepoRoot = yield* _(readLogLines(harness.nodeRepoRootLogPath)) + const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath)) + expect(nodeCwd).toEqual([]) expect(nodeRepoRoot).toEqual([]) expect(nodeScript).toEqual([])