🐛 (browse): Resolve branch/tag names containing slashes in tree/raw/commits URLs
Changes
3 files changed, +98 -7
MODIFY
src/main/java/de/workaround/web/RepositoryResource.java
+51 -7
@@ -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
@@ -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
@@ -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());