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
4 changes: 2 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,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
Expand Down
26 changes: 18 additions & 8 deletions packages/lib/src/core/templates-entrypoint/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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`

Expand Down
20 changes: 20 additions & 0 deletions packages/lib/tests/core/templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): TemplateConfig => ({
...defaultTemplateConfig,
Expand Down Expand Up @@ -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())
Expand Down
33 changes: 23 additions & 10 deletions scripts/session-backup-gist.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*
* Options:
* --session-dir <path> Path to session directory under $HOME (default: auto-detect ~/.codex, ~/.claude, ~/.qwen, or ~/.gemini)
* --pr-number <number> PR number to post comment to (optional, auto-detected from branch)
* --pr-number <number> Open PR number to post comment to (optional, auto-detected from branch)
* --repo <owner/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
Expand All @@ -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,
Expand Down Expand Up @@ -107,7 +108,7 @@ const parseArgs = () => {

Options:
--session-dir <path> Path to session directory under $HOME
--pr-number <number> PR number to post comment to
--pr-number <number> Open PR number to post comment to
--repo <owner/repo> Source repository
--no-comment Skip posting PR comment
--dry-run Show what would be uploaded
Expand Down Expand Up @@ -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,
});

Expand Down Expand Up @@ -245,20 +247,24 @@ const getPrNumberFromBranch = (repo, branch, ghEnv) => {
return null;
};

const prExists = (repo, prNumber, ghEnv) => {
const getPrState = (repo, prNumber, ghEnv) => {
const result = ghCommand([
"pr",
"view",
prNumber.toString(),
"--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) => {
Expand All @@ -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);
Expand All @@ -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 };
}
}
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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);
}
Expand Down
2 changes: 2 additions & 0 deletions scripts/session-backup-repo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
});

Expand Down