From 97f78d6f17416aa80dce1d80b62a2d3aa5542f0a Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:20:43 +0000 Subject: [PATCH 1/2] fix(core): restore post-push backup via git wrapper --- packages/lib/src/core/docker-git-scripts.ts | 5 +- .../lib/src/core/templates-entrypoint/git.ts | 135 +++++++++++++++++- packages/lib/tests/core/templates.test.ts | 17 ++- scripts/session-backup-gist.js | 2 +- 4 files changed, 147 insertions(+), 12 deletions(-) diff --git a/packages/lib/src/core/docker-git-scripts.ts b/packages/lib/src/core/docker-git-scripts.ts index 582298c..0c028c5 100644 --- a/packages/lib/src/core/docker-git-scripts.ts +++ b/packages/lib/src/core/docker-git-scripts.ts @@ -11,8 +11,9 @@ /** * Names of docker-git scripts that must be available inside generated containers. * - * These scripts are referenced by git hooks (pre-push, post-push, pre-commit) and - * session backup workflows. They are copied into each project's build context under + * These scripts are referenced by git hooks (pre-push, pre-commit), the global + * git push post-action runtime, and session backup workflows. They are copied into + * each project's build context under * `scripts/` and embedded into the Docker image at `/opt/docker-git/scripts/`. * * @pure true diff --git a/packages/lib/src/core/templates-entrypoint/git.ts b/packages/lib/src/core/templates-entrypoint/git.ts index 8969a3e..2f5c260 100644 --- a/packages/lib/src/core/templates-entrypoint/git.ts +++ b/packages/lib/src/core/templates-entrypoint/git.ts @@ -129,7 +129,7 @@ const entrypointGitHooksTemplate = String .raw`# 3) Install global git hooks to protect main/master + managed AGENTS context HOOKS_DIR="/opt/docker-git/hooks" PRE_PUSH_HOOK="$HOOKS_DIR/pre-push" -POST_PUSH_HOOK="$HOOKS_DIR/post-push" +POST_PUSH_ACTION="$HOOKS_DIR/post-push" mkdir -p "$HOOKS_DIR" cat <<'EOF' > "$PRE_PUSH_HOOK" @@ -257,7 +257,7 @@ done EOF chmod 0755 "$PRE_PUSH_HOOK" -cat <<'EOF' > "$POST_PUSH_HOOK" +cat <<'EOF' > "$POST_PUSH_ACTION" #!/usr/bin/env bash set -euo pipefail @@ -265,8 +265,9 @@ set -euo pipefail REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" cd "$REPO_ROOT" -# CHANGE: run session backup in post-push so source commit has already landed in remote -# WHY: backups should mirror successfully pushed state and not block push validation +# CHANGE: keep post-push backup logic in a reusable action script +# WHY: git has no client-side post-push hook, so the global git wrapper +# invokes this after a successful git push # REF: issue-192 if [ "${"${"}DOCKER_GIT_SKIP_SESSION_BACKUP:-}" != "1" ]; then if command -v gh >/dev/null 2>&1; then @@ -277,7 +278,7 @@ if [ "${"${"}DOCKER_GIT_SKIP_SESSION_BACKUP:-}" != "1" ]; then BACKUP_SCRIPT="/opt/docker-git/scripts/session-backup-gist.js" fi if [ -n "$BACKUP_SCRIPT" ]; then - node "$BACKUP_SCRIPT" || echo "[session-backup] Warning: session backup failed (non-fatal)" + DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 node "$BACKUP_SCRIPT" || echo "[session-backup] Warning: session backup failed (non-fatal)" else echo "[session-backup] Warning: script not found (expected repo or global path)" fi @@ -286,7 +287,129 @@ if [ "${"${"}DOCKER_GIT_SKIP_SESSION_BACKUP:-}" != "1" ]; then fi fi EOF -chmod 0755 "$POST_PUSH_HOOK" +chmod 0755 "$POST_PUSH_ACTION" + +# 5.5) Install git wrapper so post-push actions run for normal git push invocations. +# Git has no client-side post-push hook, so core.hooksPath alone is insufficient. +GIT_WRAPPER_BIN="/usr/local/bin/git" +GIT_REAL_BIN="$(type -aP git | awk -v wrapper="$GIT_WRAPPER_BIN" '$0 != wrapper { print; exit }')" +if [[ -n "$GIT_REAL_BIN" ]]; then + cat <<'EOF' > "$GIT_WRAPPER_BIN" +#!/usr/bin/env bash +set -euo pipefail + +# docker-git managed git wrapper +DOCKER_GIT_REAL_GIT_BIN="__DOCKER_GIT_REAL_BIN__" +DOCKER_GIT_POST_PUSH_ACTION="/opt/docker-git/hooks/post-push" + +docker_git_git_subcommand() { + local expect_value="0" + local arg="" + for arg in "$@"; do + if [[ "$expect_value" == "1" ]]; then + expect_value="0" + continue + fi + + case "$arg" in + --help|-h|--version|--html-path|--man-path|--info-path|--list-cmds|--list-cmds=*) + return 1 + ;; + -c|-C|--git-dir|--work-tree|--namespace|--exec-path|--super-prefix|--config-env) + 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) + continue + ;; + --) + return 1 + ;; + -*) + continue + ;; + *) + printf "%s" "$arg" + return 0 + ;; + esac + done + + return 1 +} + +docker_git_git_push_is_dry_run() { + local expect_value="0" + local parsing_push_args="0" + local arg="" + + for arg in "$@"; do + if [[ "$parsing_push_args" == "0" ]]; then + if [[ "$expect_value" == "1" ]]; then + expect_value="0" + continue + fi + + case "$arg" in + -c|-C|--git-dir|--work-tree|--namespace|--exec-path|--super-prefix|--config-env) + 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) + continue + ;; + push) + parsing_push_args="1" + continue + ;; + esac + + continue + fi + + case "$arg" in + --) + break + ;; + --dry-run|-n) + return 0 + ;; + esac + done + + return 1 +} + +docker_git_post_push_action() { + 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 + fi +} + +subcommand="" +if subcommand="$(docker_git_git_subcommand "$@")" && [[ "$subcommand" == "push" ]]; then + if "$DOCKER_GIT_REAL_GIT_BIN" "$@"; then + status=0 + else + status=$? + fi + + if [[ "$status" -eq 0 ]] && ! docker_git_git_push_is_dry_run "$@"; then + docker_git_post_push_action + fi + + exit "$status" +fi + +exec "$DOCKER_GIT_REAL_GIT_BIN" "$@" +EOF + sed -i "s#__DOCKER_GIT_REAL_BIN__#$GIT_REAL_BIN#g" "$GIT_WRAPPER_BIN" || true + chmod 0755 "$GIT_WRAPPER_BIN" || true +fi git config --system core.hooksPath "$HOOKS_DIR" || true git config --global core.hooksPath "$HOOKS_DIR" || true` diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index 236a21e..b50d7c5 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -49,15 +49,26 @@ describe("renderEntrypointDnsRepair", () => { }) describe("renderEntrypointGitHooks", () => { - it("installs pre-push protection checks and post-push backup hook", () => { + it("installs pre-push protection checks and a global git post-push runtime", () => { const hooks = renderEntrypointGitHooks() expect(hooks).toContain('PRE_PUSH_HOOK="$HOOKS_DIR/pre-push"') - expect(hooks).toContain('POST_PUSH_HOOK="$HOOKS_DIR/post-push"') + expect(hooks).toContain('POST_PUSH_ACTION="$HOOKS_DIR/post-push"') + expect(hooks).toContain('GIT_WRAPPER_BIN="/usr/local/bin/git"') + expect(hooks).toContain('type -aP git') expect(hooks).toContain("cat <<'EOF' > \"$PRE_PUSH_HOOK\"") - expect(hooks).toContain("cat <<'EOF' > \"$POST_PUSH_HOOK\"") + expect(hooks).toContain("cat <<'EOF' > \"$POST_PUSH_ACTION\"") + expect(hooks).toContain("cat <<'EOF' > \"$GIT_WRAPPER_BIN\"") expect(hooks).toContain("check_issue_managed_block_range") expect(hooks).toContain("Run session backup after successful push") + 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_git_push_is_dry_run") + 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("node \"$BACKUP_SCRIPT\"") expect(hooks).not.toContain("node \"$BACKUP_SCRIPT\" --verbose") expect(hooks.indexOf('$REPO_ROOT/scripts/session-backup-gist.js')).toBeLessThan( diff --git a/scripts/session-backup-gist.js b/scripts/session-backup-gist.js index 3a518a6..738d9a3 100644 --- a/scripts/session-backup-gist.js +++ b/scripts/session-backup-gist.js @@ -442,7 +442,7 @@ const buildSnapshotReadme = ({ backupRepo, source, manifestUrl, summary, session "", `- Manifest: ${manifestUrl}`, "", - "Generated automatically by the docker-git `post-push` session backup hook.", + "Generated automatically by the docker-git `git push` post-action.", "", ].join("\n"); From 17f16784b81c746caa0bd0af9610bba27f72436f Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:27:59 +0000 Subject: [PATCH 2/2] fix(core): split git wrapper template for lint --- .../git-post-push-wrapper.ts | 124 ++++++++++++++++++ .../lib/src/core/templates-entrypoint/git.ts | 123 +---------------- 2 files changed, 126 insertions(+), 121 deletions(-) create mode 100644 packages/lib/src/core/templates-entrypoint/git-post-push-wrapper.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 new file mode 100644 index 0000000..e0a5f8c --- /dev/null +++ b/packages/lib/src/core/templates-entrypoint/git-post-push-wrapper.ts @@ -0,0 +1,124 @@ +const entrypointGitPostPushWrapperInstall = String + .raw`# 5.5) Install git wrapper so post-push actions run for normal git push invocations. +# Git has no client-side post-push hook, so core.hooksPath alone is insufficient. +GIT_WRAPPER_BIN="/usr/local/bin/git" +GIT_REAL_BIN="$(type -aP git | awk -v wrapper="$GIT_WRAPPER_BIN" '$0 != wrapper { print; exit }')" +if [[ -n "$GIT_REAL_BIN" ]]; then + cat <<'EOF' > "$GIT_WRAPPER_BIN" +#!/usr/bin/env bash +set -euo pipefail + +# docker-git managed git wrapper +DOCKER_GIT_REAL_GIT_BIN="__DOCKER_GIT_REAL_BIN__" +DOCKER_GIT_POST_PUSH_ACTION="/opt/docker-git/hooks/post-push" + +docker_git_git_subcommand() { + local expect_value="0" + local arg="" + for arg in "$@"; do + if [[ "$expect_value" == "1" ]]; then + expect_value="0" + continue + fi + + case "$arg" in + --help|-h|--version|--html-path|--man-path|--info-path|--list-cmds|--list-cmds=*) + return 1 + ;; + -c|-C|--git-dir|--work-tree|--namespace|--exec-path|--super-prefix|--config-env) + 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) + continue + ;; + --) + return 1 + ;; + -*) + continue + ;; + *) + printf "%s" "$arg" + return 0 + ;; + esac + done + + return 1 +} + +docker_git_git_push_is_dry_run() { + local expect_value="0" + local parsing_push_args="0" + local arg="" + + for arg in "$@"; do + if [[ "$parsing_push_args" == "0" ]]; then + if [[ "$expect_value" == "1" ]]; then + expect_value="0" + continue + fi + + case "$arg" in + -c|-C|--git-dir|--work-tree|--namespace|--exec-path|--super-prefix|--config-env) + 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) + continue + ;; + push) + parsing_push_args="1" + continue + ;; + esac + + continue + fi + + case "$arg" in + --) + break + ;; + --dry-run|-n) + return 0 + ;; + esac + done + + return 1 +} + +docker_git_post_push_action() { + 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 + fi +} + +subcommand="" +if subcommand="$(docker_git_git_subcommand "$@")" && [[ "$subcommand" == "push" ]]; then + if "$DOCKER_GIT_REAL_GIT_BIN" "$@"; then + status=0 + else + status=$? + fi + + if [[ "$status" -eq 0 ]] && ! docker_git_git_push_is_dry_run "$@"; then + docker_git_post_push_action + fi + + exit "$status" +fi + +exec "$DOCKER_GIT_REAL_GIT_BIN" "$@" +EOF + sed -i "s#__DOCKER_GIT_REAL_BIN__#$GIT_REAL_BIN#g" "$GIT_WRAPPER_BIN" || true + chmod 0755 "$GIT_WRAPPER_BIN" || true +fi` + +export const renderEntrypointGitPostPushWrapperInstall = (): string => entrypointGitPostPushWrapperInstall diff --git a/packages/lib/src/core/templates-entrypoint/git.ts b/packages/lib/src/core/templates-entrypoint/git.ts index 2f5c260..700d6c0 100644 --- a/packages/lib/src/core/templates-entrypoint/git.ts +++ b/packages/lib/src/core/templates-entrypoint/git.ts @@ -1,4 +1,5 @@ import type { TemplateConfig } from "../domain.js" +import { renderEntrypointGitPostPushWrapperInstall } from "./git-post-push-wrapper.js" const renderAuthLabelResolution = (): string => String.raw`# 2) Ensure GitHub auth vars are available for SSH sessions. @@ -289,127 +290,7 @@ fi EOF chmod 0755 "$POST_PUSH_ACTION" -# 5.5) Install git wrapper so post-push actions run for normal git push invocations. -# Git has no client-side post-push hook, so core.hooksPath alone is insufficient. -GIT_WRAPPER_BIN="/usr/local/bin/git" -GIT_REAL_BIN="$(type -aP git | awk -v wrapper="$GIT_WRAPPER_BIN" '$0 != wrapper { print; exit }')" -if [[ -n "$GIT_REAL_BIN" ]]; then - cat <<'EOF' > "$GIT_WRAPPER_BIN" -#!/usr/bin/env bash -set -euo pipefail - -# docker-git managed git wrapper -DOCKER_GIT_REAL_GIT_BIN="__DOCKER_GIT_REAL_BIN__" -DOCKER_GIT_POST_PUSH_ACTION="/opt/docker-git/hooks/post-push" - -docker_git_git_subcommand() { - local expect_value="0" - local arg="" - for arg in "$@"; do - if [[ "$expect_value" == "1" ]]; then - expect_value="0" - continue - fi - - case "$arg" in - --help|-h|--version|--html-path|--man-path|--info-path|--list-cmds|--list-cmds=*) - return 1 - ;; - -c|-C|--git-dir|--work-tree|--namespace|--exec-path|--super-prefix|--config-env) - 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) - continue - ;; - --) - return 1 - ;; - -*) - continue - ;; - *) - printf "%s" "$arg" - return 0 - ;; - esac - done - - return 1 -} - -docker_git_git_push_is_dry_run() { - local expect_value="0" - local parsing_push_args="0" - local arg="" - - for arg in "$@"; do - if [[ "$parsing_push_args" == "0" ]]; then - if [[ "$expect_value" == "1" ]]; then - expect_value="0" - continue - fi - - case "$arg" in - -c|-C|--git-dir|--work-tree|--namespace|--exec-path|--super-prefix|--config-env) - 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) - continue - ;; - push) - parsing_push_args="1" - continue - ;; - esac - - continue - fi - - case "$arg" in - --) - break - ;; - --dry-run|-n) - return 0 - ;; - esac - done - - return 1 -} - -docker_git_post_push_action() { - 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 - fi -} - -subcommand="" -if subcommand="$(docker_git_git_subcommand "$@")" && [[ "$subcommand" == "push" ]]; then - if "$DOCKER_GIT_REAL_GIT_BIN" "$@"; then - status=0 - else - status=$? - fi - - if [[ "$status" -eq 0 ]] && ! docker_git_git_push_is_dry_run "$@"; then - docker_git_post_push_action - fi - - exit "$status" -fi - -exec "$DOCKER_GIT_REAL_GIT_BIN" "$@" -EOF - sed -i "s#__DOCKER_GIT_REAL_BIN__#$GIT_REAL_BIN#g" "$GIT_WRAPPER_BIN" || true - chmod 0755 "$GIT_WRAPPER_BIN" || true -fi +${renderEntrypointGitPostPushWrapperInstall()} git config --system core.hooksPath "$HOOKS_DIR" || true git config --global core.hooksPath "$HOOKS_DIR" || true`