Skip to content

Commit 35ff978

Browse files
committed
Give a project a page: its branches, its dependencies, and a way out
Work item V3 of the M3 plan. The projects list linked to /projects/[id], which was an empty directory, so V1 removed the link rather than leave a 404. This builds the page and puts the link back. The branch table is the point of it. Everything needed to decide which branch is worth reviewing sits in the row: how far it has moved from the default branch either way, what its last commit said, and a Review action that carries the branch to the new-review screen already chosen. Arriving at a form that has forgotten the choice just made is the kind of friction that makes a tool feel like paperwork. Dependency links get their own panel, because a link is what makes a review of two repositories possible: it names the package the primary consumes, so a changed exported type can be traced to the consumer that never migrated. The form offers only projects that could actually be linked, excluding itself and anything already linked, so it cannot present a choice the repository would refuse. Where the repository does refuse, its own message is shown rather than restated in different words. Deleting is a two-step confirm, refused while any review still refers to the project, and it takes the clone with it. The row and the clone must not outlive each other in either direction: a clone with no row is disk nobody can account for, and a row with no clone is a project every screen offers and nothing can use. Driving the live server found what the test had missed. Delete removed the bare repo but left the project's directory behind, empty, one per deleted project, forever. The test asserted on the clone path, which was genuinely gone, so it passed. It now asserts the parent directory is gone too, and the route removes it. Verified against a fresh server: zero directories left after a delete. Two mutations checked and both caught: leaving the clone on disk, and offering already-linked projects as link candidates.
1 parent 311d041 commit 35ff978

10 files changed

Lines changed: 694 additions & 10 deletions

File tree

docs/DECISIONS.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,3 +677,17 @@ verified evidence, in writing, here.
677677
cancelled and failed from V1 (D-12 now fully implemented). The confirmation
678678
screen was the last thing that needed the checkout; the bundle and the logs
679679
stay as the evidence behind the report.
680+
- 2026-07-31 DECIDED (V3): deleting a project removes its whole directory
681+
under the data root, not only the bare clone inside it. Found by driving the
682+
live server rather than by the test, which asserted on the clone path and so
683+
passed while an empty directory was left behind for every project ever
684+
deleted. The test now asserts the parent is gone too.
685+
- 2026-07-31 DECIDED (V3): the project detail endpoint returns the projects
686+
that could still be linked, excluding itself and anything already linked, so
687+
the form cannot offer a choice the repository would refuse. The repository's
688+
own errors for a self-link and a duplicate are passed through verbatim
689+
rather than restated in different words on the route.
690+
- 2026-07-31 DECIDED (V3): a branch row links to the new-review screen with
691+
the branch already chosen. Arriving at a form that has forgotten the choice
692+
just made is the kind of small friction that makes a tool feel like
693+
paperwork.

docs/plans/M3-FINISH-PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ confirms the wiring.
345345
| -- | ------------------------------------------------------------ | ---------- | ------ |
346346
| V1 | budget cap, engine note, no-auto-probe, worktree cleanup, 404 unlink, activity cap | - | DONE |
347347
| V2 | confirm/dismiss/complete/context routes, confirmation UI, keyboard map | V1 | DONE |
348-
| V3 | project detail page, fetch-now, links CRUD, project delete | V1 | |
348+
| V3 | project detail page, fetch-now, links CRUD, project delete | V1 | DONE |
349349
| V4 | preflight route and panel, linked toggle with suggestion | V3 | |
350350
| V5 | report renderer, report/export routes, report UI | V2 | |
351351
| V6 | resume/queued/merged/delete UI, settings editor, probe buttons | V2 | |
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Fetch a project's refs now, rather than waiting for a branch list to do it.
3+
*
4+
* Useful when a branch was pushed a moment ago and the picker has not been
5+
* opened since. Git's own message is returned verbatim on failure: an
6+
* authentication problem and an unreachable host need different answers from
7+
* the person reading it.
8+
*/
9+
10+
import { recordFetch, requireProject } from "@/server/db/repositories/projects";
11+
import { failed, handler, ok } from "@/server/api/respond";
12+
import { fetchAll } from "@/server/gitops/repo";
13+
import { runtime } from "@/server/runtime";
14+
15+
export const dynamic = "force-dynamic";
16+
17+
export async function POST(
18+
_request: Request,
19+
context: { params: Promise<{ id: string }> },
20+
): Promise<Response> {
21+
return handler(async () => {
22+
const { db } = runtime();
23+
const { id } = await context.params;
24+
const project = requireProject(db, id);
25+
26+
try {
27+
await fetchAll(project.clonePath);
28+
} catch (error) {
29+
return failed(error, 502);
30+
}
31+
32+
recordFetch(db, project.id);
33+
return ok({ lastFetchedAt: requireProject(db, id).lastFetchedAt });
34+
});
35+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/** Removing a dependency link. Reviews already run keep their linked history. */
2+
3+
import { unlinkDependency } from "@/server/db/repositories/projects";
4+
import { handler, ok } from "@/server/api/respond";
5+
import { runtime } from "@/server/runtime";
6+
7+
export const dynamic = "force-dynamic";
8+
9+
export async function DELETE(
10+
_request: Request,
11+
context: { params: Promise<{ id: string; linkId: string }> },
12+
): Promise<Response> {
13+
return handler(async () => {
14+
const { linkId } = await context.params;
15+
unlinkDependency(runtime().db, linkId);
16+
return ok({ removed: linkId });
17+
});
18+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* A project's dependency links.
3+
*
4+
* The link is what makes a review of two repositories possible: it names the
5+
* package the primary consumes, so a change to an exported type in the
6+
* dependency can be traced to the consumer that never migrated.
7+
*/
8+
9+
import { z } from "zod";
10+
import { linkDependency, listDependencyLinks } from "@/server/db/repositories/projects";
11+
import { created, failed, handler, ok, readJson } from "@/server/api/respond";
12+
import { runtime } from "@/server/runtime";
13+
14+
export const dynamic = "force-dynamic";
15+
16+
const body = z.object({
17+
dependencyProjectId: z.string().min(1),
18+
packageName: z.string().trim().min(1),
19+
note: z.string().optional(),
20+
});
21+
22+
export async function GET(
23+
_request: Request,
24+
context: { params: Promise<{ id: string }> },
25+
): Promise<Response> {
26+
return handler(async () => {
27+
const { id } = await context.params;
28+
return ok({ links: listDependencyLinks(runtime().db, id) });
29+
});
30+
}
31+
32+
export async function POST(
33+
request: Request,
34+
context: { params: Promise<{ id: string }> },
35+
): Promise<Response> {
36+
return handler(async () => {
37+
const { db } = runtime();
38+
const { id } = await context.params;
39+
const input = await readJson(request, body);
40+
41+
try {
42+
// The repository already refuses a self-link and a duplicate, with
43+
// messages worth showing, so they are passed through rather than
44+
// re-checked here in different words.
45+
return created({
46+
link: linkDependency(db, {
47+
projectId: id,
48+
dependencyProjectId: input.dependencyProjectId,
49+
packageName: input.packageName,
50+
...(input.note === undefined ? {} : { note: input.note }),
51+
}),
52+
});
53+
} catch (error) {
54+
return failed(error, 400);
55+
}
56+
});
57+
}

src/app/api/projects/[id]/route.ts

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1-
/** One project, with its dependency links and its reviews. */
1+
/** One project, with its dependency links and its reviews. Or gone. */
22

3-
import { listDependencyLinks, requireProject } from "@/server/db/repositories/projects";
3+
import {
4+
deleteProject,
5+
listDependencyLinks,
6+
listProjects,
7+
requireProject,
8+
} from "@/server/db/repositories/projects";
49
import { listReviewsForProject } from "@/server/db/repositories/reviews";
5-
import { handler, ok } from "@/server/api/respond";
10+
import { failed, handler, ok } from "@/server/api/respond";
11+
import { dirname } from "node:path";
12+
import { removeRepo } from "@/server/gitops/repo";
613
import { runtime } from "@/server/runtime";
714

815
export const dynamic = "force-dynamic";
@@ -14,10 +21,58 @@ export async function GET(
1421
return handler(async () => {
1522
const { db } = runtime();
1623
const { id } = await context.params;
24+
const links = listDependencyLinks(db, id);
25+
const byId = new Map(listProjects(db).map((project) => [project.id, project.name]));
26+
1727
return ok({
1828
project: requireProject(db, id),
19-
links: listDependencyLinks(db, id),
29+
links: links.map((link) => ({
30+
...link,
31+
dependencyName: byId.get(link.dependencyProjectId) ?? "a project that is gone",
32+
})),
33+
// Offered as link candidates, so the form is a choice rather than an id
34+
// someone has to find.
35+
linkable: listProjects(db)
36+
.filter(
37+
(candidate) =>
38+
candidate.id !== id &&
39+
candidate.cloneStatus === "ready" &&
40+
!links.some((link) => link.dependencyProjectId === candidate.id),
41+
)
42+
.map((candidate) => ({ id: candidate.id, name: candidate.name })),
2043
reviews: listReviewsForProject(db, id),
2144
});
2245
});
2346
}
47+
48+
/**
49+
* Removes a project and the clone it owns.
50+
*
51+
* The row and the clone must not outlive each other in either direction: a
52+
* clone with no row is disk nobody can account for, and a row with no clone is
53+
* a project every screen offers and nothing can use. Refused while reviews
54+
* reference it, because deleting those silently would discard the history the
55+
* reviews are for.
56+
*/
57+
export async function DELETE(
58+
_request: Request,
59+
context: { params: Promise<{ id: string }> },
60+
): Promise<Response> {
61+
return handler(async () => {
62+
const { db } = runtime();
63+
const { id } = await context.params;
64+
const project = requireProject(db, id);
65+
66+
try {
67+
deleteProject(db, id);
68+
} catch (error) {
69+
return failed(error, 409);
70+
}
71+
72+
// The project's whole directory, not just the bare repo inside it.
73+
// Removing only the clone leaves an empty directory per deleted project,
74+
// accumulating quietly forever.
75+
if (project.clonePath) await removeRepo(dirname(project.clonePath));
76+
return ok({ removed: id });
77+
});
78+
}

0 commit comments

Comments
 (0)