mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 16:02:36 +00:00
* 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>
615 lines
23 KiB
TypeScript
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 };
|
|
}
|