gitshark

Clone repository

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

← Commits

🐛 (browse): Resolve branch/tag names containing slashes in tree/raw/commits URLs

dcc9ade3d05afe72c736faaf7796d1eadd37a8fb · Michael Hainz · 2026-07-21T07:27:19Z

Changes

3 files changed, +98 -7

MODIFY src/main/java/de/workaround/web/RepositoryResource.java +51 -7
diff --git a/src/main/java/de/workaround/web/RepositoryResource.java b/src/main/java/de/workaround/web/RepositoryResource.java
index 35fe766..3e369a2 100644
--- a/src/main/java/de/workaround/web/RepositoryResource.java
+++ b/src/main/java/de/workaround/web/RepositoryResource.java
@@ -200,33 +200,37 @@
200 200 }
201 201
202 202 @GET
203 - @jakarta.ws.rs.Path("tree/{ref}{path:(/.*)?}")
203 + @jakarta.ws.rs.Path("tree/{rest:.+}")
204 204 public TemplateInstance tree(@PathParam("owner") String owner, @PathParam("name") String name,
205 - @PathParam("ref") String ref, @PathParam("path") String rawPath)
205 + @PathParam("rest") String rest)
206 206 {
207 - String path = rawPath == null || rawPath.isEmpty() ? "" : rawPath.substring(1);
208 207 Repository repo = requireReadable(owner, name);
209 208 RepoNav nav = repoNav.build(repo, uriInfo);
210 209 Path repoPath = service.repositoryPath(repo);
210 + RefPath refPath = resolveRefPath(repoPath, rest);
211 + String ref = refPath.ref();
212 + String path = refPath.path();
211 213 return browse.listTree(repoPath, ref, path)
212 214 .map(entries -> Templates.tree(repo, nav, ref, path, entries, breadcrumbs(repo, ref, path)))
213 215 .orElseGet(() -> blobView(repo, nav, repoPath, ref, path));
214 216 }
215 217
216 218 @GET
217 - @jakarta.ws.rs.Path("raw/{ref}/{path:.*}")
219 + @jakarta.ws.rs.Path("raw/{rest:.+}")
218 220 @Produces(MediaType.APPLICATION_OCTET_STREAM)
219 221 public Response raw(@PathParam("owner") String owner, @PathParam("name") String name,
220 - @PathParam("ref") String ref, @PathParam("path") String path)
222 + @PathParam("rest") String rest)
221 223 {
222 224 Repository repo = requireReadable(owner, name);
223 - GitBrowseService.BlobView blob = browse.blob(service.repositoryPath(repo), ref, path)
225 + Path repoPath = service.repositoryPath(repo);
226 + RefPath refPath = resolveRefPath(repoPath, rest);
227 + GitBrowseService.BlobView blob = browse.blob(repoPath, refPath.ref(), refPath.path())
224 228 .orElseThrow(NotFoundException::new);
225 229 return Response.ok(blob.content()).build();
226 230 }
227 231
228 232 @GET
229 - @jakarta.ws.rs.Path("commits/{ref}")
233 + @jakarta.ws.rs.Path("commits/{ref:.+}")
230 234 public TemplateInstance commits(@PathParam("owner") String owner, @PathParam("name") String name,
231 235 @PathParam("ref") String ref, @QueryParam("page") @DefaultValue("0") int page,
232 236 @QueryParam("size") @DefaultValue("50") int size)
@@ -522,6 +526,46 @@
522 526 {
523 527 }
524 528
529 + /** A ref name paired with an in-tree path, split out of a {@code tree/…} or {@code raw/…} URL remainder. */
530 + private record RefPath(String ref, String path)
531 + {
532 + }
533 +
534 + /**
535 + * Splits a {@code tree/…}/{@code raw/…} URL remainder into a ref and an in-tree path. Branch and tag names may
536 + * contain slashes (e.g. {@code feat/federation-user-follow}), so the boundary cannot be inferred from the URL
537 + * alone: the longest known branch or tag that the remainder matches wins. When nothing matches (a raw commit
538 + * SHA, or a non-existent ref that will 404 downstream) the first path segment is treated as the ref.
539 + */
540 + private RefPath resolveRefPath(Path repoPath, String rest)
541 + {
542 + String best = null;
543 + for (GitBrowseService.BranchInfo branch : browse.branches(repoPath))
544 + {
545 + best = longerMatch(best, branch.name(), rest);
546 + }
547 + for (String tag : browse.tags(repoPath))
548 + {
549 + best = longerMatch(best, tag, rest);
550 + }
551 + if (best == null)
552 + {
553 + int slash = rest.indexOf('/');
554 + return slash < 0 ? new RefPath(rest, "") : new RefPath(rest.substring(0, slash), rest.substring(slash + 1));
555 + }
556 + return new RefPath(best, rest.length() > best.length() ? rest.substring(best.length() + 1) : "");
557 + }
558 +
559 + /** Returns {@code candidate} if it matches {@code rest} (whole, or up to a {@code /}) and is longer than {@code best}. */
560 + private static String longerMatch(String best, String candidate, String rest)
561 + {
562 + if (!rest.equals(candidate) && !rest.startsWith(candidate + "/"))
563 + {
564 + return best;
565 + }
566 + return best == null || candidate.length() > best.length() ? candidate : best;
567 + }
568 +
525 569 /**
526 570 * Builds the path breadcrumb for a tree or blob view: the ref, then one crumb per path segment. Every crumb links
527 571 * to its directory tree except the last, which is the current location and is rendered as plain text.
MODIFY src/test/java/de/workaround/git/GitTestSeeder.java +18 -0
diff --git a/src/test/java/de/workaround/git/GitTestSeeder.java b/src/test/java/de/workaround/git/GitTestSeeder.java
index 65f926e..3d0e318 100644
--- a/src/test/java/de/workaround/git/GitTestSeeder.java
+++ b/src/test/java/de/workaround/git/GitTestSeeder.java
@@ -44,6 +44,24 @@
44 44 }
45 45 }
46 46
47 + /** Seeds a branch (which may contain slashes, e.g. {@code feat/x}) with the given files. */
48 + public static void seedBranch(Path barePath, String branch, Map<String, byte[]> files) throws Exception
49 + {
50 + Path work = Files.createTempDirectory("seed");
51 + try (Git git = Git.cloneRepository().setURI(barePath.toUri().toString()).setDirectory(work.toFile()).call())
52 + {
53 + for (Map.Entry<String, byte[]> file : files.entrySet())
54 + {
55 + Path target = work.resolve(file.getKey());
56 + Files.createDirectories(target.getParent() == null ? work : target.getParent());
57 + Files.write(target, file.getValue());
58 + }
59 + git.add().addFilepattern(".").call();
60 + commit(git, "branch " + branch);
61 + git.push().setRefSpecs(new RefSpec("HEAD:refs/heads/" + branch)).call();
62 + }
63 + }
64 +
47 65 /** Pushes a single commit with the given message to refs/heads/main and returns its object id. */
48 66 public static ObjectId seedCommit(Path barePath, String message) throws Exception
49 67 {
MODIFY src/test/java/de/workaround/web/WebUiTest.java +29 -0
diff --git a/src/test/java/de/workaround/web/WebUiTest.java b/src/test/java/de/workaround/web/WebUiTest.java
index f88cb8a..2955d79 100644
--- a/src/test/java/de/workaround/web/WebUiTest.java
+++ b/src/test/java/de/workaround/web/WebUiTest.java
@@ -418,6 +418,35 @@
418 418 }
419 419
420 420 @Test
421 + void treeAndCommitsResolveBranchNamesContainingSlashes() throws Exception
422 + {
423 + User owner = persistUser("ui-slash-" + unique());
424 + Repository repo = service.create(owner, "slashy", Repository.Visibility.PUBLIC, null);
425 + GitTestSeeder.seed(service.repositoryPath(repo),
426 + Map.of("README.md", "root\n".getBytes(StandardCharsets.UTF_8)));
427 + GitTestSeeder.seedBranch(service.repositoryPath(repo), "feat/federation-user-follow",
428 + Map.of("FEATURE.md", "on the feature branch\n".getBytes(StandardCharsets.UTF_8),
429 + "docs/note.txt", "nested\n".getBytes(StandardCharsets.UTF_8)));
430 +
431 + String base = "/repos/" + owner.username + "/slashy";
432 +
433 + // tree of a slash-named branch must resolve the whole ref, not just "feat"
434 + given().when().get(base + "/tree/feat/federation-user-follow")
435 + .then().statusCode(200)
436 + .body(containsString("FEATURE.md"));
437 +
438 + // a path under that branch still works: ref = feat/federation-user-follow, path = docs
439 + given().when().get(base + "/tree/feat/federation-user-follow/docs")
440 + .then().statusCode(200)
441 + .body(containsString("note.txt"));
442 +
443 + // the commits view shares the same ref parsing
444 + given().when().get(base + "/commits/feat/federation-user-follow")
445 + .then().statusCode(200)
446 + .body(containsString("branch feat/federation-user-follow"));
447 + }
448 +
449 + @Test
421 450 void repositoryPageShowsCloneUrls() throws Exception
422 451 {
423 452 User owner = persistUser("ui-gina-" + unique());

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog