feat: add branch selection to Git Stack modal with branches API endpoint (#1343)
* feat: add branch selection to Git Stack modal with branches API endpoint
- Add branch dropdown in create mode (fetches branches from new API endpoint)
- Add branch field in edit mode for existing Git stacks
- Remove redundant branch badge next to repository URL in edit mode
- Add POST /api/git/branches endpoint to list available branches
- Export buildGitEnv, buildRepoUrl, execGit from git.ts for reuse
* cla: recheck with correct author email
* fix: harden git branches endpoint and fix credential resolution
* feat(git-stacks): per-stack branch override
Add a per-stack 'branch' column to git_stacks so each stack can deploy
from a branch different from its repository's default.
- Migration 0012 (drizzle + drizzle-pg): add nullable 'branch' column
- resolveStackBranch() in git.ts: effective branch = per-stack override,
else repository default; wired into sync/deploy/test paths
- db.ts: persist/read branch; GitStackData.branch is string | null
- API routes (preview-env, stacks create/update): honor explicit branch
override with partial-update semantics (null/empty clears the override)
- UI: GitStackModal shows a 'Repository default' sentinel + per-stack
selector; GitSourceBadge displays effective branch with a '(per-stack)'
marker; stacks page passes through the per-stack branch
- tests/git-stack-branch.test.ts: unit coverage for resolveStackBranch
and effective-branch display semantics
* feat(git-branches): security hardening per PR #1343 maintainer review
Adopts the three security recommendations from the maintainer review on
the git-branch-stack feature:
1. SSRF guard (assertSafeBranchTarget)
New src/lib/server/git-branch-lookup.ts rejects repository URLs that
point at private/loopback/link-local addresses before any git
subprocess is spawned. Covers IPv4 (127/8, 169.254/16, 10/8,
172.16/12, 192.168/16, 100.64/10, 0/8), IPv6 (::1, fe80::/10,
fc00::/7, ::), IPv4-mapped IPv6 (::ffff:a.b.c.d), and a 25-entry
hostname denylist (localhost, metadata.google.internal,
host.docker.internal, etc.). Raw-URL parse only — no DNS resolution,
avoiding TOCTOU/rebinding. Runs on BOTH the url and repositoryId
paths (a stored repo's URL could also point internal).
2. Credential-exfiltration guard (assertCredentialUrlPair)
A raw url paired with a stored credentialId is only allowed when the
credential is plausibly for that host. SSH and none credentials are
always allowed (no secret to leak). Password credentials: allowed
when the username equals the URL host, or when the credential name
(or its hyphen/underscore-separated segments) equals the host or its
first label (e.g. "github-pat" → github.com). Inference-based — no
new DB column. The repositoryId path is not subject to this guard
(the pairing is the user's own stored config).
3. Timeout on git subprocesses
execGit now accepts a timeoutMs parameter (default GIT_TIMEOUT_MS =
20s, env-overridable). On timeout the process is killed with SIGKILL
and the caller receives { timedOut: true, code: 124 }. Applied to all
execGit call sites (clone, pull, fetch, ls-remote, etc.) so a dead
or black-holed remote cannot stall a request indefinitely.
OpenAPI: @openapi JSDoc block added to the branches endpoint; spec
regenerated via the upstream generator (359/359 coverage, all 6 gates
pass). /api/git/branches POST entry added to openapi.generated.json.
Svelte5: fix-svelte-optional-params.sh applied to 7 .svelte files
(x?: T → x: T = undefined) to work around esbuild's rejection of
TS-only optional-parameter syntax in lang="ts" script tags.
Tests: 59 tests in tests/git-branches.test.ts covering SSRF (blocked
IPs, boundary cases), credential affinity (allowed/rejected cases),
endpoint pipeline ordering, and parseRepoHost edge cases.
Rebased onto upstream/main (a485c85) — zero conflicts; GitStackModal.svelte
had not drifted since the branch was created.
* fix(git): close SSRF/RCE/exfil gaps across all git URL+credential endpoints (PR #1343 re-review)
1. SSRF: replace hand-rolled assertSafeBranchTarget with shared URL()-based
isSafeNotificationUrl — blocks decimal/octal/hex/short-form IPv4 and
v4-mapped IPv6 encodings (all six maintainer bypass payloads now blocked).
ALL schemes (http/https/ssh/git/scp-like) now judge the CANONICALIZED
host through that single check: non-http(s) schemes can't be passed to
URL() as-is, so the parsed host (port-stripped, IPv6 bracketed only
when it contains a colon) is run through isSafeNotificationUrl under
an https:// scheme. Closes the ssh/git encoded-IP bypass (decimal
2130706433, hex 0x7f000001, short-form 127.1 over ssh://, git:// and
scp-like all resolve to 127.0.0.1 and are now blocked).
2. Credential affinity: drop the dead second rule — with rule (i) being
username === host, the single-label variant was identical and unreachable.
3. listRemoteBranches + testRepositoryConfig call assertSafeRepoUrl explicitly
so the ext::/file:: transport denylist holds on every path into a git
subprocess, not only via buildRepoUrl.
4. preview-env: assertSafeRepoTarget (SSRF+RCE) + repoFilePath containment for
composePath/envFilePath (path traversal) + assertCredentialHostMatch
(exfil).
5. repositories/test: assertSafeRepoTarget + assertCredentialHostMatch.
Tests: 134 across 3 files (git-branches, preview-env-security,
repositories-test-security) — lock the six http(s) encoding bypasses, the
four ssh/git encoded-IP payloads at unit and endpoint level, the subdomain
attack, single-label username, path traversal, and the endpoint pipelines.
Verified safe (no change): backup/destinations/test, registries/test,
environments/test, git/repositories CRUD, git/stacks, git/repositories/[id]/test.
* fix(git): address branch stack review feedback
- scope remote branch lookup timeout to ls-remote only
- require git:edit permission for preview-env
- clarify SSRF policy while preserving private LAN Git hosts
- support free-text branch selection for new repositories
- share production branch resolution logic with tests and UI
- add testable preview-env request pipeline
This commit is contained in:
1 parent
0885e8245f
commit
28bb291c32
29 files changed
+38984
-106
No files matched your search
@@ -51,6 +51,7 @@
|
||||
import { ErrorDialog } from '$lib/components/ui/error-dialog';
|
||||
import { formatHostPortUrl } from '$lib/utils/url';
|
||||
import { formatBytes, formatBytesCompact } from '$lib/utils/format';
|
||||
import { effectiveStackBranch } from '$lib/git-stack-branch';
|
||||
|
||||
type SortField = 'name' | 'containers' | 'status' | 'cpu' | 'memory';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
@@ -938,7 +939,7 @@
|
||||
return stack.status;
|
||||
}
|
||||
|
||||
async function openGitModal(gitStack?: any) {
|
||||
async function openGitModal(gitStack: any = undefined) {
|
||||
editingGitStack = gitStack || null;
|
||||
// Fetch repositories and credentials before opening modal
|
||||
try {
|
||||
@@ -1115,10 +1116,13 @@
|
||||
editingStackName = name;
|
||||
stackModalReadonly = true;
|
||||
const src = getStackSource(name);
|
||||
// Effective branch: per-stack override wins, else repository default
|
||||
// (shared with the server-side resolver in src/lib/git-stack-branch.ts).
|
||||
const eff = effectiveStackBranch(src?.gitStack ?? null, src?.repository ?? undefined);
|
||||
stackModalGitInfo = {
|
||||
commit: src?.gitStack?.lastCommit || undefined,
|
||||
url: src?.repository?.url || undefined,
|
||||
branch: src?.repository?.branch || undefined
|
||||
branch: eff.branch
|
||||
};
|
||||
showEditModal = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, Check, GitBranch, Loader2 } from 'lucide-svelte';
|
||||
import * as Command from '$lib/components/ui/command';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { buttonVariants } from '$lib/components/ui/button';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
/**
|
||||
* Free-text, searchable branch picker.
|
||||
*
|
||||
* Reuses the repository's existing Popover + Command primitives (the same
|
||||
* interaction/style pattern as TimezoneSelector.svelte) so the user can
|
||||
* both pick a *discovered* branch and type an *arbitrary* branch name.
|
||||
*
|
||||
* Behaviour contract (maintainer review for the new-repository branch
|
||||
* field):
|
||||
* - fetched branches appear in the list and are searchable/selectable;
|
||||
* - typing a branch NOT in the list offers "Use \"<query>\"", and clicking
|
||||
* it (or pressing Enter) selects that exact value;
|
||||
* - if branch enumeration fails or returns nothing, the user can still
|
||||
* type and select any branch manually (the "Use \"<query>\"" row always
|
||||
* appears when the trimmed query is non-blank);
|
||||
* - an existing value that a later enumeration did NOT return is retained
|
||||
* (never silently reset), and the trigger keeps showing it;
|
||||
* - while enumeration is in flight a loader is shown but the field stays
|
||||
* usable;
|
||||
* - keyboard selection (ArrowUp/Down + Enter) works for both fetched rows
|
||||
* and the free-text row.
|
||||
*/
|
||||
interface Props {
|
||||
/** Current branch value (e.g. "main"). */
|
||||
value: string;
|
||||
/** Discovered branch names (from /api/git/branches). */
|
||||
branches: string[];
|
||||
/** True while branch enumeration is in flight. */
|
||||
loading: boolean;
|
||||
/** Called with the selected value (a fetched branch or the typed name). */
|
||||
onchange?: (value: string) => void;
|
||||
/** Called when the user clears the field (empty value). */
|
||||
onclear?: () => void;
|
||||
id?: string;
|
||||
class?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable('main'),
|
||||
branches = [],
|
||||
loading = false,
|
||||
onchange,
|
||||
onclear,
|
||||
id,
|
||||
class: className,
|
||||
placeholder = 'main'
|
||||
}: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let searchQuery = $state('');
|
||||
|
||||
function selectBranch(branch: string) {
|
||||
value = branch;
|
||||
open = false;
|
||||
searchQuery = '';
|
||||
onchange?.(branch);
|
||||
}
|
||||
|
||||
function clearBranch() {
|
||||
value = '';
|
||||
open = false;
|
||||
searchQuery = '';
|
||||
onclear?.();
|
||||
}
|
||||
|
||||
// Filtered fetched branches, ordered so the current value (if not in the
|
||||
// fetched list) still surfaces near the top for easy re-selection.
|
||||
const filteredBranches = $derived(
|
||||
searchQuery
|
||||
? branches.filter((b) => b.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
: branches
|
||||
);
|
||||
|
||||
// Offer the typed name as a selectable row when it is non-blank and is not
|
||||
// already one of the fetched branches.
|
||||
const freeTextBranch = $derived(
|
||||
searchQuery.trim() && !branches.includes(searchQuery.trim()) ? searchQuery.trim() : ''
|
||||
);
|
||||
|
||||
// Display: show the current value; fall back to the placeholder when empty
|
||||
// (e.g. no branch chosen -> "main").
|
||||
const displayValue = value || placeholder;
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger>
|
||||
<span
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
class={cn(buttonVariants({ variant: 'outline' }), 'w-full justify-between cursor-pointer select-none', className)}
|
||||
{id}
|
||||
>
|
||||
<span class="flex items-center gap-2 truncate">
|
||||
<GitBranch class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
{#if loading}
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
<span class="text-muted-foreground">Loading branches…</span>
|
||||
{:else}
|
||||
<span class="truncate">{displayValue}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<ChevronDown class="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</span>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-[350px] p-0 z-[200]" align="start">
|
||||
<Command.Root shouldFilter={false}>
|
||||
<Command.Input bind:value={searchQuery} placeholder="Type a branch name…" />
|
||||
<Command.List class="max-h-[300px]">
|
||||
{#if loading}
|
||||
<Command.Empty>Loading branches…</Command.Empty>
|
||||
{:else}
|
||||
{#if filteredBranches.length > 0}
|
||||
<Command.Group heading="Branches">
|
||||
{#each filteredBranches as branch}
|
||||
<Command.Item value={branch} onSelect={() => selectBranch(branch)}>
|
||||
<Check class={cn('mr-2 h-4 w-4', value === branch ? 'opacity-100' : 'opacity-0')} />
|
||||
<span class="truncate">{branch}</span>
|
||||
</Command.Item>
|
||||
{/each}
|
||||
</Command.Group>
|
||||
{/if}
|
||||
{#if freeTextBranch}
|
||||
<Command.Group heading="Custom">
|
||||
<Command.Item value={freeTextBranch} onSelect={() => selectBranch(freeTextBranch)}>
|
||||
<GitBranch class="mr-2 h-4 w-4" />
|
||||
<span class="truncate">Use "{freeTextBranch}"</span>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
{/if}
|
||||
{#if filteredBranches.length === 0 && !freeTextBranch && !loading}
|
||||
<Command.Empty>
|
||||
Type a branch name (e.g. "main") or pick from the list.
|
||||
</Command.Empty>
|
||||
{/if}
|
||||
{/if}
|
||||
</Command.List>
|
||||
</Command.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -3,14 +3,22 @@
|
||||
import { GitBranch, GitCommitHorizontal } from 'lucide-svelte';
|
||||
import { forgeIcon } from '$lib/utils/git-forge';
|
||||
import { appSettings } from '$lib/stores/settings';
|
||||
import { effectiveStackBranch } from '$lib/git-stack-branch';
|
||||
|
||||
interface Props {
|
||||
source: { repository?: { url?: string; branch?: string } | null; gitStack?: { lastCommit?: string | null } | null };
|
||||
source: { repository?: { url?: string; branch?: string } | null; gitStack?: { lastCommit?: string | null; branch?: string | null } | null };
|
||||
}
|
||||
let { source }: Props = $props();
|
||||
|
||||
const ForgeIcon = $derived(forgeIcon(source.repository?.url));
|
||||
const showHash = $derived(!!source.gitStack?.lastCommit && $appSettings.showGitCommitHash);
|
||||
// Effective branch: per-stack override wins, else repository default
|
||||
// (shared with the server-side resolver in src/lib/git-stack-branch.ts).
|
||||
const eff = $derived(
|
||||
effectiveStackBranch(source.gitStack ?? null, source.repository ?? undefined)
|
||||
);
|
||||
const effectiveBranch = eff.branch;
|
||||
const isPerStackBranch = eff.perStack === true;
|
||||
// A tooltip is only worth showing when there's real git info in it.
|
||||
const hasGitInfo = $derived(!!source.gitStack?.lastCommit || !!source.repository);
|
||||
</script>
|
||||
@@ -41,10 +49,13 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#if source.repository}
|
||||
{#if source.repository.branch}
|
||||
{#if effectiveBranch}
|
||||
<div class="flex items-center gap-2">
|
||||
<GitBranch class="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="text-xs">{source.repository.branch}</span>
|
||||
<span class="text-xs">{effectiveBranch}</span>
|
||||
{#if isPerStackBranch}
|
||||
<span class="text-[10px] text-muted-foreground">(per-stack)</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { TogglePill } from '$lib/components/ui/toggle-pill';
|
||||
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, XCircle, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X, Download, Hammer, ArrowDownToLine, Zap, FolderOpen, Ban, TriangleAlert, Settings2, Archive } from 'lucide-svelte';
|
||||
@@ -17,6 +16,7 @@
|
||||
import CronEditor from '$lib/components/cron-editor.svelte';
|
||||
import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte';
|
||||
import SecretProviderPicker from '$lib/components/SecretProviderPicker.svelte';
|
||||
import BranchCombobox from './BranchCombobox.svelte';
|
||||
import { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { focusFirstInput } from '$lib/utils';
|
||||
@@ -45,13 +45,14 @@
|
||||
name: string;
|
||||
url: string;
|
||||
branch: string;
|
||||
credential_id: number | null;
|
||||
credentialId: number | null;
|
||||
}
|
||||
|
||||
interface GitStack {
|
||||
id: number;
|
||||
stackName: string;
|
||||
repositoryId: number;
|
||||
branch?: string | null; // Per-stack branch override; null = use repository default
|
||||
environmentId: number | null;
|
||||
composePath: string;
|
||||
envFilePath: string | null;
|
||||
@@ -149,6 +150,21 @@
|
||||
let showExistsWarning = $state(false);
|
||||
let errors = $state<{ stackName?: string; repository?: string; repoName?: string; repoUrl?: string; webhookSecret?: string }>({});
|
||||
|
||||
// Branch selection
|
||||
let formBranch = $state<string | null>(null);
|
||||
let branches = $state<string[]>([]);
|
||||
let branchesLoading = $state(false);
|
||||
// Monotonic token that guards against a stale branch-enumeration response
|
||||
// overwriting `branches` for a newer repository URL (the $effect below can
|
||||
// fire multiple times as the repo selection changes; a slow response for
|
||||
// repo A must not clobber the branch list belonging to repo B).
|
||||
let branchesFetchSeq = 0;
|
||||
|
||||
// Sentinel select value meaning "no per-stack override — use the repository's
|
||||
// default branch". Contains ':' which is invalid in git refs, so it can never
|
||||
// collide with a real branch name.
|
||||
const REPO_DEFAULT_BRANCH_VALUE = ':repository-default:';
|
||||
|
||||
// Stack name validation: Docker Compose requires lowercase; must start with a
|
||||
// letter or number, and contain only lowercase letters, numbers, hyphens, underscores
|
||||
const STACK_NAME_REGEX = /^[a-z0-9][a-z0-9_-]*$/;
|
||||
@@ -361,6 +377,8 @@
|
||||
|
||||
if (formRepoMode === 'existing') {
|
||||
body.repositoryId = formRepositoryId;
|
||||
// Send the selected branch so env files are previewed from it (per-stack override)
|
||||
body.branch = formBranch || undefined;
|
||||
} else {
|
||||
body.url = formNewRepoUrl;
|
||||
body.branch = formNewRepoBranch || 'main';
|
||||
@@ -448,9 +466,11 @@
|
||||
formForceRedeploy = gitStack.forceRedeploy ?? false;
|
||||
formDeployNow = false;
|
||||
formSecretProviderId = null;
|
||||
|
||||
|
||||
// Load secret provider binding
|
||||
loadSecretProviderBindingForStack(gitStack.stackName);
|
||||
// Per-stack branch override; null means "use the repository default"
|
||||
formBranch = gitStack.branch ?? null;
|
||||
|
||||
// Load env files and overrides SYNCHRONOUSLY to avoid race conditions
|
||||
// Wait for all loads to complete before allowing any other effect to run
|
||||
@@ -497,6 +517,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBranches() {
|
||||
const seq = ++branchesFetchSeq;
|
||||
branchesLoading = true;
|
||||
branches = [];
|
||||
try {
|
||||
const body: Record<string, any> = {};
|
||||
if (formRepoMode === 'existing' && formRepositoryId) {
|
||||
body.repositoryId = formRepositoryId;
|
||||
} else if (formRepoMode === 'new' && formNewRepoUrl) {
|
||||
body.url = formNewRepoUrl;
|
||||
body.credentialId = formNewRepoCredentialId;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/git/branches', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
// A newer fetch (or a repo change) superseded this one — drop the
|
||||
// stale response so it cannot overwrite the new repo's branch list.
|
||||
if (seq !== branchesFetchSeq) return;
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (seq !== branchesFetchSeq) return;
|
||||
branches = data.branches || [];
|
||||
}
|
||||
} catch (e) {
|
||||
if (seq !== branchesFetchSeq) return;
|
||||
console.error('Failed to fetch branches:', e);
|
||||
} finally {
|
||||
if (seq === branchesFetchSeq) branchesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGitStack(deployAfterSave: boolean = false) {
|
||||
errors = {};
|
||||
let hasErrors = false;
|
||||
@@ -589,6 +644,9 @@
|
||||
|
||||
if (formRepoMode === 'existing') {
|
||||
body.repositoryId = formRepositoryId;
|
||||
// Per-stack branch override — sent on both create and update so the
|
||||
// stack payload is the single source of truth (null = inherit repo default)
|
||||
body.branch = formBranch || null;
|
||||
} else {
|
||||
// Create new repo inline
|
||||
body.repoName = formNewRepoName;
|
||||
@@ -616,9 +674,10 @@
|
||||
}
|
||||
|
||||
// Check if deployment failed
|
||||
if (data.deployResult && !data.deployResult.success) {
|
||||
const deployResult = data.deployResult as { success?: boolean; error?: string } | undefined;
|
||||
if (deployResult && !deployResult.success) {
|
||||
toast.error('Deployment failed', {
|
||||
description: data.deployResult.error || 'Unknown error'
|
||||
description: deployResult.error || 'Unknown error'
|
||||
});
|
||||
onSaved(); // Still refresh the list to show the new stack
|
||||
onClose(); // Close modal, error shown as toast
|
||||
@@ -634,6 +693,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch branches when repository selection changes
|
||||
$effect(() => {
|
||||
if (formRepoMode === 'existing' && formRepositoryId) {
|
||||
void fetchBranches();
|
||||
// A fresh stack inherits the repository's default until a branch is
|
||||
// picked. When editing, the stored per-stack override (set in
|
||||
// resetForm) must be preserved — null means repository default.
|
||||
if (!gitStack) formBranch = null;
|
||||
} else if (formRepoMode === 'new' && formNewRepoUrl) {
|
||||
void fetchBranches();
|
||||
} else {
|
||||
branches = [];
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-populate stack name from selected repo and compose path (only if user hasn't manually edited)
|
||||
$effect(() => {
|
||||
if (formRepoMode === 'existing' && formRepositoryId && !gitStack && !formStackNameUserModified) {
|
||||
@@ -807,6 +881,42 @@
|
||||
No repositories configured. Click "Add new" to add one.
|
||||
</p>
|
||||
{/if}
|
||||
<!-- Branch selection for existing repository -->
|
||||
{#if formRepoMode === 'existing' && selectedRepo}
|
||||
<div class="space-y-2">
|
||||
<Label for="existing-repo-branch">Branch</Label>
|
||||
<Select.Root type="single" value={formBranch || REPO_DEFAULT_BRANCH_VALUE} onValueChange={(v) => { formBranch = v === REPO_DEFAULT_BRANCH_VALUE ? null : v; }}>
|
||||
<Select.Trigger class="w-full">
|
||||
<span class="flex items-center gap-2">
|
||||
<GitBranch class="w-4 h-4 text-muted-foreground" />
|
||||
{#if branchesLoading}
|
||||
<Loader2 class="w-4 h-4 animate-spin" />
|
||||
{:else if formBranch}
|
||||
{formBranch}
|
||||
{:else}
|
||||
<span class="text-muted-foreground">Repository default ({selectedRepo.branch})</span>
|
||||
{/if}
|
||||
</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value={REPO_DEFAULT_BRANCH_VALUE}>
|
||||
<span class="flex items-center gap-2">
|
||||
<GitBranch class="w-4 h-4 text-muted-foreground" />
|
||||
Repository default ({selectedRepo.branch})
|
||||
</span>
|
||||
</Select.Item>
|
||||
{#if branches.length > 0}
|
||||
{#each branches as branch}
|
||||
{#if branch !== selectedRepo.branch}
|
||||
<Select.Item value={branch}>{branch}</Select.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<p class="text-xs text-muted-foreground">Branch this stack deploys from. "Repository default" follows the branch configured on the repository.</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="space-y-3 p-3 border rounded-md bg-muted/30">
|
||||
<div class="space-y-2">
|
||||
@@ -838,7 +948,21 @@
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-2">
|
||||
<Label for="new-repo-branch">Branch</Label>
|
||||
<Input id="new-repo-branch" bind:value={formNewRepoBranch} placeholder="main" />
|
||||
<!-- Free-text, searchable branch picker. Supports both discovered
|
||||
branches and arbitrary typed names (maintainer review: a new/
|
||||
private repository whose branch enumeration fails must not force the
|
||||
user onto "main" — they can type the known branch name instead). The
|
||||
"main" default is preserved when no branch has been chosen, and a
|
||||
value not returned by enumeration is never silently reset. Server-side
|
||||
Git ref validation remains authoritative. -->
|
||||
<BranchCombobox
|
||||
id="new-repo-branch"
|
||||
value={formNewRepoBranch}
|
||||
branches={branches}
|
||||
loading={branchesLoading}
|
||||
placeholder="main"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">Branch to deploy. Type a name (e.g. "main") or pick from the list.</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="new-repo-credential">Credential</Label>
|
||||
@@ -916,13 +1040,46 @@
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 rounded-md px-3 py-2">
|
||||
<FolderGit2 class="w-3.5 h-3.5 shrink-0" />
|
||||
<span class="truncate" title={selectedRepo.url}>{selectedRepo.url}</span>
|
||||
{#if selectedRepo.branch}
|
||||
<Badge variant="outline" class="text-2xs py-0 px-1.5 shrink-0">{selectedRepo.branch}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if gitStack && selectedRepo}
|
||||
<div class="space-y-2">
|
||||
<Label for="stack-branch">Branch</Label>
|
||||
<Select.Root type="single" value={formBranch || REPO_DEFAULT_BRANCH_VALUE} onValueChange={(v) => { formBranch = v === REPO_DEFAULT_BRANCH_VALUE ? null : v; }}>
|
||||
<Select.Trigger class="w-full">
|
||||
<span class="flex items-center gap-2">
|
||||
<GitBranch class="w-4 h-4 text-muted-foreground" />
|
||||
{#if branchesLoading}
|
||||
<Loader2 class="w-4 h-4 animate-spin" />
|
||||
{:else if formBranch}
|
||||
{formBranch}
|
||||
{:else}
|
||||
<span class="text-muted-foreground">Repository default ({selectedRepo.branch})</span>
|
||||
{/if}
|
||||
</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value={REPO_DEFAULT_BRANCH_VALUE}>
|
||||
<span class="flex items-center gap-2">
|
||||
<GitBranch class="w-4 h-4 text-muted-foreground" />
|
||||
Repository default ({selectedRepo.branch})
|
||||
</span>
|
||||
</Select.Item>
|
||||
{#if branches.length > 0}
|
||||
{#each branches as branch}
|
||||
{#if branch !== selectedRepo.branch}
|
||||
<Select.Item value={branch}>{branch}</Select.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<p class="text-xs text-muted-foreground">Branch this stack deploys from. "Repository default" follows the branch configured on the repository.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="compose-path">Compose file path</Label>
|
||||
<Input id="compose-path" bind:value={formComposePath} placeholder="compose.yaml" />
|
||||
|
||||
Reference in new issue
Block a user