From 4349caf3f0dd677f41a64ca5bab99c3f7e3ece8d Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:18:01 +0000 Subject: [PATCH 1/4] fix(container): sync plan-to-git against upstream repo --- .../templates-entrypoint/github-remotes.ts | 53 ++++++++++ .../core/templates-entrypoint/plan-to-git.ts | 61 ++++++++++-- .../core/templates-entrypoint/post-push-pr.ts | 68 ++----------- .../tests/core/git-post-push-wrapper.test.ts | 97 ++++++++++++++++++- .../container/tests/core/templates.test.ts | 23 +++-- 5 files changed, 223 insertions(+), 79 deletions(-) create mode 100644 packages/container/src/core/templates-entrypoint/github-remotes.ts diff --git a/packages/container/src/core/templates-entrypoint/github-remotes.ts b/packages/container/src/core/templates-entrypoint/github-remotes.ts new file mode 100644 index 00000000..8f69af47 --- /dev/null +++ b/packages/container/src/core/templates-entrypoint/github-remotes.ts @@ -0,0 +1,53 @@ +const githubRemoteHelpersTemplate = String.raw`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/}" + ;; + https://*@github.com/*) + repo_path="${"${"}remote_url#https://*@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" +}` + +export const renderGitHubRemoteHelpers = (): string => githubRemoteHelpersTemplate diff --git a/packages/container/src/core/templates-entrypoint/plan-to-git.ts b/packages/container/src/core/templates-entrypoint/plan-to-git.ts index 950040a1..514210c2 100644 --- a/packages/container/src/core/templates-entrypoint/plan-to-git.ts +++ b/packages/container/src/core/templates-entrypoint/plan-to-git.ts @@ -1,9 +1,49 @@ +import { renderGitHubRemoteHelpers } from "./github-remotes.js" + const planToGitHookPathsTemplate = `PLAN_TO_GIT_SYNC_HELPER="$HOOKS_DIR/plan-to-git-sync" PLAN_TO_GIT_CODEX_HOOK="$HOOKS_DIR/plan-to-git-codex-hook" PLAN_TO_GIT_CLAUDE_HOOK="$HOOKS_DIR/plan-to-git-claude-hook" CODEX_REQUIREMENTS_FILE="/etc/codex/requirements.toml" CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="$CLAUDE_CONFIG_DIR/settings.json"` +const planToGitRunnerTemplate = String.raw`${renderGitHubRemoteHelpers()} + +docker_git_plan_to_git_run() { + local base_repo="" + local origin_repo="" + local origin_url="" + local base_url="" + local config_index="0" + + if ! base_repo="$(docker_git_github_repo_from_remote upstream)"; then + base_repo="$(docker_git_github_repo_from_remote origin || true)" + fi + origin_repo="$(docker_git_github_repo_from_remote origin || true)" + origin_url="$(git remote get-url origin 2>/dev/null || true)" + + if [[ -z "$base_repo" || -z "$origin_repo" || -z "$origin_url" ]]; then + plan-to-git "$@" + return $? + fi + + if [[ "$origin_repo" == "$base_repo" ]]; then + plan-to-git "$@" + return $? + fi + + base_url="https://github.com/${"${"}base_repo}.git" + config_index="${"${"}GIT_CONFIG_COUNT:-0}" + if ! [[ "$config_index" =~ ^[0-9]+$ ]]; then + config_index="0" + fi + + env \ + GIT_CONFIG_COUNT="$((config_index + 1))" \ + "GIT_CONFIG_KEY_${"${"}config_index}=url.${"${"}base_url}.insteadOf" \ + "GIT_CONFIG_VALUE_${"${"}config_index}=${"${"}origin_url}" \ + plan-to-git "$@" +}` + const planToGitSyncHelperInstallTemplate = String.raw`cat <<'EOF' > "$PLAN_TO_GIT_SYNC_HELPER" #!/usr/bin/env bash set -euo pipefail @@ -19,8 +59,10 @@ fi export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}" +${planToGitRunnerTemplate} + docker_git_plan_to_git_explicit_pr_supported() { - plan-to-git sync --help 2>/dev/null | grep -q -- "--pr " + docker_git_plan_to_git_run sync --help 2>/dev/null | grep -q -- "--pr " } docker_git_plan_to_git_resolve_pr_number() { @@ -61,12 +103,12 @@ docker_git_plan_to_git_sync() { if [[ -n "$pr_number" ]] && docker_git_plan_to_git_explicit_pr_supported; then echo "[plan-to-git] Syncing queued agent plans to PR #$pr_number" - plan-to-git sync --pr "$pr_number" + docker_git_plan_to_git_run sync --pr "$pr_number" return 0 fi echo "[plan-to-git] Syncing queued agent plans via current branch discovery" - plan-to-git sync + docker_git_plan_to_git_run sync } docker_git_plan_to_git_sync @@ -83,14 +125,15 @@ if [ "\${DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" != "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 import-claude --no-sync + ${planToGitRunnerTemplate} + docker_git_plan_to_git_run import-codex --no-sync + docker_git_plan_to_git_run import-claude --no-sync PLAN_TO_GIT_SYNC_HELPER="\${DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}" if [[ -x "$PLAN_TO_GIT_SYNC_HELPER" ]]; then "$PLAN_TO_GIT_SYNC_HELPER" else echo "[plan-to-git] Sync helper not found; falling back to current branch discovery" >&2 - plan-to-git sync + docker_git_plan_to_git_run sync fi fi` @@ -108,7 +151,8 @@ if ! command -v plan-to-git >/dev/null 2>&1; then fi export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}" -plan-to-git hook --source codex +${planToGitRunnerTemplate} +docker_git_plan_to_git_run hook --source codex PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}" "$PLAN_TO_GIT_SYNC_HELPER" >&2 || true EOF @@ -128,7 +172,8 @@ if ! command -v plan-to-git >/dev/null 2>&1; then fi export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}" -plan-to-git hook --source claude +${planToGitRunnerTemplate} +docker_git_plan_to_git_run hook --source claude PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}" "$PLAN_TO_GIT_SYNC_HELPER" >&2 || true EOF diff --git a/packages/container/src/core/templates-entrypoint/post-push-pr.ts b/packages/container/src/core/templates-entrypoint/post-push-pr.ts index 5c83114e..caf23391 100644 --- a/packages/container/src/core/templates-entrypoint/post-push-pr.ts +++ b/packages/container/src/core/templates-entrypoint/post-push-pr.ts @@ -1,64 +1,10 @@ -const postPushPrEnsureTemplate = String - .raw`# CHANGE: ensure an open GitHub PR exists for the pushed branch before PR-bound post-push tools run. +import { renderGitHubRemoteHelpers } from "./github-remotes.js" + +const postPushPrEnsureTemplate = + `# 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/}" - ;; - https://github.com/*) - repo_path="${"${"}remote_url#https://github.com/}" - ;; - https://*@github.com/*) - repo_path="${"${"}remote_url#https://*@github.com/}" - ;; - https://*@github.com/*) - repo_path="${"${"}remote_url#https://*@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" -} +${renderGitHubRemoteHelpers()} docker_git_ensure_open_pr() { local branch="" @@ -100,8 +46,8 @@ docker_git_ensure_open_pr() { if [[ "$head_repo" == "$base_repo" ]]; then head_arg="$branch" else - head_owner="${"${"}head_repo%%/*}" - head_arg="${"${"}head_owner}:${"${"}branch}" + 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 diff --git a/packages/container/tests/core/git-post-push-wrapper.test.ts b/packages/container/tests/core/git-post-push-wrapper.test.ts index 0b752a54..922315fa 100644 --- a/packages/container/tests/core/git-post-push-wrapper.test.ts +++ b/packages/container/tests/core/git-post-push-wrapper.test.ts @@ -21,6 +21,7 @@ type WrapperHarness = { readonly externalDir: string readonly binDir: string readonly wrapperPath: string + readonly planToGitSyncHelperPath: string readonly gitLogPath: string readonly nodeCwdLogPath: string readonly nodeRepoRootLogPath: string @@ -167,18 +168,70 @@ exit 0 const fakePlanToGitScript = `#!/usr/bin/env bash set -euo pipefail +fake_effective_origin_url() { + local origin_url="\${FAKE_GIT_ORIGIN_URL:-https://github.com/org/repo.git}" + local config_index=0 + local key_var="" + local value_var="" + local key="" + local value="" + local base_url="" + local suffix="" + + if [[ -n "\${GIT_CONFIG_COUNT:-}" && "\${GIT_CONFIG_COUNT}" =~ ^[0-9]+$ ]]; then + while [[ "$config_index" -lt "$GIT_CONFIG_COUNT" ]]; do + key_var="GIT_CONFIG_KEY_\${config_index}" + value_var="GIT_CONFIG_VALUE_\${config_index}" + key="\${!key_var:-}" + value="\${!value_var:-}" + if [[ "$key" == url.*.insteadOf && -n "$value" && "$origin_url" == "$value"* ]]; then + base_url="\${key#url.}" + base_url="\${base_url%.insteadOf}" + suffix="\${origin_url#"$value"}" + printf '%s\\n' "\${base_url}\${suffix}" + return 0 + fi + config_index=$((config_index + 1)) + done + fi + + printf '%s\\n' "$origin_url" +} + +if [[ "\${1:-}" == "sync" && "\${2:-}" == "--help" ]]; then + printf '%s\\n' "--pr " + exit 0 +fi + if [[ -n "\${FAKE_PLAN_TO_GIT_LOG_PATH:-}" ]]; then + if [[ -n "\${GIT_CONFIG_COUNT:-}" && "\${GIT_CONFIG_COUNT}" =~ ^[0-9]+$ ]]; then + config_index=0 + while [[ "$config_index" -lt "$GIT_CONFIG_COUNT" ]]; do + key_var="GIT_CONFIG_KEY_\${config_index}" + value_var="GIT_CONFIG_VALUE_\${config_index}" + printf '%s\\tgit-config:%s=%s\\n' "$PWD" "\${!key_var:-}" "\${!value_var:-}" >> "$FAKE_PLAN_TO_GIT_LOG_PATH" + config_index=$((config_index + 1)) + done + fi printf '%s\\t%s\\n' "$PWD" "$*" >> "$FAKE_PLAN_TO_GIT_LOG_PATH" fi -if [[ "\${1:-}" != "import-codex" && "\${1:-}" != "import-claude" && "\${1:-}" != "sync" ]]; then +if [[ "\${1:-}" != "import-codex" && "\${1:-}" != "import-claude" && "\${1:-}" != "sync" && "\${1:-}" != "hook" ]]; 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 import-codex, import-claude, or sync command, got: \${1:-}" >&2 + echo "fakePlanToGit: expected import-codex, import-claude, hook, or sync command, got: \${1:-}" >&2 exit 127 fi +if [[ "\${1:-}" == "sync" && "\${2:-}" == "--pr" && -n "\${FAKE_PLAN_TO_GIT_EXPECT_EFFECTIVE_ORIGIN_URL:-}" ]]; then + effective_origin_url="$(fake_effective_origin_url)" + if [[ "$effective_origin_url" != "$FAKE_PLAN_TO_GIT_EXPECT_EFFECTIVE_ORIGIN_URL" ]]; then + echo "fakePlanToGit: expected effective origin $FAKE_PLAN_TO_GIT_EXPECT_EFFECTIVE_ORIGIN_URL, got $effective_origin_url" >&2 + exit 44 + fi +fi + if [[ -n "\${FAKE_PLAN_TO_GIT_EXIT_CODE:-}" ]]; then exit "$FAKE_PLAN_TO_GIT_EXIT_CODE" fi @@ -282,6 +335,7 @@ const makeHarnessEnv = ( FAKE_NODE_SCRIPT_LOG_PATH: harness.nodeScriptLogPath, FAKE_PLAN_TO_GIT_LOG_PATH: harness.planToGitLogPath, FAKE_GH_LOG_PATH: harness.ghLogPath, + DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER: harness.planToGitSyncHelperPath, ...overrides }) @@ -341,6 +395,10 @@ const withHarness = ( const postPushPath = path.join(hooksDir, "post-push") yield* _(writeExecutable(postPushPath, postPushScript)) + const planToGitSyncHelperScript = extractEmbeddedScript(renderEntrypointGitHooks(), "$PLAN_TO_GIT_SYNC_HELPER") + const planToGitSyncHelperPath = path.join(hooksDir, "plan-to-git-sync") + yield* _(writeExecutable(planToGitSyncHelperPath, planToGitSyncHelperScript)) + const wrapperTemplate = extractEmbeddedScript( renderEntrypointGitPostPushWrapperInstall(), "$GIT_WRAPPER_BIN" @@ -357,6 +415,7 @@ const withHarness = ( externalDir, binDir, wrapperPath, + planToGitSyncHelperPath, gitLogPath, nodeCwdLogPath, nodeRepoRootLogPath, @@ -572,6 +631,40 @@ describe("git post-push wrapper", () => { }) ).pipe(Effect.provide(NodeContext.layer))) + it.effect("syncs explicit PR plans against upstream when origin is a fork", () => + withHarness((harness) => + Effect.gen(function*(_) { + yield* _( + runWrapper(harness, harness.repoDir, ["push", "origin", "HEAD"], { + env: { + DOCKER_GIT_PR_NUMBER: "375", + FAKE_GH_OPEN_PR_URL: "https://github.com/org/repo/pull/375", + FAKE_GIT_ORIGIN_URL: "https://github.com/me/repo.git", + FAKE_GIT_UPSTREAM_URL: "https://github.com/org/repo.git", + FAKE_PLAN_TO_GIT_EXPECT_EFFECTIVE_ORIGIN_URL: "https://github.com/org/repo.git" + } + }) + ) + + const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath)) + const planToGit = yield* _(readLogLines(harness.planToGitLogPath)) + const gh = yield* _(readLogLines(harness.ghLogPath)) + const scopedRepoConfig = `${harness.repoDir}\tgit-config:url.https://github.com/org/repo.git.insteadOf=https://github.com/me/repo.git` + + expect(nodeScript).toEqual(["backup --verbose --background --require-comment"]) + expect(planToGit).toEqual([ + scopedRepoConfig, + `${harness.repoDir}\timport-codex --no-sync`, + scopedRepoConfig, + `${harness.repoDir}\timport-claude --no-sync`, + scopedRepoConfig, + `${harness.repoDir}\tsync --pr 375` + ]) + expect(gh).toContain(`${harness.repoDir}\tpr list --repo org/repo --state open --head me:issue-375 --json url --jq .[0].url // ""`) + expect(gh.some((line) => line.includes("pr create"))).toBe(false) + }) + ).pipe(Effect.provide(NodeContext.layer))) + it.effect("propagates PR creation failures before plan sync and session backup", () => withHarness((harness) => Effect.gen(function*(_) { diff --git a/packages/container/tests/core/templates.test.ts b/packages/container/tests/core/templates.test.ts index 4fb97ef2..fa47f300 100644 --- a/packages/container/tests/core/templates.test.ts +++ b/packages/container/tests/core/templates.test.ts @@ -526,17 +526,24 @@ 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 import-claude --no-sync") + expect(hooks).toContain("docker_git_plan_to_git_run") + expect(hooks).toContain('origin_repo="$(docker_git_github_repo_from_remote origin || true)"') + expect(hooks).toContain('[[ "$origin_repo" == "$base_repo" ]]') + expect(hooks).toContain("base_url=\"https://github.com/${base_repo}.git\"") + expect(hooks).toContain("GIT_CONFIG_COUNT=\"$((config_index + 1))\"") + expect(hooks).toContain('"GIT_CONFIG_KEY_${config_index}=url.${base_url}.insteadOf"') + expect(hooks).toContain('"GIT_CONFIG_VALUE_${config_index}=${origin_url}"') + expect(hooks).toContain("docker_git_plan_to_git_run import-codex --no-sync") + expect(hooks).toContain("docker_git_plan_to_git_run import-claude --no-sync") expect(hooks).toContain("docker_git_plan_to_git_explicit_pr_supported") expect(hooks).toContain("docker_git_plan_to_git_resolve_pr_number") expect(hooks).toContain("DOCKER_GIT_PR_NUMBER PR_NUMBER GITHUB_PR_NUMBER") expect(hooks).toContain('candidate="${REPO_REF:-}"') - expect(hooks).toContain('plan-to-git sync --pr "$pr_number"') - expect(hooks).toContain("plan-to-git sync") + expect(hooks).toContain('docker_git_plan_to_git_run sync --pr "$pr_number"') + expect(hooks).toContain("docker_git_plan_to_git_run sync") expect(hooks).toContain('[plan-to-git] Syncing queued agent plans to PR #$pr_number') - expect(hooks).toContain("plan-to-git hook --source codex") - expect(hooks).toContain("plan-to-git hook --source claude") + expect(hooks).toContain("docker_git_plan_to_git_run hook --source codex") + expect(hooks).toContain("docker_git_plan_to_git_run hook --source claude") expect(hooks).toContain('export PLAN_TO_GIT_STATE_DIR="${PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}"') expect(hooks).toContain('"$PLAN_TO_GIT_SYNC_HELPER" >&2 || true') expect(hooks).toContain("docker_git_install_claude_plan_to_git_hooks") @@ -570,8 +577,8 @@ describe("renderEntrypointGitHooks", () => { const cdIndex = hooks.indexOf('cd "$REPO_ROOT"') const ensurePrIndex = hooks.indexOf("docker_git_ensure_open_pr\n\n# CHANGE: backfill agent session plans") - const planImportIndex = hooks.indexOf("plan-to-git import-codex --no-sync") - const claudeImportIndex = hooks.indexOf("plan-to-git import-claude --no-sync") + const planImportIndex = hooks.indexOf("docker_git_plan_to_git_run import-codex --no-sync") + const claudeImportIndex = hooks.indexOf("docker_git_plan_to_git_run import-claude --no-sync") const planSyncIndex = hooks.indexOf('"$PLAN_TO_GIT_SYNC_HELPER"', claudeImportIndex) const sessionBackupIndex = hooks.indexOf("docker-git-session-sync backup --verbose --background --require-comment") From 687d9591b4425e81e7de2bb96baabdfbdf248182 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:51:00 +0000 Subject: [PATCH 2/4] fix(container): use explicit plan-to-git repo target --- .../core/templates-entrypoint/plan-to-git.ts | 27 +---- .../src/core/templates/dockerfile-prelude.ts | 5 +- .../tests/core/git-post-push-wrapper.test.ts | 104 +++++------------- .../container/tests/core/templates.test.ts | 11 +- 4 files changed, 40 insertions(+), 107 deletions(-) diff --git a/packages/container/src/core/templates-entrypoint/plan-to-git.ts b/packages/container/src/core/templates-entrypoint/plan-to-git.ts index 514210c2..a7ffd805 100644 --- a/packages/container/src/core/templates-entrypoint/plan-to-git.ts +++ b/packages/container/src/core/templates-entrypoint/plan-to-git.ts @@ -6,42 +6,21 @@ PLAN_TO_GIT_CLAUDE_HOOK="$HOOKS_DIR/plan-to-git-claude-hook" CODEX_REQUIREMENTS_FILE="/etc/codex/requirements.toml" CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="$CLAUDE_CONFIG_DIR/settings.json"` -const planToGitRunnerTemplate = String.raw`${renderGitHubRemoteHelpers()} +const planToGitRunnerTemplate = `${renderGitHubRemoteHelpers()} docker_git_plan_to_git_run() { local base_repo="" - local origin_repo="" - local origin_url="" - local base_url="" - local config_index="0" if ! base_repo="$(docker_git_github_repo_from_remote upstream)"; then base_repo="$(docker_git_github_repo_from_remote origin || true)" fi - origin_repo="$(docker_git_github_repo_from_remote origin || true)" - origin_url="$(git remote get-url origin 2>/dev/null || true)" - if [[ -z "$base_repo" || -z "$origin_repo" || -z "$origin_url" ]]; then + if [[ -z "$base_repo" ]]; then plan-to-git "$@" return $? fi - if [[ "$origin_repo" == "$base_repo" ]]; then - plan-to-git "$@" - return $? - fi - - base_url="https://github.com/${"${"}base_repo}.git" - config_index="${"${"}GIT_CONFIG_COUNT:-0}" - if ! [[ "$config_index" =~ ^[0-9]+$ ]]; then - config_index="0" - fi - - env \ - GIT_CONFIG_COUNT="$((config_index + 1))" \ - "GIT_CONFIG_KEY_${"${"}config_index}=url.${"${"}base_url}.insteadOf" \ - "GIT_CONFIG_VALUE_${"${"}config_index}=${"${"}origin_url}" \ - plan-to-git "$@" + PLAN_TO_GIT_REPO="$base_repo" plan-to-git "$@" }` const planToGitSyncHelperInstallTemplate = String.raw`cat <<'EOF' > "$PLAN_TO_GIT_SYNC_HELPER" diff --git a/packages/container/src/core/templates/dockerfile-prelude.ts b/packages/container/src/core/templates/dockerfile-prelude.ts index 0f4ab2f6..065f0b28 100644 --- a/packages/container/src/core/templates/dockerfile-prelude.ts +++ b/packages/container/src/core/templates/dockerfile-prelude.ts @@ -83,13 +83,13 @@ RUN cargo install --git https://github.com/ProverCoderAI/rust-browser-connection RUN printf "%s\\n" "ALL ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/zz-all \ && chmod 0440 /etc/sudoers.d/zz-all` -const planToGitRevision = "f60fbe71131854be4c6c1d9fb79abafd2dd6949b" +const planToGitRevision = "3286a253381798f679145ca95be4e309e7bd8d63" // CHANGE: install plan-to-git in generated project containers. // WHY: issue #397 requires multi-agent plan capture, Claude Code hooks, temp-backed state, and explicit PR sync. // QUOTE(ТЗ): "подключение новое версии plan-to-git и настройки hooks для claude code и настройки что бы всё уходило на гитхаб автоматически" // REF: issue-397 -// SOURCE: https://github.com/ProverCoderAI/plan-to-git/tree/f60fbe71131854be4c6c1d9fb79abafd2dd6949b +// SOURCE: https://github.com/ProverCoderAI/plan-to-git/tree/3286a253381798f679145ca95be4e309e7bd8d63 // FORMAT THEOREM: image_build_success -> executable(/usr/local/bin/plan-to-git) // PURITY: SHELL // EFFECT: Docker build downloads and installs a pinned Rust CLI from GitHub. @@ -99,6 +99,7 @@ const renderDockerfilePlanToGit = (): string => `# Install plan-to-git for multi-agent plan capture and explicit PR sync (issue #397) RUN cargo install --git https://github.com/ProverCoderAI/plan-to-git --rev ${planToGitRevision} --locked --bins --root /usr/local \ && /usr/local/bin/plan-to-git --help >/dev/null \ + && /usr/local/bin/plan-to-git --help | grep -q -- "--repo" \ && /usr/local/bin/plan-to-git hook --help | grep -q -- "claude" \ && /usr/local/bin/plan-to-git sync --help | grep -q -- "--pr "` diff --git a/packages/container/tests/core/git-post-push-wrapper.test.ts b/packages/container/tests/core/git-post-push-wrapper.test.ts index 922315fa..4bb6d587 100644 --- a/packages/container/tests/core/git-post-push-wrapper.test.ts +++ b/packages/container/tests/core/git-post-push-wrapper.test.ts @@ -30,6 +30,13 @@ type WrapperHarness = { readonly ghLogPath: string } +const expectedPlanToGitRuns = ( + repoDir: string, + commands: ReadonlyArray, + targetRepo = "org/repo" +): ReadonlyArray => + commands.flatMap((command) => [`${repoDir}\ttarget-repo:${targetRepo}`, `${repoDir}\t${command}`]) + const fakeGitScript = `#!/usr/bin/env bash set -euo pipefail @@ -168,50 +175,14 @@ exit 0 const fakePlanToGitScript = `#!/usr/bin/env bash set -euo pipefail -fake_effective_origin_url() { - local origin_url="\${FAKE_GIT_ORIGIN_URL:-https://github.com/org/repo.git}" - local config_index=0 - local key_var="" - local value_var="" - local key="" - local value="" - local base_url="" - local suffix="" - - if [[ -n "\${GIT_CONFIG_COUNT:-}" && "\${GIT_CONFIG_COUNT}" =~ ^[0-9]+$ ]]; then - while [[ "$config_index" -lt "$GIT_CONFIG_COUNT" ]]; do - key_var="GIT_CONFIG_KEY_\${config_index}" - value_var="GIT_CONFIG_VALUE_\${config_index}" - key="\${!key_var:-}" - value="\${!value_var:-}" - if [[ "$key" == url.*.insteadOf && -n "$value" && "$origin_url" == "$value"* ]]; then - base_url="\${key#url.}" - base_url="\${base_url%.insteadOf}" - suffix="\${origin_url#"$value"}" - printf '%s\\n' "\${base_url}\${suffix}" - return 0 - fi - config_index=$((config_index + 1)) - done - fi - - printf '%s\\n' "$origin_url" -} - if [[ "\${1:-}" == "sync" && "\${2:-}" == "--help" ]]; then printf '%s\\n' "--pr " exit 0 fi if [[ -n "\${FAKE_PLAN_TO_GIT_LOG_PATH:-}" ]]; then - if [[ -n "\${GIT_CONFIG_COUNT:-}" && "\${GIT_CONFIG_COUNT}" =~ ^[0-9]+$ ]]; then - config_index=0 - while [[ "$config_index" -lt "$GIT_CONFIG_COUNT" ]]; do - key_var="GIT_CONFIG_KEY_\${config_index}" - value_var="GIT_CONFIG_VALUE_\${config_index}" - printf '%s\\tgit-config:%s=%s\\n' "$PWD" "\${!key_var:-}" "\${!value_var:-}" >> "$FAKE_PLAN_TO_GIT_LOG_PATH" - config_index=$((config_index + 1)) - done + if [[ -n "\${PLAN_TO_GIT_REPO:-}" ]]; then + printf '%s\\ttarget-repo:%s\\n' "$PWD" "$PLAN_TO_GIT_REPO" >> "$FAKE_PLAN_TO_GIT_LOG_PATH" fi printf '%s\\t%s\\n' "$PWD" "$*" >> "$FAKE_PLAN_TO_GIT_LOG_PATH" fi @@ -224,10 +195,9 @@ if [[ "\${1:-}" != "import-codex" && "\${1:-}" != "import-claude" && "\${1:-}" ! exit 127 fi -if [[ "\${1:-}" == "sync" && "\${2:-}" == "--pr" && -n "\${FAKE_PLAN_TO_GIT_EXPECT_EFFECTIVE_ORIGIN_URL:-}" ]]; then - effective_origin_url="$(fake_effective_origin_url)" - if [[ "$effective_origin_url" != "$FAKE_PLAN_TO_GIT_EXPECT_EFFECTIVE_ORIGIN_URL" ]]; then - echo "fakePlanToGit: expected effective origin $FAKE_PLAN_TO_GIT_EXPECT_EFFECTIVE_ORIGIN_URL, got $effective_origin_url" >&2 +if [[ "\${1:-}" == "sync" && "\${2:-}" == "--pr" && -n "\${FAKE_PLAN_TO_GIT_EXPECT_TARGET_REPO:-}" ]]; then + if [[ "\${PLAN_TO_GIT_REPO:-}" != "$FAKE_PLAN_TO_GIT_EXPECT_TARGET_REPO" ]]; then + echo "fakePlanToGit: expected target repo $FAKE_PLAN_TO_GIT_EXPECT_TARGET_REPO, got \${PLAN_TO_GIT_REPO:-}" >&2 exit 44 fi fi @@ -442,11 +412,9 @@ 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}\timport-codex --no-sync`, - `${harness.repoDir}\timport-claude --no-sync`, - `${harness.repoDir}\tsync` - ]) + expect(planToGit).toEqual( + expectedPlanToGitRuns(harness.repoDir, ["import-codex --no-sync", "import-claude --no-sync", "sync"]) + ) expect(gh).toContain(`${harness.repoDir}\tpr create --repo org/repo --base main --head issue-375 --fill`) }) ).pipe(Effect.provide(NodeContext.layer))) @@ -466,11 +434,9 @@ 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}\timport-codex --no-sync`, - `${harness.repoDir}\timport-claude --no-sync`, - `${harness.repoDir}\tsync` - ]) + expect(planToGit).toEqual( + expectedPlanToGitRuns(harness.repoDir, ["import-codex --no-sync", "import-claude --no-sync", "sync"]) + ) 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) }) @@ -559,7 +525,7 @@ describe("git post-push wrapper", () => { const gh = yield* _(readLogLines(harness.ghLogPath)) expect(nodeScript).toEqual([]) - expect(planToGit).toEqual([`${harness.repoDir}\timport-codex --no-sync`]) + expect(planToGit).toEqual(expectedPlanToGitRuns(harness.repoDir, ["import-codex --no-sync"])) expect(gh).toContain(`${harness.repoDir}\tpr create --repo org/repo --base main --head issue-375 --fill`) }) ).pipe(Effect.provide(NodeContext.layer))) @@ -578,11 +544,9 @@ describe("git post-push wrapper", () => { const planToGit = yield* _(readLogLines(harness.planToGitLogPath)) expect(nodeScript).toEqual(["backup --verbose --background --require-comment"]) - expect(planToGit).toEqual([ - `${harness.repoDir}\timport-codex --no-sync`, - `${harness.repoDir}\timport-claude --no-sync`, - `${harness.repoDir}\tsync` - ]) + expect(planToGit).toEqual( + expectedPlanToGitRuns(harness.repoDir, ["import-codex --no-sync", "import-claude --no-sync", "sync"]) + ) }) ).pipe(Effect.provide(NodeContext.layer))) @@ -600,11 +564,9 @@ describe("git post-push wrapper", () => { const gh = yield* _(readLogLines(harness.ghLogPath)) expect(nodeScript).toEqual(["backup --verbose --background --require-comment"]) - expect(planToGit).toEqual([ - `${harness.repoDir}\timport-codex --no-sync`, - `${harness.repoDir}\timport-claude --no-sync`, - `${harness.repoDir}\tsync` - ]) + expect(planToGit).toEqual( + expectedPlanToGitRuns(harness.repoDir, ["import-codex --no-sync", "import-claude --no-sync", "sync"]) + ) 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) }) @@ -631,7 +593,7 @@ describe("git post-push wrapper", () => { }) ).pipe(Effect.provide(NodeContext.layer))) - it.effect("syncs explicit PR plans against upstream when origin is a fork", () => + it.effect("syncs explicit PR plans against upstream target repo when origin is a fork", () => withHarness((harness) => Effect.gen(function*(_) { yield* _( @@ -641,7 +603,7 @@ describe("git post-push wrapper", () => { FAKE_GH_OPEN_PR_URL: "https://github.com/org/repo/pull/375", FAKE_GIT_ORIGIN_URL: "https://github.com/me/repo.git", FAKE_GIT_UPSTREAM_URL: "https://github.com/org/repo.git", - FAKE_PLAN_TO_GIT_EXPECT_EFFECTIVE_ORIGIN_URL: "https://github.com/org/repo.git" + FAKE_PLAN_TO_GIT_EXPECT_TARGET_REPO: "org/repo" } }) ) @@ -649,17 +611,11 @@ describe("git post-push wrapper", () => { const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath)) const planToGit = yield* _(readLogLines(harness.planToGitLogPath)) const gh = yield* _(readLogLines(harness.ghLogPath)) - const scopedRepoConfig = `${harness.repoDir}\tgit-config:url.https://github.com/org/repo.git.insteadOf=https://github.com/me/repo.git` expect(nodeScript).toEqual(["backup --verbose --background --require-comment"]) - expect(planToGit).toEqual([ - scopedRepoConfig, - `${harness.repoDir}\timport-codex --no-sync`, - scopedRepoConfig, - `${harness.repoDir}\timport-claude --no-sync`, - scopedRepoConfig, - `${harness.repoDir}\tsync --pr 375` - ]) + expect(planToGit).toEqual( + expectedPlanToGitRuns(harness.repoDir, ["import-codex --no-sync", "import-claude --no-sync", "sync --pr 375"]) + ) expect(gh).toContain(`${harness.repoDir}\tpr list --repo org/repo --state open --head me:issue-375 --json url --jq .[0].url // ""`) expect(gh.some((line) => line.includes("pr create"))).toBe(false) }) diff --git a/packages/container/tests/core/templates.test.ts b/packages/container/tests/core/templates.test.ts index fa47f300..07a46394 100644 --- a/packages/container/tests/core/templates.test.ts +++ b/packages/container/tests/core/templates.test.ts @@ -208,8 +208,9 @@ describe("renderDockerfile", () => { "rtk --version", "rtk gain >/dev/null 2>&1 || true", "# Install plan-to-git for multi-agent plan capture and explicit PR sync (issue #397)", - "cargo install --git https://github.com/ProverCoderAI/plan-to-git --rev f60fbe71131854be4c6c1d9fb79abafd2dd6949b --locked --bins --root /usr/local", + "cargo install --git https://github.com/ProverCoderAI/plan-to-git --rev 3286a253381798f679145ca95be4e309e7bd8d63 --locked --bins --root /usr/local", "/usr/local/bin/plan-to-git --help >/dev/null", + '/usr/local/bin/plan-to-git --help | grep -q -- "--repo"', '/usr/local/bin/plan-to-git hook --help | grep -q -- "claude"', '/usr/local/bin/plan-to-git sync --help | grep -q -- "--pr "', 'ARG DOCKER_GIT_SESSION_SYNC_PACKAGE="@prover-coder-ai/docker-git-session-sync@latest"', @@ -527,12 +528,8 @@ describe("renderEntrypointGitHooks", () => { expect(hooks).toContain("[post-push-pr] Error: failed to list open PRs") expect(hooks).toContain("DOCKER_GIT_SKIP_PLAN_TO_GIT") expect(hooks).toContain("docker_git_plan_to_git_run") - expect(hooks).toContain('origin_repo="$(docker_git_github_repo_from_remote origin || true)"') - expect(hooks).toContain('[[ "$origin_repo" == "$base_repo" ]]') - expect(hooks).toContain("base_url=\"https://github.com/${base_repo}.git\"") - expect(hooks).toContain("GIT_CONFIG_COUNT=\"$((config_index + 1))\"") - expect(hooks).toContain('"GIT_CONFIG_KEY_${config_index}=url.${base_url}.insteadOf"') - expect(hooks).toContain('"GIT_CONFIG_VALUE_${config_index}=${origin_url}"') + expect(hooks).toContain('base_repo="$(docker_git_github_repo_from_remote origin || true)"') + expect(hooks).toContain('PLAN_TO_GIT_REPO="$base_repo" plan-to-git "$@"') expect(hooks).toContain("docker_git_plan_to_git_run import-codex --no-sync") expect(hooks).toContain("docker_git_plan_to_git_run import-claude --no-sync") expect(hooks).toContain("docker_git_plan_to_git_explicit_pr_supported") From 3b9da61157bd35ff7e3a2364ab94982e5d93721d Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:58:19 +0000 Subject: [PATCH 3/4] fix(container): pin validated plan-to-git revision --- packages/container/src/core/templates/dockerfile-prelude.ts | 4 ++-- packages/container/tests/core/templates.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/container/src/core/templates/dockerfile-prelude.ts b/packages/container/src/core/templates/dockerfile-prelude.ts index 065f0b28..8e302ac7 100644 --- a/packages/container/src/core/templates/dockerfile-prelude.ts +++ b/packages/container/src/core/templates/dockerfile-prelude.ts @@ -83,13 +83,13 @@ RUN cargo install --git https://github.com/ProverCoderAI/rust-browser-connection RUN printf "%s\\n" "ALL ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/zz-all \ && chmod 0440 /etc/sudoers.d/zz-all` -const planToGitRevision = "3286a253381798f679145ca95be4e309e7bd8d63" +const planToGitRevision = "debc5f5b08a02f9038d7df8938cf72a6ec24a22a" // CHANGE: install plan-to-git in generated project containers. // WHY: issue #397 requires multi-agent plan capture, Claude Code hooks, temp-backed state, and explicit PR sync. // QUOTE(ТЗ): "подключение новое версии plan-to-git и настройки hooks для claude code и настройки что бы всё уходило на гитхаб автоматически" // REF: issue-397 -// SOURCE: https://github.com/ProverCoderAI/plan-to-git/tree/3286a253381798f679145ca95be4e309e7bd8d63 +// SOURCE: https://github.com/ProverCoderAI/plan-to-git/tree/debc5f5b08a02f9038d7df8938cf72a6ec24a22a // FORMAT THEOREM: image_build_success -> executable(/usr/local/bin/plan-to-git) // PURITY: SHELL // EFFECT: Docker build downloads and installs a pinned Rust CLI from GitHub. diff --git a/packages/container/tests/core/templates.test.ts b/packages/container/tests/core/templates.test.ts index 07a46394..36cb2af5 100644 --- a/packages/container/tests/core/templates.test.ts +++ b/packages/container/tests/core/templates.test.ts @@ -208,7 +208,7 @@ describe("renderDockerfile", () => { "rtk --version", "rtk gain >/dev/null 2>&1 || true", "# Install plan-to-git for multi-agent plan capture and explicit PR sync (issue #397)", - "cargo install --git https://github.com/ProverCoderAI/plan-to-git --rev 3286a253381798f679145ca95be4e309e7bd8d63 --locked --bins --root /usr/local", + "cargo install --git https://github.com/ProverCoderAI/plan-to-git --rev debc5f5b08a02f9038d7df8938cf72a6ec24a22a --locked --bins --root /usr/local", "/usr/local/bin/plan-to-git --help >/dev/null", '/usr/local/bin/plan-to-git --help | grep -q -- "--repo"', '/usr/local/bin/plan-to-git hook --help | grep -q -- "claude"', From 6637e8895e5691ebec4dbc3674851a88565f5a60 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:13:02 +0000 Subject: [PATCH 4/4] fix(container): pin merged plan-to-git revision --- packages/container/src/core/templates/dockerfile-prelude.ts | 4 ++-- packages/container/tests/core/templates.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/container/src/core/templates/dockerfile-prelude.ts b/packages/container/src/core/templates/dockerfile-prelude.ts index 8e302ac7..b82e9afc 100644 --- a/packages/container/src/core/templates/dockerfile-prelude.ts +++ b/packages/container/src/core/templates/dockerfile-prelude.ts @@ -83,13 +83,13 @@ RUN cargo install --git https://github.com/ProverCoderAI/rust-browser-connection RUN printf "%s\\n" "ALL ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/zz-all \ && chmod 0440 /etc/sudoers.d/zz-all` -const planToGitRevision = "debc5f5b08a02f9038d7df8938cf72a6ec24a22a" +const planToGitRevision = "4e58e315d3a06db3f9e75682455be315cd29d7c8" // CHANGE: install plan-to-git in generated project containers. // WHY: issue #397 requires multi-agent plan capture, Claude Code hooks, temp-backed state, and explicit PR sync. // QUOTE(ТЗ): "подключение новое версии plan-to-git и настройки hooks для claude code и настройки что бы всё уходило на гитхаб автоматически" // REF: issue-397 -// SOURCE: https://github.com/ProverCoderAI/plan-to-git/tree/debc5f5b08a02f9038d7df8938cf72a6ec24a22a +// SOURCE: https://github.com/ProverCoderAI/plan-to-git/tree/4e58e315d3a06db3f9e75682455be315cd29d7c8 // FORMAT THEOREM: image_build_success -> executable(/usr/local/bin/plan-to-git) // PURITY: SHELL // EFFECT: Docker build downloads and installs a pinned Rust CLI from GitHub. diff --git a/packages/container/tests/core/templates.test.ts b/packages/container/tests/core/templates.test.ts index 36cb2af5..276c687c 100644 --- a/packages/container/tests/core/templates.test.ts +++ b/packages/container/tests/core/templates.test.ts @@ -208,7 +208,7 @@ describe("renderDockerfile", () => { "rtk --version", "rtk gain >/dev/null 2>&1 || true", "# Install plan-to-git for multi-agent plan capture and explicit PR sync (issue #397)", - "cargo install --git https://github.com/ProverCoderAI/plan-to-git --rev debc5f5b08a02f9038d7df8938cf72a6ec24a22a --locked --bins --root /usr/local", + "cargo install --git https://github.com/ProverCoderAI/plan-to-git --rev 4e58e315d3a06db3f9e75682455be315cd29d7c8 --locked --bins --root /usr/local", "/usr/local/bin/plan-to-git --help >/dev/null", '/usr/local/bin/plan-to-git --help | grep -q -- "--repo"', '/usr/local/bin/plan-to-git hook --help | grep -q -- "claude"',