feat(git-sync): nested fork routing + fork-of-dev branch rooting

A fork of a dev workspace now roots its wm-fork/** branch on the dev's
environment-label branch (the content it diverged from) and its PR merges
back into that branch: the backend passes parent_dev_workspace_label with
the deploy (parent row joined in both enqueue paths), the CLI gains
--parent-dev-workspace-label and checks it before the wm-fork- prefix
fallback when rooting a fork-of-a-fork branch, and the PR completion hook
uses it as the PR base.

Fork sync routing covers the whole live descendant chain of the
webhook/poller workspace (recursive, depth-capped) instead of direct
children only, and fork_open_prs is resolved at the root ancestor — only
the root can hold auto-pull config, so grandchild forks sync through it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
This commit is contained in:
hugocasa
2026-07-07 18:13:52 +02:00
parent fe2d169083
commit c407d2cc48
11 changed files with 240 additions and 97 deletions
+23 -14
View File
@@ -2761,6 +2761,7 @@ export async function pull(
onlyCreateBranch?: boolean;
parentWorkspaceId?: string;
devWorkspaceLabel?: string;
parentDevWorkspaceLabel?: string;
gitCommitterEmail?: string;
gitCommitterName?: string;
},
@@ -2841,20 +2842,23 @@ export async function pull(
: !!opts.useIndividualBranch;
const groupByFolder = targetIsFork ? false : !!opts.groupByFolder;
// Fork-of-a-fork: only when the parent workspace is itself a fork, root
// the new branch on the parent's fork branch (mirrors the hub script's
// `parent_workspace_id?.startsWith(FORKED_…)` gate).
if (opts.parentWorkspaceId && isForkWorkspace(opts.parentWorkspaceId)) {
const parentBranch = computeGitSyncDeployBranch({
workspaceId: opts.parentWorkspaceId,
items: deployItems,
useIndividualBranch,
groupByFolder,
clonedBranchName,
});
if (parentBranch && parentBranch !== clonedBranchName) {
checkoutGitSyncDeployBranch(parentBranch);
}
// Fork-of-a-fork: when the parent workspace is itself a fork, root the new
// branch on the parent's fork branch (the content this fork diverged from).
// A dev-workspace parent has a prefix-less id the prefix check can't see, so
// the backend passes its environment label; its branch is the label verbatim.
const parentBranch = opts.parentDevWorkspaceLabel
? opts.parentDevWorkspaceLabel
: opts.parentWorkspaceId && isForkWorkspace(opts.parentWorkspaceId)
? computeGitSyncDeployBranch({
workspaceId: opts.parentWorkspaceId,
items: deployItems,
useIndividualBranch,
groupByFolder,
clonedBranchName,
})
: null;
if (parentBranch && parentBranch !== clonedBranchName) {
checkoutGitSyncDeployBranch(parentBranch);
}
const deployBranch = computeGitSyncDeployBranch({
@@ -3389,6 +3393,7 @@ export async function gitDeploy(
onlyCreateBranch?: boolean;
parentWorkspaceId?: string;
devWorkspaceLabel?: string;
parentDevWorkspaceLabel?: string;
skipSecrets?: boolean;
gitCommitterEmail?: string;
gitCommitterName?: string;
@@ -5238,6 +5243,10 @@ const command = new Command()
"--dev-workspace-label <label:string>",
"Environment label of a dev workspace (dev/staging); its deploys go to that branch",
)
.option(
"--parent-dev-workspace-label <label:string>",
"Environment label of the parent dev workspace; roots a fork-of-dev branch on it",
)
.option("--skip-secrets", "Skip syncing only secrets variables")
.option(
"--git-committer-email <email:string>",