From c407d2cc48ecb800d1165495c23efda1c3f66063 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 7 Jul 2026 18:13:52 +0200 Subject: [PATCH] feat(git-sync): nested fork routing + fork-of-dev branch rooting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm --- ...ebfa9cee08b93e0562beb629d84c4c689f4e7.json | 24 +++++++ ...ee4dfc0e2c9521c0689ef3777c61ab06c5dd8.json | 24 ------- ...1f48da6fe47bc29b6987cf60edd56bb1663b6.json | 46 +++++++++++++ ...ad146e2ca8c1cd7c10891fe21a7638aedc381.json | 22 +++++++ ...93c4e64166a6d4d999911dfab743c43bdac35.json | 46 +++++++++++++ ...8add2f9cb8f72486b0a0f26f4545690d8021d.json | 34 ---------- ...abaf747aef7078ae57b0373f06c70b6f2f9f.json} | 20 ++++-- backend/ee-repo-ref.txt | 2 +- .../windmill-worker/src/result_processor.rs | 65 +++++++++++++++---- cli/src/commands/sync/sync.ts | 37 +++++++---- docs/git-sync-pull-design.md | 17 +++-- 11 files changed, 240 insertions(+), 97 deletions(-) create mode 100644 backend/.sqlx/query-0e016aaa6842767ac636e5b3e1febfa9cee08b93e0562beb629d84c4c689f4e7.json delete mode 100644 backend/.sqlx/query-124f7c43d603e2bb07cbadf7eaaee4dfc0e2c9521c0689ef3777c61ab06c5dd8.json create mode 100644 backend/.sqlx/query-1c018b483795d9d33baa7be2ee41f48da6fe47bc29b6987cf60edd56bb1663b6.json create mode 100644 backend/.sqlx/query-3451cbd6a783da68324f64c2e95ad146e2ca8c1cd7c10891fe21a7638aedc381.json create mode 100644 backend/.sqlx/query-69d0c59bb11a01ffe9934f62feb93c4e64166a6d4d999911dfab743c43bdac35.json delete mode 100644 backend/.sqlx/query-c2da62b9db6021ee5e5ab8342d48add2f9cb8f72486b0a0f26f4545690d8021d.json rename backend/.sqlx/{query-382b8097772c03a24c26d8326bbb83cacbde10719920e4e26888780570d7dd39.json => query-f02977ee5df3d8f734fb32836583abaf747aef7078ae57b0373f06c70b6f2f9f.json} (62%) diff --git a/backend/.sqlx/query-0e016aaa6842767ac636e5b3e1febfa9cee08b93e0562beb629d84c4c689f4e7.json b/backend/.sqlx/query-0e016aaa6842767ac636e5b3e1febfa9cee08b93e0562beb629d84c4c689f4e7.json new file mode 100644 index 0000000000..6f5d84d2d6 --- /dev/null +++ b/backend/.sqlx/query-0e016aaa6842767ac636e5b3e1febfa9cee08b93e0562beb629d84c4c689f4e7.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH RECURSIVE descendants AS (\n SELECT id, 0 AS depth FROM workspace\n WHERE parent_workspace_id = $1 AND NOT deleted\n UNION ALL\n SELECT w.id, d.depth + 1 FROM workspace w\n JOIN descendants d ON w.parent_workspace_id = d.id\n WHERE NOT w.deleted AND d.depth < 10\n )\n SELECT id as \"id!\" FROM descendants WHERE (id = $2 OR id = $3)\n ORDER BY (id = $2) DESC LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "0e016aaa6842767ac636e5b3e1febfa9cee08b93e0562beb629d84c4c689f4e7" +} diff --git a/backend/.sqlx/query-124f7c43d603e2bb07cbadf7eaaee4dfc0e2c9521c0689ef3777c61ab06c5dd8.json b/backend/.sqlx/query-124f7c43d603e2bb07cbadf7eaaee4dfc0e2c9521c0689ef3777c61ab06c5dd8.json deleted file mode 100644 index 93cffd39cf..0000000000 --- a/backend/.sqlx/query-124f7c43d603e2bb07cbadf7eaaee4dfc0e2c9521c0689ef3777c61ab06c5dd8.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id FROM workspace WHERE parent_workspace_id = $1 AND NOT deleted AND (id = $2 OR id = $3) ORDER BY (id = $2) DESC LIMIT 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "124f7c43d603e2bb07cbadf7eaaee4dfc0e2c9521c0689ef3777c61ab06c5dd8" -} diff --git a/backend/.sqlx/query-1c018b483795d9d33baa7be2ee41f48da6fe47bc29b6987cf60edd56bb1663b6.json b/backend/.sqlx/query-1c018b483795d9d33baa7be2ee41f48da6fe47bc29b6987cf60edd56bb1663b6.json new file mode 100644 index 0000000000..6449a361e6 --- /dev/null +++ b/backend/.sqlx/query-1c018b483795d9d33baa7be2ee41f48da6fe47bc29b6987cf60edd56bb1663b6.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT w.parent_workspace_id, w.is_dev_workspace, w.dev_workspace_label,\n p.is_dev_workspace as \"parent_is_dev_workspace?\", p.dev_workspace_label as \"parent_dev_label?\"\n FROM workspace w LEFT JOIN workspace p ON p.id = w.parent_workspace_id\n WHERE w.id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "parent_workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "is_dev_workspace", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "dev_workspace_label", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "parent_is_dev_workspace?", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "parent_dev_label?", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + false, + true, + false, + true + ] + }, + "hash": "1c018b483795d9d33baa7be2ee41f48da6fe47bc29b6987cf60edd56bb1663b6" +} diff --git a/backend/.sqlx/query-3451cbd6a783da68324f64c2e95ad146e2ca8c1cd7c10891fe21a7638aedc381.json b/backend/.sqlx/query-3451cbd6a783da68324f64c2e95ad146e2ca8c1cd7c10891fe21a7638aedc381.json new file mode 100644 index 0000000000..a5275d2ed1 --- /dev/null +++ b/backend/.sqlx/query-3451cbd6a783da68324f64c2e95ad146e2ca8c1cd7c10891fe21a7638aedc381.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH RECURSIVE up AS (\n SELECT id, parent_workspace_id, 0 AS depth FROM workspace WHERE id = $1\n UNION ALL\n SELECT w.id, w.parent_workspace_id, up.depth + 1\n FROM workspace w JOIN up ON w.id = up.parent_workspace_id\n WHERE up.depth < 10\n )\n SELECT git_sync FROM workspace_settings\n WHERE workspace_id = (SELECT id FROM up WHERE parent_workspace_id IS NULL LIMIT 1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "git_sync", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "3451cbd6a783da68324f64c2e95ad146e2ca8c1cd7c10891fe21a7638aedc381" +} diff --git a/backend/.sqlx/query-69d0c59bb11a01ffe9934f62feb93c4e64166a6d4d999911dfab743c43bdac35.json b/backend/.sqlx/query-69d0c59bb11a01ffe9934f62feb93c4e64166a6d4d999911dfab743c43bdac35.json new file mode 100644 index 0000000000..a8651b5a73 --- /dev/null +++ b/backend/.sqlx/query-69d0c59bb11a01ffe9934f62feb93c4e64166a6d4d999911dfab743c43bdac35.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT w.parent_workspace_id, w.is_dev_workspace, w.dev_workspace_label,\n p.is_dev_workspace as \"parent_is_dev_workspace?\", p.dev_workspace_label as \"parent_dev_label?\"\n FROM workspace w LEFT JOIN workspace p ON p.id = w.parent_workspace_id\n WHERE w.id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "parent_workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "is_dev_workspace", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "dev_workspace_label", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "parent_is_dev_workspace?", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "parent_dev_label?", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + false, + true, + false, + true + ] + }, + "hash": "69d0c59bb11a01ffe9934f62feb93c4e64166a6d4d999911dfab743c43bdac35" +} diff --git a/backend/.sqlx/query-c2da62b9db6021ee5e5ab8342d48add2f9cb8f72486b0a0f26f4545690d8021d.json b/backend/.sqlx/query-c2da62b9db6021ee5e5ab8342d48add2f9cb8f72486b0a0f26f4545690d8021d.json deleted file mode 100644 index 012630f086..0000000000 --- a/backend/.sqlx/query-c2da62b9db6021ee5e5ab8342d48add2f9cb8f72486b0a0f26f4545690d8021d.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT parent_workspace_id, is_dev_workspace, dev_workspace_label FROM workspace WHERE id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "parent_workspace_id", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "is_dev_workspace", - "type_info": "Bool" - }, - { - "ordinal": 2, - "name": "dev_workspace_label", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - true, - false, - true - ] - }, - "hash": "c2da62b9db6021ee5e5ab8342d48add2f9cb8f72486b0a0f26f4545690d8021d" -} diff --git a/backend/.sqlx/query-382b8097772c03a24c26d8326bbb83cacbde10719920e4e26888780570d7dd39.json b/backend/.sqlx/query-f02977ee5df3d8f734fb32836583abaf747aef7078ae57b0373f06c70b6f2f9f.json similarity index 62% rename from backend/.sqlx/query-382b8097772c03a24c26d8326bbb83cacbde10719920e4e26888780570d7dd39.json rename to backend/.sqlx/query-f02977ee5df3d8f734fb32836583abaf747aef7078ae57b0373f06c70b6f2f9f.json index 07601f3fae..f449de1836 100644 --- a/backend/.sqlx/query-382b8097772c03a24c26d8326bbb83cacbde10719920e4e26888780570d7dd39.json +++ b/backend/.sqlx/query-f02977ee5df3d8f734fb32836583abaf747aef7078ae57b0373f06c70b6f2f9f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n args->'__git_sync_open_pr' as \"marker\",\n args->>'repo_url_resource_path' as \"repo_path\",\n args->>'parent_workspace_id' as \"parent_workspace_id\",\n args->>'dev_workspace_label' as \"dev_workspace_label\",\n COALESCE((args->'use_individual_branch')::bool, false) as \"use_individual_branch!\",\n COALESCE((args->'group_by_folder')::bool, false) as \"group_by_folder!\",\n COALESCE(args->'items'->0->>'path', args->>'path', '') as \"item_path!\",\n COALESCE(args->'items'->0->>'parent_path', args->>'parent_path', '') as \"item_parent_path!\",\n COALESCE(args->'items'->0->>'path_type', args->>'path_type', '') as \"path_type!\",\n COALESCE(args->'items'->0->>'commit_msg', args->>'commit_msg', '') as \"commit_msg!\"\n FROM v2_job WHERE id = $1", + "query": "SELECT\n args->'__git_sync_open_pr' as \"marker\",\n args->>'repo_url_resource_path' as \"repo_path\",\n args->>'parent_workspace_id' as \"parent_workspace_id\",\n args->>'dev_workspace_label' as \"dev_workspace_label\",\n args->>'parent_dev_workspace_label' as \"parent_dev_workspace_label\",\n COALESCE((args->'use_individual_branch')::bool, false) as \"use_individual_branch!\",\n COALESCE((args->'group_by_folder')::bool, false) as \"group_by_folder!\",\n COALESCE(args->'items'->0->>'path', args->>'path', '') as \"item_path!\",\n COALESCE(args->'items'->0->>'parent_path', args->>'parent_path', '') as \"item_parent_path!\",\n COALESCE(args->'items'->0->>'path_type', args->>'path_type', '') as \"path_type!\",\n COALESCE(args->'items'->0->>'commit_msg', args->>'commit_msg', '') as \"commit_msg!\"\n FROM v2_job WHERE id = $1", "describe": { "columns": [ { @@ -25,31 +25,36 @@ }, { "ordinal": 4, + "name": "parent_dev_workspace_label", + "type_info": "Text" + }, + { + "ordinal": 5, "name": "use_individual_branch!", "type_info": "Bool" }, { - "ordinal": 5, + "ordinal": 6, "name": "group_by_folder!", "type_info": "Bool" }, { - "ordinal": 6, + "ordinal": 7, "name": "item_path!", "type_info": "Text" }, { - "ordinal": 7, + "ordinal": 8, "name": "item_parent_path!", "type_info": "Text" }, { - "ordinal": 8, + "ordinal": 9, "name": "path_type!", "type_info": "Text" }, { - "ordinal": 9, + "ordinal": 10, "name": "commit_msg!", "type_info": "Text" } @@ -69,8 +74,9 @@ null, null, null, + null, null ] }, - "hash": "382b8097772c03a24c26d8326bbb83cacbde10719920e4e26888780570d7dd39" + "hash": "f02977ee5df3d8f734fb32836583abaf747aef7078ae57b0373f06c70b6f2f9f" } diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index d9f1fbbf32..0e2e2cd591 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -0c316e0dbbe38179aea0027626abd09840543482 +a4f14db140b094e62514a6ed0d92f6f4daf2f8c6 diff --git a/backend/windmill-worker/src/result_processor.rs b/backend/windmill-worker/src/result_processor.rs index f4ecc0bb29..8559937e2b 100644 --- a/backend/windmill-worker/src/result_processor.rs +++ b/backend/windmill-worker/src/result_processor.rs @@ -947,6 +947,7 @@ async fn maybe_open_git_sync_deploy_pr(db: &DB, job_id: &uuid::Uuid, workspace_i args->>'repo_url_resource_path' as "repo_path", args->>'parent_workspace_id' as "parent_workspace_id", args->>'dev_workspace_label' as "dev_workspace_label", + args->>'parent_dev_workspace_label' as "parent_dev_workspace_label", COALESCE((args->'use_individual_branch')::bool, false) as "use_individual_branch!", COALESCE((args->'group_by_folder')::bool, false) as "group_by_folder!", COALESCE(args->'items'->0->>'path', args->>'path', '') as "item_path!", @@ -1017,17 +1018,20 @@ async fn maybe_open_git_sync_deploy_pr(db: &DB, job_id: &uuid::Uuid, workspace_i return; } }; + // A fork of a dev workspace diverged from the dev's label branch, so its PR + // merges back there; everything else targets the tracked branch. + let pr_base = row.parent_dev_workspace_label.as_deref().unwrap_or(&base); if let Err(e) = windmill_common::git_sync_ee::ensure_pull_request( db, workspace_id, &repo_url, &head, - &base, + pr_base, &row.commit_msg, ) .await { - tracing::warn!("git sync PR: failed to open PR {head} -> {base} for {repo_path}: {e:#}"); + tracing::warn!("git sync PR: failed to open PR {head} -> {pr_base} for {repo_path}: {e:#}"); } } @@ -1804,7 +1808,10 @@ mod git_sync_pr_tests { fn fork_branch_wins_and_strips_the_id_prefix() { // Generated fork id: branch suffix drops the wm-fork- prefix. assert_eq!( - git_sync_deploy_pr_head_branch("wm-fork-abc", Some("prod"), None, + git_sync_deploy_pr_head_branch( + "wm-fork-abc", + Some("prod"), + None, "main", false, false, @@ -1816,7 +1823,10 @@ mod git_sync_pr_tests { ); // Dev workspace (prefix-less id, detected via parent): verbatim suffix. assert_eq!( - git_sync_deploy_pr_head_branch("staging", Some("prod"), None, + git_sync_deploy_pr_head_branch( + "staging", + Some("prod"), + None, "main", false, false, @@ -1828,7 +1838,10 @@ mod git_sync_pr_tests { ); // Orphaned fork (parent deleted): the id prefix still identifies it. assert_eq!( - git_sync_deploy_pr_head_branch("wm-fork-abc", None, None, + git_sync_deploy_pr_head_branch( + "wm-fork-abc", + None, + None, "main", true, false, @@ -1844,7 +1857,10 @@ mod git_sync_pr_tests { fn promotion_branch_matches_the_hub_script_formula() { // Per-item form: wm_deploy/// __>. assert_eq!( - git_sync_deploy_pr_head_branch("dev", None, None, + git_sync_deploy_pr_head_branch( + "dev", + None, + None, "main", true, false, @@ -1856,7 +1872,10 @@ mod git_sync_pr_tests { ); // Grouped-by-folder form: first two path segments joined by __. assert_eq!( - git_sync_deploy_pr_head_branch("dev", None, None, + git_sync_deploy_pr_head_branch( + "dev", + None, + None, "main", true, true, @@ -1868,7 +1887,10 @@ mod git_sync_pr_tests { ); // Renamed object: falls back to the parent path. assert_eq!( - git_sync_deploy_pr_head_branch("dev", None, None, + git_sync_deploy_pr_head_branch( + "dev", + None, + None, "main", true, false, @@ -1884,7 +1906,8 @@ mod git_sync_pr_tests { fn no_branch_when_deploy_stays_on_base() { // Workspace-wide mode commits straight to the tracked branch. assert_eq!( - git_sync_deploy_pr_head_branch("dev", None, None, "main", false, false, "f/x/y", "", "script" + git_sync_deploy_pr_head_branch( + "dev", None, None, "main", false, false, "f/x/y", "", "script" ), None ); @@ -1899,12 +1922,32 @@ mod git_sync_pr_tests { fn dev_workspace_label_branch_wins() { // Dev workspaces deploy to their environment-label branch verbatim. assert_eq!( - git_sync_deploy_pr_head_branch("staging-ws", Some("prod"), Some("staging"), "main", false, false, "", "", ""), + git_sync_deploy_pr_head_branch( + "staging-ws", + Some("prod"), + Some("staging"), + "main", + false, + false, + "", + "", + "" + ), Some("staging".to_string()) ); // Label present even on a wm-fork-prefixed id: label still wins. assert_eq!( - git_sync_deploy_pr_head_branch("wm-fork-x", Some("prod"), Some("dev"), "main", false, false, "", "", ""), + git_sync_deploy_pr_head_branch( + "wm-fork-x", + Some("prod"), + Some("dev"), + "main", + false, + false, + "", + "", + "" + ), Some("dev".to_string()) ); } diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index e8d5a85434..5b8be9b311 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -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 ", "Environment label of a dev workspace (dev/staging); its deploys go to that branch", ) + .option( + "--parent-dev-workspace-label ", + "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 ", diff --git a/docs/git-sync-pull-design.md b/docs/git-sync-pull-design.md index 19daf7b379..1c326dcbb4 100644 --- a/docs/git-sync-pull-design.md +++ b/docs/git-sync-pull-design.md @@ -509,14 +509,19 @@ label branch equals the checked-out tracked branch (which would otherwise commit fork content straight to it). `reconcile_fork_branch_pull` (windmill-git-sync EE) is the shared routing core: -resolve the branch to a **live child of this parent** (`parent_workspace_id` -match + `NOT deleted`, so a crafted branch name can't route a pull into an -unrelated workspace) — the `wm-fork//` form via -`parse_fork_branch` (windmill-common), or an environment-label branch via a -dev-workspace label lookup — then load the fork's own repo entry and run the +resolve the branch to a **live descendant of this workspace** (recursive +`parent_workspace_id` walk + `NOT deleted`, so a crafted branch name can't +route a pull into an unrelated workspace) — the `wm-fork//` form +via `parse_fork_branch` (windmill-common), or an environment-label branch via +a dev-workspace label lookup — then load the fork's own repo entry and run the shared `reconcile_and_enqueue_pull` with the fork's per-ref dedup state and a `clone_ref` override so the pull job clones the fork branch instead of the -resource's tracked branch. +resource's tracked branch. Descendants (not just direct children) because +**forks of a dev workspace** also sync through the root's webhook/poller — +only the root can hold auto-pull config. A fork-of-dev roots its `wm-fork/**` +branch on the dev's label branch and its PR merges back into it (the backend +passes `parent_dev_workspace_label` with the deploy; `fork_open_prs` is +resolved at the root ancestor). State lives with the fork: its repo entry carries a server-written status-only `auto_pull` blob (`last_synced_sha` keyed by the fork branch, `last_pull_status`;