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..421761d --- /dev/null +++ b/packages/lib/tests/core/git-post-push-wrapper.test.ts @@ -0,0 +1,358 @@ +// 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 * 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 repoDir: string + readonly externalDir: string + readonly binDir: string + readonly wrapperPath: string + readonly gitLogPath: string + readonly nodeCwdLogPath: string + readonly nodeRepoRootLogPath: string + readonly nodeScriptLogPath: 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 + +if [[ "$subcommand" == "push" && -n "\${FAKE_GIT_PUSH_EXIT_CODE:-}" ]]; then + exit "$FAKE_GIT_PUSH_EXIT_CODE" +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 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` + 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 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 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>, + okExitCodes: ReadonlyArray = [0] +): 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) + const numericExitCode = Number(exitCode) + if (!okExitCodes.includes(numericExitCode)) { + return yield* _(Effect.fail(new Error(`${command} ${args.join(" ")} exited with ${String(exitCode)}`))) + } + + return new TextDecoder("utf-8").decode(stdoutBytes).trim() + }) + ) + +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, + ...overrides +}) + +const runWrapper = ( + harness: WrapperHarness, + cwd: string, + args: ReadonlyArray, + options: { + readonly env?: Readonly> + readonly okExitCodes?: ReadonlyArray + } = {} +): Effect.Effect => + runCommand( + harness.wrapperPath, + args, + cwd, + makeHarnessEnv(harness, options.env), + options.okExitCodes + ).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", () => { + 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))) + + 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([]) + }) + ).pipe(Effect.provide(NodeContext.layer))) +}) 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(