From b165e4608a44218845441d3af7e4993dc8faaf59 Mon Sep 17 00:00:00 2001 From: Jarek Krochmalski Date: Sat, 22 Aug 2026 17:11:57 +0200 Subject: [PATCH] branch selector on git stack, with per-stack branch override --- src/lib/openapi.generated.json | 13 +++++++---- src/lib/server/git.ts | 17 +++++++++----- src/routes/api/git/branches/+server.ts | 8 +++---- src/routes/stacks/BranchCombobox.svelte | 30 ++++++++++++++++++------- src/routes/stacks/GitStackModal.svelte | 22 ++++++++++-------- tests/git-branches.test.ts | 17 +++++++++----- 6 files changed, 72 insertions(+), 35 deletions(-) diff --git a/src/lib/openapi.generated.json b/src/lib/openapi.generated.json index b14a22c..154ad4d 100644 --- a/src/lib/openapi.generated.json +++ b/src/lib/openapi.generated.json @@ -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": { diff --git a/src/lib/server/git.ts b/src/lib/server/git.ts index 6dce224..8d1dc0c 100644 --- a/src/lib/server/git.ts +++ b/src/lib/server/git.ts @@ -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: "\trefs/heads/". 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) { diff --git a/src/routes/api/git/branches/+server.ts b/src/routes/api/git/branches/+server.ts index 1b0ffd9..4277ff1 100644 --- a/src/routes/api/git/branches/+server.ts +++ b/src/routes/api/git/branches/+server.ts @@ -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!} - * resp-200-example: {"branches":["main","develop","feature/test"]} + * resp-200: {branches:array!} + * 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 diff --git a/src/routes/stacks/BranchCombobox.svelte b/src/routes/stacks/BranchCombobox.svelte index 5d6b75b..99c5494 100644 --- a/src/routes/stacks/BranchCombobox.svelte +++ b/src/routes/stacks/BranchCombobox.svelte @@ -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 @@ - + 0} {#each filteredBranches as branch} - selectBranch(branch)}> - - {branch} + selectBranch(branch.name)}> + + {branch.name} + {#if defaultBranch && branch.name === defaultBranch} + default + {/if} + {branch.sha} {/each} diff --git a/src/routes/stacks/GitStackModal.svelte b/src/routes/stacks/GitStackModal.svelte index 12e0ab0..83fc897 100644 --- a/src/routes/stacks/GitStackModal.svelte +++ b/src/routes/stacks/GitStackModal.svelte @@ -152,7 +152,7 @@ // Branch selection let formBranch = $state(null); - let branches = $state([]); + 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 @@

{errors.repoUrl}

{/if} -
+
+ 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. --> -

Branch to deploy. Type a name (e.g. "main") or pick from the list.

+

Type a name or pick from the list.

@@ -990,6 +992,7 @@ {/each} +

SSH key or token for private repositories.

@@ -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})" diff --git a/tests/git-branches.test.ts b/tests/git-branches.test.ts index acaa2d2..fd8d1ec 100644 --- a/tests/git-branches.test.ts +++ b/tests/git-branches.test.ts @@ -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 () => {