diff --git a/packages/lib/src/core/docker-git-scripts.ts b/packages/lib/src/core/docker-git-scripts.ts index 4a827c3a..582298cd 100644 --- a/packages/lib/src/core/docker-git-scripts.ts +++ b/packages/lib/src/core/docker-git-scripts.ts @@ -11,8 +11,8 @@ /** * Names of docker-git scripts that must be available inside generated containers. * - * These scripts are referenced by git hooks (pre-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, post-push, pre-commit) 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 4e95ea88..8969a3e4 100644 --- a/packages/lib/src/core/templates-entrypoint/git.ts +++ b/packages/lib/src/core/templates-entrypoint/git.ts @@ -129,6 +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" mkdir -p "$HOOKS_DIR" cat <<'EOF' > "$PRE_PUSH_HOOK" @@ -256,28 +257,37 @@ done EOF chmod 0755 "$PRE_PUSH_HOOK" -cat <<'EOF' >> "$PRE_PUSH_HOOK" +cat <<'EOF' > "$POST_PUSH_HOOK" +#!/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: resolve session-backup script from /opt/docker-git/scripts (embedded) or repo-local fallback -# WHY: docker-git scripts are now embedded in the container image at /opt/docker-git/scripts -# REF: issue-176 +# 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 +# REF: issue-192 if [ "${"${"}DOCKER_GIT_SKIP_SESSION_BACKUP:-}" != "1" ]; then if command -v gh >/dev/null 2>&1; then BACKUP_SCRIPT="" - if [ -f /opt/docker-git/scripts/session-backup-gist.js ]; then - BACKUP_SCRIPT="/opt/docker-git/scripts/session-backup-gist.js" - elif [ -f "$REPO_ROOT/scripts/session-backup-gist.js" ]; then + if [ -f "$REPO_ROOT/scripts/session-backup-gist.js" ]; then BACKUP_SCRIPT="$REPO_ROOT/scripts/session-backup-gist.js" + elif [ -f /opt/docker-git/scripts/session-backup-gist.js ]; then + BACKUP_SCRIPT="/opt/docker-git/scripts/session-backup-gist.js" fi if [ -n "$BACKUP_SCRIPT" ]; then - node "$BACKUP_SCRIPT" --verbose || echo "[session-backup] Warning: session backup failed (non-fatal)" + 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 + else + echo "[session-backup] Warning: gh CLI not found (skipping session backup)" fi fi EOF +chmod 0755 "$POST_PUSH_HOOK" + 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 866c13b5..236a21e4 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -4,6 +4,7 @@ import { defaultTemplateConfig, type TemplateConfig } from "../../src/core/domai import { renderDockerCompose } from "../../src/core/templates/docker-compose.js" import { renderEntrypoint } from "../../src/core/templates-entrypoint.js" import { renderEntrypointDnsRepair } from "../../src/core/templates-entrypoint/dns-repair.js" +import { renderEntrypointGitHooks } from "../../src/core/templates-entrypoint/git.js" const makeTemplateConfig = (overrides: Partial = {}): TemplateConfig => ({ ...defaultTemplateConfig, @@ -47,6 +48,25 @@ describe("renderEntrypointDnsRepair", () => { }) }) +describe("renderEntrypointGitHooks", () => { + it("installs pre-push protection checks and post-push backup hook", () => { + 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("cat <<'EOF' > \"$PRE_PUSH_HOOK\"") + expect(hooks).toContain("cat <<'EOF' > \"$POST_PUSH_HOOK\"") + expect(hooks).toContain("check_issue_managed_block_range") + expect(hooks).toContain("Run session backup after successful push") + 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( + hooks.indexOf("/opt/docker-git/scripts/session-backup-gist.js") + ) + expect(hooks).toContain("[session-backup] Warning: gh CLI not found") + }) +}) + describe("renderDockerCompose", () => { it("renders fallback DNS servers for the main container even without Playwright", () => { const compose = renderDockerCompose(makeTemplateConfig()) diff --git a/scripts/session-backup-gist.js b/scripts/session-backup-gist.js index cc352ced..c3250ec9 100644 --- a/scripts/session-backup-gist.js +++ b/scripts/session-backup-gist.js @@ -12,7 +12,7 @@ * * Options: * --session-dir Path to session directory under $HOME (default: auto-detect ~/.codex, ~/.claude, ~/.qwen, or ~/.gemini) - * --pr-number PR number to post comment to (optional, auto-detected from branch) + * --pr-number Open PR number to post comment to (optional, auto-detected from branch) * --repo Source repository (optional, auto-detected from git remote) * --no-comment Skip posting PR comment * --dry-run Show what would be uploaded without actually uploading @@ -29,6 +29,7 @@ const fs = require("node:fs"); const path = require("node:path"); const { execSync, spawnSync } = require("node:child_process"); const os = require("node:os"); +const GH_MAX_BUFFER_BYTES = 32 * 1024 * 1024; const { buildBlobUrl, @@ -107,7 +108,7 @@ const parseArgs = () => { Options: --session-dir Path to session directory under $HOME - --pr-number PR number to post comment to + --pr-number Open PR number to post comment to --repo Source repository --no-comment Skip posting PR comment --dry-run Show what would be uploaded @@ -142,6 +143,7 @@ const ghCommand = (args, ghEnv) => { const result = spawnSync("gh", args, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], + maxBuffer: GH_MAX_BUFFER_BYTES, env: ghEnv, }); @@ -245,7 +247,7 @@ const getPrNumberFromBranch = (repo, branch, ghEnv) => { return null; }; -const prExists = (repo, prNumber, ghEnv) => { +const getPrState = (repo, prNumber, ghEnv) => { const result = ghCommand([ "pr", "view", @@ -253,12 +255,16 @@ const prExists = (repo, prNumber, ghEnv) => { "--repo", repo, "--json", - "number", + "state", "--jq", - ".number", + ".state", ], ghEnv); - return result.success && result.stdout === prNumber.toString(); + return result.success ? result.stdout : null; +}; + +const prIsOpen = (repo, prNumber, ghEnv) => { + return getPrState(repo, prNumber, ghEnv) === "OPEN"; }; const getPrNumberFromWorkspaceBranch = (branch) => { @@ -275,9 +281,12 @@ const findPrContext = (repos, branch, verbose, ghEnv) => { for (const repo of repos) { log(verbose, `Checking open PR in ${repo} for branch ${branch}`); const prNumber = getPrNumberFromBranch(repo, branch, ghEnv); - if (prNumber !== null) { + if (prNumber !== null && prIsOpen(repo, prNumber, ghEnv)) { return { repo, prNumber }; } + if (prNumber !== null) { + log(verbose, `Skipping PR #${prNumber} in ${repo}: PR is not open`); + } } const workspacePrNumber = getPrNumberFromWorkspaceBranch(branch); @@ -287,7 +296,7 @@ const findPrContext = (repos, branch, verbose, ghEnv) => { for (const repo of repos) { log(verbose, `Checking workspace PR #${workspacePrNumber} in ${repo} for branch ${branch}`); - if (prExists(repo, workspacePrNumber, ghEnv)) { + if (prIsOpen(repo, workspacePrNumber, ghEnv)) { return { repo, prNumber: workspacePrNumber }; } } @@ -410,7 +419,7 @@ const buildSnapshotReadme = ({ backupRepo, source, manifestUrl, summary, session "", `- Manifest: ${manifestUrl}`, "", - "Generated automatically by the docker-git `pre-push` session backup hook.", + "Generated automatically by the docker-git `post-push` session backup hook.", "", ].join("\n"); @@ -492,7 +501,11 @@ const main = () => { let prContext = null; if (args.prNumber !== null) { - prContext = { repo: sourceRepo, prNumber: args.prNumber }; + if (prIsOpen(sourceRepo, args.prNumber, ghEnv)) { + prContext = { repo: sourceRepo, prNumber: args.prNumber }; + } else { + console.log(`[session-backup] Skipping PR comment: PR #${args.prNumber} is not open`); + } } else if (args.postComment) { prContext = findPrContext(repoCandidates, branch, verbose, ghEnv); } diff --git a/scripts/session-backup-repo.js b/scripts/session-backup-repo.js index fcf69c42..6806a647 100644 --- a/scripts/session-backup-repo.js +++ b/scripts/session-backup-repo.js @@ -7,6 +7,7 @@ const { spawnSync } = require("node:child_process"); const BACKUP_REPO_NAME = "docker-git-sessions"; const BACKUP_DEFAULT_BRANCH = "main"; +const GH_MAX_BUFFER_BYTES = 32 * 1024 * 1024; // Keep each stored object below GitHub's 100 MB limit while transport batches stay smaller. const MAX_REPO_FILE_SIZE = 99 * 1000 * 1000; const MAX_PUSH_BATCH_BYTES = 50 * 1000 * 1000; @@ -163,6 +164,7 @@ const ghCommand = (args, ghEnv, inputFilePath = null) => { const result = spawnSync("gh", resolvedArgs, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], + maxBuffer: GH_MAX_BUFFER_BYTES, env: ghEnv, });