Skip to content

Commit 4349caf

Browse files
committed
fix(container): sync plan-to-git against upstream repo
1 parent 3610670 commit 4349caf

5 files changed

Lines changed: 223 additions & 79 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
const githubRemoteHelpersTemplate = String.raw`docker_git_github_repo_from_remote_url() {
2+
local remote_url="$1"
3+
local repo_path=""
4+
local owner=""
5+
local repo=""
6+
7+
case "$remote_url" in
8+
https://github.com/*)
9+
repo_path="${"${"}remote_url#https://github.com/}"
10+
;;
11+
https://*@github.com/*)
12+
repo_path="${"${"}remote_url#https://*@github.com/}"
13+
;;
14+
ssh://git@github.com/*)
15+
repo_path="${"${"}remote_url#ssh://git@github.com/}"
16+
;;
17+
git@github.com:*)
18+
repo_path="${"${"}remote_url#git@github.com:}"
19+
;;
20+
*)
21+
return 1
22+
;;
23+
esac
24+
25+
repo_path="${"${"}repo_path%%\?*}"
26+
repo_path="${"${"}repo_path%%#*}"
27+
repo_path="${"${"}repo_path%/}"
28+
repo_path="${"${"}repo_path%.git}"
29+
owner="${"${"}repo_path%%/*}"
30+
repo="${"${"}repo_path#*/}"
31+
repo="${"${"}repo%%/*}"
32+
repo="${"${"}repo%.git}"
33+
34+
if [[ -z "$owner" || -z "$repo" || "$owner" == "$repo_path" ]]; then
35+
return 1
36+
fi
37+
38+
printf "%s/%s\n" "$owner" "$repo"
39+
}
40+
41+
docker_git_github_repo_from_remote() {
42+
local remote="$1"
43+
local remote_url=""
44+
45+
remote_url="$(git remote get-url "$remote" 2>/dev/null || true)"
46+
if [[ -z "$remote_url" ]]; then
47+
return 1
48+
fi
49+
50+
docker_git_github_repo_from_remote_url "$remote_url"
51+
}`
52+
53+
export const renderGitHubRemoteHelpers = (): string => githubRemoteHelpersTemplate

packages/container/src/core/templates-entrypoint/plan-to-git.ts

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,49 @@
1+
import { renderGitHubRemoteHelpers } from "./github-remotes.js"
2+
13
const planToGitHookPathsTemplate = `PLAN_TO_GIT_SYNC_HELPER="$HOOKS_DIR/plan-to-git-sync"
24
PLAN_TO_GIT_CODEX_HOOK="$HOOKS_DIR/plan-to-git-codex-hook"
35
PLAN_TO_GIT_CLAUDE_HOOK="$HOOKS_DIR/plan-to-git-claude-hook"
46
CODEX_REQUIREMENTS_FILE="/etc/codex/requirements.toml"
57
CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="$CLAUDE_CONFIG_DIR/settings.json"`
68

9+
const planToGitRunnerTemplate = String.raw`${renderGitHubRemoteHelpers()}
10+
11+
docker_git_plan_to_git_run() {
12+
local base_repo=""
13+
local origin_repo=""
14+
local origin_url=""
15+
local base_url=""
16+
local config_index="0"
17+
18+
if ! base_repo="$(docker_git_github_repo_from_remote upstream)"; then
19+
base_repo="$(docker_git_github_repo_from_remote origin || true)"
20+
fi
21+
origin_repo="$(docker_git_github_repo_from_remote origin || true)"
22+
origin_url="$(git remote get-url origin 2>/dev/null || true)"
23+
24+
if [[ -z "$base_repo" || -z "$origin_repo" || -z "$origin_url" ]]; then
25+
plan-to-git "$@"
26+
return $?
27+
fi
28+
29+
if [[ "$origin_repo" == "$base_repo" ]]; then
30+
plan-to-git "$@"
31+
return $?
32+
fi
33+
34+
base_url="https://github.com/${"${"}base_repo}.git"
35+
config_index="${"${"}GIT_CONFIG_COUNT:-0}"
36+
if ! [[ "$config_index" =~ ^[0-9]+$ ]]; then
37+
config_index="0"
38+
fi
39+
40+
env \
41+
GIT_CONFIG_COUNT="$((config_index + 1))" \
42+
"GIT_CONFIG_KEY_${"${"}config_index}=url.${"${"}base_url}.insteadOf" \
43+
"GIT_CONFIG_VALUE_${"${"}config_index}=${"${"}origin_url}" \
44+
plan-to-git "$@"
45+
}`
46+
747
const planToGitSyncHelperInstallTemplate = String.raw`cat <<'EOF' > "$PLAN_TO_GIT_SYNC_HELPER"
848
#!/usr/bin/env bash
949
set -euo pipefail
@@ -19,8 +59,10 @@ fi
1959
2060
export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}"
2161
62+
${planToGitRunnerTemplate}
63+
2264
docker_git_plan_to_git_explicit_pr_supported() {
23-
plan-to-git sync --help 2>/dev/null | grep -q -- "--pr <PR>"
65+
docker_git_plan_to_git_run sync --help 2>/dev/null | grep -q -- "--pr <PR>"
2466
}
2567
2668
docker_git_plan_to_git_resolve_pr_number() {
@@ -61,12 +103,12 @@ docker_git_plan_to_git_sync() {
61103
62104
if [[ -n "$pr_number" ]] && docker_git_plan_to_git_explicit_pr_supported; then
63105
echo "[plan-to-git] Syncing queued agent plans to PR #$pr_number"
64-
plan-to-git sync --pr "$pr_number"
106+
docker_git_plan_to_git_run sync --pr "$pr_number"
65107
return 0
66108
fi
67109
68110
echo "[plan-to-git] Syncing queued agent plans via current branch discovery"
69-
plan-to-git sync
111+
docker_git_plan_to_git_run sync
70112
}
71113
72114
docker_git_plan_to_git_sync
@@ -83,14 +125,15 @@ if [ "\${DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" != "1" ]; then
83125
echo "[plan-to-git] Error: plan-to-git not found" >&2
84126
exit 1
85127
fi
86-
plan-to-git import-codex --no-sync
87-
plan-to-git import-claude --no-sync
128+
${planToGitRunnerTemplate}
129+
docker_git_plan_to_git_run import-codex --no-sync
130+
docker_git_plan_to_git_run import-claude --no-sync
88131
PLAN_TO_GIT_SYNC_HELPER="\${DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}"
89132
if [[ -x "$PLAN_TO_GIT_SYNC_HELPER" ]]; then
90133
"$PLAN_TO_GIT_SYNC_HELPER"
91134
else
92135
echo "[plan-to-git] Sync helper not found; falling back to current branch discovery" >&2
93-
plan-to-git sync
136+
docker_git_plan_to_git_run sync
94137
fi
95138
fi`
96139

@@ -108,7 +151,8 @@ if ! command -v plan-to-git >/dev/null 2>&1; then
108151
fi
109152
110153
export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}"
111-
plan-to-git hook --source codex
154+
${planToGitRunnerTemplate}
155+
docker_git_plan_to_git_run hook --source codex
112156
PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}"
113157
"$PLAN_TO_GIT_SYNC_HELPER" >&2 || true
114158
EOF
@@ -128,7 +172,8 @@ if ! command -v plan-to-git >/dev/null 2>&1; then
128172
fi
129173
130174
export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}"
131-
plan-to-git hook --source claude
175+
${planToGitRunnerTemplate}
176+
docker_git_plan_to_git_run hook --source claude
132177
PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}"
133178
"$PLAN_TO_GIT_SYNC_HELPER" >&2 || true
134179
EOF

packages/container/src/core/templates-entrypoint/post-push-pr.ts

Lines changed: 7 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,10 @@
1-
const postPushPrEnsureTemplate = String
2-
.raw`# CHANGE: ensure an open GitHub PR exists for the pushed branch before PR-bound post-push tools run.
1+
import { renderGitHubRemoteHelpers } from "./github-remotes.js"
2+
3+
const postPushPrEnsureTemplate =
4+
`# CHANGE: ensure an open GitHub PR exists for the pushed branch before PR-bound post-push tools run.
35
# 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.
46
# REF: issue-375
5-
docker_git_github_repo_from_remote_url() {
6-
local remote_url="$1"
7-
local repo_path=""
8-
local owner=""
9-
local repo=""
10-
11-
case "$remote_url" in
12-
https://github.com/*)
13-
repo_path="${"${"}remote_url#https://github.com/}"
14-
;;
15-
https://github.com/*)
16-
repo_path="${"${"}remote_url#https://github.com/}"
17-
;;
18-
https://*@github.com/*)
19-
repo_path="${"${"}remote_url#https://*@github.com/}"
20-
;;
21-
https://*@github.com/*)
22-
repo_path="${"${"}remote_url#https://*@github.com/}"
23-
;;
24-
ssh://git@github.com/*)
25-
repo_path="${"${"}remote_url#ssh://git@github.com/}"
26-
;;
27-
git@github.com:*)
28-
repo_path="${"${"}remote_url#git@github.com:}"
29-
;;
30-
*)
31-
return 1
32-
;;
33-
esac
34-
35-
repo_path="${"${"}repo_path%%\?*}"
36-
repo_path="${"${"}repo_path%%#*}"
37-
repo_path="${"${"}repo_path%/}"
38-
repo_path="${"${"}repo_path%.git}"
39-
owner="${"${"}repo_path%%/*}"
40-
repo="${"${"}repo_path#*/}"
41-
repo="${"${"}repo%%/*}"
42-
repo="${"${"}repo%.git}"
43-
44-
if [[ -z "$owner" || -z "$repo" || "$owner" == "$repo_path" ]]; then
45-
return 1
46-
fi
47-
48-
printf "%s/%s\n" "$owner" "$repo"
49-
}
50-
51-
docker_git_github_repo_from_remote() {
52-
local remote="$1"
53-
local remote_url=""
54-
55-
remote_url="$(git remote get-url "$remote" 2>/dev/null || true)"
56-
if [[ -z "$remote_url" ]]; then
57-
return 1
58-
fi
59-
60-
docker_git_github_repo_from_remote_url "$remote_url"
61-
}
7+
${renderGitHubRemoteHelpers()}
628
639
docker_git_ensure_open_pr() {
6410
local branch=""
@@ -100,8 +46,8 @@ docker_git_ensure_open_pr() {
10046
if [[ "$head_repo" == "$base_repo" ]]; then
10147
head_arg="$branch"
10248
else
103-
head_owner="${"${"}head_repo%%/*}"
104-
head_arg="${"${"}head_owner}:${"${"}branch}"
49+
head_owner="\${head_repo%%/*}"
50+
head_arg="\${head_owner}:\${branch}"
10551
fi
10652
10753
if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$head_arg" --json url --jq '.[0].url // ""' 2>/dev/null)"; then

packages/container/tests/core/git-post-push-wrapper.test.ts

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type WrapperHarness = {
2121
readonly externalDir: string
2222
readonly binDir: string
2323
readonly wrapperPath: string
24+
readonly planToGitSyncHelperPath: string
2425
readonly gitLogPath: string
2526
readonly nodeCwdLogPath: string
2627
readonly nodeRepoRootLogPath: string
@@ -167,18 +168,70 @@ exit 0
167168
const fakePlanToGitScript = `#!/usr/bin/env bash
168169
set -euo pipefail
169170
171+
fake_effective_origin_url() {
172+
local origin_url="\${FAKE_GIT_ORIGIN_URL:-https://github.com/org/repo.git}"
173+
local config_index=0
174+
local key_var=""
175+
local value_var=""
176+
local key=""
177+
local value=""
178+
local base_url=""
179+
local suffix=""
180+
181+
if [[ -n "\${GIT_CONFIG_COUNT:-}" && "\${GIT_CONFIG_COUNT}" =~ ^[0-9]+$ ]]; then
182+
while [[ "$config_index" -lt "$GIT_CONFIG_COUNT" ]]; do
183+
key_var="GIT_CONFIG_KEY_\${config_index}"
184+
value_var="GIT_CONFIG_VALUE_\${config_index}"
185+
key="\${!key_var:-}"
186+
value="\${!value_var:-}"
187+
if [[ "$key" == url.*.insteadOf && -n "$value" && "$origin_url" == "$value"* ]]; then
188+
base_url="\${key#url.}"
189+
base_url="\${base_url%.insteadOf}"
190+
suffix="\${origin_url#"$value"}"
191+
printf '%s\\n' "\${base_url}\${suffix}"
192+
return 0
193+
fi
194+
config_index=$((config_index + 1))
195+
done
196+
fi
197+
198+
printf '%s\\n' "$origin_url"
199+
}
200+
201+
if [[ "\${1:-}" == "sync" && "\${2:-}" == "--help" ]]; then
202+
printf '%s\\n' "--pr <PR>"
203+
exit 0
204+
fi
205+
170206
if [[ -n "\${FAKE_PLAN_TO_GIT_LOG_PATH:-}" ]]; then
207+
if [[ -n "\${GIT_CONFIG_COUNT:-}" && "\${GIT_CONFIG_COUNT}" =~ ^[0-9]+$ ]]; then
208+
config_index=0
209+
while [[ "$config_index" -lt "$GIT_CONFIG_COUNT" ]]; do
210+
key_var="GIT_CONFIG_KEY_\${config_index}"
211+
value_var="GIT_CONFIG_VALUE_\${config_index}"
212+
printf '%s\\tgit-config:%s=%s\\n' "$PWD" "\${!key_var:-}" "\${!value_var:-}" >> "$FAKE_PLAN_TO_GIT_LOG_PATH"
213+
config_index=$((config_index + 1))
214+
done
215+
fi
171216
printf '%s\\t%s\\n' "$PWD" "$*" >> "$FAKE_PLAN_TO_GIT_LOG_PATH"
172217
fi
173218
174-
if [[ "\${1:-}" != "import-codex" && "\${1:-}" != "import-claude" && "\${1:-}" != "sync" ]]; then
219+
if [[ "\${1:-}" != "import-codex" && "\${1:-}" != "import-claude" && "\${1:-}" != "sync" && "\${1:-}" != "hook" ]]; then
175220
if [[ -n "\${FAKE_PLAN_TO_GIT_LOG_PATH:-}" ]]; then
176221
printf '%s\\tunexpected-command:%s\\n' "$PWD" "\${1:-<empty>}" >> "$FAKE_PLAN_TO_GIT_LOG_PATH"
177222
fi
178-
echo "fakePlanToGit: expected import-codex, import-claude, or sync command, got: \${1:-<empty>}" >&2
223+
echo "fakePlanToGit: expected import-codex, import-claude, hook, or sync command, got: \${1:-<empty>}" >&2
179224
exit 127
180225
fi
181226
227+
if [[ "\${1:-}" == "sync" && "\${2:-}" == "--pr" && -n "\${FAKE_PLAN_TO_GIT_EXPECT_EFFECTIVE_ORIGIN_URL:-}" ]]; then
228+
effective_origin_url="$(fake_effective_origin_url)"
229+
if [[ "$effective_origin_url" != "$FAKE_PLAN_TO_GIT_EXPECT_EFFECTIVE_ORIGIN_URL" ]]; then
230+
echo "fakePlanToGit: expected effective origin $FAKE_PLAN_TO_GIT_EXPECT_EFFECTIVE_ORIGIN_URL, got $effective_origin_url" >&2
231+
exit 44
232+
fi
233+
fi
234+
182235
if [[ -n "\${FAKE_PLAN_TO_GIT_EXIT_CODE:-}" ]]; then
183236
exit "$FAKE_PLAN_TO_GIT_EXIT_CODE"
184237
fi
@@ -282,6 +335,7 @@ const makeHarnessEnv = (
282335
FAKE_NODE_SCRIPT_LOG_PATH: harness.nodeScriptLogPath,
283336
FAKE_PLAN_TO_GIT_LOG_PATH: harness.planToGitLogPath,
284337
FAKE_GH_LOG_PATH: harness.ghLogPath,
338+
DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER: harness.planToGitSyncHelperPath,
285339
...overrides
286340
})
287341

@@ -341,6 +395,10 @@ const withHarness = <A, E, R>(
341395
const postPushPath = path.join(hooksDir, "post-push")
342396
yield* _(writeExecutable(postPushPath, postPushScript))
343397

398+
const planToGitSyncHelperScript = extractEmbeddedScript(renderEntrypointGitHooks(), "$PLAN_TO_GIT_SYNC_HELPER")
399+
const planToGitSyncHelperPath = path.join(hooksDir, "plan-to-git-sync")
400+
yield* _(writeExecutable(planToGitSyncHelperPath, planToGitSyncHelperScript))
401+
344402
const wrapperTemplate = extractEmbeddedScript(
345403
renderEntrypointGitPostPushWrapperInstall(),
346404
"$GIT_WRAPPER_BIN"
@@ -357,6 +415,7 @@ const withHarness = <A, E, R>(
357415
externalDir,
358416
binDir,
359417
wrapperPath,
418+
planToGitSyncHelperPath,
360419
gitLogPath,
361420
nodeCwdLogPath,
362421
nodeRepoRootLogPath,
@@ -572,6 +631,40 @@ describe("git post-push wrapper", () => {
572631
})
573632
).pipe(Effect.provide(NodeContext.layer)))
574633

634+
it.effect("syncs explicit PR plans against upstream when origin is a fork", () =>
635+
withHarness((harness) =>
636+
Effect.gen(function*(_) {
637+
yield* _(
638+
runWrapper(harness, harness.repoDir, ["push", "origin", "HEAD"], {
639+
env: {
640+
DOCKER_GIT_PR_NUMBER: "375",
641+
FAKE_GH_OPEN_PR_URL: "https://github.com/org/repo/pull/375",
642+
FAKE_GIT_ORIGIN_URL: "https://github.com/me/repo.git",
643+
FAKE_GIT_UPSTREAM_URL: "https://github.com/org/repo.git",
644+
FAKE_PLAN_TO_GIT_EXPECT_EFFECTIVE_ORIGIN_URL: "https://github.com/org/repo.git"
645+
}
646+
})
647+
)
648+
649+
const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath))
650+
const planToGit = yield* _(readLogLines(harness.planToGitLogPath))
651+
const gh = yield* _(readLogLines(harness.ghLogPath))
652+
const scopedRepoConfig = `${harness.repoDir}\tgit-config:url.https://github.com/org/repo.git.insteadOf=https://github.com/me/repo.git`
653+
654+
expect(nodeScript).toEqual(["backup --verbose --background --require-comment"])
655+
expect(planToGit).toEqual([
656+
scopedRepoConfig,
657+
`${harness.repoDir}\timport-codex --no-sync`,
658+
scopedRepoConfig,
659+
`${harness.repoDir}\timport-claude --no-sync`,
660+
scopedRepoConfig,
661+
`${harness.repoDir}\tsync --pr 375`
662+
])
663+
expect(gh).toContain(`${harness.repoDir}\tpr list --repo org/repo --state open --head me:issue-375 --json url --jq .[0].url // ""`)
664+
expect(gh.some((line) => line.includes("pr create"))).toBe(false)
665+
})
666+
).pipe(Effect.provide(NodeContext.layer)))
667+
575668
it.effect("propagates PR creation failures before plan sync and session backup", () =>
576669
withHarness((harness) =>
577670
Effect.gen(function*(_) {

0 commit comments

Comments
 (0)