✨ (repos): Fork a repository into your own namespace
Changes
19 files changed, +618 -8
MODIFY
README.md
+8 -1
@@ -38,6 +38,13 @@
38
38
managing mirrors, and managing collaborators stay owner-only. Guides:
39
39
[for users](docs/users/collaborators.md), [for admins](docs/admins/collaborators.md)
40
40
- Per-repository images: the repo owner can upload a custom image (same PNG/JPEG/GIF/WebP, ≤ 2 MB, validated rules as avatars) on a dedicated owner-only repository **Settings** page (`/repos/{owner}/{name}/settings`), stored on the filesystem keyed by repo UUID. It replaces the owner's avatar wherever the repository is shown (repo lists, repo sidebar); a repository with no custom image falls back to its owner's avatar. Served at `GET /repos/{owner}/{name}/image`, visibility-guarded so a private repo's image never leaks (`404` for non-viewers), and removable back to the fallback
41
+- **Forks** — any logged-in user can fork a repository they can read into their own namespace with
42
+ one click (or `POST /api/v1/repos/{owner}/{name}/fork`). The fork carries the source's name,
43
+ visibility, and description, copies every branch and tag via a local bare clone, and records a
44
+ `forked from {owner}/{name}` link back to its parent. A private source can only be forked by
45
+ someone who can already read it, so a fork never exposes it. Guides:
46
+ [for users](docs/users/forking.md), [for admins](docs/admins/forking.md),
47
+ [architecture](docs/maintainers/forking.md)
41
48
- **Search** — a simple case-insensitive substring search over repositories (owner, name,
42
49
description) and people (username, display name), from the header search box or `GET /search`,
43
50
and as JSON at `GET /api/v1/search?q=<term>`. Repository hits obey the same visibility rule as
@@ -141,7 +148,7 @@
141
148
142
149
| Area | Tools |
143
150
|---|---|
144
-| Repositories | `listRepositories`, `getRepository`, `createRepository`, `deleteRepository` |
151
+| Repositories | `listRepositories`, `getRepository`, `createRepository`, `forkRepository`, `deleteRepository` |
145
152
| Issues | `listIssues`, `getIssue`, `createIssue`, `updateIssueStatus`, `deleteIssue` |
146
153
| Merge requests | `listMergeRequests`, `getMergeRequest`, `createMergeRequest`, `mergeMergeRequest`, `closeMergeRequest`, `listMergeRequestComments`, `addMergeRequestComment` |
147
154
| User | `currentUser` |
MODIFY
docs/README.md
+6 -0
@@ -8,6 +8,8 @@
8
8
9
9
### For users
10
10
11
+- **[Forking](users/forking.md)** — make your own copy of any repository you can
12
+ read, what a fork carries, and the one-fork-per-name rule.
11
13
- **[Search](users/search.md)** — the header search box: case-insensitive
12
14
substring search over repositories and people, and how visibility is honored.
13
15
- **[Profile settings](users/profile.md)** — change your username and display
@@ -35,6 +37,8 @@
35
37
- **[Getting Started](admins/getting-started.md)** — deploy git-shark with Docker
36
38
Compose, from zero to a running instance behind TLS with OIDC login and SSH git
37
39
access.
40
+- **[Forks](admins/forking.md)** — the fork endpoints, the `parent_repo_id`
41
+ column and its `ON DELETE SET NULL` semantics, and storage implications.
38
42
- **[Search](admins/search.md)** — the `/search` and `/api/v1/search` endpoints,
39
43
JSON shape, matching semantics, visibility enforcement (no configuration).
40
44
- **[Persistent data](admins/persistent-data.md)** — every store that must survive
@@ -52,6 +56,8 @@
52
56
53
57
### For maintainers
54
58
59
+- **[Forking](maintainers/forking.md)** — how forks are created (clone vs copy),
60
+ the `parent` model, visibility rules, and what works versus what is still to do.
55
61
- **[Avatars](maintainers/avatars.md)** — profile-picture storage, validation,
56
62
and rendering, plus what's covered and what's out of scope.
57
63
- **[Repository images](maintainers/repo-images.md)** — per-repository image
ADD
docs/admins/forking.md
+48 -0
@@ -0,0 +1,48 @@
1
+# Forks
2
+
3
+Forking lets a user copy any repository they can read into their own namespace.
4
+It needs **no configuration** — no `GITSHARK_*` properties and no background
5
+jobs.
6
+
7
+## Schema
8
+
9
+Migration `V17__repository_fork.sql` adds one column to `repositories`:
10
+
11
+| Column | Type | Notes |
12
+|---|---|---|
13
+| `parent_repo_id` | `uuid` NULL | FK → `repositories(id)`, `ON DELETE SET NULL`, indexed (`idx_repositories_parent`) |
14
+
15
+`ON DELETE SET NULL` means deleting a source repository leaves its forks
16
+standing as independent repositories (their `parent_repo_id` is cleared) rather
17
+than cascading the deletion. No new tables.
18
+
19
+## Endpoints
20
+
21
+| Method & path | Auth | Effect |
22
+|---|---|---|
23
+| `POST /api/v1/repos/{owner}/{name}/fork` | Bearer token (required) | Fork into the caller's namespace; `201` with the new `RepositoryView`, `409` if the caller already has a repository of that name, `404` if the source is not readable, `401` without a token |
24
+| `POST /repos/{owner}/{name}/fork` | Session (required) | Same, from the UI; `303` to the fork (or to the caller's existing repository of that name), `403` for anonymous callers |
25
+
26
+The `RepositoryView` payload gains two nullable fields, `parentOwner` and
27
+`parentName`. They are populated only when the repository is a fork **and the
28
+caller can read the parent**, so a source turned private after being forked is
29
+never disclosed through its forks. Listing endpoints omit them entirely.
30
+
31
+## Behavior
32
+
33
+- The fork copies the source's name, visibility, and description, and clones the
34
+ bare repository on disk with `git clone --bare` semantics (all branches, plus
35
+ reachable tags) into the forking user's storage directory.
36
+- Visibility is enforced up front: the caller must be able to **read** the
37
+ source, so a private repository is never exposed through a fork. A private
38
+ source produces a private fork.
39
+- Storage grows by roughly the size of the source repository per fork — clones
40
+ are independent copies, not shared object stores. Account for this in disk
41
+ provisioning if forking is heavily used.
42
+
43
+## Storage
44
+
45
+Forks live under the same `gitshark.storage.root` layout as any other
46
+repository (`<owner-id>/<repo-id>.git`); see
47
+[Persistent data](persistent-data.md). Nothing fork-specific needs a separate
48
+volume.
ADD
docs/maintainers/forking.md
+84 -0
@@ -0,0 +1,84 @@
1
+# Forking architecture
2
+
3
+How a fork is created, stored, and represented, and the decisions behind it.
4
+
5
+## Component map
6
+
7
+- **`GitRepositoryService.fork(User actor, Repository source)`** — the single
8
+ entry point. Validates read access, checks for a name collision in the
9
+ actor's namespace, persists the new `Repository` row (with `parent` set to the
10
+ source), then clones the bare repository on disk. Reused by every surface.
11
+- **`Repository.parent`** (`@ManyToOne`, column `parent_repo_id`) — the fork
12
+ link. `@ManyToOne` is EAGER by default, so `parent` loads with the repository
13
+ and both the API projection and the Qute sidebar can render “forked from …”
14
+ without a second query. `Repository.isFork()` is the null check.
15
+- **`RepositoryApiResource.fork`** — `POST /api/v1/repos/{owner}/{name}/fork`,
16
+ returns `201`/`409`/`404`/`401`.
17
+- **`RepositoryTools.forkRepository`** — the MCP mirror of the REST endpoint.
18
+- **`RepositoryResource.fork`** — the UI `POST …/fork`, redirecting to the fork.
19
+- **`ApiModels.RepositoryView`** — carries `parentOwner`/`parentName`.
20
+
21
+## Data flow
22
+
23
+1. The surface resolves the source through the normal visibility gate
24
+ (`requireReadable`), so an unreadable private source is a `404`/`403` before
25
+ `fork()` runs.
26
+2. `fork()` re-checks `accessPolicy.canRead(actor, source)` — defense in depth,
27
+ independent of the caller — then guards against a duplicate name in the
28
+ actor's namespace.
29
+3. It persists the fork row inside the method's transaction.
30
+4. It clones the source's bare repository into the fork's on-disk path with
31
+ `Git.cloneRepository().setBare(true).setCloneAllBranches(true)` over a
32
+ `file://` URI, copying HEAD, all branches, and reachable tags.
33
+
34
+## Decisions
35
+
36
+- **Clone, not filesystem copy.** A JGit bare clone reproduces the ref set and
37
+ object database cleanly and skips source-specific junk (stale `config`
38
+ remotes, hooks). The trade-off is that each fork is an independent object
39
+ store — no shared/alternates dedup — which is acceptable at git-shark's target
40
+ scale and keeps deletion trivial. If storage pressure ever matters, git
41
+ alternates are the place to look.
42
+- **`ON DELETE SET NULL`, not cascade.** Deleting a source must not delete other
43
+ people's forks. A fork whose parent is gone simply loses its “forked from”
44
+ link and becomes a standalone repository.
45
+- **Visibility inherited, never widened.** The fork takes the source's
46
+ visibility and the actor must already be able to read the source, so forking
47
+ can never turn a private repository public or expose it to someone new.
48
+- **Parent link gated per viewer, not just at fork time.** The "forked from"
49
+ link and the API's `parentOwner`/`parentName` are shown only when the *current
50
+ viewer* can read the parent (`accessPolicy.canRead(viewer, parent)`), computed
51
+ in `RepoNavService` for the UI and via `canSeeParent(...)` in the REST/MCP
52
+ resources. This matters because a source can be flipped to private *after*
53
+ being forked; without the per-viewer check, a public fork would keep leaking
54
+ the now-private parent's owner/name. `RepositoryView.of(repo)` hides the parent
55
+ by default, so listings never disclose it — callers opt in with
56
+ `of(repo, showParent)`.
57
+- **Personal namespace only (v1).** Forks land under the acting user, never an
58
+ organisation. Forking into an org is a future extension.
59
+- **Fork button open to any logged-in reader**, and a repeat fork redirects to
60
+ the caller's existing repository of that name rather than erroring — a
61
+ minimal stand-in for a fork picker.
62
+
63
+## What works today
64
+
65
+- Fork any readable repository into your personal namespace (UI, REST, MCP).
66
+- Full ref copy: HEAD, all branches, reachable tags.
67
+- `parent`/`isFork` model, “forked from” link, and `parentOwner`/`parentName`
68
+ in the API projection.
69
+- Visibility enforcement (private sources never exposed; private fork of a
70
+ private source), including the per-viewer "forked from" gate that keeps a
71
+ source turned private after forking from leaking through its public forks.
72
+- `ON DELETE SET NULL` so forks outlive their source.
73
+
74
+## What still needs to be implemented
75
+
76
+- **Cross-repo merge requests** — opening an MR from a fork branch against the
77
+ parent repository. The `parent` link is in place; the MR subsystem does not
78
+ yet target a different repository.
79
+- **Fork syncing** — “update from upstream” to pull later parent changes into a
80
+ fork.
81
+- **Fork discovery** — a fork network/graph view and a “forks” count on the
82
+ source.
83
+- **Forking into an organisation** namespace.
84
+- **Storage sharing** (git alternates) to avoid a full object copy per fork.
ADD
docs/users/forking.md
+44 -0
@@ -0,0 +1,44 @@
1
+# Forking a repository
2
+
3
+A **fork** is your own copy of someone else's repository, living under your
4
+namespace. Fork a project to experiment freely, then push your branch and open a
5
+merge request back to the original.
6
+
7
+## How to fork
8
+
9
+Open any repository you can see and click the **fork** button (⑂) in the
10
+repository sidebar, next to Clone and Pin. git-shark creates a new repository at
11
+`/<your-handle>/<name>` and sends you straight to it.
12
+
13
+The fork starts as a faithful copy of the source at that moment:
14
+
15
+- the same **name**, **description**, and **visibility** as the source;
16
+- **every branch and tag**, with the same default branch;
17
+- a **“forked from `owner/name`”** link in the sidebar pointing back at the
18
+ original.
19
+
20
+You own the fork outright — push, rename, change its visibility, or delete it
21
+without touching the original. A fork does **not** stay in sync with its source
22
+automatically; pulling later changes from upstream is a manual `git` operation
23
+for now.
24
+
25
+## What you can fork
26
+
27
+You can fork any repository you are allowed to read: every public repository,
28
+plus private ones you own or that are shared with you (as a collaborator or
29
+through an organisation). A private repository can never be forked by someone
30
+who cannot already read it, so forking never exposes a private project.
31
+
32
+If you fork a **private** repository, your fork is created **private** too.
33
+
34
+## If you already have a fork
35
+
36
+You can only have one repository of a given name in your namespace. If you
37
+already forked a project (or own a repository with that name), the fork button
38
+simply takes you to that existing repository instead of creating a duplicate.
39
+
40
+## Forking from the API or an AI client
41
+
42
+- REST: `POST /api/v1/repos/{owner}/{name}/fork` with your access token — see the
43
+ [admin reference](../admins/forking.md).
44
+- MCP: the `forkRepository` tool, listed in the [AI clients guide](mcp.md).
MODIFY
docs/users/mcp.md
+1 -1
@@ -69,7 +69,7 @@
69
69
| Area | Tools | Token needed? |
70
70
|---|---|---|
71
71
| Repositories | `listRepositories`, `getRepository` | no (public repos) |
72
-| | `createRepository`, `deleteRepository` | yes (owner) |
72
+| | `createRepository`, `forkRepository`, `deleteRepository` | yes |
73
73
| Issues | `listIssues`, `getIssue` | no (public repos) |
74
74
| | `createIssue`, `updateIssueStatus`, `deleteIssue` | yes (owner or collaborator) |
75
75
| Merge requests | `listMergeRequests`, `getMergeRequest`, `listMergeRequestComments` | no (public repos) |
MODIFY
src/main/java/de/workaround/api/ApiModels.java
+14 -2
@@ -22,12 +22,24 @@
22
22
// -- responses --
23
23
24
24
public record RepositoryView(String owner, String name, Repository.Visibility visibility, String description,
25
- Instant createdAt)
25
+ Instant createdAt, String parentOwner, String parentName)
26
26
{
27
+ /** Projection with the parent hidden — safe default for listings where the viewer's read access is unknown. */
27
28
public static RepositoryView of(Repository repo)
28
29
{
30
+ return of(repo, false);
31
+ }
32
+
33
+ /**
34
+ * @param showParent whether the caller may see the fork's parent; when false (or the repo is not a
35
+ * fork) {@code parentOwner}/{@code parentName} are null, so a source turned private is never
36
+ * disclosed through its forks.
37
+ */
38
+ public static RepositoryView of(Repository repo, boolean showParent)
39
+ {
40
+ boolean parent = showParent && repo.parent != null;
29
41
return new RepositoryView(repo.ownerHandle(), repo.name, repo.visibility, repo.description,
30
- repo.createdAt);
42
+ repo.createdAt, parent ? repo.parent.ownerHandle() : null, parent ? repo.parent.name : null);
31
43
}
32
44
}
33
45
MODIFY
src/main/java/de/workaround/api/RepositoryApiResource.java
+27 -1
@@ -76,7 +76,27 @@
76
76
@Path("{owner}/{name}")
77
77
public ApiModels.RepositoryView get(@PathParam("owner") String owner, @PathParam("name") String name)
78
78
{
79
- return ApiModels.RepositoryView.of(requireReadable(owner, name));
79
+ Repository repo = requireReadable(owner, name);
80
+ return ApiModels.RepositoryView.of(repo, canSeeParent(repo));
81
+ }
82
+
83
+ @POST
84
+ @Path("{owner}/{name}/fork")
85
+ public Response fork(@PathParam("owner") String owner, @PathParam("name") String name)
86
+ {
87
+ User user = principal.require();
88
+ Repository source = requireReadable(owner, name);
89
+ try
90
+ {
91
+ Repository forked = service.fork(user, source);
92
+ return Response.created(URI.create("/api/v1/repos/" + user.username + "/" + forked.name))
93
+ .entity(ApiModels.RepositoryView.of(forked, canSeeParent(forked))).build();
94
+ }
95
+ catch (RepositoryAlreadyExistsException e)
96
+ {
97
+ return Response.status(Response.Status.CONFLICT).entity(e.getMessage())
98
+ .type(MediaType.TEXT_PLAIN).build();
99
+ }
80
100
}
81
101
82
102
@DELETE
@@ -90,6 +110,12 @@
90
110
return Response.noContent().build();
91
111
}
92
112
113
+ /** Whether the caller may see this repo's fork parent — true only when it is a fork of a repo they can read. */
114
+ private boolean canSeeParent(Repository repo)
115
+ {
116
+ return repo.parent != null && accessPolicy.canRead(principal.orNull(), repo.parent);
117
+ }
118
+
93
119
private Repository requireReadable(String owner, String name)
94
120
{
95
121
Repository repo = service.find(owner, name).orElseThrow(NotFoundException::new);
MODIFY
src/main/java/de/workaround/git/GitRepositoryService.java
+43 -0
@@ -75,6 +75,49 @@
75
75
return initialize(repository, name, visibility, description);
76
76
}
77
77
78
+ /**
79
+ * Forks {@code source} into {@code actor}'s personal namespace: a new repository carrying the source's
80
+ * name, visibility and description, its {@code parent} pointing back at the source, with every ref
81
+ * (branches and tags) copied on disk via a local bare clone. The actor must be able to read the source,
82
+ * so a private repository can never be forked — and thereby exposed — by someone without read access.
83
+ */
84
+ @Transactional
85
+ public Repository fork(User actor, Repository source)
86
+ {
87
+ if (actor == null)
88
+ {
89
+ throw new ForbiddenOperationException("Authentication required to fork a repository");
90
+ }
91
+ if (!accessPolicy.canRead(actor, source))
92
+ {
93
+ throw new ForbiddenOperationException("You cannot fork a repository you cannot read");
94
+ }
95
+ if (repositories.findByOwnerUserAndName(actor, source.name).isPresent())
96
+ {
97
+ throw new RepositoryAlreadyExistsException(actor.username, source.name);
98
+ }
99
+ Repository fork = new Repository();
100
+ fork.ownerUser = actor;
101
+ fork.parent = source;
102
+ fork.name = source.name;
103
+ fork.visibility = source.visibility;
104
+ fork.description = source.description;
105
+ fork.persist();
106
+
107
+ Path from = repositoryPath(source);
108
+ Path to = repositoryPath(fork);
109
+ try (Git git = Git.cloneRepository().setBare(true).setCloneAllBranches(true)
110
+ .setURI(from.toUri().toString()).setDirectory(to.toFile()).call())
111
+ {
112
+ // clone copies HEAD, all branches and (by default) reachable tags — the full ref set
113
+ }
114
+ catch (GitAPIException e)
115
+ {
116
+ throw new IllegalStateException("Failed to clone " + from + " into fork " + to, e);
117
+ }
118
+ return fork;
119
+ }
120
+
78
121
private Repository initialize(Repository repository, String name, Repository.Visibility visibility,
79
122
String description)
80
123
{
MODIFY
src/main/java/de/workaround/mcp/RepositoryTools.java
+21 -1
@@ -28,6 +28,9 @@
28
28
@Inject
29
29
McpPrincipal principal;
30
30
31
+ @Inject
32
+ de.workaround.git.AccessPolicy accessPolicy;
33
+
31
34
@Tool(description = "List repositories visible to the caller: all public repositories, plus your own private ones when a valid token is supplied.")
32
35
public List<ApiModels.RepositoryView> listRepositories()
33
36
{
@@ -38,7 +41,8 @@
38
41
public ApiModels.RepositoryView getRepository(@ToolArg(description = "Owner username") String owner,
39
42
@ToolArg(description = "Repository name") String name)
40
43
{
41
- return ApiModels.RepositoryView.of(access.requireReadable(principal.orNull(), owner, name));
44
+ Repository repo = access.requireReadable(principal.orNull(), owner, name);
45
+ return ApiModels.RepositoryView.of(repo, canSeeParent(repo));
42
46
}
43
47
44
48
@Tool(description = "Create a new repository owned by the authenticated user. Requires a personal access token.")
@@ -56,6 +60,22 @@
56
60
return ApiModels.RepositoryView.of(repo);
57
61
}
58
62
63
+ @Tool(description = "Fork a repository you can read into your own namespace, copying all branches and tags. Requires a personal access token.")
64
+ @Transactional
65
+ public ApiModels.RepositoryView forkRepository(@ToolArg(description = "Owner username of the repository to fork") String owner,
66
+ @ToolArg(description = "Name of the repository to fork") String name)
67
+ {
68
+ User user = principal.require();
69
+ Repository source = access.requireReadable(user, owner, name);
70
+ Repository forked = service.fork(user, source);
71
+ return ApiModels.RepositoryView.of(forked, canSeeParent(forked));
72
+ }
73
+
74
+ private boolean canSeeParent(Repository repo)
75
+ {
76
+ return repo.parent != null && accessPolicy.canRead(principal.orNull(), repo.parent);
77
+ }
78
+
59
79
@Tool(description = "Delete a repository you own. Requires a personal access token. This is irreversible.")
60
80
@Transactional
61
81
public String deleteRepository(@ToolArg(description = "Owner username") String owner,
MODIFY
src/main/java/de/workaround/model/Repository.java
+8 -0
@@ -16,6 +16,7 @@
16
16
import jakarta.persistence.GeneratedValue;
17
17
import jakarta.persistence.GenerationType;
18
18
import jakarta.persistence.Id;
19
+import jakarta.persistence.JoinColumn;
19
20
import jakarta.persistence.ManyToOne;
20
21
import jakarta.persistence.Table;
21
22
@@ -40,6 +41,13 @@
40
41
@Enumerated(EnumType.STRING)
41
42
public Visibility visibility;
42
43
44
+ // The repository this one was forked from, or null for a repository created from scratch. Eagerly
45
+ // loaded (default for @ManyToOne) so the UI and API can show "forked from {owner}/{name}" without
46
+ // a second query.
47
+ @ManyToOne
48
+ @JoinColumn(name = "parent_repo_id")
49
+ public Repository parent;
50
+
43
51
public String description;
44
52
45
53
// Custom repository image: bytes live on the filesystem (gitshark.storage.repo-images) keyed by id.
MODIFY
src/main/java/de/workaround/web/RepoNav.java
+6 -1
@@ -9,6 +9,11 @@
9
9
*/
10
10
public record RepoNav(Repository repo, boolean loggedIn, boolean isOwner, boolean pinned, boolean empty,
11
11
String defaultBranch, int commitCount, int branchCount, int tagCount, long openIssueCount, long openMrCount,
12
- String httpUrl, String sshUrl, String currentPath)
12
+ String httpUrl, String sshUrl, String currentPath, boolean parentVisible)
13
13
{
14
+ /** Whether to reveal the "forked from" link: the repo is a fork and the current viewer may read its parent. */
15
+ public boolean showParent()
16
+ {
17
+ return parentVisible;
18
+ }
14
19
}
MODIFY
src/main/java/de/workaround/web/RepoNavService.java
+4 -1
@@ -57,6 +57,9 @@
57
57
boolean loggedIn = user != null;
58
58
boolean isOwner = loggedIn && accessPolicy.canAdmin(user, repo);
59
59
boolean pinned = loggedIn && pinService.isPinned(user, repo);
60
+ // Only reveal the parent to a viewer who could read it directly, so a source that later turned
61
+ // private is not disclosed through its public forks.
62
+ boolean parentVisible = repo.parent != null && accessPolicy.canRead(user, repo.parent);
60
63
int commitCount = empty ? 0 : browse.commitCount(path, defaultBranch);
61
64
int branchCount = browse.branches(path).size();
62
65
int tagCount = browse.tags(path).size();
@@ -67,6 +70,6 @@
67
70
String sshUrl = "ssh://git@" + uriInfo.getBaseUri().getHost() + ":" + sshPort + "/" + repo.ownerHandle()
68
71
+ "/" + repo.name + ".git";
69
72
return new RepoNav(repo, loggedIn, isOwner, pinned, empty, defaultBranch, commitCount, branchCount, tagCount,
70
- openIssueCount, openMrCount, httpUrl, sshUrl, uriInfo.getRequestUri().getRawPath());
73
+ openIssueCount, openMrCount, httpUrl, sshUrl, uriInfo.getRequestUri().getRawPath(), parentVisible);
71
74
}
72
75
}
MODIFY
src/main/java/de/workaround/web/RepositoryResource.java
+19 -0
@@ -369,6 +369,25 @@
369
369
}
370
370
371
371
@POST
372
+ @jakarta.ws.rs.Path("fork")
373
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
374
+ public Response fork(@PathParam("owner") String owner, @PathParam("name") String name)
375
+ {
376
+ Repository source = requireReadable(owner, name);
377
+ User user = currentUser.require();
378
+ try
379
+ {
380
+ Repository forked = service.fork(user, source);
381
+ return Response.seeOther(URI.create("/repos/" + forked.ownerHandle() + "/" + forked.name)).build();
382
+ }
383
+ catch (de.workaround.git.RepositoryAlreadyExistsException e)
384
+ {
385
+ // The user already has a fork (or a repo of that name) — send them to it rather than erroring.
386
+ return Response.seeOther(URI.create("/repos/" + user.username + "/" + source.name)).build();
387
+ }
388
+ }
389
+
390
+ @POST
372
391
@jakarta.ws.rs.Path("pin")
373
392
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
374
393
public Response pin(@PathParam("owner") String owner, @PathParam("name") String name,
ADD
src/main/resources/db/migration/V17__repository_fork.sql
+7 -0
@@ -0,0 +1,7 @@
1
+-- Fork relationship: a repository may point at the repository it was forked from. Nullable — most
2
+-- repositories are not forks. ON DELETE SET NULL so deleting a source repository leaves its forks
3
+-- standing as independent repositories rather than cascading their removal.
4
+alter table repositories
5
+ add column parent_repo_id uuid references repositories (id) on delete set null;
6
+
7
+create index idx_repositories_parent on repositories (parent_repo_id);
MODIFY
src/main/resources/templates/RepositoryResource/sidebar.html
+8 -0
@@ -2,6 +2,9 @@
2
2
<div class="owner">{#repoAvatar repo=nav.repo /} {nav.repo.ownerHandle} /</div>
3
3
<div class="repo-name"><a href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}">{nav.repo.name}</a></div>
4
4
<span class="tag{#if nav.repo.visibility.name() == 'PRIVATE'} tag-private{/if}"><span class="dot"></span> {nav.repo.visibility.name()}</span>
5
+ {#if nav.showParent}
6
+ <p class="forked-from muted"><span class="g">⑂</span> forked from <a href="/repos/{nav.repo.parent.ownerHandle}/{nav.repo.parent.name}">{nav.repo.parent.ownerHandle}/{nav.repo.parent.name}</a></p>
7
+ {/if}
5
8
{#if nav.repo.description}
6
9
<p class="desc">{nav.repo.description}</p>
7
10
{/if}
@@ -10,6 +13,11 @@
10
13
<span class="g">⤓</span> Clone
11
14
</button>
12
15
{#if nav.loggedIn}
16
+ <form class="inline" method="post" action="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/fork">
17
+ <button class="btn-icon" type="submit" title="Fork repository" aria-label="Fork repository">
18
+ <span class="g">⑂</span>
19
+ </button>
20
+ </form>
13
21
<form class="inline" method="post" action="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/{#if nav.pinned}unpin{#else}pin{/if}">
14
22
<input type="hidden" name="redirect" value="{nav.currentPath}">
15
23
<button class="btn-icon{#if nav.pinned} pinned{/if}" type="submit" title="{#if nav.pinned}Unpin{#else}Pin{/if} repository" aria-label="{#if nav.pinned}Unpin{#else}Pin{/if} repository">
MODIFY
src/test/java/de/workaround/api/RepositoryApiTest.java
+63 -0
@@ -134,6 +134,69 @@
134
134
}
135
135
136
136
@Test
137
+ void forkCreatesRepositoryUnderCallerWithParentRecorded()
138
+ {
139
+ User owner = persistUser("api-fork-src-" + shortId());
140
+ service.create(owner, "tocopy", Repository.Visibility.PUBLIC, "original");
141
+ User forker = persistUser("api-forker-" + shortId());
142
+ String token = mintToken(forker);
143
+
144
+ given().header("Authorization", "Bearer " + token)
145
+ .when().post("/api/v1/repos/" + owner.username + "/tocopy/fork")
146
+ .then().statusCode(201)
147
+ .body("owner", equalTo(forker.username))
148
+ .body("name", equalTo("tocopy"))
149
+ .body("parentOwner", equalTo(owner.username))
150
+ .body("parentName", equalTo("tocopy"));
151
+ }
152
+
153
+ @Test
154
+ void forkStopsExposingParentAfterSourceTurnsPrivate()
155
+ {
156
+ User owner = persistUser("api-flip-owner-" + shortId());
157
+ Repository source = service.create(owner, "flip", Repository.Visibility.PUBLIC, null);
158
+ User forker = persistUser("api-flip-forker-" + shortId());
159
+ String token = mintToken(forker);
160
+
161
+ given().header("Authorization", "Bearer " + token)
162
+ .when().post("/api/v1/repos/" + owner.username + "/flip/fork")
163
+ .then().statusCode(201)
164
+ .body("parentOwner", equalTo(owner.username));
165
+
166
+ // owner makes the source private; the forker can no longer read it
167
+ service.changeVisibility(owner, source, Repository.Visibility.PRIVATE);
168
+
169
+ given().header("Authorization", "Bearer " + token)
170
+ .when().get("/api/v1/repos/" + forker.username + "/flip")
171
+ .then().statusCode(200)
172
+ .body("parentOwner", org.hamcrest.Matchers.nullValue())
173
+ .body("parentName", org.hamcrest.Matchers.nullValue());
174
+ }
175
+
176
+ @Test
177
+ void forkRequiresAuthentication()
178
+ {
179
+ User owner = persistUser("api-fork-noauth-" + shortId());
180
+ service.create(owner, "pub", Repository.Visibility.PUBLIC, null);
181
+
182
+ given().when().post("/api/v1/repos/" + owner.username + "/pub/fork")
183
+ .then().statusCode(401);
184
+ }
185
+
186
+ @Test
187
+ void forkingAnUnreadablePrivateRepositoryIs404()
188
+ {
189
+ User owner = persistUser("api-fork-priv-" + shortId());
190
+ service.create(owner, "secret", Repository.Visibility.PRIVATE, null);
191
+ User stranger = persistUser("api-fork-stranger-" + shortId());
192
+ String token = mintToken(stranger);
193
+
194
+ given().header("Authorization", "Bearer " + token)
195
+ .when().post("/api/v1/repos/" + owner.username + "/secret/fork")
196
+ .then().statusCode(404);
197
+ }
198
+
199
+ @Test
137
200
void unknownBearerTokenIsUnauthorized()
138
201
{
139
202
given().header("Authorization", "Bearer gs_not-a-real-token")
ADD
src/test/java/de/workaround/git/RepositoryForkTest.java
+104 -0
@@ -0,0 +1,104 @@
1
+package de.workaround.git;
2
+
3
+import java.nio.file.Files;
4
+import java.nio.file.Path;
5
+import java.util.List;
6
+import java.util.Map;
7
+import java.util.UUID;
8
+
9
+import org.eclipse.jgit.api.Git;
10
+import org.eclipse.jgit.transport.RefSpec;
11
+import org.junit.jupiter.api.Test;
12
+
13
+import de.workaround.model.Repository;
14
+import de.workaround.model.User;
15
+import io.quarkus.test.TestTransaction;
16
+import io.quarkus.test.junit.QuarkusTest;
17
+import jakarta.inject.Inject;
18
+
19
+import static org.junit.jupiter.api.Assertions.assertEquals;
20
+import static org.junit.jupiter.api.Assertions.assertThrows;
21
+import static org.junit.jupiter.api.Assertions.assertTrue;
22
+
23
+@QuarkusTest
24
+class RepositoryForkTest
25
+{
26
+ @Inject
27
+ GitRepositoryService service;
28
+
29
+ @Inject
30
+ GitBrowseService browse;
31
+
32
+ @Test
33
+ @TestTransaction
34
+ void forkCopiesAllRefsAndRecordsParent() throws Exception
35
+ {
36
+ User owner = persistUser();
37
+ User forker = persistUser();
38
+ Repository source = service.create(owner, "src", Repository.Visibility.PUBLIC, "the original");
39
+ seedWithBranchAndTag(service.repositoryPath(source));
40
+
41
+ Repository fork = service.fork(forker, source);
42
+
43
+ assertEquals(source.id, fork.parent.id, "fork records its parent");
44
+ assertEquals(forker.id, fork.ownerUser.id, "fork is owned by the forking user");
45
+ assertEquals(source.visibility, fork.visibility, "fork inherits source visibility");
46
+ assertEquals("src", fork.name);
47
+
48
+ Path forkPath = service.repositoryPath(fork);
49
+ assertTrue(Files.exists(forkPath.resolve("HEAD")), "fork bare repo exists on disk");
50
+ List<String> branches = browse.branches(forkPath).stream().map(GitBrowseService.BranchInfo::name).toList();
51
+ assertTrue(branches.contains("main"), "main branch copied");
52
+ assertTrue(branches.contains("feature"), "feature branch copied");
53
+ assertTrue(browse.tags(forkPath).contains("v1"), "tag copied");
54
+ }
55
+
56
+ @Test
57
+ @TestTransaction
58
+ void cannotForkAPrivateRepositoryYouCannotRead()
59
+ {
60
+ User owner = persistUser();
61
+ User stranger = persistUser();
62
+ Repository priv = service.create(owner, "secret", Repository.Visibility.PRIVATE, null);
63
+
64
+ assertThrows(ForbiddenOperationException.class, () -> service.fork(stranger, priv));
65
+ }
66
+
67
+ @Test
68
+ @TestTransaction
69
+ void forkingTheSameRepositoryTwiceConflicts()
70
+ {
71
+ User owner = persistUser();
72
+ User forker = persistUser();
73
+ Repository source = service.create(owner, "dup", Repository.Visibility.PUBLIC, null);
74
+ service.fork(forker, source);
75
+
76
+ assertThrows(RepositoryAlreadyExistsException.class, () -> service.fork(forker, source));
77
+ }
78
+
79
+ private static void seedWithBranchAndTag(Path barePath) throws Exception
80
+ {
81
+ GitTestSeeder.seed(barePath, Map.of("README.md", "hi".getBytes()));
82
+ Path work = Files.createTempDirectory("fork-seed");
83
+ try (Git git = Git.cloneRepository().setURI(barePath.toUri().toString()).setDirectory(work.toFile()).call())
84
+ {
85
+ git.checkout().setCreateBranch(true).setName("feature").call();
86
+ Files.writeString(work.resolve("feature.txt"), "feature work\n");
87
+ git.add().addFilepattern(".").call();
88
+ git.commit().setMessage("feature commit").setSign(false)
89
+ .setAuthor("seed", "seed@example.com").setCommitter("seed", "seed@example.com").call();
90
+ git.tag().setName("v1").call();
91
+ git.push().setRefSpecs(new RefSpec("refs/heads/feature:refs/heads/feature")).setPushTags().call();
92
+ }
93
+ }
94
+
95
+ private static User persistUser()
96
+ {
97
+ String name = "user-" + UUID.randomUUID();
98
+ User user = new User();
99
+ user.oidcSub = "sub-" + name;
100
+ user.username = name;
101
+ user.persist();
102
+ return user;
103
+ }
104
+}
ADD
src/test/java/de/workaround/web/RepositoryForkUiTest.java
+103 -0
@@ -0,0 +1,103 @@
1
+package de.workaround.web;
2
+
3
+import java.util.UUID;
4
+
5
+import org.junit.jupiter.api.Test;
6
+
7
+import de.workaround.git.GitRepositoryService;
8
+import de.workaround.model.Repository;
9
+import de.workaround.model.User;
10
+import io.quarkus.test.junit.QuarkusTest;
11
+import io.quarkus.test.security.TestSecurity;
12
+import io.restassured.http.ContentType;
13
+import jakarta.inject.Inject;
14
+import jakarta.transaction.Transactional;
15
+
16
+import static io.restassured.RestAssured.given;
17
+import static org.hamcrest.CoreMatchers.containsString;
18
+import static org.hamcrest.CoreMatchers.not;
19
+
20
+@QuarkusTest
21
+class RepositoryForkUiTest
22
+{
23
+ @Inject
24
+ GitRepositoryService service;
25
+
26
+ @Inject
27
+ User.Repo userRepo;
28
+
29
+ @Test
30
+ @TestSecurity(user = "forker-ui")
31
+ void forkingRedirectsToTheForkWhichShowsForkedFrom()
32
+ {
33
+ persistUser("forker-ui");
34
+ User owner = persistUser("fork-owner-" + unique());
35
+ service.create(owner, "tofork", Repository.Visibility.PUBLIC, null);
36
+
37
+ given().redirects().follow(false)
38
+ .contentType(ContentType.URLENC)
39
+ .when().post("/repos/" + owner.username + "/tofork/fork")
40
+ .then().statusCode(303)
41
+ .header("Location", containsString("/repos/forker-ui/tofork"));
42
+
43
+ given().when().get("/repos/forker-ui/tofork")
44
+ .then().statusCode(200)
45
+ .body(containsString("forked from"))
46
+ .body(containsString("/repos/" + owner.username + "/tofork"));
47
+ }
48
+
49
+ @Test
50
+ @TestSecurity(user = "flip-forker")
51
+ void forkHidesForkedFromAfterSourceTurnsPrivate()
52
+ {
53
+ persistUser("flip-forker");
54
+ User owner = persistUser("flip-owner-" + unique());
55
+ Repository source = service.create(owner, "flipui", Repository.Visibility.PUBLIC, null);
56
+
57
+ given().redirects().follow(false)
58
+ .contentType(ContentType.URLENC)
59
+ .when().post("/repos/" + owner.username + "/flipui/fork")
60
+ .then().statusCode(303);
61
+
62
+ given().when().get("/repos/flip-forker/flipui")
63
+ .then().statusCode(200).body(containsString("forked from"));
64
+
65
+ // owner makes the source private — the fork must stop revealing where it came from
66
+ service.changeVisibility(owner, source, Repository.Visibility.PRIVATE);
67
+
68
+ given().when().get("/repos/flip-forker/flipui")
69
+ .then().statusCode(200).body(not(containsString("forked from")));
70
+ }
71
+
72
+ @Test
73
+ void anonymousCannotFork()
74
+ {
75
+ User owner = persistUser("fork-anon-" + unique());
76
+ service.create(owner, "pub", Repository.Visibility.PUBLIC, null);
77
+
78
+ given().redirects().follow(false)
79
+ .contentType(ContentType.URLENC)
80
+ .when().post("/repos/" + owner.username + "/pub/fork")
81
+ .then().statusCode(403);
82
+ }
83
+
84
+ private static String unique()
85
+ {
86
+ return UUID.randomUUID().toString().substring(0, 8);
87
+ }
88
+
89
+ @Transactional
90
+ User persistUser(String name)
91
+ {
92
+ User existing = userRepo.findByOidcSubOptional(name).orElse(null);
93
+ if (existing != null)
94
+ {
95
+ return existing;
96
+ }
97
+ User user = new User();
98
+ user.oidcSub = name;
99
+ user.username = name;
100
+ user.persist();
101
+ return user;
102
+ }
103
+}