branch selector on git stack, with per-stack branch override
This commit is contained in:
6 files changed
+72
-35
No files matched your search
@@ -12217,9 +12217,14 @@
|
||||
},
|
||||
"example": {
|
||||
"branches": [
|
||||
"main",
|
||||
"develop",
|
||||
"feature/test"
|
||||
{
|
||||
"name": "main",
|
||||
"sha": "a1b2c3d"
|
||||
},
|
||||
{
|
||||
"name": "develop",
|
||||
"sha": "e4f5a6b"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -12246,7 +12251,7 @@
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Accepts either repositoryId (an existing repository, using its stored URL and credential) or url + credentialId (a new repository). SECURITY: the repository target is checked against the shared SSRF policy — loopback, link-local/cloud-metadata and other reserved dangerous targets are rejected, while ordinary private-LAN addresses are intentionally allowed so self-hosted Git servers remain supported. The ls-remote is bounded by a hard timeout.",
|
||||
"description": "Accepts either repositoryId (an existing repository, using its stored URL and credential) or url + credentialId (a new repository). Each branch carries its short commit SHA. SECURITY: the repository target is checked against the shared SSRF policy — loopback, link-local/cloud-metadata and other reserved dangerous targets are rejected, while ordinary private-LAN addresses are intentionally allowed so self-hosted Git servers remain supported. The ls-remote is bounded by a hard timeout.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
|
||||
+12
-5
@@ -743,11 +743,17 @@ export async function testRepositoryConfig(options: {
|
||||
* explicitly, rather than only via buildRepoUrl — a future parser change in
|
||||
* buildRepoUrl must not be able to reopen command execution on this path.
|
||||
*/
|
||||
export interface RemoteBranch {
|
||||
name: string;
|
||||
/** Short (7-char) commit SHA the branch points at, from ls-remote. */
|
||||
sha: string;
|
||||
}
|
||||
|
||||
export async function listRemoteBranches(options: {
|
||||
url: string;
|
||||
credentialId?: number | null;
|
||||
timeoutMs?: number;
|
||||
}): Promise<{ branches: string[]; error?: string }> {
|
||||
}): Promise<{ branches: RemoteBranch[]; error?: string }> {
|
||||
const { url, credentialId, timeoutMs } = options;
|
||||
|
||||
// The effective branch-lookup timeout: an explicitly provided (valid)
|
||||
@@ -782,13 +788,14 @@ export async function listRemoteBranches(options: {
|
||||
return { branches: [], error: cleanGitError(result.stderr) };
|
||||
}
|
||||
|
||||
// Each line: "<sha>\trefs/heads/<name>". Keep the SHA (short) so the
|
||||
// picker can show it; it was previously discarded.
|
||||
const branches = result.stdout.split('\n')
|
||||
.filter(l => l.trim())
|
||||
.map(l => {
|
||||
const m = l.match(/refs\/heads\/(.+)$/);
|
||||
return m ? m[1] : null;
|
||||
const m = l.match(/^([0-9a-f]+)\s+refs\/heads\/(.+)$/);
|
||||
return m ? { name: m[2], sha: m[1].slice(0, 7) } : null;
|
||||
})
|
||||
.filter(Boolean) as string[];
|
||||
.filter(Boolean) as RemoteBranch[];
|
||||
|
||||
return { branches };
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -25,16 +25,16 @@ import { assertSafeRepoTarget } from '$lib/server/git-branch-lookup';
|
||||
* credentialId?: number|null // Credential for the url (new-repo flow)
|
||||
* }
|
||||
*
|
||||
* Returns: { branches: string[] }
|
||||
* Returns: { branches: { name: string, sha: string }[] }
|
||||
*/
|
||||
/**
|
||||
* @openapi
|
||||
* summary: List remote branches for a git repository via `git ls-remote`
|
||||
* description: Accepts either repositoryId (an existing repository, using its stored URL and credential) or url + credentialId (a new repository). SECURITY: the repository target is checked against the shared SSRF policy — loopback, link-local/cloud-metadata and other reserved dangerous targets are rejected, while ordinary private-LAN addresses are intentionally allowed so self-hosted Git servers remain supported. The ls-remote is bounded by a hard timeout.
|
||||
* description: Accepts either repositoryId (an existing repository, using its stored URL and credential) or url + credentialId (a new repository). Each branch carries its short commit SHA. SECURITY: the repository target is checked against the shared SSRF policy — loopback, link-local/cloud-metadata and other reserved dangerous targets are rejected, while ordinary private-LAN addresses are intentionally allowed so self-hosted Git servers remain supported. The ls-remote is bounded by a hard timeout.
|
||||
* body: {repositoryId:integer, url:string, credentialId:integer}
|
||||
* body-example: {"url":"https://github.com/example/repo.git","credentialId":2}
|
||||
* resp-200: {branches:array<string>!}
|
||||
* resp-200-example: {"branches":["main","develop","feature/test"]}
|
||||
* resp-200: {branches:array<object>!}
|
||||
* resp-200-example: {"branches":[{"name":"main","sha":"a1b2c3d"},{"name":"develop","sha":"e4f5a6b"}]}
|
||||
* resp-400: The URL points at a loopback/link-local/metadata/reserved target, or neither repositoryId nor url was supplied
|
||||
* resp-403: Permission denied (requires git:edit)
|
||||
* resp-404: The referenced repository does not exist
|
||||
|
||||
@@ -27,11 +27,18 @@
|
||||
* - keyboard selection (ArrowUp/Down + Enter) works for both fetched rows
|
||||
* and the free-text row.
|
||||
*/
|
||||
interface Branch {
|
||||
name: string;
|
||||
sha: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Current branch value (e.g. "main"). */
|
||||
value: string;
|
||||
/** Discovered branch names (from /api/git/branches). */
|
||||
branches: string[];
|
||||
/** Discovered branches (from /api/git/branches), name + short SHA. */
|
||||
branches: Branch[];
|
||||
/** Repository default branch name, highlighted in the list when known. */
|
||||
defaultBranch?: string;
|
||||
/** True while branch enumeration is in flight. */
|
||||
loading: boolean;
|
||||
/** Called with the selected value (a fetched branch or the typed name). */
|
||||
@@ -49,6 +56,7 @@
|
||||
let {
|
||||
value = $bindable('main'),
|
||||
branches = [],
|
||||
defaultBranch,
|
||||
loading = false,
|
||||
onchange,
|
||||
onclear,
|
||||
@@ -79,14 +87,16 @@
|
||||
// fetched list) still surfaces near the top for easy re-selection.
|
||||
const filteredBranches = $derived(
|
||||
searchQuery
|
||||
? branches.filter((b) => b.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
? branches.filter((b) => b.name.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() : ''
|
||||
searchQuery.trim() && !branches.some((b) => b.name === searchQuery.trim())
|
||||
? searchQuery.trim()
|
||||
: ''
|
||||
);
|
||||
|
||||
// Display: show the current value; fall back to the placeholder when empty
|
||||
@@ -95,7 +105,7 @@
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger>
|
||||
<Popover.Trigger class="w-full">
|
||||
<span
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
@@ -131,9 +141,13 @@
|
||||
{#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 value={branch.name} onSelect={() => selectBranch(branch.name)}>
|
||||
<Check class={cn('mr-2 h-4 w-4 shrink-0', value === branch.name ? 'opacity-100' : 'opacity-0')} />
|
||||
<span class="truncate">{branch.name}</span>
|
||||
{#if defaultBranch && branch.name === defaultBranch}
|
||||
<span class="ml-2 shrink-0 text-[10px] font-medium uppercase tracking-wide text-amber-500">default</span>
|
||||
{/if}
|
||||
<span class="ml-auto shrink-0 pl-2 font-mono text-xs text-muted-foreground">{branch.sha}</span>
|
||||
</Command.Item>
|
||||
{/each}
|
||||
</Command.Group>
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
|
||||
// Branch selection
|
||||
let formBranch = $state<string | null>(null);
|
||||
let branches = $state<string[]>([]);
|
||||
let branches = $state<{ name: string; sha: 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
|
||||
@@ -888,6 +888,7 @@
|
||||
id="existing-repo-branch"
|
||||
value={formBranch ?? ''}
|
||||
branches={branches}
|
||||
defaultBranch={selectedRepo.branch}
|
||||
loading={branchesLoading}
|
||||
placeholder="Repository default ({selectedRepo.branch})"
|
||||
clearLabel="Repository default ({selectedRepo.branch})"
|
||||
@@ -925,24 +926,25 @@
|
||||
<p class="text-xs text-destructive">{errors.repoUrl}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="grid grid-cols-2 items-start gap-3">
|
||||
<div class="space-y-2">
|
||||
<Label for="new-repo-branch">Branch</Label>
|
||||
<!-- 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. -->
|
||||
branches and arbitrary typed names: 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"
|
||||
class="w-full"
|
||||
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>
|
||||
<p class="text-xs text-muted-foreground">Type a name or pick from the list.</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="new-repo-credential">Credential</Label>
|
||||
@@ -990,6 +992,7 @@
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<p class="text-xs text-muted-foreground">SSH key or token for private repositories.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1031,6 +1034,7 @@
|
||||
id="stack-branch"
|
||||
value={formBranch ?? ''}
|
||||
branches={branches}
|
||||
defaultBranch={selectedRepo.branch}
|
||||
loading={branchesLoading}
|
||||
placeholder="Repository default ({selectedRepo.branch})"
|
||||
clearLabel="Repository default ({selectedRepo.branch})"
|
||||
|
||||
@@ -86,10 +86,10 @@ async function postGitBranches(
|
||||
if (!result) return { status: 500, json: { error: 'git ls-remote failed' } };
|
||||
if (result.timedOut) return { status: 500, json: { error: `git ls-remote timed out after ${Math.round(GIT_TIMEOUT_MS / 1000)}s` } };
|
||||
if (result.code !== 0) return { status: 500, json: { error: result.stderr } };
|
||||
const BRANCH_RE = new RegExp('refs/heads/(.+)$');
|
||||
const branches = result.stdout.split('\n').filter((l) => l.trim()).map((l) => {
|
||||
const BRANCH_RE = new RegExp('^([0-9a-f]+)\\s+refs/heads/(.+)$');
|
||||
const branches = result.stdout.split('\n').map((l) => {
|
||||
const m = l.match(BRANCH_RE);
|
||||
return m ? m[1] : null;
|
||||
return m ? { name: m[2], sha: m[1].slice(0, 7) } : null;
|
||||
}).filter(Boolean);
|
||||
return { status: 200, json: { branches } };
|
||||
}
|
||||
@@ -313,7 +313,11 @@ describe('POST /api/git/branches — pipeline', () => {
|
||||
});
|
||||
const res = await postGitBranches({ repositoryId: repo.id });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.json.branches).toEqual(['main', 'develop', 'feature/test']);
|
||||
expect(res.json.branches).toEqual([
|
||||
{ name: 'main', sha: 'a1b2c3d' },
|
||||
{ name: 'develop', sha: 'd4e5f6a' },
|
||||
{ name: 'feature/test', sha: 'c3d4e5f' }
|
||||
]);
|
||||
});
|
||||
|
||||
test('raw-url path returns branches', async () => {
|
||||
@@ -324,7 +328,10 @@ describe('POST /api/git/branches — pipeline', () => {
|
||||
});
|
||||
const res = await postGitBranches({ url: 'https://github.com/test/repo.git' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.json.branches).toEqual(['main', 'develop']);
|
||||
expect(res.json.branches).toEqual([
|
||||
{ name: 'main', sha: 'a1b2c3d' },
|
||||
{ name: 'develop', sha: 'd4e5f6a' }
|
||||
]);
|
||||
});
|
||||
|
||||
test('unknown repositoryId returns 404', async () => {
|
||||
|
||||
Reference in new issue
Block a user