Skip to content

Commit 6e2fa2e

Browse files
hotlongCopilot
andcommitted
feat(branches): add branch foundation to project revisions
PR-1 of the branch → preview → github-link sequence. Adds git-style logical branches to sys_project_revision so future preview URLs and GitHub integration can reference them. Schema: - sys_project_revision: + branch (text, default 'main', max 63) - sys_project_revision: + is_branch_head (boolean, default false) - 2 new indexes on (project_id, branch, ...) API (packages/services/service-cloud): - POST /cloud/projects/:id/metadata: accepts ?branch= or body.branch, validates slug, maintains is_branch_head pointer atomically - GET /cloud/projects/:id/revisions: ?branch= filter (NULL-tolerant for un-migrated rows when filter is 'main') - GET /cloud/projects/:id/branches: list with head + revision count - POST /cloud/projects/:id/branches/:name/rename - DEL /cloud/projects/:id/branches/:name (soft: clears head only; cannot delete 'main' or branch carrying current revision) CLI: - objectstack publish --branch <name> (env OS_PUBLISH_BRANCH) Client SDK: - listRevisions({branch}), listBranches, renameBranch, deleteBranch Studio: - Revisions tab: Branches summary card with rename/delete + Branch column with click-to-filter chip and 'head' badge - Overview: branch chip on current commit + branch badges on recent Tests: - 18 new tests in test/branches.test.ts (normalizeBranch slug rules, groupByBranch NULL handling, setBranchHead atomic flip, route integration); 81/81 service-cloud tests green; 100/100 turbo tasks Branch slug regex rejects 12-hex strings to avoid future collision with commit-pinned preview subdomain scheme. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 85cbaa6 commit 6e2fa2e

12 files changed

Lines changed: 1028 additions & 18 deletions

File tree

apps/studio/src/hooks/useProjects.ts

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -381,21 +381,61 @@ export interface ProjectRevisionRow {
381381
publishedBy: string | null;
382382
note: string | null;
383383
isCurrent: boolean;
384+
branch: string;
385+
isBranchHead: boolean;
384386
}
385387

386-
export function useRevisions(projectId: string | undefined) {
388+
export function useRevisions(projectId: string | undefined, opts?: { branch?: string }) {
387389
const client = useClient() as any;
388390
const [items, setItems] = useState<ProjectRevisionRow[]>([]);
389391
const [loading, setLoading] = useState(false);
390392
const [error, setError] = useState<Error | null>(null);
393+
const branch = opts?.branch;
391394

392395
const reload = useCallback(async () => {
393396
if (!projectId || !client?.projects?.listRevisions) return;
394397
setLoading(true);
395398
setError(null);
396399
try {
397-
const res = await client.projects.listRevisions(projectId, { limit: 100 });
398-
setItems(res.items ?? []);
400+
const res = await client.projects.listRevisions(projectId, { limit: 100, branch });
401+
setItems((res.items ?? []) as ProjectRevisionRow[]);
402+
} catch (err) {
403+
setError(err as Error);
404+
} finally {
405+
setLoading(false);
406+
}
407+
}, [client, projectId, branch]);
408+
409+
useEffect(() => {
410+
reload();
411+
}, [reload]);
412+
413+
return { items, loading, error, reload };
414+
}
415+
416+
export interface ProjectBranchRow {
417+
branch: string;
418+
headCommitId: string;
419+
headRevisionId: string;
420+
revisionCount: number;
421+
headPublishedAt: string | null;
422+
headNote: string | null;
423+
isCurrent: boolean;
424+
}
425+
426+
export function useBranches(projectId: string | undefined) {
427+
const client = useClient() as any;
428+
const [items, setItems] = useState<ProjectBranchRow[]>([]);
429+
const [loading, setLoading] = useState(false);
430+
const [error, setError] = useState<Error | null>(null);
431+
432+
const reload = useCallback(async () => {
433+
if (!projectId || !client?.projects?.listBranches) return;
434+
setLoading(true);
435+
setError(null);
436+
try {
437+
const res = await client.projects.listBranches(projectId);
438+
setItems((res?.branches ?? []) as ProjectBranchRow[]);
399439
} catch (err) {
400440
setError(err as Error);
401441
} finally {
@@ -410,6 +450,32 @@ export function useRevisions(projectId: string | undefined) {
410450
return { items, loading, error, reload };
411451
}
412452

453+
export function useBranchMutations() {
454+
const client = useClient() as any;
455+
const [busy, setBusy] = useState(false);
456+
const [error, setError] = useState<Error | null>(null);
457+
458+
const rename = useCallback(
459+
async (projectId: string, from: string, to: string) => {
460+
if (!client?.projects?.renameBranch) throw new Error('Client not ready');
461+
setBusy(true); setError(null);
462+
try { return await client.projects.renameBranch(projectId, from, to); }
463+
catch (e) { setError(e as Error); throw e; }
464+
finally { setBusy(false); }
465+
}, [client]);
466+
467+
const remove = useCallback(
468+
async (projectId: string, name: string) => {
469+
if (!client?.projects?.deleteBranch) throw new Error('Client not ready');
470+
setBusy(true); setError(null);
471+
try { return await client.projects.deleteBranch(projectId, name); }
472+
catch (e) { setError(e as Error); throw e; }
473+
finally { setBusy(false); }
474+
}, [client]);
475+
476+
return { rename, remove, busy, error };
477+
}
478+
413479
export interface ProjectMemberRow {
414480
id: string;
415481
user_id: string;

apps/studio/src/routes/projects.$projectId.index.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,10 +207,18 @@ function RealProjectOverview({ projectId }: { projectId: string }) {
207207
<div className="mt-2 truncate font-mono text-sm font-medium">
208208
{currentRevision ? currentRevision.commitId.slice(0, 12) : '—'}
209209
</div>
210-
<div className="mt-1 truncate text-xs text-muted-foreground">
211-
{currentRevision?.publishedAt
212-
? `Published ${new Date(currentRevision.publishedAt).toLocaleDateString()}`
213-
: 'No artifact published yet'}
210+
<div className="mt-1 flex items-center gap-1 truncate text-xs text-muted-foreground">
211+
{currentRevision ? (
212+
<>
213+
<span className="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-foreground/80">
214+
<GitCommit className="h-2.5 w-2.5" />
215+
{currentRevision.branch ?? 'main'}
216+
</span>
217+
<span>· {new Date(currentRevision.publishedAt).toLocaleDateString()}</span>
218+
</>
219+
) : (
220+
'No artifact published yet'
221+
)}
214222
</div>
215223
</Card>
216224

@@ -295,6 +303,9 @@ function RealProjectOverview({ projectId }: { projectId: string }) {
295303
<div className="min-w-0 flex-1">
296304
<div className="flex items-center gap-2">
297305
<code className="font-mono text-sm">{r.commitId.slice(0, 12)}</code>
306+
{r.branch && r.branch !== 'main' && (
307+
<Badge variant="outline" className="text-[10px]">{r.branch}</Badge>
308+
)}
298309
{r.isCurrent && (
299310
<Badge variant="secondary" className="text-[10px]">current</Badge>
300311
)}

apps/studio/src/routes/projects.$projectId.revisions.tsx

Lines changed: 145 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
import { createFileRoute, useParams } from '@tanstack/react-router';
1515
import { useMemo, useState } from 'react';
16-
import { Copy, RotateCcw, Loader2, Eye } from 'lucide-react';
16+
import { Copy, RotateCcw, Loader2, Eye, GitBranch, Trash2, Pencil } from 'lucide-react';
1717
import { Card } from '@/components/ui/card';
1818
import { Button } from '@/components/ui/button';
1919
import { Badge } from '@/components/ui/badge';
@@ -22,6 +22,8 @@ import {
2222
useProjectDetail,
2323
useRevisions,
2424
useActivateRevision,
25+
useBranches,
26+
useBranchMutations,
2527
} from '@/hooks/useProjects';
2628
import { toast } from '@/hooks/use-toast';
2729

@@ -40,8 +42,11 @@ function formatBytes(n: number): string {
4042
function ProjectRevisionsComponent() {
4143
const { projectId } = useParams({ from: '/projects/$projectId/revisions' });
4244
const { detail } = useProjectDetail(projectId);
43-
const { items, loading, reload } = useRevisions(projectId);
45+
const [branchFilter, setBranchFilter] = useState<string | null>(null);
46+
const { items, loading, reload } = useRevisions(projectId, { branch: branchFilter ?? undefined });
47+
const { items: branches, loading: branchesLoading, reload: reloadBranches } = useBranches(projectId);
4448
const { activate, activating } = useActivateRevision();
49+
const { rename: renameBranch, remove: removeBranch, busy: branchMutating } = useBranchMutations();
4550
const [pendingCommit, setPendingCommit] = useState<string | null>(null);
4651

4752
const project = detail?.project;
@@ -101,6 +106,34 @@ function ProjectRevisionsComponent() {
101106
window.open(url, '_blank', 'noopener,noreferrer');
102107
};
103108

109+
const handleRenameBranch = async (from: string) => {
110+
const to = window.prompt(`Rename branch "${from}" to:`, from);
111+
if (!to || to === from) return;
112+
try {
113+
await renameBranch(projectId, from, to);
114+
toast({ title: 'Branch renamed', description: `${from}${to}` });
115+
if (branchFilter === from) setBranchFilter(to);
116+
await Promise.all([reload(), reloadBranches()]);
117+
} catch (err) {
118+
toast({ title: 'Rename failed', description: (err as Error).message, variant: 'destructive' });
119+
}
120+
};
121+
122+
const handleDeleteBranch = async (name: string) => {
123+
if (!window.confirm(
124+
`Delete branch "${name}"?\n\nThe branch's revisions will remain (their commit URLs still resolve), ` +
125+
`but branch-tracking preview URLs for "${name}" will stop working.`,
126+
)) return;
127+
try {
128+
await removeBranch(projectId, name);
129+
toast({ title: 'Branch deleted', description: `Branch "${name}" demoted` });
130+
if (branchFilter === name) setBranchFilter(null);
131+
await Promise.all([reload(), reloadBranches()]);
132+
} catch (err) {
133+
toast({ title: 'Delete failed', description: (err as Error).message, variant: 'destructive' });
134+
}
135+
};
136+
104137
return (
105138
<main className="flex min-w-0 flex-1 flex-col overflow-auto bg-background">
106139
{project && (
@@ -128,6 +161,99 @@ function ProjectRevisionsComponent() {
128161
)}
129162
</div>
130163

164+
{/* Branches summary card */}
165+
<Card className="overflow-hidden">
166+
<div className="flex items-center justify-between border-b bg-muted/40 px-4 py-2.5">
167+
<h2 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
168+
<GitBranch className="h-3.5 w-3.5" />
169+
Branches
170+
<span className="font-normal normal-case tracking-normal text-muted-foreground/70">
171+
({branchesLoading ? '…' : branches.length})
172+
</span>
173+
</h2>
174+
{branchFilter && (
175+
<Button
176+
variant="ghost"
177+
size="sm"
178+
className="h-7 px-2 text-xs"
179+
onClick={() => setBranchFilter(null)}
180+
>
181+
Show all branches
182+
</Button>
183+
)}
184+
</div>
185+
{branchesLoading ? (
186+
<div className="px-4 py-3 text-xs text-muted-foreground">Loading branches…</div>
187+
) : branches.length === 0 ? (
188+
<div className="px-4 py-3 text-xs text-muted-foreground">
189+
No branches yet. Run{' '}
190+
<code className="font-mono">objectstack publish --branch &lt;name&gt;</code> to create one.
191+
</div>
192+
) : (
193+
<div className="divide-y divide-border">
194+
{branches.map((b) => {
195+
const isFiltered = branchFilter === b.branch;
196+
const isMain = b.branch === 'main';
197+
return (
198+
<div
199+
key={b.branch}
200+
className={`flex items-center justify-between gap-3 px-4 py-2.5 hover:bg-muted/20 ${
201+
isFiltered ? 'bg-muted/30' : ''
202+
}`}
203+
>
204+
<button
205+
type="button"
206+
onClick={() => setBranchFilter(isFiltered ? null : b.branch)}
207+
className="flex min-w-0 flex-1 items-center gap-2 text-left"
208+
title={isFiltered ? 'Click to clear filter' : `Click to filter to ${b.branch}`}
209+
>
210+
<GitBranch className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
211+
<code className="truncate font-mono text-sm font-medium">{b.branch}</code>
212+
{isMain && (
213+
<Badge variant="outline" className="text-[10px]">default</Badge>
214+
)}
215+
{b.isCurrent && (
216+
<Badge variant="default" className="text-[10px]">current</Badge>
217+
)}
218+
<span className="text-xs text-muted-foreground">
219+
head <code className="font-mono">{b.headCommitId.slice(0, 12)}</code>
220+
{b.revisionCount > 1 && ` · ${b.revisionCount} revisions`}
221+
{b.headPublishedAt && ` · ${new Date(b.headPublishedAt).toLocaleDateString()}`}
222+
</span>
223+
</button>
224+
<div className="flex items-center gap-1">
225+
{!isMain && (
226+
<Button
227+
variant="ghost"
228+
size="sm"
229+
className="h-7 w-7 p-0"
230+
onClick={() => handleRenameBranch(b.branch)}
231+
disabled={branchMutating}
232+
title="Rename branch"
233+
>
234+
<Pencil className="h-3.5 w-3.5" />
235+
</Button>
236+
)}
237+
{!isMain && !b.isCurrent && (
238+
<Button
239+
variant="ghost"
240+
size="sm"
241+
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
242+
onClick={() => handleDeleteBranch(b.branch)}
243+
disabled={branchMutating}
244+
title="Delete branch (revisions remain accessible by commit URL)"
245+
>
246+
<Trash2 className="h-3.5 w-3.5" />
247+
</Button>
248+
)}
249+
</div>
250+
</div>
251+
);
252+
})}
253+
</div>
254+
)}
255+
</Card>
256+
131257
{loading ? (
132258
<Card className="flex items-center justify-center p-12 text-sm text-muted-foreground">
133259
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -148,6 +274,7 @@ function ProjectRevisionsComponent() {
148274
<thead className="bg-muted/40 text-xs uppercase tracking-wider text-muted-foreground">
149275
<tr>
150276
<th className="px-4 py-3 text-left font-medium">Commit</th>
277+
<th className="px-4 py-3 text-left font-medium">Branch</th>
151278
<th className="px-4 py-3 text-left font-medium">Size</th>
152279
<th className="px-4 py-3 text-left font-medium">Built</th>
153280
<th className="px-4 py-3 text-left font-medium">By</th>
@@ -174,6 +301,22 @@ function ProjectRevisionsComponent() {
174301
</div>
175302
)}
176303
</td>
304+
<td className="px-4 py-3 align-top">
305+
<button
306+
type="button"
307+
onClick={() =>
308+
setBranchFilter(branchFilter === r.branch ? null : r.branch)
309+
}
310+
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-muted"
311+
title={`Filter by branch ${r.branch}`}
312+
>
313+
<GitBranch className="h-3 w-3 text-muted-foreground" />
314+
<code className="font-mono text-xs">{r.branch}</code>
315+
{r.isBranchHead && (
316+
<Badge variant="outline" className="text-[10px]">head</Badge>
317+
)}
318+
</button>
319+
</td>
177320
<td className="px-4 py-3 align-top text-xs text-muted-foreground">
178321
{formatBytes(r.sizeBytes)}
179322
</td>

packages/cli/src/commands/publish.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ export default class Publish extends Command {
3939
char: 'n',
4040
description: 'Optional human-readable note to attach to this revision',
4141
}),
42+
branch: Flags.string({
43+
char: 'b',
44+
description: 'Logical branch this publish belongs to (e.g. main, staging, feature-x). Default: main.',
45+
env: 'OS_PUBLISH_BRANCH',
46+
default: 'main',
47+
}),
4248
};
4349

4450
async run(): Promise<void> {
@@ -66,8 +72,11 @@ export default class Publish extends Command {
6672
printSuccess(`Loaded artifact (${(artifactRaw.length / 1024).toFixed(1)} KB)`);
6773

6874
// 2. POST to the control-plane publish endpoint
69-
const noteQs = flags.note ? `?note=${encodeURIComponent(flags.note)}` : '';
70-
const serverUrl = `${flags.server}/api/v1/cloud/projects/${flags.project}/metadata${noteQs}`;
75+
const qsParams = new URLSearchParams();
76+
if (flags.note) qsParams.set('note', flags.note);
77+
if (flags.branch) qsParams.set('branch', flags.branch);
78+
const qs = qsParams.toString();
79+
const serverUrl = `${flags.server}/api/v1/cloud/projects/${flags.project}/metadata${qs ? `?${qs}` : ''}`;
7180
printStep(`Publishing to ${serverUrl}...`);
7281

7382
const response = await (async () => {
@@ -116,6 +125,7 @@ export default class Publish extends Command {
116125
console.log('');
117126
printSuccess('Artifact published successfully');
118127
printKV(' Project', flags.project);
128+
printKV(' Branch', data?.branch ?? flags.branch);
119129
if (data?.commitId) printKV(' Commit', data.commitId);
120130
const checksumStr = typeof data?.checksum === 'string'
121131
? data.checksum

0 commit comments

Comments
 (0)