Fix branch names with slashes in tree/blob/commits routes

Branch names like "feat/initial-implementation" were being split on "/"
by both the API router and Astro page routes, causing "Ref not found" errors.

Changed tree/blob routes to capture the full remaining path, then resolve
the ref by matching against known branch names from longest to shortest.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Maher Kassim committed on Apr 3, 2026, 04:31 PM 9327dbcc01
Parent: 49d02a2
Showing 6 changed files with 257 additions and 213 deletions
A packages/ui/src/pages/[owner]/[repo]/blob/[...refAndPath].astro
@@ -1,0 +1,112 @@
1 +---
2 +import Repo from '../../../../layouts/Repo.astro';
3 +import { apiGet } from '../../../../lib/api';
4 +
5 +const { owner, repo, refAndPath } = Astro.params;
6 +const cookie = Astro.request.headers.get('cookie') || '';
7 +
8 +let blob: any = null;
9 +let ref = '';
10 +let path = '';
11 +let error = '';
12 +
13 +try {
14 + blob = await apiGet(`/api/repos/${owner}/${repo}/blob/${refAndPath}`, cookie);
15 + ref = blob.ref || '';
16 + path = blob.path || '';
17 +} catch (e: any) {
18 + error = e.message;
19 +}
20 +
21 +const pathSegments = path.split('/').filter(Boolean);
22 +const fileName = pathSegments[pathSegments.length - 1] || '';
23 +
24 +const breadcrumbs = pathSegments.map((seg: string, i: number) => ({
25 + name: seg,
26 + href: i < pathSegments.length - 1
27 + ? `/${owner}/${repo}/tree/${ref}/${pathSegments.slice(0, i + 1).join('/')}`
28 + : `/${owner}/${repo}/blob/${ref}/${pathSegments.slice(0, i + 1).join('/')}`,
29 + isLast: i === pathSegments.length - 1,
30 +}));
31 +
32 +const lines = blob?.content?.split('\n') || [];
33 +const isBinary = blob?.encoding === 'base64';
34 +
35 +function formatSize(bytes: number): string {
36 + if (bytes < 1024) return `${bytes} B`;
37 + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
38 + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
39 +}
40 +---
41 +
42 +<Repo owner={owner!} repo={repo!} activeTab="code" ref={ref}>
43 + {error && <div class="flash-error">{error}</div>}
44 +
45 + {/* Breadcrumb */}
46 + <nav style="margin-bottom: 16px; font-size: 0.875rem;">
47 + <a href={`/${owner}/${repo}`}>{repo}</a>
48 + {breadcrumbs.map((bc: any) => (
49 + <>
50 + <span style="color: var(--text-muted);"> / </span>
51 + {bc.isLast ? (
52 + <strong>{bc.name}</strong>
53 + ) : (
54 + <a href={bc.href}>{bc.name}</a>
55 + )}
56 + </>
57 + ))}
58 + </nav>
59 +
60 + {blob && (
61 + <div class="card" style="padding: 0; overflow: hidden;">
62 + {/* File header */}
63 + <div style="padding: 8px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center;">
64 + <span style="font-size: 0.8125rem; color: var(--text-muted);">
65 + {lines.length} lines &middot; {formatSize(blob.size)}
66 + </span>
67 + </div>
68 +
69 + {isBinary ? (
70 + <div style="padding: 32px; text-align: center; color: var(--text-muted);">
71 + Binary file ({formatSize(blob.size)})
72 + </div>
73 + ) : (
74 + <div style="overflow-x: auto;">
75 + <table style="border-collapse: collapse; width: 100%;">
76 + <tbody>
77 + {lines.map((line: string, i: number) => (
78 + <tr>
79 + <td style="
80 + padding: 0 12px;
81 + text-align: right;
82 + color: var(--text-muted);
83 + user-select: none;
84 + white-space: nowrap;
85 + font-family: var(--font-mono);
86 + font-size: 0.8125rem;
87 + line-height: 1.5;
88 + vertical-align: top;
89 + border-right: 1px solid var(--border);
90 + min-width: 50px;
91 + ">
92 + {i + 1}
93 + </td>
94 + <td style="
95 + padding: 0 12px;
96 + white-space: pre;
97 + font-family: var(--font-mono);
98 + font-size: 0.8125rem;
99 + line-height: 1.5;
100 + ">
101 + {line}
102 + </td>
103 + </tr>
104 + ))}
105 + </tbody>
106 + </table>
107 + </div>
108 + )}
109 + </div>
110 + )}
111 +</Repo>
112 +
D packages/ui/src/pages/[owner]/[repo]/blob/[ref]/[...path].astro
@@ -1,119 +1,0 @@
1 ----
2 -import Repo from '../../../../../layouts/Repo.astro';
3 -import { apiGet } from '../../../../../lib/api';
4 -
5 -const { owner, repo, ref, path } = Astro.params;
6 -const cookie = Astro.request.headers.get('cookie') || '';
7 -
8 -let blob: any = null;
9 -let error = '';
10 -
11 -try {
12 - blob = await apiGet(`/api/repos/${owner}/${repo}/blob/${ref}/${path}`, cookie);
13 -} catch (e: any) {
14 - error = e.message;
15 -}
16 -
17 -// Build breadcrumb segments
18 -const pathSegments = (path || '').split('/').filter(Boolean);
19 -const fileName = pathSegments[pathSegments.length - 1] || '';
20 -const dirPath = pathSegments.slice(0, -1).join('/');
21 -
22 -const breadcrumbs = pathSegments.map((seg, i) => ({
23 - name: seg,
24 - href: i < pathSegments.length - 1
25 - ? `/${owner}/${repo}/tree/${ref}/${pathSegments.slice(0, i + 1).join('/')}`
26 - : `/${owner}/${repo}/blob/${ref}/${pathSegments.slice(0, i + 1).join('/')}`,
27 - isLast: i === pathSegments.length - 1,
28 -}));
29 -
30 -// Determine language for basic syntax hint
31 -const ext = fileName.split('.').pop()?.toLowerCase() || '';
32 -const lines = blob?.content?.split('\n') || [];
33 -const isBinary = blob?.encoding === 'base64';
34 -
35 -function formatSize(bytes: number): string {
36 - if (bytes < 1024) return `${bytes} B`;
37 - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
38 - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
39 -}
40 ----
41 -
42 -<Repo owner={owner!} repo={repo!} activeTab="code" ref={ref}>
43 - {error && <div class="flash-error">{error}</div>}
44 -
45 - {/* Breadcrumb */}
46 - <nav style="margin-bottom: 16px; font-size: 0.875rem;">
47 - <a href={`/${owner}/${repo}`}>{repo}</a>
48 - {breadcrumbs.map((bc) => (
49 - <>
50 - <span style="color: var(--text-muted);"> / </span>
51 - {bc.isLast ? (
52 - <strong>{bc.name}</strong>
53 - ) : (
54 - <a href={bc.href}>{bc.name}</a>
55 - )}
56 - </>
57 - ))}
58 - </nav>
59 -
60 - {blob && (
61 - <div class="card" style="padding: 0; overflow: hidden;">
62 - {/* File header */}
63 - <div style="padding: 8px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center;">
64 - <span style="font-size: 0.8125rem; color: var(--text-muted);">
65 - {lines.length} lines &middot; {formatSize(blob.size)}
66 - </span>
67 - <a
68 - href={`/api/repos/${owner}/${repo}/blob/${ref}/${path}`}
69 - class="btn"
70 - style="font-size: 0.75rem; padding: 2px 10px;"
71 - >
72 - Raw
73 - </a>
74 - </div>
75 -
76 - {isBinary ? (
77 - <div style="padding: 32px; text-align: center; color: var(--text-muted);">
78 - Binary file ({formatSize(blob.size)})
79 - </div>
80 - ) : (
81 - <div style="overflow-x: auto;">
82 - <table style="border-collapse: collapse; width: 100%;">
83 - <tbody>
84 - {lines.map((line: string, i: number) => (
85 - <tr>
86 - <td style="
87 - padding: 0 12px;
88 - text-align: right;
89 - color: var(--text-muted);
90 - user-select: none;
91 - white-space: nowrap;
92 - font-family: var(--font-mono);
93 - font-size: 0.8125rem;
94 - line-height: 1.5;
95 - vertical-align: top;
96 - border-right: 1px solid var(--border);
97 - min-width: 50px;
98 - ">
99 - {i + 1}
100 - </td>
101 - <td style="
102 - padding: 0 12px;
103 - white-space: pre;
104 - font-family: var(--font-mono);
105 - font-size: 0.8125rem;
106 - line-height: 1.5;
107 - ">
108 - {line}
109 - </td>
110 - </tr>
111 - ))}
112 - </tbody>
113 - </table>
114 - </div>
115 - )}
116 - </div>
117 - )}
118 -</Repo>
119 -
A packages/ui/src/pages/[owner]/[repo]/tree/[...refAndPath].astro
@@ -1,0 +1,80 @@
1 +---
2 +import Repo from '../../../../layouts/Repo.astro';
3 +import { apiGet } from '../../../../lib/api';
4 +
5 +const { owner, repo, refAndPath } = Astro.params;
6 +const cookie = Astro.request.headers.get('cookie') || '';
7 +
8 +let tree: any = null;
9 +let ref = '';
10 +let path = '';
11 +let error = '';
12 +
13 +try {
14 + tree = await apiGet(`/api/repos/${owner}/${repo}/tree/${refAndPath}`, cookie);
15 + ref = tree.ref || '';
16 + path = tree.path || '';
17 +} catch (e: any) {
18 + error = e.message;
19 +}
20 +
21 +// Build breadcrumb segments from the resolved path
22 +const pathSegments = path.split('/').filter(Boolean);
23 +const breadcrumbs = pathSegments.map((seg: string, i: number) => ({
24 + name: seg,
25 + href: `/${owner}/${repo}/tree/${ref}/${pathSegments.slice(0, i + 1).join('/')}`,
26 +}));
27 +---
28 +
29 +<Repo owner={owner!} repo={repo!} activeTab="code" ref={ref}>
30 + {error && <div class="flash-error">{error}</div>}
31 +
32 + {/* Breadcrumb */}
33 + <nav style="margin-bottom: 16px; font-size: 0.875rem;">
34 + <a href={`/${owner}/${repo}`}>{repo}</a>
35 + {breadcrumbs.map((bc: any) => (
36 + <>
37 + <span style="color: var(--text-muted);"> / </span>
38 + <a href={bc.href}>{bc.name}</a>
39 + </>
40 + ))}
41 + </nav>
42 +
43 + {tree && (
44 + <div class="card" style="padding: 0; overflow: hidden;">
45 + <table style="width: 100%; border-collapse: collapse;">
46 + <tbody>
47 + {/* ".." entry to go up */}
48 + {pathSegments.length > 0 && (
49 + <tr style="border-bottom: 1px solid var(--border);">
50 + <td style="padding: 8px 16px; font-size: 0.875rem;">
51 + <a href={pathSegments.length === 1
52 + ? `/${owner}/${repo}/tree/${ref}`
53 + : `/${owner}/${repo}/tree/${ref}/${pathSegments.slice(0, -1).join('/')}`
54 + }>..</a>
55 + </td>
56 + </tr>
57 + )}
58 + {tree.entries.map((entry: any) => {
59 + const fullPath = path ? `${path}/${entry.name}` : entry.name;
60 + return (
61 + <tr style="border-bottom: 1px solid var(--border);">
62 + <td style="padding: 8px 16px; font-size: 0.875rem;">
63 + <span style="color: var(--text-muted); margin-right: 8px;">
64 + {entry.type === 'dir' ? '\u{1F4C1}' : '\u{1F4C4}'}
65 + </span>
66 + {entry.type === 'dir' ? (
67 + <a href={`/${owner}/${repo}/tree/${ref}/${fullPath}`}>{entry.name}</a>
68 + ) : (
69 + <a href={`/${owner}/${repo}/blob/${ref}/${fullPath}`}>{entry.name}</a>
70 + )}
71 + </td>
72 + </tr>
73 + );
74 + })}
75 + </tbody>
76 + </table>
77 + </div>
78 + )}
79 +</Repo>
80 +
D packages/ui/src/pages/[owner]/[repo]/tree/[ref]/[...path].astro
@@ -1,77 +1,0 @@
1 ----
2 -import Repo from '../../../../../layouts/Repo.astro';
3 -import { apiGet } from '../../../../../lib/api';
4 -
5 -const { owner, repo, ref, path } = Astro.params;
6 -const cookie = Astro.request.headers.get('cookie') || '';
7 -
8 -let tree: any = null;
9 -let error = '';
10 -
11 -try {
12 - const pathStr = path || '';
13 - tree = await apiGet(`/api/repos/${owner}/${repo}/tree/${ref}/${pathStr}`, cookie);
14 -} catch (e: any) {
15 - error = e.message;
16 -}
17 -
18 -// Build breadcrumb segments
19 -const pathSegments = (path || '').split('/').filter(Boolean);
20 -const breadcrumbs = pathSegments.map((seg, i) => ({
21 - name: seg,
22 - href: `/${owner}/${repo}/tree/${ref}/${pathSegments.slice(0, i + 1).join('/')}`,
23 -}));
24 ----
25 -
26 -<Repo owner={owner!} repo={repo!} activeTab="code" ref={ref}>
27 - {error && <div class="flash-error">{error}</div>}
28 -
29 - {/* Breadcrumb */}
30 - <nav style="margin-bottom: 16px; font-size: 0.875rem;">
31 - <a href={`/${owner}/${repo}`}>{repo}</a>
32 - {breadcrumbs.map((bc) => (
33 - <>
34 - <span style="color: var(--text-muted);"> / </span>
35 - <a href={bc.href}>{bc.name}</a>
36 - </>
37 - ))}
38 - </nav>
39 -
40 - {tree && (
41 - <div class="card" style="padding: 0; overflow: hidden;">
42 - <table style="width: 100%; border-collapse: collapse;">
43 - <tbody>
44 - {/* ".." entry to go up */}
45 - {pathSegments.length > 0 && (
46 - <tr style="border-bottom: 1px solid var(--border);">
47 - <td style="padding: 8px 16px; font-size: 0.875rem;">
48 - <a href={pathSegments.length === 1
49 - ? `/${owner}/${repo}`
50 - : `/${owner}/${repo}/tree/${ref}/${pathSegments.slice(0, -1).join('/')}`
51 - }>..</a>
52 - </td>
53 - </tr>
54 - )}
55 - {tree.entries.map((entry: any) => {
56 - const fullPath = path ? `${path}/${entry.name}` : entry.name;
57 - return (
58 - <tr style="border-bottom: 1px solid var(--border);">
59 - <td style="padding: 8px 16px; font-size: 0.875rem;">
60 - <span style="color: var(--text-muted); margin-right: 8px;">
61 - {entry.type === 'dir' ? '📁' : '📄'}
62 - </span>
63 - {entry.type === 'dir' ? (
64 - <a href={`/${owner}/${repo}/tree/${ref}/${fullPath}`}>{entry.name}</a>
65 - ) : (
66 - <a href={`/${owner}/${repo}/blob/${ref}/${fullPath}`}>{entry.name}</a>
67 - )}
68 - </td>
69 - </tr>
70 - );
71 - })}
72 - </tbody>
73 - </table>
74 - </div>
75 - )}
76 -</Repo>
77 -
M packages/worker/src/handlers/api/browse.ts
@@ -31,7 +31,51 @@
31 31 return { repo, browseService, refStore };
32 32 }
33 33
34 -/** GET /api/repos/:owner/:repo/tree/:ref/:path* */
34 +/**
35 + * Resolve a combined "ref/path" string into separate ref and path parts.
36 + * Branch names can contain slashes (e.g. "feat/initial-implementation"),
37 + * so we try matching against known branches from longest to shortest.
38 + */
39 +async function resolveRefAndPath(
40 + refStore: RefStore,
41 + refAndPath: string,
42 + defaultBranch: string
43 +): Promise<{ ref: string; path: string }> {
44 + if (!refAndPath) {
45 + return { ref: defaultBranch, path: '' };
46 + }
47 +
48 + const segments = refAndPath.split('/');
49 +
50 + // Try progressively longer prefixes as the ref name
51 + // Start from longest (most specific) to shortest
52 + const allRefs = await refStore.getAll();
53 + const branchNames = allRefs
54 + .filter((r) => r.name.startsWith('refs/heads/'))
55 + .map((r) => r.name.replace('refs/heads/', ''));
56 + const tagNames = allRefs
57 + .filter((r) => r.name.startsWith('refs/tags/'))
58 + .map((r) => r.name.replace('refs/tags/', ''));
59 + const allNames = new Set([...branchNames, ...tagNames]);
60 +
61 + // Try from longest possible ref to shortest
62 + for (let i = segments.length; i >= 1; i--) {
63 + const candidate = segments.slice(0, i).join('/');
64 + if (allNames.has(candidate)) {
65 + return { ref: candidate, path: segments.slice(i).join('/') };
66 + }
67 + }
68 +
69 + // Also check if it's a raw SHA
70 + if (/^[0-9a-f]{40}$/.test(segments[0])) {
71 + return { ref: segments[0], path: segments.slice(1).join('/') };
72 + }
73 +
74 + // Fall back to first segment as ref
75 + return { ref: segments[0], path: segments.slice(1).join('/') };
76 +}
77 +
78 +/** GET /api/repos/:owner/:repo/tree/:refAndPath* */
35 79 export async function handleApiTree(
36 80 request: Request,
37 81 hctx: HandlerContext
@@ -39,14 +83,16 @@
39 83 const ctx = await getBrowseContext(request, hctx);
40 84 if (!ctx) return Response.json({ error: 'Not found' }, { status: 404 });
41 85
42 - const { ref, path } = hctx.params;
43 - const commitSha = await ctx.browseService.resolveRef(ref || ctx.repo.default_branch);
44 - const entries = await ctx.browseService.listTree(commitSha, path || '');
86 + const { ref, path } = await resolveRefAndPath(
87 + ctx.refStore, hctx.params.refAndPath || '', ctx.repo.default_branch
88 + );
89 + const commitSha = await ctx.browseService.resolveRef(ref);
90 + const entries = await ctx.browseService.listTree(commitSha, path);
45 91
46 - return Response.json({ ref, commit: commitSha, path: path || '', entries });
92 + return Response.json({ ref, commit: commitSha, path, entries });
47 93 }
48 94
49 -/** GET /api/repos/:owner/:repo/blob/:ref/:path* */
95 +/** GET /api/repos/:owner/:repo/blob/:refAndPath* */
50 96 export async function handleApiBlob(
51 97 request: Request,
52 98 hctx: HandlerContext
@@ -54,10 +100,12 @@
54 100 const ctx = await getBrowseContext(request, hctx);
55 101 if (!ctx) return Response.json({ error: 'Not found' }, { status: 404 });
56 102
57 - const { ref, path } = hctx.params;
103 + const { ref, path } = await resolveRefAndPath(
104 + ctx.refStore, hctx.params.refAndPath || '', ctx.repo.default_branch
105 + );
58 106 if (!path) return Response.json({ error: 'Path required' }, { status: 400 });
59 107
60 - const commitSha = await ctx.browseService.resolveRef(ref || ctx.repo.default_branch);
108 + const commitSha = await ctx.browseService.resolveRef(ref);
61 109 const { content, sha } = await ctx.browseService.getBlob(commitSha, path);
62 110
63 111 // Check if content is likely text
@@ -89,7 +137,7 @@
89 137 }
90 138 }
91 139
92 -/** GET /api/repos/:owner/:repo/commits/:ref */
140 +/** GET /api/repos/:owner/:repo/commits/:ref (ref may contain slashes) */
93 141 export async function handleApiCommits(
94 142 request: Request,
95 143 hctx: HandlerContext
@@ -97,14 +145,14 @@
97 145 const ctx = await getBrowseContext(request, hctx);
98 146 if (!ctx) return Response.json({ error: 'Not found' }, { status: 404 });
99 147
100 - const { ref } = hctx.params;
148 + const refName = hctx.params.ref || ctx.repo.default_branch;
101 149 const url = new URL(request.url);
102 150 const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 100);
103 151
104 - const commitSha = await ctx.browseService.resolveRef(ref || ctx.repo.default_branch);
152 + const commitSha = await ctx.browseService.resolveRef(refName);
105 153 const commits = await ctx.browseService.getCommitLog(commitSha, limit);
106 154
107 - return Response.json({ ref, commits });
155 + return Response.json({ ref: refName, commits });
108 156 }
109 157
110 158 /** GET /api/repos/:owner/:repo/commit/:sha */
M packages/worker/src/router.ts
@@ -96,19 +96,19 @@
96 96 // REST API - Browsing
97 97 {
98 98 method: 'GET',
99 - pattern: /^\/api\/repos\/([^/]+)\/([^/]+)\/tree\/([^/]+)(?:\/(.+))?$/,
100 - paramNames: ['owner', 'repo', 'ref', 'path'],
99 + pattern: /^\/api\/repos\/([^/]+)\/([^/]+)\/tree\/(.+)$/,
100 + paramNames: ['owner', 'repo', 'refAndPath'],
101 101 handler: handleApiTree,
102 102 },
103 103 {
104 104 method: 'GET',
105 - pattern: /^\/api\/repos\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/,
106 - paramNames: ['owner', 'repo', 'ref', 'path'],
105 + pattern: /^\/api\/repos\/([^/]+)\/([^/]+)\/blob\/(.+)$/,
106 + paramNames: ['owner', 'repo', 'refAndPath'],
107 107 handler: handleApiBlob,
108 108 },
109 109 {
110 110 method: 'GET',
111 - pattern: /^\/api\/repos\/([^/]+)\/([^/]+)\/commits\/([^/]+)$/,
111 + pattern: /^\/api\/repos\/([^/]+)\/([^/]+)\/commits\/(.+)$/,
112 112 paramNames: ['owner', 'repo', 'ref'],
113 113 handler: handleApiCommits,
114 114 },