From 8a62c12f1efa9bec423224cb42fd2d0f7b27f86d Mon Sep 17 00:00:00 2001
From: skulidropek <66840575+skulidropek@users.noreply.github.com>
Date: Sat, 6 Jun 2026 10:01:48 +0000
Subject: [PATCH 1/3] fix(shell): ensure PR exists after git push
---
.../core/templates-entrypoint/git-hooks.ts | 127 +++++++++++++
.../tests/docker-git/core-templates.test.ts | 19 ++
.../core/templates-entrypoint/git-hooks.ts | 127 +++++++++++++
.../tests/core/git-post-push-wrapper.test.ts | 177 +++++++++++++++++-
packages/lib/tests/core/templates.test.ts | 16 ++
5 files changed, 463 insertions(+), 3 deletions(-)
diff --git a/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts b/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts
index 197751dc..59bb5f0b 100644
--- a/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts
+++ b/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts
@@ -145,6 +145,133 @@ if [[ -z "$REPO_ROOT" || ! -d "$REPO_ROOT" ]]; then
fi
cd "$REPO_ROOT"
+# CHANGE: ensure an open GitHub PR exists for the pushed branch before PR-bound post-push tools run.
+# WHY: issue #375 requires every successful git push to leave the branch with an open PR; plan sync and session backup both target PR discussion.
+# REF: issue-375
+docker_git_github_repo_from_remote_url() {
+ local remote_url="$1"
+ local repo_path=""
+ local owner=""
+ local repo=""
+
+ case "$remote_url" in
+ https://github.com/*)
+ repo_path="${"${"}remote_url#https://github.com/}"
+ ;;
+ http://github.com/*)
+ repo_path="${"${"}remote_url#http://github.com/}"
+ ;;
+ https://*@github.com/*)
+ repo_path="${"${"}remote_url#https://*@github.com/}"
+ ;;
+ http://*@github.com/*)
+ repo_path="${"${"}remote_url#http://*@github.com/}"
+ ;;
+ ssh://git@github.com/*)
+ repo_path="${"${"}remote_url#ssh://git@github.com/}"
+ ;;
+ git@github.com:*)
+ repo_path="${"${"}remote_url#git@github.com:}"
+ ;;
+ *)
+ return 1
+ ;;
+ esac
+
+ repo_path="${"${"}repo_path%%\?*}"
+ repo_path="${"${"}repo_path%%#*}"
+ repo_path="${"${"}repo_path%/}"
+ repo_path="${"${"}repo_path%.git}"
+ owner="${"${"}repo_path%%/*}"
+ repo="${"${"}repo_path#*/}"
+ repo="${"${"}repo%%/*}"
+ repo="${"${"}repo%.git}"
+
+ if [[ -z "$owner" || -z "$repo" || "$owner" == "$repo_path" ]]; then
+ return 1
+ fi
+
+ printf "%s/%s\n" "$owner" "$repo"
+}
+
+docker_git_github_repo_from_remote() {
+ local remote="$1"
+ local remote_url=""
+
+ remote_url="$(git remote get-url "$remote" 2>/dev/null || true)"
+ if [[ -z "$remote_url" ]]; then
+ return 1
+ fi
+
+ docker_git_github_repo_from_remote_url "$remote_url"
+}
+
+docker_git_ensure_open_pr() {
+ local branch=""
+ local base_repo=""
+ local head_repo=""
+ local head_owner=""
+ local head_arg=""
+ local base_branch=""
+ local pr_url=""
+
+ if ! command -v gh >/dev/null 2>&1; then
+ echo "[post-push-pr] Error: gh CLI not found" >&2
+ return 1
+ fi
+
+ branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
+ if [[ -z "$branch" || "$branch" == "HEAD" ]]; then
+ echo "[post-push-pr] Error: cannot create PR from detached HEAD" >&2
+ return 1
+ fi
+
+ if ! base_repo="$(docker_git_github_repo_from_remote upstream)"; then
+ if ! base_repo="$(docker_git_github_repo_from_remote origin)"; then
+ echo "[post-push-pr] Skipped: no GitHub remote found"
+ return 0
+ fi
+ fi
+
+ if ! head_repo="$(docker_git_github_repo_from_remote origin)"; then
+ head_repo="$base_repo"
+ fi
+
+ base_branch="$(gh repo view "$base_repo" --json defaultBranchRef --jq '.defaultBranchRef.name' 2>/dev/null || true)"
+ if [[ -z "$base_branch" ]]; then
+ echo "[post-push-pr] Error: failed to resolve default branch for $base_repo" >&2
+ return 1
+ fi
+
+ if [[ "$head_repo" == "$base_repo" ]]; then
+ head_arg="$branch"
+ else
+ head_owner="${"${"}head_repo%%/*}"
+ head_arg="${"${"}head_owner}:${"${"}branch}"
+ fi
+
+ if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$head_arg" --json url --jq '.[0].url // ""' 2>/dev/null)"; then
+ echo "[post-push-pr] Error: failed to list open PRs for $head_arg in $base_repo" >&2
+ return 1
+ fi
+ if [[ -z "$pr_url" && "$head_arg" != "$branch" ]]; then
+ if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$branch" --json url --jq '.[0].url // ""' 2>/dev/null)"; then
+ echo "[post-push-pr] Error: failed to list open PRs for $branch in $base_repo" >&2
+ return 1
+ fi
+ fi
+
+ if [[ -n "$pr_url" ]]; then
+ echo "[post-push-pr] Open PR: $pr_url"
+ return 0
+ fi
+
+ echo "[post-push-pr] Creating PR for $head_arg into $base_repo:$base_branch"
+ gh pr create --repo "$base_repo" --base "$base_branch" --head "$head_arg" --fill
+}
+
+docker_git_ensure_open_pr
+
# CHANGE: sync captured Codex plans to the current branch PR after push.
# WHY: issue #369 requires the agent plan to be uploaded to PR discussion.
# REF: issue-369
diff --git a/packages/app/tests/docker-git/core-templates.test.ts b/packages/app/tests/docker-git/core-templates.test.ts
index 9d9ab295..87911034 100644
--- a/packages/app/tests/docker-git/core-templates.test.ts
+++ b/packages/app/tests/docker-git/core-templates.test.ts
@@ -110,12 +110,31 @@ describe("app planFiles", () => {
"args = [\"--project\", \"$DOCKER_GIT_BROWSER_PROJECT\", \"--network\", \"$DOCKER_GIT_BROWSER_NETWORK\"]"
)
expect(entrypoint.contents).toContain("plan-to-git sync")
+ expect(entrypoint.contents).toContain("docker_git_ensure_open_pr")
+ expect(entrypoint.contents).toContain("gh pr list --repo \"$base_repo\" --state open --head \"$head_arg\"")
+ expect(entrypoint.contents).toContain(
+ "gh pr create --repo \"$base_repo\" --base \"$base_branch\" --head \"$head_arg\" --fill"
+ )
expect(entrypoint.contents).toContain("plan-to-git hook --source codex")
expect(entrypoint.contents).toContain("CODEX_REQUIREMENTS_FILE=\"/etc/codex/requirements.toml\"")
expect(entrypoint.contents).toContain("managed_dir = \"/opt/docker-git/hooks\"")
expect(entrypoint.contents).toContain("[[hooks.UserPromptSubmit]]")
expect(entrypoint.contents).toContain("[[hooks.Stop]]")
expect(entrypoint.contents).toContain("command = \"/opt/docker-git/hooks/plan-to-git-codex-hook\"")
+
+ const cdIndex = entrypoint.contents.indexOf("cd \"$REPO_ROOT\"")
+ const ensurePrIndex = entrypoint.contents.indexOf(
+ "docker_git_ensure_open_pr\n\n# CHANGE: sync captured Codex plans"
+ )
+ const planSyncIndex = entrypoint.contents.indexOf("plan-to-git sync")
+ const sessionBackupIndex = entrypoint.contents.indexOf(
+ "docker-git-session-sync backup --verbose --background --require-comment"
+ )
+
+ expect(cdIndex).toBeGreaterThanOrEqual(0)
+ expect(ensurePrIndex).toBeGreaterThan(cdIndex)
+ expect(planSyncIndex).toBeGreaterThan(ensurePrIndex)
+ expect(sessionBackupIndex).toBeGreaterThan(planSyncIndex)
})
)
})
diff --git a/packages/lib/src/core/templates-entrypoint/git-hooks.ts b/packages/lib/src/core/templates-entrypoint/git-hooks.ts
index 197751dc..59bb5f0b 100644
--- a/packages/lib/src/core/templates-entrypoint/git-hooks.ts
+++ b/packages/lib/src/core/templates-entrypoint/git-hooks.ts
@@ -145,6 +145,133 @@ if [[ -z "$REPO_ROOT" || ! -d "$REPO_ROOT" ]]; then
fi
cd "$REPO_ROOT"
+# CHANGE: ensure an open GitHub PR exists for the pushed branch before PR-bound post-push tools run.
+# WHY: issue #375 requires every successful git push to leave the branch with an open PR; plan sync and session backup both target PR discussion.
+# REF: issue-375
+docker_git_github_repo_from_remote_url() {
+ local remote_url="$1"
+ local repo_path=""
+ local owner=""
+ local repo=""
+
+ case "$remote_url" in
+ https://github.com/*)
+ repo_path="${"${"}remote_url#https://github.com/}"
+ ;;
+ http://github.com/*)
+ repo_path="${"${"}remote_url#http://github.com/}"
+ ;;
+ https://*@github.com/*)
+ repo_path="${"${"}remote_url#https://*@github.com/}"
+ ;;
+ http://*@github.com/*)
+ repo_path="${"${"}remote_url#http://*@github.com/}"
+ ;;
+ ssh://git@github.com/*)
+ repo_path="${"${"}remote_url#ssh://git@github.com/}"
+ ;;
+ git@github.com:*)
+ repo_path="${"${"}remote_url#git@github.com:}"
+ ;;
+ *)
+ return 1
+ ;;
+ esac
+
+ repo_path="${"${"}repo_path%%\?*}"
+ repo_path="${"${"}repo_path%%#*}"
+ repo_path="${"${"}repo_path%/}"
+ repo_path="${"${"}repo_path%.git}"
+ owner="${"${"}repo_path%%/*}"
+ repo="${"${"}repo_path#*/}"
+ repo="${"${"}repo%%/*}"
+ repo="${"${"}repo%.git}"
+
+ if [[ -z "$owner" || -z "$repo" || "$owner" == "$repo_path" ]]; then
+ return 1
+ fi
+
+ printf "%s/%s\n" "$owner" "$repo"
+}
+
+docker_git_github_repo_from_remote() {
+ local remote="$1"
+ local remote_url=""
+
+ remote_url="$(git remote get-url "$remote" 2>/dev/null || true)"
+ if [[ -z "$remote_url" ]]; then
+ return 1
+ fi
+
+ docker_git_github_repo_from_remote_url "$remote_url"
+}
+
+docker_git_ensure_open_pr() {
+ local branch=""
+ local base_repo=""
+ local head_repo=""
+ local head_owner=""
+ local head_arg=""
+ local base_branch=""
+ local pr_url=""
+
+ if ! command -v gh >/dev/null 2>&1; then
+ echo "[post-push-pr] Error: gh CLI not found" >&2
+ return 1
+ fi
+
+ branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
+ if [[ -z "$branch" || "$branch" == "HEAD" ]]; then
+ echo "[post-push-pr] Error: cannot create PR from detached HEAD" >&2
+ return 1
+ fi
+
+ if ! base_repo="$(docker_git_github_repo_from_remote upstream)"; then
+ if ! base_repo="$(docker_git_github_repo_from_remote origin)"; then
+ echo "[post-push-pr] Skipped: no GitHub remote found"
+ return 0
+ fi
+ fi
+
+ if ! head_repo="$(docker_git_github_repo_from_remote origin)"; then
+ head_repo="$base_repo"
+ fi
+
+ base_branch="$(gh repo view "$base_repo" --json defaultBranchRef --jq '.defaultBranchRef.name' 2>/dev/null || true)"
+ if [[ -z "$base_branch" ]]; then
+ echo "[post-push-pr] Error: failed to resolve default branch for $base_repo" >&2
+ return 1
+ fi
+
+ if [[ "$head_repo" == "$base_repo" ]]; then
+ head_arg="$branch"
+ else
+ head_owner="${"${"}head_repo%%/*}"
+ head_arg="${"${"}head_owner}:${"${"}branch}"
+ fi
+
+ if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$head_arg" --json url --jq '.[0].url // ""' 2>/dev/null)"; then
+ echo "[post-push-pr] Error: failed to list open PRs for $head_arg in $base_repo" >&2
+ return 1
+ fi
+ if [[ -z "$pr_url" && "$head_arg" != "$branch" ]]; then
+ if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$branch" --json url --jq '.[0].url // ""' 2>/dev/null)"; then
+ echo "[post-push-pr] Error: failed to list open PRs for $branch in $base_repo" >&2
+ return 1
+ fi
+ fi
+
+ if [[ -n "$pr_url" ]]; then
+ echo "[post-push-pr] Open PR: $pr_url"
+ return 0
+ fi
+
+ echo "[post-push-pr] Creating PR for $head_arg into $base_repo:$base_branch"
+ gh pr create --repo "$base_repo" --base "$base_branch" --head "$head_arg" --fill
+}
+
+docker_git_ensure_open_pr
+
# CHANGE: sync captured Codex plans to the current branch PR after push.
# WHY: issue #369 requires the agent plan to be uploaded to PR discussion.
# REF: issue-369
diff --git a/packages/lib/tests/core/git-post-push-wrapper.test.ts b/packages/lib/tests/core/git-post-push-wrapper.test.ts
index 487e5fd5..c57b7c86 100644
--- a/packages/lib/tests/core/git-post-push-wrapper.test.ts
+++ b/packages/lib/tests/core/git-post-push-wrapper.test.ts
@@ -26,6 +26,7 @@ type WrapperHarness = {
readonly nodeRepoRootLogPath: string
readonly nodeScriptLogPath: string
readonly planToGitLogPath: string
+ readonly ghLogPath: string
}
const fakeGitScript = `#!/usr/bin/env bash
@@ -76,6 +77,28 @@ if [[ "$subcommand" == "rev-parse" ]]; then
fi
exit 128
fi
+ next_next_index=$((index + 2))
+ if [[ "$next_index" -lt "$#" && "$next_next_index" -lt "$#" && "\${args[$next_index]}" == "--abbrev-ref" && "\${args[$next_next_index]}" == "HEAD" ]]; then
+ printf '%s\\n' "\${FAKE_GIT_BRANCH:-issue-375}"
+ exit 0
+ fi
+fi
+
+if [[ "$subcommand" == "remote" ]]; then
+ next_index=$((index + 1))
+ next_next_index=$((index + 2))
+ if [[ "$next_index" -lt "$#" && "$next_next_index" -lt "$#" && "\${args[$next_index]}" == "get-url" ]]; then
+ remote_name="\${args[$next_next_index]}"
+ if [[ "$remote_name" == "upstream" && -n "\${FAKE_GIT_UPSTREAM_URL:-}" ]]; then
+ printf '%s\\n' "$FAKE_GIT_UPSTREAM_URL"
+ exit 0
+ fi
+ if [[ "$remote_name" == "origin" ]]; then
+ printf '%s\\n' "\${FAKE_GIT_ORIGIN_URL:-https://github.com/org/repo.git}"
+ exit 0
+ fi
+ exit 2
+ fi
fi
if [[ "$subcommand" == "push" && -n "\${FAKE_GIT_PUSH_EXIT_CODE:-}" ]]; then
@@ -107,6 +130,37 @@ exit 0
const fakeGhScript = `#!/usr/bin/env bash
set -euo pipefail
+
+if [[ -n "\${FAKE_GH_LOG_PATH:-}" ]]; then
+ printf '%s\\t%s\\n' "$PWD" "$*" >> "$FAKE_GH_LOG_PATH"
+fi
+
+if [[ "\${1:-}" == "repo" && "\${2:-}" == "view" ]]; then
+ if [[ -n "\${FAKE_GH_REPO_VIEW_EXIT_CODE:-}" ]]; then
+ exit "$FAKE_GH_REPO_VIEW_EXIT_CODE"
+ fi
+ printf '%s\\n' "\${FAKE_GH_DEFAULT_BRANCH:-main}"
+ exit 0
+fi
+
+if [[ "\${1:-}" == "pr" && "\${2:-}" == "list" ]]; then
+ if [[ -n "\${FAKE_GH_PR_LIST_EXIT_CODE:-}" ]]; then
+ exit "$FAKE_GH_PR_LIST_EXIT_CODE"
+ fi
+ if [[ -n "\${FAKE_GH_OPEN_PR_URL:-}" ]]; then
+ printf '%s\\n' "$FAKE_GH_OPEN_PR_URL"
+ fi
+ exit 0
+fi
+
+if [[ "\${1:-}" == "pr" && "\${2:-}" == "create" ]]; then
+ if [[ -n "\${FAKE_GH_PR_CREATE_EXIT_CODE:-}" ]]; then
+ exit "$FAKE_GH_PR_CREATE_EXIT_CODE"
+ fi
+ printf '%s\\n' "\${FAKE_GH_CREATED_PR_URL:-https://github.com/org/repo/pull/375}"
+ exit 0
+fi
+
exit 0
`
@@ -227,6 +281,7 @@ const makeHarnessEnv = (
FAKE_NODE_REPO_ROOT_LOG_PATH: harness.nodeRepoRootLogPath,
FAKE_NODE_SCRIPT_LOG_PATH: harness.nodeScriptLogPath,
FAKE_PLAN_TO_GIT_LOG_PATH: harness.planToGitLogPath,
+ FAKE_GH_LOG_PATH: harness.ghLogPath,
...overrides
})
@@ -269,6 +324,7 @@ const withHarness = (
const nodeRepoRootLogPath = path.join(rootDir, "node-repo-root.log")
const nodeScriptLogPath = path.join(rootDir, "node-script.log")
const planToGitLogPath = path.join(rootDir, "plan-to-git.log")
+ const ghLogPath = path.join(rootDir, "gh.log")
yield* _(fs.makeDirectory(path.join(repoDir, ".git"), { recursive: true }))
yield* _(fs.makeDirectory(externalDir, { recursive: true }))
@@ -305,7 +361,8 @@ const withHarness = (
nodeCwdLogPath,
nodeRepoRootLogPath,
nodeScriptLogPath,
- planToGitLogPath
+ planToGitLogPath,
+ ghLogPath
})
)
})
@@ -321,11 +378,13 @@ describe("git post-push wrapper", () => {
const nodeRepoRoot = yield* _(readLogLines(harness.nodeRepoRootLogPath))
const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath))
const planToGit = yield* _(readLogLines(harness.planToGitLogPath))
+ const gh = yield* _(readLogLines(harness.ghLogPath))
expect(nodeCwd).toEqual([harness.repoDir])
expect(nodeRepoRoot).toEqual([harness.repoDir])
expect(nodeScript).toEqual(["backup --verbose --background --require-comment"])
expect(planToGit).toEqual([`${harness.repoDir}\tsync`])
+ expect(gh).toContain(`${harness.repoDir}\tpr create --repo org/repo --base main --head issue-375 --fill`)
})
).pipe(Effect.provide(NodeContext.layer)))
@@ -339,11 +398,13 @@ describe("git post-push wrapper", () => {
const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath))
const gitLog = yield* _(readLogLines(harness.gitLogPath))
const planToGit = yield* _(readLogLines(harness.planToGitLogPath))
+ const gh = yield* _(readLogLines(harness.ghLogPath))
expect(nodeCwd).toEqual([harness.repoDir])
expect(nodeRepoRoot).toEqual([harness.repoDir])
expect(nodeScript).toEqual(["backup --verbose --background --require-comment"])
expect(planToGit).toEqual([`${harness.repoDir}\tsync`])
+ expect(gh).toContain(`${harness.repoDir}\tpr create --repo org/repo --base main --head issue-375 --fill`)
expect(gitLog.some((line) => line.startsWith(`${harness.externalDir}\t-C ${harness.repoDir} push`))).toBe(true)
})
).pipe(Effect.provide(NodeContext.layer)))
@@ -358,11 +419,13 @@ describe("git post-push wrapper", () => {
const nodeRepoRoot = yield* _(readLogLines(harness.nodeRepoRootLogPath))
const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath))
const planToGit = yield* _(readLogLines(harness.planToGitLogPath))
+ const gh = yield* _(readLogLines(harness.ghLogPath))
expect(nodeCwd).toEqual([])
expect(nodeRepoRoot).toEqual([])
expect(nodeScript).toEqual([])
expect(planToGit).toEqual([])
+ expect(gh).toEqual([])
})
).pipe(Effect.provide(NodeContext.layer)))
@@ -385,15 +448,17 @@ describe("git post-push wrapper", () => {
const nodeRepoRoot = yield* _(readLogLines(harness.nodeRepoRootLogPath))
const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath))
const planToGit = yield* _(readLogLines(harness.planToGitLogPath))
+ const gh = yield* _(readLogLines(harness.ghLogPath))
expect(nodeCwd).toEqual([])
expect(nodeRepoRoot).toEqual([])
expect(nodeScript).toEqual([])
expect(planToGit).toEqual([])
+ expect(gh).toEqual([])
})
).pipe(Effect.provide(NodeContext.layer)))
- it.effect("skips plan sync when disabled but still runs session backup", () =>
+ it.effect("skips plan sync when disabled but still ensures a PR and runs session backup", () =>
withHarness((harness) =>
Effect.gen(function*(_) {
yield* _(
@@ -404,13 +469,15 @@ describe("git post-push wrapper", () => {
const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath))
const planToGit = yield* _(readLogLines(harness.planToGitLogPath))
+ const gh = yield* _(readLogLines(harness.ghLogPath))
expect(nodeScript).toEqual(["backup --verbose --background --require-comment"])
expect(planToGit).toEqual([])
+ expect(gh).toContain(`${harness.repoDir}\tpr create --repo org/repo --base main --head issue-375 --fill`)
})
).pipe(Effect.provide(NodeContext.layer)))
- it.effect("propagates plan sync failures before session backup", () =>
+ it.effect("propagates plan sync failures after ensuring a PR and before session backup", () =>
withHarness((harness) =>
Effect.gen(function*(_) {
yield* _(
@@ -422,9 +489,11 @@ describe("git post-push wrapper", () => {
const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath))
const planToGit = yield* _(readLogLines(harness.planToGitLogPath))
+ const gh = yield* _(readLogLines(harness.ghLogPath))
expect(nodeScript).toEqual([])
expect(planToGit).toEqual([`${harness.repoDir}\tsync`])
+ expect(gh).toContain(`${harness.repoDir}\tpr create --repo org/repo --base main --head issue-375 --fill`)
})
).pipe(Effect.provide(NodeContext.layer)))
@@ -445,4 +514,106 @@ describe("git post-push wrapper", () => {
expect(planToGit).toEqual([`${harness.repoDir}\tsync`])
})
).pipe(Effect.provide(NodeContext.layer)))
+
+ it.effect("reuses an existing open PR instead of creating a duplicate", () =>
+ withHarness((harness) =>
+ Effect.gen(function*(_) {
+ yield* _(
+ runWrapper(harness, harness.repoDir, ["push", "origin", "HEAD"], {
+ env: { FAKE_GH_OPEN_PR_URL: "https://github.com/org/repo/pull/375" }
+ })
+ )
+
+ const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath))
+ const planToGit = yield* _(readLogLines(harness.planToGitLogPath))
+ const gh = yield* _(readLogLines(harness.ghLogPath))
+
+ expect(nodeScript).toEqual(["backup --verbose --background --require-comment"])
+ expect(planToGit).toEqual([`${harness.repoDir}\tsync`])
+ expect(gh).toContain(`${harness.repoDir}\tpr list --repo org/repo --state open --head issue-375 --json url --jq .[0].url // ""`)
+ expect(gh.some((line) => line.includes("pr create"))).toBe(false)
+ })
+ ).pipe(Effect.provide(NodeContext.layer)))
+
+ it.effect("creates fork PRs against upstream with an owner-qualified head branch", () =>
+ withHarness((harness) =>
+ Effect.gen(function*(_) {
+ yield* _(
+ runWrapper(harness, harness.repoDir, ["push", "origin", "HEAD"], {
+ env: {
+ FAKE_GIT_ORIGIN_URL: "https://github.com/me/repo.git",
+ FAKE_GIT_UPSTREAM_URL: "https://github.com/org/repo.git"
+ }
+ })
+ )
+
+ const gh = yield* _(readLogLines(harness.ghLogPath))
+
+ expect(gh).toContain(`${harness.repoDir}\trepo view org/repo --json defaultBranchRef --jq .defaultBranchRef.name`)
+ expect(gh).toContain(`${harness.repoDir}\tpr list --repo org/repo --state open --head me:issue-375 --json url --jq .[0].url // ""`)
+ expect(gh).toContain(`${harness.repoDir}\tpr list --repo org/repo --state open --head issue-375 --json url --jq .[0].url // ""`)
+ expect(gh).toContain(`${harness.repoDir}\tpr create --repo org/repo --base main --head me:issue-375 --fill`)
+ })
+ ).pipe(Effect.provide(NodeContext.layer)))
+
+ it.effect("propagates PR creation failures before plan sync and session backup", () =>
+ withHarness((harness) =>
+ Effect.gen(function*(_) {
+ yield* _(
+ runWrapper(harness, harness.repoDir, ["push", "origin", "HEAD"], {
+ env: { FAKE_GH_PR_CREATE_EXIT_CODE: "41" },
+ okExitCodes: [41]
+ })
+ )
+
+ const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath))
+ const planToGit = yield* _(readLogLines(harness.planToGitLogPath))
+ const gh = yield* _(readLogLines(harness.ghLogPath))
+
+ expect(nodeScript).toEqual([])
+ expect(planToGit).toEqual([])
+ expect(gh).toContain(`${harness.repoDir}\tpr create --repo org/repo --base main --head issue-375 --fill`)
+ })
+ ).pipe(Effect.provide(NodeContext.layer)))
+
+ it.effect("propagates PR list failures without creating a duplicate PR", () =>
+ withHarness((harness) =>
+ Effect.gen(function*(_) {
+ yield* _(
+ runWrapper(harness, harness.repoDir, ["push", "origin", "HEAD"], {
+ env: { FAKE_GH_PR_LIST_EXIT_CODE: "42" },
+ okExitCodes: [1]
+ })
+ )
+
+ const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath))
+ const planToGit = yield* _(readLogLines(harness.planToGitLogPath))
+ const gh = yield* _(readLogLines(harness.ghLogPath))
+
+ expect(nodeScript).toEqual([])
+ expect(planToGit).toEqual([])
+ expect(gh).toContain(`${harness.repoDir}\tpr list --repo org/repo --state open --head issue-375 --json url --jq .[0].url // ""`)
+ expect(gh.some((line) => line.includes("pr create"))).toBe(false)
+ })
+ ).pipe(Effect.provide(NodeContext.layer)))
+
+ it.effect("fails on detached HEAD before listing or creating PRs", () =>
+ withHarness((harness) =>
+ Effect.gen(function*(_) {
+ yield* _(
+ runWrapper(harness, harness.repoDir, ["push", "origin", "HEAD"], {
+ env: { FAKE_GIT_BRANCH: "HEAD" },
+ okExitCodes: [1]
+ })
+ )
+
+ const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath))
+ const planToGit = yield* _(readLogLines(harness.planToGitLogPath))
+ const gh = yield* _(readLogLines(harness.ghLogPath))
+
+ expect(nodeScript).toEqual([])
+ expect(planToGit).toEqual([])
+ expect(gh).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 a167f97a..6ad8a755 100644
--- a/packages/lib/tests/core/templates.test.ts
+++ b/packages/lib/tests/core/templates.test.ts
@@ -480,6 +480,12 @@ describe("renderEntrypointGitHooks", () => {
expect(hooks).toContain("cat <<'EOF' > \"$GIT_WRAPPER_BIN\"")
expect(hooks).toContain("check_issue_managed_block_range")
expect(hooks).toContain("Run plan sync and session backup after successful push")
+ expect(hooks).toContain("docker_git_ensure_open_pr")
+ expect(hooks).toContain("docker_git_github_repo_from_remote_url")
+ expect(hooks).toContain("gh pr list --repo \"$base_repo\" --state open --head \"$head_arg\"")
+ expect(hooks).toContain("gh pr create --repo \"$base_repo\" --base \"$base_branch\" --head \"$head_arg\" --fill")
+ expect(hooks).toContain("[post-push-pr] Error: cannot create PR from detached HEAD")
+ expect(hooks).toContain("[post-push-pr] Error: failed to list open PRs")
expect(hooks).toContain("DOCKER_GIT_SKIP_PLAN_TO_GIT")
expect(hooks).toContain("plan-to-git sync")
expect(hooks).toContain("plan-to-git hook --source codex")
@@ -507,6 +513,16 @@ describe("renderEntrypointGitHooks", () => {
expect(hooks).not.toContain("node \"$BACKUP_SCRIPT\"")
expect(hooks).not.toContain("session-backup-gist.js")
expect(hooks).toContain("[session-backup] Error: gh CLI not found")
+
+ const cdIndex = hooks.indexOf('cd "$REPO_ROOT"')
+ const ensurePrIndex = hooks.indexOf("docker_git_ensure_open_pr\n\n# CHANGE: sync captured Codex plans")
+ const planSyncIndex = hooks.indexOf("plan-to-git sync")
+ const sessionBackupIndex = hooks.indexOf("docker-git-session-sync backup --verbose --background --require-comment")
+
+ expect(cdIndex).toBeGreaterThanOrEqual(0)
+ expect(ensurePrIndex).toBeGreaterThan(cdIndex)
+ expect(planSyncIndex).toBeGreaterThan(ensurePrIndex)
+ expect(sessionBackupIndex).toBeGreaterThan(planSyncIndex)
})
})
From ed459e5fa4a6063240329bbb9db54cbdb59954bd Mon Sep 17 00:00:00 2001
From: skulidropek <66840575+skulidropek@users.noreply.github.com>
Date: Sat, 6 Jun 2026 10:28:48 +0000
Subject: [PATCH 2/3] refactor(shell): extract post-push PR template
---
.../core/templates-entrypoint/git-hooks.ts | 128 +----------------
.../core/templates-entrypoint/post-push-pr.ts | 133 ++++++++++++++++++
.../core/templates-entrypoint/git-hooks.ts | 128 +----------------
.../core/templates-entrypoint/post-push-pr.ts | 129 +++++++++++++++++
packages/lib/tests/core/templates.test.ts | 6 +
5 files changed, 272 insertions(+), 252 deletions(-)
create mode 100644 packages/app/src/lib/core/templates-entrypoint/post-push-pr.ts
create mode 100644 packages/lib/src/core/templates-entrypoint/post-push-pr.ts
diff --git a/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts b/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts
index 59bb5f0b..b000f492 100644
--- a/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts
+++ b/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts
@@ -1,4 +1,5 @@
import { renderEntrypointGitPostPushWrapperInstall } from "./git-post-push-wrapper.js"
+import { renderPostPushPrEnsure } from "./post-push-pr.js"
const entrypointGitHooksTemplate = String
.raw`# 3) Install global git hooks to protect main/master + managed AGENTS context
@@ -145,132 +146,7 @@ if [[ -z "$REPO_ROOT" || ! -d "$REPO_ROOT" ]]; then
fi
cd "$REPO_ROOT"
-# CHANGE: ensure an open GitHub PR exists for the pushed branch before PR-bound post-push tools run.
-# WHY: issue #375 requires every successful git push to leave the branch with an open PR; plan sync and session backup both target PR discussion.
-# REF: issue-375
-docker_git_github_repo_from_remote_url() {
- local remote_url="$1"
- local repo_path=""
- local owner=""
- local repo=""
-
- case "$remote_url" in
- https://github.com/*)
- repo_path="${"${"}remote_url#https://github.com/}"
- ;;
- http://github.com/*)
- repo_path="${"${"}remote_url#http://github.com/}"
- ;;
- https://*@github.com/*)
- repo_path="${"${"}remote_url#https://*@github.com/}"
- ;;
- http://*@github.com/*)
- repo_path="${"${"}remote_url#http://*@github.com/}"
- ;;
- ssh://git@github.com/*)
- repo_path="${"${"}remote_url#ssh://git@github.com/}"
- ;;
- git@github.com:*)
- repo_path="${"${"}remote_url#git@github.com:}"
- ;;
- *)
- return 1
- ;;
- esac
-
- repo_path="${"${"}repo_path%%\?*}"
- repo_path="${"${"}repo_path%%#*}"
- repo_path="${"${"}repo_path%/}"
- repo_path="${"${"}repo_path%.git}"
- owner="${"${"}repo_path%%/*}"
- repo="${"${"}repo_path#*/}"
- repo="${"${"}repo%%/*}"
- repo="${"${"}repo%.git}"
-
- if [[ -z "$owner" || -z "$repo" || "$owner" == "$repo_path" ]]; then
- return 1
- fi
-
- printf "%s/%s\n" "$owner" "$repo"
-}
-
-docker_git_github_repo_from_remote() {
- local remote="$1"
- local remote_url=""
-
- remote_url="$(git remote get-url "$remote" 2>/dev/null || true)"
- if [[ -z "$remote_url" ]]; then
- return 1
- fi
-
- docker_git_github_repo_from_remote_url "$remote_url"
-}
-
-docker_git_ensure_open_pr() {
- local branch=""
- local base_repo=""
- local head_repo=""
- local head_owner=""
- local head_arg=""
- local base_branch=""
- local pr_url=""
-
- if ! command -v gh >/dev/null 2>&1; then
- echo "[post-push-pr] Error: gh CLI not found" >&2
- return 1
- fi
-
- branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
- if [[ -z "$branch" || "$branch" == "HEAD" ]]; then
- echo "[post-push-pr] Error: cannot create PR from detached HEAD" >&2
- return 1
- fi
-
- if ! base_repo="$(docker_git_github_repo_from_remote upstream)"; then
- if ! base_repo="$(docker_git_github_repo_from_remote origin)"; then
- echo "[post-push-pr] Skipped: no GitHub remote found"
- return 0
- fi
- fi
-
- if ! head_repo="$(docker_git_github_repo_from_remote origin)"; then
- head_repo="$base_repo"
- fi
-
- base_branch="$(gh repo view "$base_repo" --json defaultBranchRef --jq '.defaultBranchRef.name' 2>/dev/null || true)"
- if [[ -z "$base_branch" ]]; then
- echo "[post-push-pr] Error: failed to resolve default branch for $base_repo" >&2
- return 1
- fi
-
- if [[ "$head_repo" == "$base_repo" ]]; then
- head_arg="$branch"
- else
- head_owner="${"${"}head_repo%%/*}"
- head_arg="${"${"}head_owner}:${"${"}branch}"
- fi
-
- if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$head_arg" --json url --jq '.[0].url // ""' 2>/dev/null)"; then
- echo "[post-push-pr] Error: failed to list open PRs for $head_arg in $base_repo" >&2
- return 1
- fi
- if [[ -z "$pr_url" && "$head_arg" != "$branch" ]]; then
- if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$branch" --json url --jq '.[0].url // ""' 2>/dev/null)"; then
- echo "[post-push-pr] Error: failed to list open PRs for $branch in $base_repo" >&2
- return 1
- fi
- fi
-
- if [[ -n "$pr_url" ]]; then
- echo "[post-push-pr] Open PR: $pr_url"
- return 0
- fi
-
- echo "[post-push-pr] Creating PR for $head_arg into $base_repo:$base_branch"
- gh pr create --repo "$base_repo" --base "$base_branch" --head "$head_arg" --fill
-}
-
-docker_git_ensure_open_pr
+${renderPostPushPrEnsure()}
# CHANGE: sync captured Codex plans to the current branch PR after push.
# WHY: issue #369 requires the agent plan to be uploaded to PR discussion.
diff --git a/packages/app/src/lib/core/templates-entrypoint/post-push-pr.ts b/packages/app/src/lib/core/templates-entrypoint/post-push-pr.ts
new file mode 100644
index 00000000..8ef23598
--- /dev/null
+++ b/packages/app/src/lib/core/templates-entrypoint/post-push-pr.ts
@@ -0,0 +1,133 @@
+/* jscpd:ignore-start */
+// Mirror of packages/lib/src/core/templates-entrypoint/post-push-pr.ts.
+// The lib template test asserts rendered output equality to prevent drift.
+const postPushPrEnsureTemplate = String
+ .raw`# CHANGE: ensure an open GitHub PR exists for the pushed branch before PR-bound post-push tools run.
+# WHY: issue #375 requires every successful git push to leave the branch with an open PR; plan sync and session backup both target PR discussion.
+# REF: issue-375
+docker_git_github_repo_from_remote_url() {
+ local remote_url="$1"
+ local repo_path=""
+ local owner=""
+ local repo=""
+
+ case "$remote_url" in
+ https://github.com/*)
+ repo_path="${"${"}remote_url#https://github.com/}"
+ ;;
+ http://github.com/*)
+ repo_path="${"${"}remote_url#http://github.com/}"
+ ;;
+ https://*@github.com/*)
+ repo_path="${"${"}remote_url#https://*@github.com/}"
+ ;;
+ http://*@github.com/*)
+ repo_path="${"${"}remote_url#http://*@github.com/}"
+ ;;
+ ssh://git@github.com/*)
+ repo_path="${"${"}remote_url#ssh://git@github.com/}"
+ ;;
+ git@github.com:*)
+ repo_path="${"${"}remote_url#git@github.com:}"
+ ;;
+ *)
+ return 1
+ ;;
+ esac
+
+ repo_path="${"${"}repo_path%%\?*}"
+ repo_path="${"${"}repo_path%%#*}"
+ repo_path="${"${"}repo_path%/}"
+ repo_path="${"${"}repo_path%.git}"
+ owner="${"${"}repo_path%%/*}"
+ repo="${"${"}repo_path#*/}"
+ repo="${"${"}repo%%/*}"
+ repo="${"${"}repo%.git}"
+
+ if [[ -z "$owner" || -z "$repo" || "$owner" == "$repo_path" ]]; then
+ return 1
+ fi
+
+ printf "%s/%s\n" "$owner" "$repo"
+}
+
+docker_git_github_repo_from_remote() {
+ local remote="$1"
+ local remote_url=""
+
+ remote_url="$(git remote get-url "$remote" 2>/dev/null || true)"
+ if [[ -z "$remote_url" ]]; then
+ return 1
+ fi
+
+ docker_git_github_repo_from_remote_url "$remote_url"
+}
+
+docker_git_ensure_open_pr() {
+ local branch=""
+ local base_repo=""
+ local head_repo=""
+ local head_owner=""
+ local head_arg=""
+ local base_branch=""
+ local pr_url=""
+
+ if ! command -v gh >/dev/null 2>&1; then
+ echo "[post-push-pr] Error: gh CLI not found" >&2
+ return 1
+ fi
+
+ branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
+ if [[ -z "$branch" || "$branch" == "HEAD" ]]; then
+ echo "[post-push-pr] Error: cannot create PR from detached HEAD" >&2
+ return 1
+ fi
+
+ if ! base_repo="$(docker_git_github_repo_from_remote upstream)"; then
+ if ! base_repo="$(docker_git_github_repo_from_remote origin)"; then
+ echo "[post-push-pr] Skipped: no GitHub remote found"
+ return 0
+ fi
+ fi
+
+ if ! head_repo="$(docker_git_github_repo_from_remote origin)"; then
+ head_repo="$base_repo"
+ fi
+
+ base_branch="$(gh repo view "$base_repo" --json defaultBranchRef --jq '.defaultBranchRef.name' 2>/dev/null || true)"
+ if [[ -z "$base_branch" ]]; then
+ echo "[post-push-pr] Error: failed to resolve default branch for $base_repo" >&2
+ return 1
+ fi
+
+ if [[ "$head_repo" == "$base_repo" ]]; then
+ head_arg="$branch"
+ else
+ head_owner="${"${"}head_repo%%/*}"
+ head_arg="${"${"}head_owner}:${"${"}branch}"
+ fi
+
+ if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$head_arg" --json url --jq '.[0].url // ""' 2>/dev/null)"; then
+ echo "[post-push-pr] Error: failed to list open PRs for $head_arg in $base_repo" >&2
+ return 1
+ fi
+ if [[ -z "$pr_url" && "$head_arg" != "$branch" ]]; then
+ if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$branch" --json url --jq '.[0].url // ""' 2>/dev/null)"; then
+ echo "[post-push-pr] Error: failed to list open PRs for $branch in $base_repo" >&2
+ return 1
+ fi
+ fi
+
+ if [[ -n "$pr_url" ]]; then
+ echo "[post-push-pr] Open PR: $pr_url"
+ return 0
+ fi
+
+ echo "[post-push-pr] Creating PR for $head_arg into $base_repo:$base_branch"
+ gh pr create --repo "$base_repo" --base "$base_branch" --head "$head_arg" --fill
+}
+
+docker_git_ensure_open_pr`
+
+export const renderPostPushPrEnsure = (): string => postPushPrEnsureTemplate
+/* jscpd:ignore-end */
diff --git a/packages/lib/src/core/templates-entrypoint/git-hooks.ts b/packages/lib/src/core/templates-entrypoint/git-hooks.ts
index 59bb5f0b..b000f492 100644
--- a/packages/lib/src/core/templates-entrypoint/git-hooks.ts
+++ b/packages/lib/src/core/templates-entrypoint/git-hooks.ts
@@ -1,4 +1,5 @@
import { renderEntrypointGitPostPushWrapperInstall } from "./git-post-push-wrapper.js"
+import { renderPostPushPrEnsure } from "./post-push-pr.js"
const entrypointGitHooksTemplate = String
.raw`# 3) Install global git hooks to protect main/master + managed AGENTS context
@@ -145,132 +146,7 @@ if [[ -z "$REPO_ROOT" || ! -d "$REPO_ROOT" ]]; then
fi
cd "$REPO_ROOT"
-# CHANGE: ensure an open GitHub PR exists for the pushed branch before PR-bound post-push tools run.
-# WHY: issue #375 requires every successful git push to leave the branch with an open PR; plan sync and session backup both target PR discussion.
-# REF: issue-375
-docker_git_github_repo_from_remote_url() {
- local remote_url="$1"
- local repo_path=""
- local owner=""
- local repo=""
-
- case "$remote_url" in
- https://github.com/*)
- repo_path="${"${"}remote_url#https://github.com/}"
- ;;
- http://github.com/*)
- repo_path="${"${"}remote_url#http://github.com/}"
- ;;
- https://*@github.com/*)
- repo_path="${"${"}remote_url#https://*@github.com/}"
- ;;
- http://*@github.com/*)
- repo_path="${"${"}remote_url#http://*@github.com/}"
- ;;
- ssh://git@github.com/*)
- repo_path="${"${"}remote_url#ssh://git@github.com/}"
- ;;
- git@github.com:*)
- repo_path="${"${"}remote_url#git@github.com:}"
- ;;
- *)
- return 1
- ;;
- esac
-
- repo_path="${"${"}repo_path%%\?*}"
- repo_path="${"${"}repo_path%%#*}"
- repo_path="${"${"}repo_path%/}"
- repo_path="${"${"}repo_path%.git}"
- owner="${"${"}repo_path%%/*}"
- repo="${"${"}repo_path#*/}"
- repo="${"${"}repo%%/*}"
- repo="${"${"}repo%.git}"
-
- if [[ -z "$owner" || -z "$repo" || "$owner" == "$repo_path" ]]; then
- return 1
- fi
-
- printf "%s/%s\n" "$owner" "$repo"
-}
-
-docker_git_github_repo_from_remote() {
- local remote="$1"
- local remote_url=""
-
- remote_url="$(git remote get-url "$remote" 2>/dev/null || true)"
- if [[ -z "$remote_url" ]]; then
- return 1
- fi
-
- docker_git_github_repo_from_remote_url "$remote_url"
-}
-
-docker_git_ensure_open_pr() {
- local branch=""
- local base_repo=""
- local head_repo=""
- local head_owner=""
- local head_arg=""
- local base_branch=""
- local pr_url=""
-
- if ! command -v gh >/dev/null 2>&1; then
- echo "[post-push-pr] Error: gh CLI not found" >&2
- return 1
- fi
-
- branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
- if [[ -z "$branch" || "$branch" == "HEAD" ]]; then
- echo "[post-push-pr] Error: cannot create PR from detached HEAD" >&2
- return 1
- fi
-
- if ! base_repo="$(docker_git_github_repo_from_remote upstream)"; then
- if ! base_repo="$(docker_git_github_repo_from_remote origin)"; then
- echo "[post-push-pr] Skipped: no GitHub remote found"
- return 0
- fi
- fi
-
- if ! head_repo="$(docker_git_github_repo_from_remote origin)"; then
- head_repo="$base_repo"
- fi
-
- base_branch="$(gh repo view "$base_repo" --json defaultBranchRef --jq '.defaultBranchRef.name' 2>/dev/null || true)"
- if [[ -z "$base_branch" ]]; then
- echo "[post-push-pr] Error: failed to resolve default branch for $base_repo" >&2
- return 1
- fi
-
- if [[ "$head_repo" == "$base_repo" ]]; then
- head_arg="$branch"
- else
- head_owner="${"${"}head_repo%%/*}"
- head_arg="${"${"}head_owner}:${"${"}branch}"
- fi
-
- if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$head_arg" --json url --jq '.[0].url // ""' 2>/dev/null)"; then
- echo "[post-push-pr] Error: failed to list open PRs for $head_arg in $base_repo" >&2
- return 1
- fi
- if [[ -z "$pr_url" && "$head_arg" != "$branch" ]]; then
- if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$branch" --json url --jq '.[0].url // ""' 2>/dev/null)"; then
- echo "[post-push-pr] Error: failed to list open PRs for $branch in $base_repo" >&2
- return 1
- fi
- fi
-
- if [[ -n "$pr_url" ]]; then
- echo "[post-push-pr] Open PR: $pr_url"
- return 0
- fi
-
- echo "[post-push-pr] Creating PR for $head_arg into $base_repo:$base_branch"
- gh pr create --repo "$base_repo" --base "$base_branch" --head "$head_arg" --fill
-}
-
-docker_git_ensure_open_pr
+${renderPostPushPrEnsure()}
# CHANGE: sync captured Codex plans to the current branch PR after push.
# WHY: issue #369 requires the agent plan to be uploaded to PR discussion.
diff --git a/packages/lib/src/core/templates-entrypoint/post-push-pr.ts b/packages/lib/src/core/templates-entrypoint/post-push-pr.ts
new file mode 100644
index 00000000..73d4924a
--- /dev/null
+++ b/packages/lib/src/core/templates-entrypoint/post-push-pr.ts
@@ -0,0 +1,129 @@
+const postPushPrEnsureTemplate = String
+ .raw`# CHANGE: ensure an open GitHub PR exists for the pushed branch before PR-bound post-push tools run.
+# WHY: issue #375 requires every successful git push to leave the branch with an open PR; plan sync and session backup both target PR discussion.
+# REF: issue-375
+docker_git_github_repo_from_remote_url() {
+ local remote_url="$1"
+ local repo_path=""
+ local owner=""
+ local repo=""
+
+ case "$remote_url" in
+ https://github.com/*)
+ repo_path="${"${"}remote_url#https://github.com/}"
+ ;;
+ http://github.com/*)
+ repo_path="${"${"}remote_url#http://github.com/}"
+ ;;
+ https://*@github.com/*)
+ repo_path="${"${"}remote_url#https://*@github.com/}"
+ ;;
+ http://*@github.com/*)
+ repo_path="${"${"}remote_url#http://*@github.com/}"
+ ;;
+ ssh://git@github.com/*)
+ repo_path="${"${"}remote_url#ssh://git@github.com/}"
+ ;;
+ git@github.com:*)
+ repo_path="${"${"}remote_url#git@github.com:}"
+ ;;
+ *)
+ return 1
+ ;;
+ esac
+
+ repo_path="${"${"}repo_path%%\?*}"
+ repo_path="${"${"}repo_path%%#*}"
+ repo_path="${"${"}repo_path%/}"
+ repo_path="${"${"}repo_path%.git}"
+ owner="${"${"}repo_path%%/*}"
+ repo="${"${"}repo_path#*/}"
+ repo="${"${"}repo%%/*}"
+ repo="${"${"}repo%.git}"
+
+ if [[ -z "$owner" || -z "$repo" || "$owner" == "$repo_path" ]]; then
+ return 1
+ fi
+
+ printf "%s/%s\n" "$owner" "$repo"
+}
+
+docker_git_github_repo_from_remote() {
+ local remote="$1"
+ local remote_url=""
+
+ remote_url="$(git remote get-url "$remote" 2>/dev/null || true)"
+ if [[ -z "$remote_url" ]]; then
+ return 1
+ fi
+
+ docker_git_github_repo_from_remote_url "$remote_url"
+}
+
+docker_git_ensure_open_pr() {
+ local branch=""
+ local base_repo=""
+ local head_repo=""
+ local head_owner=""
+ local head_arg=""
+ local base_branch=""
+ local pr_url=""
+
+ if ! command -v gh >/dev/null 2>&1; then
+ echo "[post-push-pr] Error: gh CLI not found" >&2
+ return 1
+ fi
+
+ branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
+ if [[ -z "$branch" || "$branch" == "HEAD" ]]; then
+ echo "[post-push-pr] Error: cannot create PR from detached HEAD" >&2
+ return 1
+ fi
+
+ if ! base_repo="$(docker_git_github_repo_from_remote upstream)"; then
+ if ! base_repo="$(docker_git_github_repo_from_remote origin)"; then
+ echo "[post-push-pr] Skipped: no GitHub remote found"
+ return 0
+ fi
+ fi
+
+ if ! head_repo="$(docker_git_github_repo_from_remote origin)"; then
+ head_repo="$base_repo"
+ fi
+
+ base_branch="$(gh repo view "$base_repo" --json defaultBranchRef --jq '.defaultBranchRef.name' 2>/dev/null || true)"
+ if [[ -z "$base_branch" ]]; then
+ echo "[post-push-pr] Error: failed to resolve default branch for $base_repo" >&2
+ return 1
+ fi
+
+ if [[ "$head_repo" == "$base_repo" ]]; then
+ head_arg="$branch"
+ else
+ head_owner="${"${"}head_repo%%/*}"
+ head_arg="${"${"}head_owner}:${"${"}branch}"
+ fi
+
+ if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$head_arg" --json url --jq '.[0].url // ""' 2>/dev/null)"; then
+ echo "[post-push-pr] Error: failed to list open PRs for $head_arg in $base_repo" >&2
+ return 1
+ fi
+ if [[ -z "$pr_url" && "$head_arg" != "$branch" ]]; then
+ if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$branch" --json url --jq '.[0].url // ""' 2>/dev/null)"; then
+ echo "[post-push-pr] Error: failed to list open PRs for $branch in $base_repo" >&2
+ return 1
+ fi
+ fi
+
+ if [[ -n "$pr_url" ]]; then
+ echo "[post-push-pr] Open PR: $pr_url"
+ return 0
+ fi
+
+ echo "[post-push-pr] Creating PR for $head_arg into $base_repo:$base_branch"
+ gh pr create --repo "$base_repo" --base "$base_branch" --head "$head_arg" --fill
+}
+
+docker_git_ensure_open_pr`
+
+export const renderPostPushPrEnsure = (): string => postPushPrEnsureTemplate
diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts
index 6ad8a755..ff77756c 100644
--- a/packages/lib/tests/core/templates.test.ts
+++ b/packages/lib/tests/core/templates.test.ts
@@ -11,7 +11,9 @@ import { renderDockerfile } from "../../src/core/templates/dockerfile.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"
+import { renderPostPushPrEnsure as renderLibPostPushPrEnsure } from "../../src/core/templates-entrypoint/post-push-pr.js"
import { renderPromptScript } from "../../src/core/templates-prompt.js"
+import { renderPostPushPrEnsure as renderAppPostPushPrEnsure } from "../../../app/src/lib/core/templates-entrypoint/post-push-pr.js"
const makeTemplateConfig = (overrides: Partial = {}): TemplateConfig => ({
...defaultTemplateConfig,
@@ -464,6 +466,10 @@ describe("renderEntrypoint clone cache", () => {
})
describe("renderEntrypointGitHooks", () => {
+ it("keeps the app mirror of the post-push PR fragment in sync with lib", () => {
+ expect(renderAppPostPushPrEnsure()).toBe(renderLibPostPushPrEnsure())
+ })
+
it("installs pre-push protection checks, plan sync, and a global git post-push runtime", () => {
const hooks = renderEntrypointGitHooks()
From cc36eda409b597606e7b4c25c99919d8cb482e67 Mon Sep 17 00:00:00 2001
From: skulidropek <66840575+skulidropek@users.noreply.github.com>
Date: Sat, 6 Jun 2026 10:57:48 +0000
Subject: [PATCH 3/3] fix(shell): backfill codex plans before sync
---
.../core/templates-entrypoint/git-hooks.ts | 7 +++--
.../tests/docker-git/core-templates.test.ts | 7 +++--
.../core/templates-entrypoint/git-hooks.ts | 7 +++--
.../tests/core/git-post-push-wrapper.test.ts | 28 +++++++++++++------
packages/lib/tests/core/templates.test.ts | 7 +++--
5 files changed, 38 insertions(+), 18 deletions(-)
diff --git a/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts b/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts
index b000f492..a862cd48 100644
--- a/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts
+++ b/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts
@@ -148,14 +148,15 @@ cd "$REPO_ROOT"
${renderPostPushPrEnsure()}
-# CHANGE: sync captured Codex plans to the current branch PR after push.
-# WHY: issue #369 requires the agent plan to be uploaded to PR discussion.
-# REF: issue-369
+# CHANGE: backfill Codex session plans before syncing the current branch PR.
+# WHY: live Codex hooks can be unavailable in already-running sessions; session logs are the durable fallback.
+# REF: issue-375
if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" != "1" ]; then
if ! command -v plan-to-git >/dev/null 2>&1; then
echo "[plan-to-git] Error: plan-to-git not found" >&2
exit 1
fi
+ plan-to-git import-codex --no-sync
plan-to-git sync
fi
diff --git a/packages/app/tests/docker-git/core-templates.test.ts b/packages/app/tests/docker-git/core-templates.test.ts
index 87911034..95fcf98c 100644
--- a/packages/app/tests/docker-git/core-templates.test.ts
+++ b/packages/app/tests/docker-git/core-templates.test.ts
@@ -116,6 +116,7 @@ describe("app planFiles", () => {
"gh pr create --repo \"$base_repo\" --base \"$base_branch\" --head \"$head_arg\" --fill"
)
expect(entrypoint.contents).toContain("plan-to-git hook --source codex")
+ expect(entrypoint.contents).toContain("plan-to-git import-codex --no-sync")
expect(entrypoint.contents).toContain("CODEX_REQUIREMENTS_FILE=\"/etc/codex/requirements.toml\"")
expect(entrypoint.contents).toContain("managed_dir = \"/opt/docker-git/hooks\"")
expect(entrypoint.contents).toContain("[[hooks.UserPromptSubmit]]")
@@ -124,8 +125,9 @@ describe("app planFiles", () => {
const cdIndex = entrypoint.contents.indexOf("cd \"$REPO_ROOT\"")
const ensurePrIndex = entrypoint.contents.indexOf(
- "docker_git_ensure_open_pr\n\n# CHANGE: sync captured Codex plans"
+ "docker_git_ensure_open_pr\n\n# CHANGE: backfill Codex session plans"
)
+ const planImportIndex = entrypoint.contents.indexOf("plan-to-git import-codex --no-sync")
const planSyncIndex = entrypoint.contents.indexOf("plan-to-git sync")
const sessionBackupIndex = entrypoint.contents.indexOf(
"docker-git-session-sync backup --verbose --background --require-comment"
@@ -133,7 +135,8 @@ describe("app planFiles", () => {
expect(cdIndex).toBeGreaterThanOrEqual(0)
expect(ensurePrIndex).toBeGreaterThan(cdIndex)
- expect(planSyncIndex).toBeGreaterThan(ensurePrIndex)
+ expect(planImportIndex).toBeGreaterThan(ensurePrIndex)
+ expect(planSyncIndex).toBeGreaterThan(planImportIndex)
expect(sessionBackupIndex).toBeGreaterThan(planSyncIndex)
})
)
diff --git a/packages/lib/src/core/templates-entrypoint/git-hooks.ts b/packages/lib/src/core/templates-entrypoint/git-hooks.ts
index b000f492..a862cd48 100644
--- a/packages/lib/src/core/templates-entrypoint/git-hooks.ts
+++ b/packages/lib/src/core/templates-entrypoint/git-hooks.ts
@@ -148,14 +148,15 @@ cd "$REPO_ROOT"
${renderPostPushPrEnsure()}
-# CHANGE: sync captured Codex plans to the current branch PR after push.
-# WHY: issue #369 requires the agent plan to be uploaded to PR discussion.
-# REF: issue-369
+# CHANGE: backfill Codex session plans before syncing the current branch PR.
+# WHY: live Codex hooks can be unavailable in already-running sessions; session logs are the durable fallback.
+# REF: issue-375
if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" != "1" ]; then
if ! command -v plan-to-git >/dev/null 2>&1; then
echo "[plan-to-git] Error: plan-to-git not found" >&2
exit 1
fi
+ plan-to-git import-codex --no-sync
plan-to-git sync
fi
diff --git a/packages/lib/tests/core/git-post-push-wrapper.test.ts b/packages/lib/tests/core/git-post-push-wrapper.test.ts
index c57b7c86..f3dcba41 100644
--- a/packages/lib/tests/core/git-post-push-wrapper.test.ts
+++ b/packages/lib/tests/core/git-post-push-wrapper.test.ts
@@ -171,11 +171,11 @@ if [[ -n "\${FAKE_PLAN_TO_GIT_LOG_PATH:-}" ]]; then
printf '%s\\t%s\\n' "$PWD" "$*" >> "$FAKE_PLAN_TO_GIT_LOG_PATH"
fi
-if [[ "\${1:-}" != "sync" ]]; then
+if [[ "\${1:-}" != "import-codex" && "\${1:-}" != "sync" ]]; then
if [[ -n "\${FAKE_PLAN_TO_GIT_LOG_PATH:-}" ]]; then
printf '%s\\tunexpected-command:%s\\n' "$PWD" "\${1:-}" >> "$FAKE_PLAN_TO_GIT_LOG_PATH"
fi
- echo "fakePlanToGit: expected sync command, got: \${1:-}" >&2
+ echo "fakePlanToGit: expected import-codex or sync command, got: \${1:-}" >&2
exit 127
fi
@@ -383,7 +383,10 @@ describe("git post-push wrapper", () => {
expect(nodeCwd).toEqual([harness.repoDir])
expect(nodeRepoRoot).toEqual([harness.repoDir])
expect(nodeScript).toEqual(["backup --verbose --background --require-comment"])
- expect(planToGit).toEqual([`${harness.repoDir}\tsync`])
+ expect(planToGit).toEqual([
+ `${harness.repoDir}\timport-codex --no-sync`,
+ `${harness.repoDir}\tsync`
+ ])
expect(gh).toContain(`${harness.repoDir}\tpr create --repo org/repo --base main --head issue-375 --fill`)
})
).pipe(Effect.provide(NodeContext.layer)))
@@ -403,7 +406,10 @@ describe("git post-push wrapper", () => {
expect(nodeCwd).toEqual([harness.repoDir])
expect(nodeRepoRoot).toEqual([harness.repoDir])
expect(nodeScript).toEqual(["backup --verbose --background --require-comment"])
- expect(planToGit).toEqual([`${harness.repoDir}\tsync`])
+ expect(planToGit).toEqual([
+ `${harness.repoDir}\timport-codex --no-sync`,
+ `${harness.repoDir}\tsync`
+ ])
expect(gh).toContain(`${harness.repoDir}\tpr create --repo org/repo --base main --head issue-375 --fill`)
expect(gitLog.some((line) => line.startsWith(`${harness.externalDir}\t-C ${harness.repoDir} push`))).toBe(true)
})
@@ -477,7 +483,7 @@ describe("git post-push wrapper", () => {
})
).pipe(Effect.provide(NodeContext.layer)))
- it.effect("propagates plan sync failures after ensuring a PR and before session backup", () =>
+ it.effect("propagates plan import failures after ensuring a PR and before session backup", () =>
withHarness((harness) =>
Effect.gen(function*(_) {
yield* _(
@@ -492,7 +498,7 @@ describe("git post-push wrapper", () => {
const gh = yield* _(readLogLines(harness.ghLogPath))
expect(nodeScript).toEqual([])
- expect(planToGit).toEqual([`${harness.repoDir}\tsync`])
+ expect(planToGit).toEqual([`${harness.repoDir}\timport-codex --no-sync`])
expect(gh).toContain(`${harness.repoDir}\tpr create --repo org/repo --base main --head issue-375 --fill`)
})
).pipe(Effect.provide(NodeContext.layer)))
@@ -511,7 +517,10 @@ describe("git post-push wrapper", () => {
const planToGit = yield* _(readLogLines(harness.planToGitLogPath))
expect(nodeScript).toEqual(["backup --verbose --background --require-comment"])
- expect(planToGit).toEqual([`${harness.repoDir}\tsync`])
+ expect(planToGit).toEqual([
+ `${harness.repoDir}\timport-codex --no-sync`,
+ `${harness.repoDir}\tsync`
+ ])
})
).pipe(Effect.provide(NodeContext.layer)))
@@ -529,7 +538,10 @@ describe("git post-push wrapper", () => {
const gh = yield* _(readLogLines(harness.ghLogPath))
expect(nodeScript).toEqual(["backup --verbose --background --require-comment"])
- expect(planToGit).toEqual([`${harness.repoDir}\tsync`])
+ expect(planToGit).toEqual([
+ `${harness.repoDir}\timport-codex --no-sync`,
+ `${harness.repoDir}\tsync`
+ ])
expect(gh).toContain(`${harness.repoDir}\tpr list --repo org/repo --state open --head issue-375 --json url --jq .[0].url // ""`)
expect(gh.some((line) => line.includes("pr create"))).toBe(false)
})
diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts
index ff77756c..ce980124 100644
--- a/packages/lib/tests/core/templates.test.ts
+++ b/packages/lib/tests/core/templates.test.ts
@@ -493,6 +493,7 @@ describe("renderEntrypointGitHooks", () => {
expect(hooks).toContain("[post-push-pr] Error: cannot create PR from detached HEAD")
expect(hooks).toContain("[post-push-pr] Error: failed to list open PRs")
expect(hooks).toContain("DOCKER_GIT_SKIP_PLAN_TO_GIT")
+ expect(hooks).toContain("plan-to-git import-codex --no-sync")
expect(hooks).toContain("plan-to-git sync")
expect(hooks).toContain("plan-to-git hook --source codex")
expect(hooks).toContain("[features]")
@@ -521,13 +522,15 @@ describe("renderEntrypointGitHooks", () => {
expect(hooks).toContain("[session-backup] Error: gh CLI not found")
const cdIndex = hooks.indexOf('cd "$REPO_ROOT"')
- const ensurePrIndex = hooks.indexOf("docker_git_ensure_open_pr\n\n# CHANGE: sync captured Codex plans")
+ const ensurePrIndex = hooks.indexOf("docker_git_ensure_open_pr\n\n# CHANGE: backfill Codex session plans")
+ const planImportIndex = hooks.indexOf("plan-to-git import-codex --no-sync")
const planSyncIndex = hooks.indexOf("plan-to-git sync")
const sessionBackupIndex = hooks.indexOf("docker-git-session-sync backup --verbose --background --require-comment")
expect(cdIndex).toBeGreaterThanOrEqual(0)
expect(ensurePrIndex).toBeGreaterThan(cdIndex)
- expect(planSyncIndex).toBeGreaterThan(ensurePrIndex)
+ expect(planImportIndex).toBeGreaterThan(ensurePrIndex)
+ expect(planSyncIndex).toBeGreaterThan(planImportIndex)
expect(sessionBackupIndex).toBeGreaterThan(planSyncIndex)
})
})