Files
windmill/cli/src/core/context.ts
T
Ruben Fiszel dcee9fe7b1 feat: cli branch override (#7592)
* fix(cli): prevent duplicate 'Using non-dotted paths' log message

Add a flag to track whether the message has already been logged,
so it only prints once even if setNonDottedPaths is called multiple times.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(cli): add --branch option to sync pull/push commands

Add a --branch argument that allows overriding the current git branch
for sync operations. This enables:
- Using branch-specific settings even when not in a git repository
- Overriding the detected git branch when needed

The branch override is applied to:
- getEffectiveSettings() for branch-specific config overrides
- getSpecificItemsForCurrentBranch() for branch-specific items

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(cli): correct log message for workspace fork branches

Use rawGitBranch instead of currentBranch in the log message when
showing the origin of a workspace fork branch.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(cli): extend --branch support to specificItems functionality

Update getBranchSpecificPath and isCurrentBranchFile to accept optional
branchOverride parameter. This ensures that branch-specific file handling
(for variables, resources, triggers) works correctly with --branch flag.

Updated functions:
- getBranchSpecificPath(): now accepts branchOverride
- isCurrentBranchFile(): now accepts branchOverride
- elementsToMap(): now accepts branchOverride
- compareDynFSElement(): now accepts branchOverride
- prettyChanges(): now accepts branchOverride

All call sites updated to pass opts.branch through the call chain.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(cli): resolve TypeScript type errors

- Fix Timeout type in dev.ts using ReturnType<typeof setTimeout>
- Add proper type casts for unknown error types
- Cast FlowModule to any to resolve generated type mismatch
- Cast Uint8Array to BlobPart for Blob constructor compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(cli): add unit tests for branch detection and --branch override

- Add specific_items.test.ts with 35 tests covering:
  - toBranchSpecificPath and fromBranchSpecificPath conversions
  - isSpecificItem pattern matching
  - isBranchSpecificFile detection
  - Round-trip path conversions
  - branchOverride parameter functionality

- Add conf_branch_override.test.ts with 6 tests covering:
  - getEffectiveSettings with branchOverride parameter
  - Branch-specific overrides application
  - promotionOverrides precedence
  - Fallback to top-level settings

- Fix containerized_backend.ts to use dynamic paths instead of
  hardcoded user home directories

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore(cli): add conf.ts barrel file for test imports

Re-exports from src/core/conf.ts to support existing test imports.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* all

* fix(cli): pass --branch override to workspace resolution

Previously, the --branch flag was only used for config resolution but
not for workspace resolution. This caused confusing log messages that
showed the git branch (e.g., master) before showing the override branch.

Changes:
- Add branchOverride parameter to tryResolveBranchWorkspace()
- Add branchOverride parameter to resolveWorkspace()
- Pass opts.branch from sync pull/push to resolveWorkspace()
- Log "Using branch override" early in workspace resolution
- Remove duplicate log from getEffectiveSettings()

Now when using --branch foobar, the logs show:
  Using branch override: foobar
  Applied settings for Git branch: foobar
  ...

Instead of the confusing previous output that mentioned both master
and foobar.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(cli): remove redundant branch detection in elementsToMap

isCurrentBranchFile() already validates that a branch exists (via
branchOverride or git detection) before returning true. No need to
pre-compute currentBranch before calling it.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 20:55:01 +00:00

555 lines
16 KiB
TypeScript

// deno-lint-ignore-file no-explicit-any
import { colors, log, Select, Confirm, Input } from "../../deps.ts";
import { loginInteractive } from "./login.ts";
import { GlobalOptions } from "../types.ts";
import { getHeaders } from "../utils/utils.ts";
import {
getActiveWorkspace,
getWorkspaceByName,
Workspace,
allWorkspaces,
addWorkspace,
} from "../commands/workspace/workspace.ts";
import { getLastUsedProfile, setLastUsedProfile } from "./branch-profiles.ts";
import { readConfigFile } from "./conf.ts";
import {
getCurrentGitBranch,
getOriginalBranchForWorkspaceForks,
getWorkspaceIdForWorkspaceForkFromBranchName,
isGitRepository,
} from "../utils/git.ts";
import { WM_FORK_PREFIX } from "./constants.ts";
// Helper function to select from multiple matching profiles
async function selectFromMultipleProfiles(
profiles: Workspace[],
baseUrl: string,
workspaceId: string,
context: string,
configDir?: string
): Promise<Workspace> {
if (profiles.length === 1) {
return profiles[0];
}
// Check for last used profile
const lastUsedProfileName = await getLastUsedProfile(
"",
baseUrl,
workspaceId,
configDir
);
if (lastUsedProfileName) {
const lastUsedProfile = profiles.find(
(p) => p.name === lastUsedProfileName
);
if (lastUsedProfile) {
log.info(
colors.green(
`Using last used profile '${lastUsedProfile.name}' for ${context}`
)
);
return lastUsedProfile;
}
}
// No last used or it no longer exists - prompt for selection
if (!Deno.stdin.isTerminal() || !Deno.stdout.isTerminal()) {
const selectedProfile = profiles[0];
log.info(
colors.yellow(
`Multiple profiles found for ${context}. Using first available profile: '${selectedProfile.name}'`
)
);
// Save selection for next time
await setLastUsedProfile(
"", // No branch context for general workspace selection
baseUrl,
workspaceId,
selectedProfile.name,
configDir
);
return selectedProfile;
}
log.info(
colors.yellow(`\nMultiple workspace profiles found for ${context}:`)
);
const selectedName = await Select.prompt({
message: "Select profile",
options: profiles.map((p) => ({
name: `${p.name} (${p.workspaceId} on ${p.remote})`,
value: p.name,
})),
});
const selectedProfile = profiles.find((p) => p.name === selectedName)!;
// Save selection for next time
await setLastUsedProfile(
"", // No branch context for general workspace selection
baseUrl,
workspaceId,
selectedProfile.name,
configDir
);
return selectedProfile;
}
/**
* Prompts the user to create a new workspace profile interactively
*/
async function createWorkspaceProfileInteractively(
normalizedBaseUrl: string,
workspaceId: string,
currentBranch: string,
opts: GlobalOptions,
context: { rawBranch: string; isForked: boolean }
): Promise<Workspace | undefined> {
// Log appropriate message based on context
if (!context.isForked) {
log.info(
colors.yellow(
`\nNo workspace profile found for branch '${context.rawBranch}'\n` +
`(${normalizedBaseUrl}, ${workspaceId})`
)
);
} else {
log.info(
colors.yellow(
`\nNo workspace profile was found for this forked workspace\n` +
`(${normalizedBaseUrl}, ${workspaceId})`
)
);
}
if (!Deno.stdin.isTerminal() || !Deno.stdout.isTerminal()) {
log.info(
"Not a TTY, cannot create profile interactively. Use 'wmill workspace add' first."
);
return undefined;
}
const shouldCreate = await Confirm.prompt({
message: "Would you like to create a new workspace profile?",
default: true,
});
if (!shouldCreate) {
return undefined;
}
// Prompt for profile details
const profileName = await Input.prompt({
message: "Profile name",
default: workspaceId,
});
const token = await loginInteractive(normalizedBaseUrl);
if (!token) {
log.error("Failed to obtain token");
return undefined;
}
// Create the new profile
const newWorkspace: Workspace = {
name: profileName,
remote: normalizedBaseUrl,
workspaceId: workspaceId,
token: token,
};
await addWorkspace(newWorkspace, opts);
// Set as last used for this branch
await setLastUsedProfile(
currentBranch,
normalizedBaseUrl,
workspaceId,
profileName,
opts.configDir
);
log.info(
colors.green(
`✓ Created profile '${profileName}' for ${workspaceId} on ${normalizedBaseUrl}`
)
);
log.info(colors.green(`✓ Profile '${profileName}' is now active`));
return newWorkspace;
}
export type Context = {
workspace: string;
baseUrl: string;
urlStore: string;
token: string;
};
async function tryResolveWorkspace(
opts: GlobalOptions
): Promise<
{ isError: false; value: Workspace } | { isError: true; error: string }
> {
const cache = (opts as any).__secret_workspace;
if (cache) return { isError: false, value: cache };
if (opts.workspace) {
const e = await getWorkspaceByName(opts.workspace, opts.configDir);
if (!e) {
return {
isError: true,
error: colors.red.underline("Given workspace does not exist."),
};
}
(opts as any).__secret_workspace = e;
return { isError: false, value: e };
}
// Only check for explicit workspace, don't fallback to active workspace here
return {
isError: true,
error: colors.red.underline("No explicit workspace given."),
};
}
export async function tryResolveBranchWorkspace(
opts: GlobalOptions,
branchOverride?: string
): Promise<Workspace | undefined> {
let rawBranch: string | null = null;
let currentBranch: string;
let originalBranchIfForked: string | null = null;
let workspaceIdIfForked: string | null = null;
if (branchOverride) {
// Use branch override directly
currentBranch = branchOverride;
log.info(`Using branch override: ${branchOverride}`);
} else {
// Only try branch-based resolution if in a Git repository
if (!isGitRepository()) {
return undefined;
}
rawBranch = getCurrentGitBranch();
if (!rawBranch) {
return undefined;
}
originalBranchIfForked = getOriginalBranchForWorkspaceForks(rawBranch);
workspaceIdIfForked =
getWorkspaceIdForWorkspaceForkFromBranchName(rawBranch);
if (originalBranchIfForked) {
log.info(
`Using original branch \`${originalBranchIfForked}\` for finding workspace profile from gitBranches section in wmill.yaml`
);
currentBranch = originalBranchIfForked;
} else {
currentBranch = rawBranch;
}
}
// Read wmill.yaml to check for branch workspace configuration
const config = await readConfigFile();
const branchConfig = config.gitBranches?.[currentBranch];
// Check if branch has workspace configuration
if (!branchConfig?.baseUrl || !branchConfig?.workspaceId) {
return undefined;
}
log.info(
`Using branch configuration for branch \`${currentBranch}\` set in gitBranches`
);
const { baseUrl, workspaceId } = branchConfig;
let normalizedBaseUrl: string;
try {
normalizedBaseUrl = new URL(baseUrl).toString();
} catch (error) {
log.error(
colors.red(`Invalid baseUrl in branch configuration: ${baseUrl}`)
);
return undefined;
}
// Find all profiles matching this baseUrl and workspaceId
const allProfiles = await allWorkspaces(opts.configDir);
const matchingProfiles = allProfiles.filter(
(w) => w.remote === normalizedBaseUrl && w.workspaceId === workspaceId
);
if (matchingProfiles.length === 0) {
// No matching profile exists - prompt to create one
return await createWorkspaceProfileInteractively(
normalizedBaseUrl,
workspaceId,
currentBranch,
opts,
{ rawBranch: rawBranch ?? currentBranch, isForked: !!originalBranchIfForked }
);
}
// Handle multiple profiles - use special branch-aware logic
let selectedProfile: Workspace;
if (matchingProfiles.length === 1) {
selectedProfile = matchingProfiles[0];
log.info(
colors.green(
`Using workspace profile '${selectedProfile.name}' for branch '${currentBranch}' with workspace id \`${workspaceId}\``
)
);
} else {
// For multiple profiles, check branch-specific last used first
const lastUsedName = await getLastUsedProfile(
currentBranch,
normalizedBaseUrl,
workspaceId,
opts.configDir
);
if (lastUsedName) {
const lastUsedProfile = matchingProfiles.find(
(p) => p.name === lastUsedName
);
if (lastUsedProfile) {
log.info(
colors.green(
`Using workspace profile '${lastUsedProfile.name}' for branch '${currentBranch}' (last used)`
)
);
return lastUsedProfile;
}
}
// Fall back to general selection logic
selectedProfile = await selectFromMultipleProfiles(
matchingProfiles,
normalizedBaseUrl,
workspaceId,
`branch '${currentBranch}'`,
opts.configDir
);
// Save branch-specific selection
await setLastUsedProfile(
currentBranch,
normalizedBaseUrl,
workspaceId,
selectedProfile.name,
opts.configDir
);
log.info(
colors.green(
`Using workspace profile '${selectedProfile.name}' for branch '${currentBranch}'`
)
);
}
if (workspaceIdIfForked) {
selectedProfile.name = `${selectedProfile.name}/${workspaceIdIfForked}`;
selectedProfile.workspaceId = workspaceIdIfForked;
log.info(
`Inferred workspace id \`${workspaceId}\` from branch name because this is a workspace fork branch (\`${rawBranch}\`). `
);
}
return selectedProfile;
}
export async function resolveWorkspace(
opts: GlobalOptions,
branchOverride?: string
): Promise<Workspace> {
const cache = (opts as any).__secret_workspace;
if (cache) return cache;
if (opts.baseUrl) {
if (opts.workspace && opts.token) {
let normalizedBaseUrl: string;
try {
normalizedBaseUrl = new URL(opts.baseUrl).toString(); // add trailing slash if not present
} catch (error) {
log.info(colors.red(`Invalid base URL: ${opts.baseUrl}`));
return Deno.exit(-1);
}
// Try to find existing workspace profile by name, then by workspaceId + remote
if (opts.workspace) {
// Try by workspace name first
let existingWorkspace = await getWorkspaceByName(
opts.workspace,
opts.configDir
);
// If not found by name, try to find by workspaceId + remote match
if (!existingWorkspace) {
const { allWorkspaces } = await import(
"../commands/workspace/workspace.ts"
);
const workspaces = await allWorkspaces(opts.configDir);
const matchingWorkspaces = workspaces.filter(
(w) =>
w.workspaceId === opts.workspace && w.remote === normalizedBaseUrl
);
if (matchingWorkspaces.length >= 1) {
existingWorkspace = await selectFromMultipleProfiles(
matchingWorkspaces,
normalizedBaseUrl,
opts.workspace!,
`workspace "${opts.workspace}" on ${normalizedBaseUrl}`,
opts.configDir
);
}
}
if (existingWorkspace) {
// Validate that the base URL matches the profile's remote
if (existingWorkspace.remote !== normalizedBaseUrl) {
log.info(
colors.red(
`Base URL mismatch: --base-url is ${normalizedBaseUrl} but workspace profile "${opts.workspace}" uses ${existingWorkspace.remote}`
)
);
return Deno.exit(-1);
}
// Use the existing workspace profile (preserves workspace name)
return {
...existingWorkspace,
token: opts.token, // Use the provided token
};
}
}
// No existing profile found, create temporary workspace
return {
remote: normalizedBaseUrl,
workspaceId: opts.workspace,
name: opts.workspace,
token: opts.token,
};
} else {
log.info(
colors.red(
"If you specify a base URL with --base-url, you must also specify a workspace (--workspace) and token (--token)."
)
);
return Deno.exit(-1);
}
}
const branch = branchOverride ?? getCurrentGitBranch();
// Try explicit workspace flag first (should override branch-based resolution). Unless it's a
// forked workspace, that we detect through the branch name (only when not using branchOverride)
const res = await tryResolveWorkspace(opts);
if (!res.isError) {
if (branchOverride || !branch || !branch.startsWith(WM_FORK_PREFIX)) {
return res.value;
} else {
log.info(
`Found an active workspace \`${res.value.name}\` but the branch name indicates this is a forked workspace. Ignoring active workspace and trying to resolve the correct workspace from the branch name \`${branch}\``
);
}
}
// Try branch-based resolution (medium priority)
const branchWorkspace = await tryResolveBranchWorkspace(opts, branchOverride);
if (branchWorkspace) {
(opts as any).__secret_workspace = branchWorkspace;
return branchWorkspace;
} else if (!branchOverride) {
// Only check for fork errors when not using branchOverride
const originalBranch = getOriginalBranchForWorkspaceForks(branch);
if (originalBranch) {
log.error(
colors.red.bold(
`Failed to resolve workspace profile for workspace fork. This most likely means that the original branch \`${originalBranch}\` where \`${branch}\` is originally forked from, is not setup in the wmill.yaml. You need to update the \`gitBranches\` section for \`${originalBranch}\` to include workspaceId and baseUrl.`
)
);
return Deno.exit(-1);
}
}
// Fall back to active workspace (lowest priority)
const activeWorkspace = await getActiveWorkspace(opts);
if (activeWorkspace) {
(opts as any).__secret_workspace = activeWorkspace;
return activeWorkspace;
}
// If everything failed, show error
log.info(colors.red.bold("No workspace given and no default set."));
return Deno.exit(-1);
}
export async function fetchVersion(baseUrl: string): Promise<string> {
const requestHeaders = new Headers();
const extraHeaders = getHeaders();
if (extraHeaders) {
for (const [key, value] of Object.entries(extraHeaders)) {
requestHeaders.set(key, value);
}
}
const response = await fetch(
new URL(new URL(baseUrl).origin + "/api/version"),
{ headers: requestHeaders, method: "GET" }
);
if (!response.ok) {
// Consume response body even on error to avoid resource leak
await response.text();
throw new Error(
`Failed to fetch version: ${response.status} ${response.statusText}`
);
}
return await response.text();
}
export async function tryResolveVersion(
opts: GlobalOptions
): Promise<number | undefined> {
if ((opts as any).__cache_version) {
return (opts as any).__cache_version;
}
const workspaceRes = await tryResolveWorkspace(opts);
if (workspaceRes.isError) return undefined;
const version = await fetchVersion(workspaceRes.value.remote);
try {
return Number.parseInt(
version.split("-", 1)[0].replaceAll(".", "").replace("v", "")
);
} catch {
return undefined;
}
}
export function validatePath(path: string): boolean {
if (!(path.startsWith("g") || path.startsWith("u") || path.startsWith("f"))) {
log.info(
colors.red(
"Given remote path looks invalid. Remote paths are typically of the form <u|g|f>/<username|group|folder>/..."
)
);
return false;
}
return true;
}