gitshark

Clone repository

git clone https://gitshark.de/git/workaround/Gitshark.git
git clone git@gitshark.de:workaround/Gitshark.git

← Commits

✨ (repos): Fork a repository into your own namespace

b3567cbe1e9ec35152d3c2b5bf0268a470d3006c · Michael Hainz · 2026-07-14T19:59:01Z

Changes

19 files changed, +618 -8

MODIFY README.md +8 -1
diff --git a/README.md b/README.md
index ff8884b..260fd79 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/README.md b/docs/README.md
index 404f980..39141dc 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -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
diff --git a/docs/admins/forking.md b/docs/admins/forking.md
new file mode 100644
index 0000000..25fe58e
--- /dev/null
+++ b/docs/admins/forking.md
@@ -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
diff --git a/docs/maintainers/forking.md b/docs/maintainers/forking.md
new file mode 100644
index 0000000..af7e542
--- /dev/null
+++ b/docs/maintainers/forking.md
@@ -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
diff --git a/docs/users/forking.md b/docs/users/forking.md
new file mode 100644
index 0000000..c623274
--- /dev/null
+++ b/docs/users/forking.md
@@ -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
diff --git a/docs/users/mcp.md b/docs/users/mcp.md
index 121b172..62497e3 100644
--- a/docs/users/mcp.md
+++ b/docs/users/mcp.md
@@ -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
diff --git a/src/main/java/de/workaround/api/ApiModels.java b/src/main/java/de/workaround/api/ApiModels.java
index b6be8db..756c204 100644
--- a/src/main/java/de/workaround/api/ApiModels.java
+++ b/src/main/java/de/workaround/api/ApiModels.java
@@ -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
diff --git a/src/main/java/de/workaround/api/RepositoryApiResource.java b/src/main/java/de/workaround/api/RepositoryApiResource.java
index f1e4024..9384f22 100644
--- a/src/main/java/de/workaround/api/RepositoryApiResource.java
+++ b/src/main/java/de/workaround/api/RepositoryApiResource.java
@@ -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
diff --git a/src/main/java/de/workaround/git/GitRepositoryService.java b/src/main/java/de/workaround/git/GitRepositoryService.java
index 70ea047..10cee35 100644
--- a/src/main/java/de/workaround/git/GitRepositoryService.java
+++ b/src/main/java/de/workaround/git/GitRepositoryService.java
@@ -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
diff --git a/src/main/java/de/workaround/mcp/RepositoryTools.java b/src/main/java/de/workaround/mcp/RepositoryTools.java
index 9664bd6..396c9fd 100644
--- a/src/main/java/de/workaround/mcp/RepositoryTools.java
+++ b/src/main/java/de/workaround/mcp/RepositoryTools.java
@@ -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
diff --git a/src/main/java/de/workaround/model/Repository.java b/src/main/java/de/workaround/model/Repository.java
index 8a67cb2..6d11ead 100644
--- a/src/main/java/de/workaround/model/Repository.java
+++ b/src/main/java/de/workaround/model/Repository.java
@@ -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
diff --git a/src/main/java/de/workaround/web/RepoNav.java b/src/main/java/de/workaround/web/RepoNav.java
index 0a17aac..4005d7c 100644
--- a/src/main/java/de/workaround/web/RepoNav.java
+++ b/src/main/java/de/workaround/web/RepoNav.java
@@ -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
diff --git a/src/main/java/de/workaround/web/RepoNavService.java b/src/main/java/de/workaround/web/RepoNavService.java
index 0253e50..509c057 100644
--- a/src/main/java/de/workaround/web/RepoNavService.java
+++ b/src/main/java/de/workaround/web/RepoNavService.java
@@ -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
diff --git a/src/main/java/de/workaround/web/RepositoryResource.java b/src/main/java/de/workaround/web/RepositoryResource.java
index a9cd77a..47f8e1e 100644
--- a/src/main/java/de/workaround/web/RepositoryResource.java
+++ b/src/main/java/de/workaround/web/RepositoryResource.java
@@ -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
diff --git a/src/main/resources/db/migration/V17__repository_fork.sql b/src/main/resources/db/migration/V17__repository_fork.sql
new file mode 100644
index 0000000..21c7192
--- /dev/null
+++ b/src/main/resources/db/migration/V17__repository_fork.sql
@@ -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
diff --git a/src/main/resources/templates/RepositoryResource/sidebar.html b/src/main/resources/templates/RepositoryResource/sidebar.html
index 3164686..91ff6c1 100644
--- a/src/main/resources/templates/RepositoryResource/sidebar.html
+++ b/src/main/resources/templates/RepositoryResource/sidebar.html
@@ -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
diff --git a/src/test/java/de/workaround/api/RepositoryApiTest.java b/src/test/java/de/workaround/api/RepositoryApiTest.java
index 59f79c7..58e0ffd 100644
--- a/src/test/java/de/workaround/api/RepositoryApiTest.java
+++ b/src/test/java/de/workaround/api/RepositoryApiTest.java
@@ -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
diff --git a/src/test/java/de/workaround/git/RepositoryForkTest.java b/src/test/java/de/workaround/git/RepositoryForkTest.java
new file mode 100644
index 0000000..0b4ad83
--- /dev/null
+++ b/src/test/java/de/workaround/git/RepositoryForkTest.java
@@ -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
diff --git a/src/test/java/de/workaround/web/RepositoryForkUiTest.java b/src/test/java/de/workaround/web/RepositoryForkUiTest.java
new file mode 100644
index 0000000..aca2a00
--- /dev/null
+++ b/src/test/java/de/workaround/web/RepositoryForkUiTest.java
@@ -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 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog