Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/lib/src/core/docker-git-scripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
124 changes: 124 additions & 0 deletions packages/lib/src/core/templates-entrypoint/git-post-push-wrapper.ts
Original file line number Diff line number Diff line change
@@ -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
16 changes: 10 additions & 6 deletions packages/lib/src/core/templates-entrypoint/git.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -129,7 +130,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"
Expand Down Expand Up @@ -257,16 +258,17 @@ done
EOF
chmod 0755 "$PRE_PUSH_HOOK"

cat <<'EOF' > "$POST_PUSH_HOOK"
cat <<'EOF' > "$POST_PUSH_ACTION"
#!/usr/bin/env bash
set -euo pipefail

# 5) Run session backup after successful push
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
Expand All @@ -277,7 +279,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
Expand All @@ -286,7 +288,9 @@ if [ "${"${"}DOCKER_GIT_SKIP_SESSION_BACKUP:-}" != "1" ]; then
fi
fi
EOF
chmod 0755 "$POST_PUSH_HOOK"
chmod 0755 "$POST_PUSH_ACTION"

${renderEntrypointGitPostPushWrapperInstall()}

git config --system core.hooksPath "$HOOKS_DIR" || true
git config --global core.hooksPath "$HOOKS_DIR" || true`
Expand Down
17 changes: 14 additions & 3 deletions packages/lib/tests/core/templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion scripts/session-backup-gist.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down