/** * Unit tests for git utility functions. * Tests pure functions only — no git subprocess calls. */ import { expect, test, describe } from "bun:test"; import { getOriginalBranchForWorkspaceForks, getWorkspaceIdForWorkspaceForkFromBranchName, computeGitSyncDeployBranch, composeGitSyncCommitHeader, forkBranchName, gitSyncIncludePattern, deriveGitSyncDeployIncludes, gitSyncCommitMessage, isForkWorkspace, } from "../src/utils/git.ts"; // ============================================================================= // getOriginalBranchForWorkspaceForks // ============================================================================= describe("getOriginalBranchForWorkspaceForks", () => { test("extracts original branch from valid fork branch name", () => { expect(getOriginalBranchForWorkspaceForks("wm-fork/main/my-workspace")).toBe("main"); }); test("extracts multi-segment original branch", () => { expect( getOriginalBranchForWorkspaceForks("wm-fork/feature/cool-thing/my-workspace") ).toBe("feature/cool-thing"); }); test("returns null for null input", () => { expect(getOriginalBranchForWorkspaceForks(null)).toBeNull(); }); test("returns null for empty string", () => { expect(getOriginalBranchForWorkspaceForks("")).toBeNull(); }); test("returns null for non-fork branch", () => { expect(getOriginalBranchForWorkspaceForks("main")).toBeNull(); expect(getOriginalBranchForWorkspaceForks("feature/my-feature")).toBeNull(); }); test("returns null for branch that starts with wm-fork but has no slashes after", () => { expect(getOriginalBranchForWorkspaceForks("wm-fork")).toBeNull(); }); test("returns null when branch segment between slashes is empty", () => { // "wm-fork//workspace" — start=8, end=8, end - start = 0 expect(getOriginalBranchForWorkspaceForks("wm-fork//workspace")).toBeNull(); }); }); // ============================================================================= // getWorkspaceIdForWorkspaceForkFromBranchName // ============================================================================= describe("getWorkspaceIdForWorkspaceForkFromBranchName", () => { test("extracts workspace id from valid fork branch name", () => { expect( getWorkspaceIdForWorkspaceForkFromBranchName("wm-fork/main/my-workspace") ).toBe("wm-fork-my-workspace"); }); test("returns null for non-fork branch", () => { expect(getWorkspaceIdForWorkspaceForkFromBranchName("main")).toBeNull(); expect( getWorkspaceIdForWorkspaceForkFromBranchName("feature/my-feature") ).toBeNull(); }); test("extracts workspace id with multi-segment original branch", () => { expect( getWorkspaceIdForWorkspaceForkFromBranchName("wm-fork/feature/cool/ws-id") ).toBe("wm-fork-ws-id"); }); }); // ============================================================================= // computeGitSyncDeployBranch — the regression guard. // // hub/28229 dropped this logic and pushed every deploy straight to the cloned // base branch (e.g. protected `main`), breaking promotion. These pin the // hub/28217 / hub/28230 branch-selection semantics. // ============================================================================= describe("computeGitSyncDeployBranch", () => { const base = { workspaceId: "prod", clonedBranchName: "main", groupByFolder: false, }; test("use_individual_branch=true -> dedicated wm_deploy branch (NOT main)", () => { const branch = computeGitSyncDeployBranch({ ...base, useIndividualBranch: true, items: [{ path_type: "script", path: "f/foo/bar" }], }); expect(branch).toBe("wm_deploy/prod/script/f__foo__bar"); expect(branch).not.toBe("main"); }); test("dev workspace (parent + label) deploys to the label branch", () => { expect( computeGitSyncDeployBranch({ ...base, workspaceId: "staging-ws", parentWorkspaceId: "prod", devWorkspaceLabel: "staging", useIndividualBranch: false, items: [{ path_type: "script", path: "f/foo/bar" }], }) ).toBe("staging"); }); test("dev workspace in promotion mode -> per-item wm_deploy branch, not the label branch", () => { expect( computeGitSyncDeployBranch({ ...base, workspaceId: "staging-ws", parentWorkspaceId: "prod", devWorkspaceLabel: "staging", useIndividualBranch: true, items: [{ path_type: "script", path: "f/foo/bar" }], }) ).toBe("wm_deploy/staging-ws/script/f__foo__bar"); }); test("dev promotion user/group objects go to the env-label branch, never the base", () => { // Non-branchable objects must not fall through to null (= the parent's // tracked branch) on a dev workspace — that would push dev content to prod. for (const path_type of ["user", "group"]) { expect( computeGitSyncDeployBranch({ ...base, workspaceId: "staging-ws", parentWorkspaceId: "prod", devWorkspaceLabel: "staging", useIndividualBranch: true, items: [{ path_type, path: "u/alice", parent_path: null }], }) ).toBe("staging"); } }); test("dev workspace in promotion mode honors group_by_folder", () => { expect( computeGitSyncDeployBranch({ ...base, workspaceId: "staging-ws", parentWorkspaceId: "prod", devWorkspaceLabel: "staging", useIndividualBranch: true, groupByFolder: true, items: [{ path_type: "script", path: "f/foo/bar" }], }) ).toBe("wm_deploy/staging-ws/f__foo"); }); test("use_individual_branch=false -> null (stay on base/main, workspace-wide mode)", () => { expect( computeGitSyncDeployBranch({ ...base, useIndividualBranch: false, items: [{ path_type: "script", path: "f/foo/bar" }], }) ).toBeNull(); }); test("group_by_folder=true -> per-folder wm_deploy branch (first 2 path segments)", () => { expect( computeGitSyncDeployBranch({ ...base, useIndividualBranch: true, groupByFolder: true, items: [{ path_type: "script", path: "f/team_a/deep/nested" }], }) ).toBe("wm_deploy/prod/f__team_a"); }); test("datatable migration branches off its repo-relative migrations/ path", () => { const items = [ { path_type: "datatable_migration", path: "migrations/datatable/mydb/20260101000000_add_users", }, ]; expect( computeGitSyncDeployBranch({ ...base, useIndividualBranch: true, items }) ).toBe( "wm_deploy/prod/datatable_migration/migrations__datatable__mydb__20260101000000_add_users" ); // group_by_folder collapses every data table's migrations onto one branch — // the backend's debounce key takes the same two segments. expect( computeGitSyncDeployBranch({ ...base, useIndividualBranch: true, groupByFolder: true, items, }) ).toBe("wm_deploy/prod/migrations__datatable"); }); test("falls back to parent_path when path is absent", () => { expect( computeGitSyncDeployBranch({ ...base, useIndividualBranch: true, items: [{ path_type: "flow", path: null, parent_path: "f/x/y" }], }) ).toBe("wm_deploy/prod/flow/f__x__y"); }); test("falls back to parent_path when the backend serializes path as \"\" (rename out of filter)", () => { // The backend emits "" (not null) for a path that no longer matches the repo // filter; it must still get its own branch, not fall through to the base. expect( computeGitSyncDeployBranch({ ...base, workspaceId: "staging-ws", parentWorkspaceId: "prod", devWorkspaceLabel: "staging", useIndividualBranch: true, items: [{ path_type: "resource", path: "", parent_path: "f/folder/old" }], }) ).toBe("wm_deploy/staging-ws/resource/f__folder__old"); }); test("user/group objects never get a dedicated branch", () => { expect( computeGitSyncDeployBranch({ ...base, useIndividualBranch: true, items: [{ path_type: "user", path: "u/alice" }], }) ).toBeNull(); expect( computeGitSyncDeployBranch({ ...base, useIndividualBranch: true, items: [{ path_type: "group", path: "g/admins" }], }) ).toBeNull(); }); test("empty items -> null", () => { expect( computeGitSyncDeployBranch({ ...base, useIndividualBranch: true, items: [], }) ).toBeNull(); }); test("fork workspace -> wm-fork// regardless of individual-branch", () => { expect( computeGitSyncDeployBranch({ workspaceId: "wm-fork-myfork", clonedBranchName: "main", groupByFolder: false, useIndividualBranch: false, items: [{ path_type: "script", path: "f/foo/bar" }], }) ).toBe("wm-fork/main/myfork"); }); test("prefix-less fork (parent set, no label) beats the wm_deploy derivation", () => { expect( computeGitSyncDeployBranch({ workspaceId: "mydev", parentWorkspaceId: "prod", clonedBranchName: "main", groupByFolder: false, useIndividualBranch: true, items: [{ path_type: "script", path: "f/foo/bar" }], }) ).toBe("wm-fork/main/mydev"); }); // A throwaway fork OF a dev workspace is named after the tracked (cloned) // branch, NOT the parent dev's label: the child is not itself a dev // workspace, so it carries no devWorkspaceLabel. The dev label reaches this // deploy only as the checkout base + PR target (handled in sync.ts), never as // the branch name — otherwise the branch would be `wm-fork/