Files
windmill/cli/src/utils/git.ts
T
Ruben Fiszelandwindmill-internal-app[bot] bfc3f5242a feat: sync data table migrations to git, gated by a new object type (#10436)
* fix: auto-sync data table migrations to the linked git repo

* fix: deploy data table migrations when a data table is renamed or deleted

* fix: hold new data table names to the git-sync-safe charset

* fix: reject leading-dot data table names and warn on unsyncable legacy names

* chore: update ee-repo-ref to 15c9eef2a4f867eb90d841aee1ce762f4b725589

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

Previous ee-repo-ref: e2fb073a3d0057683666424e463b2ce423664caa

New ee-repo-ref: 15c9eef2a4f867eb90d841aee1ce762f4b725589

Automated by sync-ee-ref workflow.

* feat: make data table migrations a git-sync object type with its own toggle

* fix: never let an untracked checkout delete data table migrations on push

* fix: confirm ambiguous data table migration deletions instead of dropping them

* fix: settle ambiguous migration deletions before the dry-run preview prints

* fix: restore the split shared-UI comment and count migration records in prompts

* chore: keep the deletion-safety doc block attached to its function

* fix: trust git history, not the working tree, for migration deletions

* fix: scope migration history to HEAD, detect shallow clones and subdir roots

* chore: give the unattested-history case a remedy that applies to it

* chore: pair each unattested-history cause with its own remedy

* fix: treat a sparse checkout as unattested history for migration deletions

* fix: normalize the sparse-checkout boolean and give it a remedy that works

* chore: describe both shapes of unattested migration history

* chore: update ee-repo-ref to a786cd42b5aaf0aa6789fbb723d956560f93b1b3

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

Previous ee-repo-ref: 4f312642b5d8fd37ab5e20473a011d6f1d299cf6

New ee-repo-ref: a786cd42b5aaf0aa6789fbb723d956560f93b1b3

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-07-31 23:33:47 +02:00

615 lines
23 KiB
TypeScript

import * as log from "../core/log.ts";
import { execSync, spawnSync } from "node:child_process";
import { WM_FORK_PREFIX } from "../core/constants.ts";
// Fork *workspace id* prefix ("wm-fork-"). WM_FORK_PREFIX is the *branch*
// prefix ("wm-fork") used inside the wm-fork/<branch>/<id> branch name.
const FORK_WORKSPACE_PREFIX = `${WM_FORK_PREFIX}-`;
export function getCurrentGitBranch(): string | null {
try {
const result = execSync("git rev-parse --abbrev-ref HEAD", {
encoding: "utf8",
stdio: "pipe"
});
const branch = result.trim();
return branch || null;
} catch (error) {
log.debug(`Failed to get Git branch: ${error}`);
return null;
}
}
/** Whether a local branch with this exact name exists. */
export function gitBranchExists(branchName: string): boolean {
const r = spawnSync(
"git",
["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`],
{ stdio: "pipe" },
);
return r.status === 0;
}
/**
* Rename the currently checked-out branch (`git branch -m <newName>`). Used by
* `wmill workspace fork --from-branch` to turn an existing working branch into
* the `wm-fork/<base>/<id>` fork branch in place, preserving its commits.
*/
export function renameCurrentGitBranch(newName: string): void {
const r = spawnSync("git", ["branch", "-m", newName], {
encoding: "utf8",
stdio: "pipe",
});
if ((r.status ?? 1) !== 0) {
throw new Error(
`git branch -m ${newName} failed (exit ${r.status}): ${r.stderr ?? ""}`,
);
}
}
export function getOriginalBranchForWorkspaceForks(branchName: string | null): string | null {
if (!branchName || !branchName.startsWith(WM_FORK_PREFIX)) {
return null
}
const start = branchName.indexOf("/") + 1;
const end = branchName.lastIndexOf("/");
if (start < 0 || end < 0 || end - start <= 0) {
return null
}
return branchName.slice(start, end)
}
export function getWorkspaceIdForWorkspaceForkFromBranchName(branchName: string): string | null {
if (!branchName.startsWith(WM_FORK_PREFIX)) {
return null
}
const start = branchName.lastIndexOf("/") + 1;
if (start < 0) {
return null
}
return `${WM_FORK_PREFIX}-${branchName.slice(start)}`
}
/**
* Whether this checkout can vouch for what it once held under `migrations/datatable/**`.
*
* `known` lists every such path recorded on the current branch (paths stay listed after
* the commit that removed them, so a real deletion is still recognisable). `unknown`
* means a file's absence from the working tree proves nothing, either because the
* history can't be read — no repository, a shallow clone's truncated history, an
* unresolvable root, a failing git — or because the working tree deliberately doesn't
* mirror it, as in a sparse checkout. Both shapes carry the same obligation on the
* caller: trust nothing, rather than read absence as evidence.
*/
export type RecordedMigrationPaths =
| { kind: "known"; paths: Set<string> }
| { kind: "unknown"; reason: string; remedy: string };
/**
* Resolve [`RecordedMigrationPaths`] for the checkout at the current directory.
*
* This is the durable answer to "did this checkout ever track that migration?", which
* the working tree cannot give: an absent `migrations/datatable/` is equally a clone
* that never pulled migrations and one where the last was deleted, and creating a
* migration locally (`wmill datatable migrate new`) makes the directory appear without
* anything having been tracked.
*
* Scoped to `HEAD`, not `--all`: a migration that only ever existed on some other
* branch is not evidence that *this* branch ever tracked it, and using it as such would
* authorize deleting it. Output paths are repo-root-relative, so the `--show-prefix` of
* the working directory is stripped to match the cwd-relative paths a sync diff uses.
*/
export function gitRecordedDatatableMigrationPaths(): RecordedMigrationPaths {
if (!isGitRepository()) {
return {
kind: "unknown",
reason: "this directory is not a git repository",
remedy: "Run the push from a git checkout of the synced repository",
};
}
const shallow = spawnSync("git", ["rev-parse", "--is-shallow-repository"], {
encoding: "utf8",
stdio: "pipe",
});
if ((shallow.stdout ?? "").trim() === "true") {
return {
kind: "unknown",
reason: "this is a shallow clone, so its history is truncated",
remedy:
"Fetch the full history (for actions/checkout, fetch-depth: 0)",
};
}
// A sparse checkout can record migrations in history while never materialising
// them in the working tree, so their absence there says nothing about whether the
// user deleted them. Over-protective for a sparse cone that does include
// migrations/, which the interactive prompt can still override.
// `--type=bool` normalises git's booleans (1, yes, on, …) to true/false; a raw
// `--get` would let `core.sparseCheckout = 1` walk straight past this.
const sparse = spawnSync(
"git",
["config", "--type=bool", "--get", "core.sparseCheckout"],
{ encoding: "utf8", stdio: "pipe" },
);
if ((sparse.stdout ?? "").trim() === "true") {
return {
kind: "unknown",
reason:
"this is a sparse checkout, so its working tree may not hold every tracked file",
remedy: "Run the push from a full (non-sparse) checkout",
};
}
const prefixOut = spawnSync("git", ["rev-parse", "--show-prefix"], {
encoding: "utf8",
stdio: "pipe",
});
if ((prefixOut.status ?? 1) !== 0) {
return {
kind: "unknown",
reason: "the repository root could not be resolved",
remedy: "Check that git runs correctly in this directory",
};
}
const prefix = (prefixOut.stdout ?? "").trim();
const r = spawnSync(
"git",
["log", "HEAD", "--format=", "--name-only", "--", "migrations/datatable"],
{ encoding: "utf8", stdio: "pipe", maxBuffer: 64 * 1024 * 1024 },
);
if ((r.status ?? 1) !== 0) {
log.debug(`Could not read git history for migrations: ${r.stderr ?? ""}`);
return {
kind: "unknown",
reason: "its history could not be read",
remedy: "Check that git runs correctly in this directory",
};
}
const paths = new Set<string>();
for (const line of (r.stdout ?? "").split("\n")) {
const p = line.trim();
if (p.length === 0) continue;
if (prefix.length > 0) {
if (!p.startsWith(prefix)) continue;
paths.add(p.slice(prefix.length));
} else {
paths.add(p);
}
}
return { kind: "known", paths };
}
export function isGitRepository(): boolean {
try {
execSync("git rev-parse --git-dir", {
encoding: "utf8",
stdio: "pipe"
});
return true;
} catch (error) {
log.debug(`Failed to check Git repository: ${error}`);
return false;
}
}
// ===========================================================================
// Git-sync deployment-callback branch/commit/push.
//
// The git-sync hub script ("sync-script-to-git-repo-windmill") historically
// owned this logic. When `use_individual_branch` is set, a deploy must land on
// a dedicated `wm_deploy/<workspace>/<...>` branch (or a `wm-fork/<branch>/<id>`
// branch for forks) instead of the repo's protected base branch — otherwise
// the push to a protected `main` is rejected (GH006). A hub-script rewrite once
// dropped this and every deploy pushed straight to `main`; keeping the logic
// here (in-repo, behind `wmill sync pull`) makes it deterministically testable.
// ===========================================================================
export interface GitSyncDeployItem {
path_type: string;
path?: string | null;
parent_path?: string | null;
commit_msg?: string;
}
// A throwaway fork syncs to its own wm-fork/<branch>/<id> branch. The hub script
// force-disables use_individual_branch / group_by_folder for these (that disabling
// also changes the include/promotion derivation — so callers must apply it BEFORE
// deriving includes, not just for branch naming). A dev workspace is the exception:
// with promotion on it keeps use_individual_branch / group_by_folder and gets
// per-item wm_deploy/** branches like a root workspace.
//
// Fork-ness is "has a parent workspace" OR the "wm-fork-" id prefix. Regular forks
// get an auto-generated `wm-fork-<slug>` id, but dev workspaces keep a custom id
// with no prefix — so the prefix alone misses them. Mirrors the backend's
// `parent.is_some() || starts_with(WM_FORK_PREFIX)` rule (the prefix also covers a
// fork whose parent was deleted, since parent_workspace_id is ON DELETE SET NULL).
export function isForkWorkspace(
workspaceId: string,
parentWorkspaceId?: string | null,
): boolean {
return !!parentWorkspaceId || workspaceId.startsWith(FORK_WORKSPACE_PREFIX);
}
// Mirrors the hub script's get_fork_branch_name. A dev workspace syncs with its
// environment-label branch verbatim ("dev"/"staging" — a first-class top-level
// branch; the backend passes the label with the deploy). A `wm-fork-<slug>`
// throwaway fork id becomes "wm-fork/<originalBranch>/<slug>"; a prefix-less id
// without a label falls back to "wm-fork/<originalBranch>/<id>".
export function forkBranchName(
workspaceId: string,
originalBranch: string,
devWorkspaceLabel?: string | null,
): string {
if (devWorkspaceLabel) {
return devWorkspaceLabel;
}
const branchPrefix = `${WM_FORK_PREFIX}/${originalBranch}/`;
return workspaceId.startsWith(FORK_WORKSPACE_PREFIX)
? workspaceId.replace(FORK_WORKSPACE_PREFIX, branchPrefix)
: `${branchPrefix}${workspaceId}`;
}
// Pure branch-name resolution mirroring the hub script's git_checkout_branch.
// Returns null when the deploy should stay on the cloned/base branch (i.e.
// workspace-wide mode, or user/group objects which never get their own branch).
export function computeGitSyncDeployBranch(params: {
workspaceId: string;
parentWorkspaceId?: string | null;
devWorkspaceLabel?: string | null;
items: GitSyncDeployItem[];
useIndividualBranch: boolean;
groupByFolder: boolean;
clonedBranchName: string;
}): string | null {
const {
workspaceId,
parentWorkspaceId,
devWorkspaceLabel,
items,
useIndividualBranch,
groupByFolder,
clonedBranchName,
} = params;
// A dev workspace in promotion mode falls through to the wm_deploy/** formula
// below (per-item/-folder PRs that promote into its parent). Throwaway forks,
// and dev workspaces with promotion off, sync to their own wm-fork/<branch>/<id>
// (or env-label) branch.
const isDevWorkspace = !!devWorkspaceLabel;
if (
isForkWorkspace(workspaceId, parentWorkspaceId) &&
!(isDevWorkspace && useIndividualBranch)
) {
return forkBranchName(workspaceId, clonedBranchName, devWorkspaceLabel);
}
// A dev workspace's deploys must never fall through to the base branch — that
// is its parent's tracked branch, so a null here would push dev content
// straight to prod. Anything without its own wm_deploy/** branch (user/group
// objects, an unresolvable ref) goes to the dev's env-label branch instead.
// A root workspace has no such isolation, so its fallback stays null (base).
const fallback = isDevWorkspace
? forkBranchName(workspaceId, clonedBranchName, devWorkspaceLabel)
: null;
if (items.length === 0) return fallback;
const first = items[0];
// `use_individual_branch` disables debouncing, so items is length 1 here.
if (
!useIndividualBranch ||
first.path_type === "user" ||
first.path_type === "group"
) {
return fallback;
}
// `||` not `??`: the backend serializes a path that no longer matches the repo
// filter (a rename out of the included set) as "" with the old path in
// parent_path. That "" must fall back to parent_path — mirroring the backend's
// `!item_path.is_empty()` derivation. `??` would keep "", return null, and skip
// the branch checkout, letting the removal land on the tracked base branch.
const ref = first.path || first.parent_path;
if (!ref) return fallback;
return groupByFolder
? `wm_deploy/${workspaceId}/${ref.split("/").slice(0, 2).join("__")}`
: `wm_deploy/${workspaceId}/${first.path_type}/${ref.replaceAll("/", "__")}`;
}
// Mirrors the hub script's composeCommitHeader: summarise the deployed object
// types, e.g. "[WM]: Deployed 2 scripts, 1 flow and 3 other objects".
export function composeGitSyncCommitHeader(
items: GitSyncDeployItem[],
): string {
const typeCounts = new Map<string, number>();
for (const item of items) {
typeCounts.set(item.path_type, (typeCounts.get(item.path_type) ?? 0) + 1);
}
const sorted = Array.from(typeCounts.entries()).sort((a, b) => b[1] - a[1]);
const parts: string[] = [];
let othersCount = 0;
for (let i = 0; i < sorted.length; i++) {
const [pathType, count] = sorted[i];
if (i < 3) {
const label = count > 1 ? `${pathType}s` : pathType;
if (i === 2 && sorted.length === 3) {
parts.push(`and ${count} ${label}`);
} else {
parts.push(`${count} ${label}`);
}
} else {
othersCount += count;
}
}
let header = `[WM]: Deployed ${parts.join(", ")}`;
if (othersCount > 0) {
header += ` and ${othersCount} other object${othersCount > 1 ? "s" : ""}`;
}
return header;
}
// Mirrors the hub script's git_push commit-message construction EXACTLY,
// including its quirks: it pushes `commit_msg` once per item unconditionally
// (so the array length equals items.length even when some msgs are empty),
// uses the single message verbatim when there is exactly one item, otherwise
// the composed header + newline-joined descriptions, and falls back to
// "no commit msg" when the chosen header is empty/undefined. join() coerces
// undefined entries to "" just like the original.
export function gitSyncCommitMessage(items: GitSyncDeployItem[]): {
header: string;
description: string;
} {
const descs = items.map((i) => i.commit_msg);
const [h, d] =
descs.length === 1
? [descs[0], ""]
: [composeGitSyncCommitHeader(items), descs.join("\n")];
return {
header: h === undefined || h === "" ? "no commit msg" : h,
description: d ?? "",
};
}
// Mirrors the hub script's regexFromPath: the include glob(s) that select an
// object's files for `wmill sync pull --extra-includes`. Some types expand to
// two comma-separated patterns (dotted + double-underscore folder layouts).
export function gitSyncIncludePattern(
pathType: string,
path: string,
): string {
switch (pathType) {
case "flow":
return `${path}.flow/*,${path}__flow/*`;
case "app":
return `${path}.app/*,${path}__app/*`;
case "raw_app":
return `${path}.raw_app/**,${path}__raw_app/**`;
case "folder":
return `${path}/folder.meta.*`;
case "resourcetype":
return `${path}.resource-type.*`;
case "resource":
return `${path}.resource.*`;
case "variable":
return `${path}.variable.*`;
case "schedule":
return `${path}.schedule.*`;
case "user":
return `${path}.user.*`;
case "group":
return `${path}.group.*`;
case "httptrigger":
return `${path}.http_trigger.*`;
case "websockettrigger":
return `${path}.websocket_trigger.*`;
case "kafkatrigger":
return `${path}.kafka_trigger.*`;
case "natstrigger":
return `${path}.nats_trigger.*`;
case "postgrestrigger":
return `${path}.postgres_trigger.*`;
case "mqtttrigger":
return `${path}.mqtt_trigger.*`;
case "amqptrigger":
return `${path}.amqp_trigger.*`;
case "sqstrigger":
return `${path}.sqs_trigger.*`;
case "gcptrigger":
return `${path}.gcp_trigger.*`;
case "azuretrigger":
return `${path}.azure_trigger.*`;
case "emailtrigger":
return `${path}.email_trigger.*`;
case "datatable_migration":
// One migration is two files under `migrations/datatable/<dt>/`; the
// backend already sends the repo-relative base path.
return `${path}.up.sql,${path}.down.sql`;
default:
// Scripts: `${path}.*` matches the dotted layout
// (`${path}.script.yaml` etc.), `${path}__mod/**` matches the folder
// layout used by scripts with companion modules
// (`${path}__mod/script.ts`, `${path}__mod/helper.ts`, ...). Without the
// second pattern the module files are filtered out of the pull and the
// subsequent `git add '${path}**'` fails with "pathspec did not match".
return `${path}.*,${path}__mod/**`;
}
}
// `forcedIncludes` carries ONLY the include-* flags that must be force-set to
// true (overriding the repo's wmill.yaml). Kinds not present are intentionally
// omitted (never set to false) so the caller can spread this object and let
// the repo's effective config govern the rest — see deriveGitSyncDeployIncludes.
export type GitSyncForcedIncludes = Partial<{
includeSchedules: boolean;
includeGroups: boolean;
includeUsers: boolean;
includeTriggers: boolean;
includeSettings: boolean;
includeKey: boolean;
}>;
export interface GitSyncDeployIncludes {
extraIncludes: string[];
forcedIncludes: GitSyncForcedIncludes;
}
// Mirrors the hub script's wmill_sync_pull include-derivation: build the
// --extra-includes set from the deployed items, and decide which default-
// excluded object kinds (triggers, schedules, groups, users, settings, key)
// must be force-included in the pull. Replaces the script's regexFromPath +
// per-kind --include-* construction so the hub script can drop both.
//
// Branch-mode distinction (this is load-bearing — see the trigger-promotion
// bug it fixes):
// - Workspace-wide mode: the repo is a full mirror of the workspace, so a
// deployed object of a default-excluded kind MUST be re-included, even if
// wmill.yaml would otherwise skip it. We force the flag on.
// - Individual-branch (promotion) mode: the repo is a filtered prod surface
// whose own wmill.yaml filters decide what gets promoted. We force NOTHING
// here and the keys stay absent, so the caller's pull resolves them from
// the target's effective config (a deployed trigger lands iff the target
// includes triggers). Forcing `false` (the original behavior) did NOT
// defer — it CLOBBERED the effective config via Object.assign in pull's
// option merge, silently dropping kinds the target actually wanted (e.g. a
// deployed trigger when the target has includeTriggers: true), and the
// server then omitted the object from the tarball entirely.
export function deriveGitSyncDeployIncludes(
items: GitSyncDeployItem[],
useIndividualBranch: boolean,
): GitSyncDeployIncludes {
const extraIncludes: string[] = [];
for (const { path_type, path, parent_path } of items) {
if (path) {
extraIncludes.push(...gitSyncIncludePattern(path_type, path).split(","));
}
if (parent_path) {
extraIncludes.push(
...gitSyncIncludePattern(path_type, parent_path).split(","),
);
}
}
const forcedIncludes: GitSyncForcedIncludes = {};
if (!useIndividualBranch) {
const has = (pred: (t: string) => boolean) =>
items.some((i) => pred(i.path_type));
if (has((t) => t === "schedule")) forcedIncludes.includeSchedules = true;
if (has((t) => t === "group")) forcedIncludes.includeGroups = true;
if (has((t) => t === "user")) forcedIncludes.includeUsers = true;
if (has((t) => t.includes("trigger"))) forcedIncludes.includeTriggers = true;
if (has((t) => t === "settings")) forcedIncludes.includeSettings = true;
if (has((t) => t === "key")) forcedIncludes.includeKey = true;
}
return { extraIncludes, forcedIncludes };
}
function git(
args: string[],
opts?: { allowFail?: boolean },
): { status: number; stdout: string; stderr: string } {
const r = spawnSync("git", args, { encoding: "utf8", stdio: "pipe" });
const status = r.status ?? 1;
if (r.error) {
if (opts?.allowFail) return { status, stdout: "", stderr: String(r.error) };
throw r.error;
}
if (status !== 0 && !opts?.allowFail) {
throw new Error(
`git ${args.join(" ")} failed (exit ${status}): ${r.stderr ?? ""}`,
);
}
return { status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
}
// Checkout (or create) the dedicated deploy branch, mirroring the hub script:
// try `git checkout <branch>`, on failure create it with -b and enable
// push.autoSetupRemote so the subsequent bare `git push` targets it.
export function checkoutGitSyncDeployBranch(branch: string): void {
const existing = git(["checkout", branch], { allowFail: true });
if (existing.status === 0) {
log.info(`Switched to existing branch ${branch}`);
return;
}
git(["checkout", "-b", branch]);
git(["config", "--add", "--bool", "push.autoSetupRemote", "true"]);
log.info(`Created and switched to branch ${branch}`);
}
// Stage, commit and push the deploy, mirroring the hub script's git_push:
// stage `wmill-lock.yaml` plus each item's path tree, no-op when nothing is
// staged, and push the *current* branch with a rebase-retry fallback.
export function gitSyncDeployPush(params: {
items: GitSyncDeployItem[];
authorName: string;
authorEmail: string;
// Committer identity (git config user.*). Defaults to the author identity,
// matching the hub script's non-GPG branch. For GPG-signed repos the hub
// sets the committer email to the GPG key's email — the caller passes that
// through here so the committed identity stays 1:1.
committerName?: string;
committerEmail?: string;
onlyCreateBranch?: boolean;
}): { pushed: boolean } {
const { items, authorName, authorEmail, onlyCreateBranch } = params;
const committerName = params.committerName ?? authorName;
const committerEmail = params.committerEmail ?? authorEmail;
git(["config", "user.email", committerEmail]);
git(["config", "user.name", committerName]);
// only_create_branch: publish the (possibly empty) branch ref and stop.
// Mirrors the hub script's early `git push --porcelain` (NOT swallowed —
// a failure here fails the job, exactly as in the hub).
if (onlyCreateBranch) {
git(["push", "--porcelain"]);
return { pushed: true };
}
// 1:1 with the hub script: `git add wmill-lock.yaml <path>**` as a single
// command per item, failure swallowed (its try/catch). `commit_msg` is
// collected once per item unconditionally — see gitSyncCommitMessage.
for (const { path, parent_path } of items) {
if (path) {
git(["add", "wmill-lock.yaml", `${path}**`], { allowFail: true });
}
if (parent_path) {
git(["add", "wmill-lock.yaml", `${parent_path}**`], { allowFail: true });
}
}
// `git diff --cached --quiet` exits 1 iff there is something staged.
const staged = git(["diff", "--cached", "--quiet"], { allowFail: true });
if (staged.status === 0) {
log.info("No changes detected, nothing to commit.");
return { pushed: false };
}
const { header, description } = gitSyncCommitMessage(items);
git([
"commit",
"--author",
`${authorName} <${authorEmail}>`,
"-m",
header,
"-m",
description,
]);
const push = git(["push", "--porcelain"], { allowFail: true });
if (push.status !== 0) {
log.info(`Push failed, rebasing and retrying: ${push.stderr}`);
git(["pull", "--rebase"]);
git(["push", "--porcelain"]);
}
return { pushed: true };
}