Files
windmill/cli/test/git_unit.test.ts
hugocasa 2ce21c9ef8 feat(git-sync): enable per-item promotion mode on dev workspaces (#10205)
* feat(git-sync): enable per-item promotion mode on dev workspaces

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style: keep unrelated git-sync Alert copy at its original wrapping

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(git-sync): fall back to parent_path on empty deploy path + bump ee ref

computeGitSyncDeployBranch used ?? so a backend-serialized empty path (rename out of the repo filter) skipped the deploy branch and could commit to the tracked base; use || to fall back to parent_path like the backend. Bumps ee-repo-ref for the single-object promotion_open_prs fix (windmill-ee-private#679).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(git-sync): route dev-promotion non-branchable objects off the tracked base

user/group objects (and any unresolvable ref) returned null in promotion mode, so a dev-workspace deploy pushed them straight to the parent's tracked branch. Fall back to the dev's env-label branch instead; the backend opens no PR for them (isolated, not promoted).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(git-sync): dev-workspace promotion via a toggle on the inherited repo

A dev workspace reuses the single repo it inherited from prod: a 'Promote to prod via Git' toggle flips it between sync mode (deploys to the dev branch) and promotion mode (per-item wm_deploy/** PRs to prod), with a per-item/per-folder sub-toggle. Removes the redundant separate-promotion-repo setup for dev workspaces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(git-sync): dev-promotion regression test + widen git_sync_e2e path filter

Adds a CLI integration case covering dev-workspace promotion (script -> wm_deploy branch; user/group -> env-label branch, main never touched). Widens the git-sync-test.yml relevance filter to the deploy-branch derivation, git-sync guard, and CLI git-deploy files so the e2e suite runs on PRs like this one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(git-sync): gate dev promotion toggle on EE, fix card mode + workflow path filters

Codex review: (1) show the dev promotion toggle only under an active EE license and revert the optimistic save if the backend rejects it; (2) derive the dev card's display mode from use_individual_branch so promotion copy shows in promotion mode; (3) mirror the new relevance paths into the workflow's top-level push/pull_request filters so it actually triggers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(git-sync): only use the single-card dev promotion UX when the dev has one repo

Codex review: an attached dev workspace keeps its own repositories rather than inheriting prod's. Gating the single-card + toggle + hidden-secondaries UX on repositories.length <= 1 makes a multi-repo attached dev fall back to the normal layout, so no active repo is hidden and an unrelated repo isn't presented as prod's promotion target.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(git-sync): runtime EE-plan gate for promotion mode, consistent with auto-pull/PR

Codex review: promotion mode only had the CE compile rejection, while auto-pull and PR creation runtime-gate on the active plan (check_git_sync_ee_license). Add check_promotion_license and call it from both edit_git_sync_config and edit_git_sync_repository, plus the matching CE rejection on edit_git_sync_config so the two endpoints are symmetric. Promotion is now gated like every other git-sync EE setting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(git-sync): dev promotion must reuse the parent workspace's repository

Codex review: repository count doesn't prove a dev inherited prod's repo — an attached dev keeps its own. check_dev_promotion_targets_parent_repo resolves the promotion repo's URL and rejects enabling promotion unless it matches one the parent (prod) tracks, so branches/PRs can't target an unrelated repository. Called from both git-sync edit endpoints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(git-sync): dev promotion save-time check uses shared parent-repo matcher (url+branch)

Delegates to windmill_common::git_sync_ee::dev_promotion_target_matches_parent so the settings gate and the deploy-time safety net share one url+branch identity check. Bumps ee-repo-ref for the EE deploy-time enforcement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: bump ee-repo-ref for private resolve_repo_url_and_branch

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: bump ee-repo-ref for promotion-target matcher authz doc

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(git-sync): bump hub scripts to gitsync-cli versions, fix promotion tooltips

Point LATEST_GIT_SYNC_SCRIPT_PATH (28790 -> 28796) and
GIT_SYNC_PULL_SCRIPT_PATH / gitInitRepo (28789 -> 28795) at the hub
versions pinning windmill-cli@1.763.1-gitsync.0, which carries the
dev-workspace promotion routing. Slugs unchanged, so the GitHub-App
token check and hub script cache are unaffected.

Tooltips: enabling promotion pushes a PR-ready wm_deploy/** branch;
Windmill only opens the pull request itself when automatic pull
requests are enabled. Reword both toggles to stop promising a PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(git-sync): dev promotion mirrors to the env-label branch, PR toggles exclusive by branch type

Bump ee-repo-ref for the dispatcher changes: a promotion dev's deploys
now also push to its env-label branch (one extra mirror job per batch,
users/groups mirror-only), and `fork_open_prs` no longer applies to a
dev in promotion mode where `promotion_open_prs` governs.

Frontend: the fork-PR toggle tooltip states its actual coverage
(wm-fork/** and the dev branch of a dev workspace) and that a promotion
dev's own pull request toggle takes over for wm_deploy/** branches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(git-sync): reject dev promotion on pre-28796 pinned sync scripts

An older pinned sync script bundles a CLI that force-disables per-item
branches on every fork, so enabling promotion on a dev workspace with
such a pin would silently keep deploying to the env-label branch. Both
git-sync edit endpoints now reject the combination with an actionable
error; the EE dispatchers (via ee-repo-ref bump) demote inherited
configs to promotion-off semantics so markers, branch keys and the
mirror match the branch the CLI actually pushes. Roots and auto-managed
repositories are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(git-sync): serialize dev promotion toggle saves

The promotion and per-folder toggles persist immediately via whole-repo
saves; leaving them interactive while one is pending lets rapid flips
race, and the earlier save (enabling runs extra backend checks) can
commit last, silently reversing the state the UI shows. Both toggles now
disable while a save is in flight.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(git-sync): lock auto-PR toggle during promotion save, rename-out branch routing

Frontend: the automatic-PR toggle is revealed by the promotion toggle's
in-flight save; an edit made mid-save was absorbed into the saved
baseline without reaching the backend. It now disables during that save.

EE (ee-repo-ref bump): dispatcher debounce/concurrency keys and PR
markers follow the CLI's parent_path fallback for rename-out items, so
their wm_deploy/** branches debounce per-branch and open their PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(git-sync): condense comments to durable constraints

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 8bf73f803158bcbf7b8d55a36f4a1ebfcc1bbcd9

This commit updates the EE repository reference after PR #679 was merged in windmill-ee-private.

Previous ee-repo-ref: c2cd718cb53d234f909f485bd7cd43ed9605ffd1

New ee-repo-ref: 8bf73f803158bcbf7b8d55a36f4a1ebfcc1bbcd9

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-07-21 17:07:52 +00:00

491 lines
18 KiB
TypeScript

/**
* 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("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/<clonedBranch>/<id> 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/<label>/<id>` and
// the poller's `wm-fork/<tracked>/*` enumeration would miss it.
test("throwaway fork of a dev workspace -> wm-fork/<tracked>/<id>, not the dev label", () => {
const branch = computeGitSyncDeployBranch({
workspaceId: "wm-fork-child",
parentWorkspaceId: "prodstaging", // the dev workspace (prefix-less id)
devWorkspaceLabel: undefined, // child is not a dev workspace
clonedBranchName: "main",
groupByFolder: false,
useIndividualBranch: false,
items: [{ path_type: "script", path: "f/foo/bar" }],
});
expect(branch).toBe("wm-fork/main/child");
expect(branch).not.toBe("wm-fork/dev/child");
});
});
describe("forkBranchName", () => {
test("maps wm-fork-<id> to wm-fork/<branch>/<id>", () => {
expect(forkBranchName("wm-fork-abc", "main")).toBe("wm-fork/main/abc");
});
test("dev workspace label wins: the branch is the label verbatim", () => {
expect(forkBranchName("staging-ws", "main", "staging")).toBe("staging");
expect(forkBranchName("wm-fork-abc", "main", "dev")).toBe("dev");
});
test("prefix-less id without a label falls back to the wm-fork form", () => {
expect(forkBranchName("staging-ws", "main")).toBe("wm-fork/main/staging-ws");
});
});
describe("composeGitSyncCommitHeader", () => {
test("single type", () => {
expect(
composeGitSyncCommitHeader([{ path_type: "script", path: "a" }])
).toBe("[WM]: Deployed 1 script");
});
test("exactly 3 types: pluralises, orders by count, joins last with 'and'", () => {
expect(
composeGitSyncCommitHeader([
{ path_type: "script", path: "a" },
{ path_type: "script", path: "b" },
{ path_type: "flow", path: "c" },
{ path_type: "app", path: "d" },
])
).toBe("[WM]: Deployed 2 scripts, 1 flow, and 1 app");
});
test("4+ types: overflow collapses into 'other objects'", () => {
expect(
composeGitSyncCommitHeader([
{ path_type: "script", path: "a" },
{ path_type: "script", path: "b" },
{ path_type: "flow", path: "c" },
{ path_type: "app", path: "d" },
{ path_type: "resource", path: "e" },
{ path_type: "variable", path: "f" },
])
).toBe("[WM]: Deployed 2 scripts, 1 flow, 1 app and 2 other objects");
});
});
// =============================================================================
// gitSyncIncludePattern / deriveGitSyncDeployIncludes — replaces the hub
// script's regexFromPath + wmill_sync_pull include-derivation.
// =============================================================================
describe("gitSyncIncludePattern", () => {
test("script includes __mod/ folder for module scripts", () => {
expect(gitSyncIncludePattern("script", "f/foo/bar")).toBe(
"f/foo/bar.*,f/foo/bar__mod/**"
);
});
test("flow/app expand to dotted + __ folder patterns", () => {
expect(gitSyncIncludePattern("flow", "f/x")).toBe("f/x.flow/*,f/x__flow/*");
expect(gitSyncIncludePattern("app", "f/x")).toBe("f/x.app/*,f/x__app/*");
expect(gitSyncIncludePattern("raw_app", "f/x")).toBe(
"f/x.raw_app/**,f/x__raw_app/**"
);
});
test("folder and triggers", () => {
expect(gitSyncIncludePattern("folder", "f/x")).toBe("f/x/folder.meta.*");
expect(gitSyncIncludePattern("httptrigger", "f/t")).toBe(
"f/t.http_trigger.*"
);
expect(gitSyncIncludePattern("gcptrigger", "f/t")).toBe(
"f/t.gcp_trigger.*"
);
expect(gitSyncIncludePattern("amqptrigger", "f/t")).toBe(
"f/t.amqp_trigger.*"
);
});
});
describe("deriveGitSyncDeployIncludes", () => {
test("splits multi-pattern includes (flow) and includes parent_path", () => {
const r = deriveGitSyncDeployIncludes(
[{ path_type: "flow", path: "f/a", parent_path: "f/b" }],
false
);
expect(r.extraIncludes).toEqual([
"f/a.flow/*",
"f/a__flow/*",
"f/b.flow/*",
"f/b__flow/*",
]);
});
test("workspace-wide mode force-includes deployed default-excluded kinds", () => {
const r = deriveGitSyncDeployIncludes(
[
{ path_type: "schedule", path: "f/s" },
{ path_type: "group", path: "g/g" },
{ path_type: "httptrigger", path: "f/t" },
{ path_type: "settings", path: "" },
{ path_type: "key", path: "" },
{ path_type: "user", path: "u/u" },
],
false
);
// Full-mirror repo: a deployed object of a default-excluded kind must be
// re-included even if wmill.yaml would skip it, so the flag is forced on.
expect(r.forcedIncludes).toEqual({
includeSchedules: true,
includeGroups: true,
includeTriggers: true,
includeSettings: true,
includeKey: true,
includeUsers: true,
});
});
test("workspace-wide mode only forces the kinds actually deployed", () => {
const r = deriveGitSyncDeployIncludes(
[{ path_type: "script", path: "f/s" }],
false
);
// Scripts are included by default — nothing to force.
expect(r.forcedIncludes).toEqual({});
});
test("individual-branch (promotion) mode forces NOTHING — defers to wmill.yaml", () => {
// Regression: these flags used to be force-disabled (set to false) in
// promotion mode, which CLOBBERED the promotion target's effective
// wmill.yaml config (an explicit false wins in pull's Object.assign merge).
// The server then stripped the object from the tarball, the pull wrote
// nothing, and `git add '<path>**'` failed with "pathspec did not match
// any files". Forcing nothing leaves the keys absent so the target's
// effective filters govern; extraIncludes still scopes the pull to the
// changed object.
const r = deriveGitSyncDeployIncludes(
[
{ path_type: "schedule", path: "f/s" },
{ path_type: "kafkatrigger", path: "f/t" },
],
true
);
expect(r.forcedIncludes).toEqual({});
expect(r.extraIncludes).toContain("f/s.schedule.*");
expect(r.extraIncludes).toContain("f/t.kafka_trigger.*");
});
test("regression: http_trigger promotion deploy does not clobber the target's includeTriggers", () => {
// Brad's scenario: an HTTP trigger is deployed and the promotion repo uses
// individual branches. path_type is "httptrigger" (the no-underscore value
// the backend puts on item.path_type — see git_sync_ee.rs
// insert_path_type_and_return_message). includeTriggers must NOT be forced
// false here, so the target's effective includeTriggers (true in Brad's
// config) is honored and the trigger file is pulled and committed.
const r = deriveGitSyncDeployIncludes(
[{ path_type: "httptrigger", path: "f/platform/on_call_chat_http_route" }],
true
);
expect(r.forcedIncludes.includeTriggers).toBeUndefined();
expect(r.extraIncludes).toContain(
"f/platform/on_call_chat_http_route.http_trigger.*"
);
});
});
// =============================================================================
// isForkWorkspace + gitSyncCommitMessage — 1:1 fidelity with the hub script.
// =============================================================================
describe("isForkWorkspace", () => {
test("true only for the wm-fork- workspace-id prefix", () => {
expect(isForkWorkspace("wm-fork-abc")).toBe(true);
expect(isForkWorkspace("prod")).toBe(false);
// "wm-fork" without the trailing dash is the BRANCH prefix, not a ws id
expect(isForkWorkspace("wm-fork")).toBe(false);
});
test("a parent workspace id marks prefix-less ids (dev workspaces) as forks", () => {
expect(isForkWorkspace("mydev", "prod")).toBe(true);
expect(isForkWorkspace("mydev", undefined)).toBe(false);
expect(isForkWorkspace("wm-fork-abc", undefined)).toBe(true);
});
});
describe("gitSyncCommitMessage (mirrors hub git_push quirks)", () => {
test("single item: uses its commit_msg verbatim, empty description", () => {
expect(
gitSyncCommitMessage([{ path_type: "script", path: "a", commit_msg: "msg" }])
).toEqual({ header: "msg", description: "" });
});
test("single item with NO commit_msg -> 'no commit msg' (hub fallback)", () => {
expect(
gitSyncCommitMessage([{ path_type: "script", path: "a" }])
).toEqual({ header: "no commit msg", description: "" });
});
test("multi item: composed header + newline-joined msgs, undefined -> ''", () => {
// hub pushes commit_msg once per item UNCONDITIONALLY, so the second
// (missing) entry joins as an empty string -> "first\n".
expect(
gitSyncCommitMessage([
{ path_type: "script", path: "a", commit_msg: "first" },
{ path_type: "flow", path: "b" },
])
).toEqual({ header: "[WM]: Deployed 1 script, 1 flow", description: "first\n" });
});
});