~jan0sch/smederee

Showing details for patch 191d72b579b56ff4705471efd5db9ef946cbfbdf.
2023-03-26 (Sun), 4:37 PM - Jens Grassel - 191d72b579b56ff4705471efd5db9ef946cbfbdf

Branches/Forks: Add more information and interaction.

- show extra tab "Branches" for the repo if branches exist
- add necessary functionality to `VcsMetadataRepository`
- adjust repository templates to include branch information
- add notes about nomenclature of darcs (forks = branches)
Summary of changes
1 files added
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryBranches.scala.html
13 files modified with 291 lines added and 84 lines removed
  • build.sbt with 1 added and 1 removed lines
  • modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala with 45 added and 0 removed lines
  • modules/hub/src/main/resources/messages.properties with 6 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala with 13 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala with 9 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala with 192 added and 70 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/deleteRepository.scala.html with 3 added and 2 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/editRepository.scala.html with 3 added and 2 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryFiles.scala.html with 3 added and 2 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryHistory.scala.html with 3 added and 2 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html with 6 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html with 4 added and 2 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryPatch.scala.html with 3 added and 2 removed lines
diff -rN -u old-smederee/build.sbt new-smederee/build.sbt
--- old-smederee/build.sbt	2025-01-31 08:01:11.238738201 +0000
+++ new-smederee/build.sbt	2025-01-31 08:01:11.238738201 +0000
@@ -216,7 +216,7 @@
         "cats.syntax.all._",
         "de.smederee.html._",
         "de.smederee.i18n._",
-        "de.smederee.security.CsrfToken",
+        "de.smederee.security.{ CsrfToken, UserId, Username }",
         "org.http4s.Uri"
       )
     )
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala	2025-01-31 08:01:11.238738201 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala	2025-01-31 08:01:11.238738201 +0000
@@ -169,6 +169,51 @@
     }
   }
 
+  test("findVcsRepositoryBranches must return all branches") {
+    (genValidAccounts.suchThat(_.size > 4).sample, genValidVcsRepositories.suchThat(_.size > 4).sample) match {
+      case (Some(accounts), Some(repositories)) =>
+        val vcsRepositories = accounts.zip(repositories).map { tuple =>
+          val (account, repo) = tuple
+          repo.copy(owner = account.toVcsRepositoryOwner)
+        }
+        val dbConfig = configuration.database
+        val tx       = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val repo     = new DoobieVcsMetadataRepository[IO](tx)
+        val test = for {
+          _ <- accounts.traverse(account =>
+            createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+          )
+          written <- vcsRepositories.traverse(repo.createVcsRepository)
+          original <- vcsRepositories.headOption.traverse(vcsRepository =>
+            loadVcsRepositoryId(vcsRepository.owner.uid, vcsRepository.name)
+          )
+          toFork <- vcsRepositories.drop(1).take(5).traverse { vcsRepository =>
+            loadVcsRepositoryId(vcsRepository.owner.uid, vcsRepository.name)
+          }
+          forked <- (original, toFork) match {
+            case (Some(Some(originalId)), forkIds) => forkIds.flatten.traverse(id => repo.createFork(originalId, id))
+            case _                                 => IO.pure(List.empty)
+          }
+          foundForks <- original match {
+            case Some(Some(originalId)) => repo.findVcsRepositoryBranches(originalId).compile.toList
+            case _                      => IO.pure(List.empty)
+          }
+        } yield (written, forked, foundForks)
+        test.map { result =>
+          val (written, forked, foundForks) = result
+          assert(written.sum === vcsRepositories.size, "Test repository data was not written to database!")
+          assert(forked.sum === vcsRepositories.drop(1).take(5).size, "Number of created forks does not match!")
+          assert(foundForks.size === vcsRepositories.drop(1).take(5).size, "Number of found forks does not match!")
+          foundForks.zip(vcsRepositories.drop(1).take(5)).map { tuple =>
+            val ((ownerName, repoName), repo) = tuple
+            assertEquals(ownerName, repo.owner.name)
+            assertEquals(repoName, repo.name)
+          }
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
   test("loadVcsRepositoryId must return the id of an existing repository") {
     (genValidAccount.sample, genValidVcsRepository.sample) match {
       case (Some(account), Some(repository)) =>
diff -rN -u old-smederee/modules/hub/src/main/resources/messages.properties new-smederee/modules/hub/src/main/resources/messages.properties
--- old-smederee/modules/hub/src/main/resources/messages.properties	2025-01-31 08:01:11.238738201 +0000
+++ new-smederee/modules/hub/src/main/resources/messages.properties	2025-01-31 08:01:11.238738201 +0000
@@ -50,6 +50,8 @@
 form.edit-repo.website.placeholder=https://example.com
 form.edit-repo.website.help=An optional URI pointing to the website of your project.
 form.fork.button.submit=Clone to your account.
+form.fork.button.submit.not-validated=Please validate your account first, only validated users can create repositories.
+form.fork.help=Please remember that darcs has a different nomenclature than for example git. Cloning this repository to your account will create a branch (equal to a git fork).
 form.label.create.button.submit=Create label
 form.label.colour=Colour
 form.label.colour.help=Pick a colour which will be used as background colour for the label.
@@ -194,6 +196,8 @@
 repositories.yours.column.name=Name
 repositories.yours.none-found=Looks like you don''t have any repositories created yet.
 
+repository.branches.summary={0} branches exist for this repository.
+
 repository.changes.patch.description=Showing details for patch {0}.
 repository.changes.patch.title.link=Show details for patch {0}.
 repository.changes.patch.summary.title=Summary of changes
@@ -216,6 +220,7 @@
 repository.labels.list.empty=There are no labels defined.
 repository.labels.list.title={0} labels.
 
+repository.menu.branches=Branches ({0})
 repository.menu.changes.next=Next
 repository.menu.changes=Changes
 repository.menu.delete=Delete
@@ -239,7 +244,7 @@
 repository.description.title=Summary:
 repository.description.forked-from=Forked from:
 
-repository.overview.clone.fork=Create your personal fork.
+repository.overview.clone.fork=Create your personal branch (fork).
 repository.overview.clone.title=Clone this repository
 repository.overview.clone.read-only=read-only
 repository.overview.clone.read-write=read-write
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala	2025-01-31 08:01:11.238738201 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala	2025-01-31 08:01:11.238738201 +0000
@@ -73,6 +73,19 @@
     query.query[VcsRepository].option.transact(tx)
   }
 
+  override def findVcsRepositoryBranches(originalRepositoryId: Long): Stream[F, (Username, VcsRepositoryName)] = {
+    val query = sql"""SELECT
+      "accounts"."name" AS "owner_name",
+      "repos"."name" AS "repository_name"
+      FROM "hub"."forks" AS "forks"
+      JOIN "hub"."repositories" AS "repos"
+      ON "forks"."forked_repo" = "repos"."id"
+      JOIN "hub"."accounts" AS "accounts"
+      ON "repos"."owner" = "accounts"."uid"
+      WHERE "forks"."original_repo" = $originalRepositoryId"""
+    query.query[(Username, VcsRepositoryName)].stream.transact(tx)
+  }
+
   override def findVcsRepositoryId(owner: VcsRepositoryOwner, name: VcsRepositoryName): F[Option[Long]] = {
     val nameFilter  = fr"""name = $name"""
     val ownerFilter = fr"""owner = ${owner.uid}"""
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala	2025-01-31 08:01:11.238738201 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala	2025-01-31 08:01:11.238738201 +0000
@@ -68,6 +68,15 @@
     */
   def findVcsRepository(owner: VcsRepositoryOwner, name: VcsRepositoryName): F[Option[VcsRepository]]
 
+  /** Find all branches (created via the `fork` functionality) for the repository with the given id.
+    *
+    * @param originalRepositoryId
+    *   The id from the original repository from which the branches (forks) were created.
+    * @return
+    *   A stream of tuples holding the username of the branch owner and the branch (repository) names.
+    */
+  def findVcsRepositoryBranches(originalRepositoryId: Long): Stream[F, (Username, VcsRepositoryName)]
+
   /** Search for the internal database specific (auto generated) ID of the given owner / repository combination which
     * serves as a primary key for the database table.
     *
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala	2025-01-31 08:01:11.238738201 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala	2025-01-31 08:01:11.242738200 +0000
@@ -289,6 +289,57 @@
       }
     } yield resp
 
+  /** Logic for rendering an overview of the existing branches of a repository.
+    *
+    * @param csrf
+    *   An optional CSRF-Token that shall be used.
+    * @param user
+    *   An optional user account for whom the list of repositories shall be rendered.
+    * @param repositoryOwnerName
+    *   The name of the user who owns the repository.
+    * @param repositoryName
+    *   The actual name of the repository.
+    * @return
+    *   An HTTP response containing the rendered HTML.
+    */
+  private def doShowRepositoryBranches(
+      csrf: Option[CsrfToken]
+  )(user: Option[Account])(repositoryOwnerName: Username, repositoryName: VcsRepositoryName): F[Response[F]] =
+    for {
+      language  <- Sync[F].delay(user.flatMap(_.language).getOrElse(LanguageCode("en")))
+      repoAndId <- loadRepo(user)(repositoryOwnerName, repositoryName)
+      repo = repoAndId.map(_._1)
+      actionBaseUri <- Sync[F].delay(
+        linkConfig.createFullUri(
+          Uri(path =
+            Uri.Path(
+              Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString))
+            )
+          )
+        )
+      )
+      branches <- repoAndId.map(_._2) match {
+        case Some(repoId) => vcsMetadataRepo.findVcsRepositoryBranches(repoId).compile.toList
+        case _            => Sync[F].delay(List.empty)
+      }
+      parentFork <- repo match {
+        case None       => Sync[F].pure(None)
+        case Some(repo) => vcsMetadataRepo.findVcsRepositoryParentFork(repo.owner, repo.name)
+      }
+      resp <- repo match {
+        case None => NotFound("Repository not found!")
+        case Some(repo) =>
+          Ok(
+            views.html.showRepositoryBranches(baseUri, lang = language)(
+              actionBaseUri,
+              csrf,
+              s"Smederee/~$repositoryOwnerName/$repositoryName".some,
+              user
+            )(repo, branches)
+          )
+      }
+    } yield resp
+
   /** Logic for rendering the content of a repository directory or file visible to the given user account.
     *
     * @param csrf
@@ -333,6 +384,10 @@
           )
         )
       )
+      branches <- repoAndId.map(_._2) match {
+        case Some(repoId) => vcsMetadataRepo.findVcsRepositoryBranches(repoId).compile.toList
+        case _            => Sync[F].delay(List.empty)
+      }
       vcsLog <- darcs.log(directory.toNIO)(repositoryName.toString)(Chain(s"--max-count=2", "--xml-output"))
       xmlLog <- Sync[F].delay(scala.xml.XML.loadString(vcsLog.stdout.toList.mkString))
       patches <- Sync[F].delay(
@@ -372,6 +427,7 @@
               user
             )(
               repo,
+              vcsRepositoryBranches = branches,
               vcsRepositoryHistory = patches,
               vcsRepositoryParentFork = parentFork,
               vcsRepositoryReadme = readme,
@@ -470,6 +526,10 @@
         case true =>
           Sync[F].pure(List.empty)
       }
+      branches <- repoAndId.map(_._2) match {
+        case Some(repoId) => vcsMetadataRepo.findVcsRepositoryBranches(repoId).compile.toList
+        case _            => Sync[F].delay(List.empty)
+      }
       resp <-
         repo match {
           case None => NotFound("Repository not found!")
@@ -487,7 +547,7 @@
                   Option(goBackUri),
                   s"Smederee/~$repositoryOwnerName/$repositoryName".some,
                   user
-                )(fileContent, listing, repositoryBaseUri, repo)
+                )(fileContent, listing, repositoryBaseUri, repo, branches)
               )
         }
     } yield resp
@@ -524,6 +584,10 @@
           )
         )
       )
+      branches <- repoAndId.map(_._2) match {
+        case Some(repoId) => vcsMetadataRepo.findVcsRepositoryBranches(repoId).compile.toList
+        case _            => Sync[F].delay(List.empty)
+      }
       countChanges    <- darcs.log(directory.toNIO)(repositoryName.toString)(Chain("--count"))
       numberOfChanges <- Sync[F].delay(countChanges.stdout.toList.mkString.trim.toInt)
       maxCount = 10
@@ -579,7 +643,7 @@
               Option(goBackUri),
               s"Smederee - History of ~$repositoryOwnerName/$repositoryName".some,
               user
-            )(patches, next, repositoryBaseUri, repo)
+            )(patches, next, repositoryBaseUri, repo, branches)
           )
       }
     } yield resp
@@ -624,6 +688,10 @@
       cleanedPatchDiff <- Sync[F].delay(darcsDiff.stdout.toList.mkString.split(patchCutMarker)(0))
       patchDetails     <- Sync[F].delay(cleanedPatchDiff)
       htmlPatchDetails <- Sync[F].delay(new UtilsAnsiHtml().convertAnsiToHtml(patchDetails))
+      branches <- repoAndId.map(_._2) match {
+        case Some(repoId) => vcsMetadataRepo.findVcsRepositoryBranches(repoId).compile.toList
+        case _            => Sync[F].delay(List.empty)
+      }
       actionBaseUri <- Sync[F].delay(
         linkConfig.createFullUri(
           Uri(path =
@@ -641,7 +709,8 @@
               .showRepositoryPatch(baseUri, lang = language)(actionBaseUri, csrf, patch.map(_.name.toString), user)(
                 patch,
                 htmlPatchDetails,
-                repo
+                repo,
+                branches
               )
           )
       }
@@ -778,70 +847,81 @@
         ) / "fork" as user =>
       ar.req.decodeStrict[F, UrlForm] { urlForm =>
         for {
-          csrf      <- Sync[F].delay(ar.req.getCsrfToken)
-          repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName)
-          sourceRepository = repoAndId.map(_._1)
-          // Check if a repository with that name already exists for the user.
-          loadedTargetRepo <- vcsMetadataRepo.findVcsRepository(user.toVcsRepositoryOwner, repositoryName)
-          // If no repo exists we copy and adjust the source one, otherwise we return `None`.
-          targetRepository = loadedTargetRepo.fold(
-            sourceRepository.map(_.copy(owner = user.toVcsRepositoryOwner))
-          )(_ => None)
-          targetUri <- Sync[F].delay(
-            linkConfig.createFullUri(
-              Uri(path =
-                Uri.Path(
-                  Vector(
-                    Uri.Path.Segment(s"~${user.name.toString}"),
-                    Uri.Path.Segment(repositoryName.toString)
-                  )
-                )
+          csrf     <- Sync[F].delay(ar.req.getCsrfToken)
+          language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+          resp <- user.validatedEmail match {
+            case false =>
+              Forbidden(
+                views.html.errors
+                  .unvalidatedAccount(lang = language)(csrf, "Smederee - Account not validated!".some, user)
               )
-            )
-          )
-          sourceDirectory <- Sync[F].delay(
-            Paths.get(
-              darcsConfig.repositoriesDirectory.toString,
-              repositoryOwnerName.toString,
-              repositoryName.toString
-            )
-          )
-          targetDirectory <- Sync[F].delay(
-            Paths.get(
-              darcsConfig.repositoriesDirectory.toString,
-              user.name.toString,
-              repositoryName.toString
-            )
-          )
-          output <- targetRepository match {
-            case None => Sync[F].pure(0)
-            case Some(repoMetadata) =>
+            case true =>
               for {
-                _ <- Sync[F].delay(
-                  Files.createDirectories(
-                    Paths.get(darcsConfig.repositoriesDirectory.toString, repoMetadata.owner.name.toString)
+                repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName)
+                sourceRepository = repoAndId.map(_._1)
+                // Check if a repository with that name already exists for the user.
+                loadedTargetRepo <- vcsMetadataRepo.findVcsRepository(user.toVcsRepositoryOwner, repositoryName)
+                // If no repo exists we copy and adjust the source one, otherwise we return `None`.
+                targetRepository = loadedTargetRepo.fold(
+                  sourceRepository.map(_.copy(owner = user.toVcsRepositoryOwner))
+                )(_ => None)
+                targetUri <- Sync[F].delay(
+                  linkConfig.createFullUri(
+                    Uri(path =
+                      Uri.Path(
+                        Vector(
+                          Uri.Path.Segment(s"~${user.name.toString}"),
+                          Uri.Path.Segment(repositoryName.toString)
+                        )
+                      )
+                    )
+                  )
+                )
+                sourceDirectory <- Sync[F].delay(
+                  Paths.get(
+                    darcsConfig.repositoriesDirectory.toString,
+                    repositoryOwnerName.toString,
+                    repositoryName.toString
+                  )
+                )
+                targetDirectory <- Sync[F].delay(
+                  Paths.get(
+                    darcsConfig.repositoriesDirectory.toString,
+                    user.name.toString,
+                    repositoryName.toString
                   )
                 )
-                darcsClone <- darcs.clone(sourceDirectory, targetDirectory)(Chain("--complete"))
-                createRepo <-
-                  if (darcsClone.exitValue === 0)
+                output <- targetRepository match {
+                  case None => Sync[F].pure(0)
+                  case Some(repoMetadata) =>
                     for {
-                      create <- vcsMetadataRepo.createVcsRepository(repoMetadata)
-                      // Get IDs to create the fork entry in the database.
-                      source <- sourceRepository.traverse(sourceRepo =>
-                        vcsMetadataRepo.findVcsRepositoryId(sourceRepo.owner, sourceRepo.name)
+                      _ <- Sync[F].delay(
+                        Files.createDirectories(
+                          Paths.get(darcsConfig.repositoriesDirectory.toString, repoMetadata.owner.name.toString)
+                        )
                       )
-                      target <- vcsMetadataRepo.findVcsRepositoryId(repoMetadata.owner, repoMetadata.name)
-                      fork <- (source, target) match {
-                        case (Some(Some(source)), Some(target)) => vcsMetadataRepo.createFork(source, target)
-                        case _                                  => Sync[F].pure(0)
-                      }
-                    } yield create + fork
-                  else
-                    Sync[F].pure(0) // Do not create DB entry if darcs init failed!
-              } yield createRepo
+                      darcsClone <- darcs.clone(sourceDirectory, targetDirectory)(Chain("--complete"))
+                      createRepo <-
+                        if (darcsClone.exitValue === 0)
+                          for {
+                            create <- vcsMetadataRepo.createVcsRepository(repoMetadata)
+                            // Get IDs to create the fork entry in the database.
+                            source <- sourceRepository.traverse(sourceRepo =>
+                              vcsMetadataRepo.findVcsRepositoryId(sourceRepo.owner, sourceRepo.name)
+                            )
+                            target <- vcsMetadataRepo.findVcsRepositoryId(repoMetadata.owner, repoMetadata.name)
+                            fork <- (source, target) match {
+                              case (Some(Some(source)), Some(target)) => vcsMetadataRepo.createFork(source, target)
+                              case _                                  => Sync[F].pure(0)
+                            }
+                          } yield create + fork
+                        else
+                          Sync[F].pure(0) // Do not create DB entry if darcs init failed!
+                    } yield createRepo
+                }
+                resp <- SeeOther(Location(targetUri))
+              } yield resp
           }
-          resp <- SeeOther(Location(targetUri))
         } yield resp
       }
   }
@@ -994,6 +1074,10 @@
           )
           repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName)
           repo = repoAndId.map(_._1)
+          branches <- repoAndId.map(_._2) match {
+            case Some(repoId) => vcsMetadataRepo.findVcsRepositoryBranches(repoId).compile.toList
+            case _            => Sync[F].delay(List.empty)
+          }
           resp <- repo match {
             case None => NotFound()
             case Some(repo) =>
@@ -1042,7 +1126,8 @@
                         repoUri,
                         Option(s"~$repositoryOwnerName/$repositoryName - edit"),
                         user,
-                        repo
+                        repo,
+                        branches
                       )(formData, FormErrors.fromNec(errors))
                     )
                   case Validated.Valid(updatedVcsRepository) =>
@@ -1080,16 +1165,23 @@
   private val showCreateRepositoryForm: AuthedRoutes[Account, F] = AuthedRoutes.of {
     case ar @ GET -> Root / "repo" / "create" as user =>
       for {
-        csrf <- Sync[F].delay(ar.req.getCsrfToken)
+        csrf     <- Sync[F].delay(ar.req.getCsrfToken)
+        language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
         resp <- user.validatedEmail match {
           case false =>
             Forbidden(
-              views.html.errors.unvalidatedAccount()(csrf, "Smederee - Account not validated!".some, user)
+              views.html.errors
+                .unvalidatedAccount(lang = language)(csrf, "Smederee - Account not validated!".some, user)
             )
           case true =>
             Ok(
               views.html
-                .createRepository()(createRepoPath, csrf, "Smederee - Create a new repository".some, user)()
+                .createRepository(lang = language)(
+                  createRepoPath,
+                  csrf,
+                  "Smederee - Create a new repository".some,
+                  user
+                )()
             )
         }
       } yield resp
@@ -1103,6 +1195,10 @@
         csrf      <- Sync[F].delay(ar.req.getCsrfToken)
         repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName)
         repo = repoAndId.map(_._1)
+        branches <- repoAndId.map(_._2) match {
+          case Some(repoId) => vcsMetadataRepo.findVcsRepositoryBranches(repoId).compile.toList
+          case _            => Sync[F].delay(List.empty)
+        }
         deleteAction <- Sync[F].delay(
           linkConfig.createFullUri(
             Uri(path =
@@ -1138,7 +1234,8 @@
                 csrf,
                 Option(s"~$repositoryOwnerName/$repositoryName - delete"),
                 user,
-                repo
+                repo,
+                branches
               )
             )
         }
@@ -1153,6 +1250,10 @@
         csrf      <- Sync[F].delay(ar.req.getCsrfToken)
         repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName)
         repo = repoAndId.map(_._1)
+        branches <- repoAndId.map(_._2) match {
+          case Some(repoId) => vcsMetadataRepo.findVcsRepositoryBranches(repoId).compile.toList
+          case _            => Sync[F].delay(List.empty)
+        }
         editAction <- Sync[F].delay(
           linkConfig.createFullUri(
             Uri(path =
@@ -1188,7 +1289,8 @@
                 repoUri,
                 Option(s"~$repositoryOwnerName/$repositoryName - edit"),
                 user,
-                repo
+                repo,
+                branches
               )(formData)
             )
           case _ => NotFound()
@@ -1212,6 +1314,26 @@
       } yield resp
   }
 
+  private val showRepositoryBranches: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ GET -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter(
+          repositoryName
+        ) / "branches" as user =>
+      for {
+        csrf <- Sync[F].delay(ar.req.getCsrfToken)
+        resp <- doShowRepositoryBranches(csrf)(user.some)(repositoryOwnerName, repositoryName)
+      } yield resp
+  }
+
+  private val showRepositoryBranchesForGuests: HttpRoutes[F] = HttpRoutes.of {
+    case req @ GET -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter(
+          repositoryName
+        ) / "branches" =>
+      for {
+        csrf <- Sync[F].delay(req.getCsrfToken)
+        resp <- doShowRepositoryBranches(csrf)(None)(repositoryOwnerName, repositoryName)
+      } yield resp
+  }
+
   private val showRepositoryOverview: AuthedRoutes[Account, F] = AuthedRoutes.of {
     case ar @ GET -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter(
           repositoryName
@@ -1296,13 +1418,13 @@
     downloadDistribution <+> forkRepository <+> showAllRepositories <+> showRepositories <+>
       parseCreateRepositoryForm <+> parseDeleteRepositoryForm <+> parseEditRepositoryForm <+>
       showCreateRepositoryForm <+> showDeleteRepositoryForm <+> showEditRepositoryForm <+>
-      showRepositoryOverview <+> showRepositoryHistory <+> showRepositoryPatchDetails <+>
-      showRepositoryFiles
+      showRepositoryOverview <+> showRepositoryBranches <+> showRepositoryHistory <+>
+      showRepositoryPatchDetails <+> showRepositoryFiles
 
   val routes =
     cloneRepository <+> downloadDistributionForGuests <+> showAllRepositoriesForGuests <+>
-      showRepositoriesForGuests <+> showRepositoryOverviewForGuests <+> showRepositoryHistoryForGuests <+>
-      showRepositoryPatchDetailsForGuests <+> showRepositoryFilesForGuests
+      showRepositoriesForGuests <+> showRepositoryOverviewForGuests <+> showRepositoryBranchesForGuests <+>
+      showRepositoryHistoryForGuests <+> showRepositoryPatchDetailsForGuests <+> showRepositoryFilesForGuests
 
 }
 
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/deleteRepository.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/deleteRepository.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/deleteRepository.scala.html	2025-01-31 08:01:11.238738201 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/deleteRepository.scala.html	2025-01-31 08:01:11.242738200 +0000
@@ -7,7 +7,8 @@
   csrf: Option[CsrfToken] = None,
   title: Option[String] = None,
   user: Account,
-  vcsRepository: VcsRepository
+  vcsRepository: VcsRepository,
+  vcsRepositoryBranches: List[(Username, VcsRepositoryName)]
 )
 @main(baseUri, lang)()(csrf, title, user.some) {
 @defining(lang.toLocale) { implicit locale =>
@@ -16,7 +17,7 @@
       <div class="pure-u-1">
         <div class="l-box-left-right">
           <h2><a href="@{baseUri.addSegment(s"~${vcsRepository.owner.name}")}">~@vcsRepository.owner.name</a>/@vcsRepository.name</h2>
-          @showRepositoryMenu(baseUri)(deleteAction.some, repositoryBaseUri, user.some, vcsRepository)
+          @showRepositoryMenu(baseUri)(deleteAction.some, vcsRepositoryBranches.size, repositoryBaseUri, user.some, vcsRepository)
           <div class="repo-summary-description">
             @Messages("repository.delete.title")
           </div>
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editRepository.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editRepository.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editRepository.scala.html	2025-01-31 08:01:11.238738201 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editRepository.scala.html	2025-01-31 08:01:11.242738200 +0000
@@ -10,7 +10,8 @@
   repositoryBaseUri: Uri,
   title: Option[String] = None,
   user: Account,
-  vcsRepository: VcsRepository
+  vcsRepository: VcsRepository,
+  vcsRepositoryBranches: List[(Username, VcsRepositoryName)]
 )(formData: Map[String, String] = Map.empty,
   formErrors: FormErrors = FormErrors.empty
 )
@@ -21,7 +22,7 @@
       <div class="pure-u-1">
         <div class="l-box-left-right">
           <h2><a href="@{baseUri.addSegment(s"~${vcsRepository.owner.name}")}">~@vcsRepository.owner.name</a>/@vcsRepository.name</h2>
-          @showRepositoryMenu(baseUri)(action.some, repositoryBaseUri, user.some, vcsRepository)
+          @showRepositoryMenu(baseUri)(action.some, vcsRepositoryBranches.size, repositoryBaseUri, user.some, vcsRepository)
           <div class="repo-summary-description">
             @Messages("repository.edit.title")
           </div>
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryBranches.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryBranches.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryBranches.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryBranches.scala.html	2025-01-31 08:01:11.242738200 +0000
@@ -0,0 +1,45 @@
+@import de.smederee.hub._
+
+@(baseUri: Uri,
+  lang: LanguageCode = LanguageCode("en")
+)(actionBaseUri: Uri,
+  csrf: Option[CsrfToken] = None,
+  title: Option[String] = None,
+  user: Option[Account]
+)(vcsRepository: VcsRepository,
+  vcsRepositoryBranches: List[(Username, VcsRepositoryName)]
+)
+@main(baseUri, lang)()(csrf, title, user) {
+@defining(lang.toLocale) { implicit locale =>
+  <div class="content">
+    <div class="pure-g">
+      <div class="pure-u-1">
+        <div class="l-box-left-right">
+          <h2><a href="@{baseUri.addSegment(s"~${vcsRepository.owner.name}")}">~@vcsRepository.owner.name</a>/@vcsRepository.name</h2>
+          @showRepositoryMenu(baseUri)(actionBaseUri.addSegment("branches").some, vcsRepositoryBranches.size, actionBaseUri, user, vcsRepository)
+          <div class="repo-summary-description">
+            @Messages("repository.branches.summary", vcsRepositoryBranches.size)
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="content">
+    <div class="pure-g">
+      <div class="pure-u-1-1 pure-u-md-1-1">
+        <div class="l-box">
+          <ul>
+          @for(branch <- vcsRepositoryBranches) {
+            @defining(branch._1) { branchOwner =>
+              @defining(branch._2) { branchName =>
+                <li><a href="@{baseUri.addSegment(s"~${branchOwner}").addSegment(branchName.toString)}">~@branchOwner/@branchName</a></li>
+              }
+            }
+          }
+          </ul>
+        </div>
+      </div>
+    </div>
+  </div>
+}
+}
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryFiles.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryFiles.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryFiles.scala.html	2025-01-31 08:01:11.238738201 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryFiles.scala.html	2025-01-31 08:01:11.242738200 +0000
@@ -12,7 +12,8 @@
 )(fileContent: List[String],
   listing: IndexedSeq[(os.RelPath, os.StatInfo)],
   repositoryBaseUri: Uri,
-  vcsRepository: VcsRepository
+  vcsRepository: VcsRepository,
+  vcsRepositoryBranches: List[(Username, VcsRepositoryName)],
 )
 @main(baseUri, lang)()(csrf, title, user) {
 @defining(lang.toLocale) { implicit locale =>
@@ -21,7 +22,7 @@
       <div class="pure-u-1-1 pure-u-md-1-1">
         <div class="l-box-left-right">
           <h2><a href="@{baseUri.addSegment(s"~${vcsRepository.owner.name}")}">~@vcsRepository.owner.name</a>/@vcsRepository.name</h2>
-          @showRepositoryMenu(baseUri)(repositoryBaseUri.addSegment("files").some, repositoryBaseUri, user, vcsRepository)
+          @showRepositoryMenu(baseUri)(repositoryBaseUri.addSegment("files").some, vcsRepositoryBranches.size, repositoryBaseUri, user, vcsRepository)
           <div class="repo-summary-description">
             <code>@{actionBaseUri.path.toString.replaceFirst("/files", "")}</code>
           </div>
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryHistory.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryHistory.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryHistory.scala.html	2025-01-31 08:01:11.238738201 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryHistory.scala.html	2025-01-31 08:01:11.242738200 +0000
@@ -10,7 +10,8 @@
 )(history: List[VcsRepositoryPatchMetadata],
   nextEntry: Option[Int],
   repositoryBaseUri: Uri,
-  vcsRepository: VcsRepository
+  vcsRepository: VcsRepository,
+  vcsRepositoryBranches: List[(Username, VcsRepositoryName)],
 )
 @main(baseUri, lang)()(csrf, title, user) {
 @defining(lang.toLocale) { implicit locale =>
@@ -19,7 +20,7 @@
       <div class="pure-u-1-1 pure-u-md-1-1">
         <div class="l-box-left-right">
           <h2><a href="@{baseUri.addSegment(s"~${vcsRepository.owner.name}")}">~@vcsRepository.owner.name</a>/@vcsRepository.name</h2>
-          @showRepositoryMenu(baseUri)(repositoryBaseUri.addSegment("history").some, repositoryBaseUri, user, vcsRepository)
+          @showRepositoryMenu(baseUri)(repositoryBaseUri.addSegment("history").some, vcsRepositoryBranches.size, repositoryBaseUri, user, vcsRepository)
           <div class="repo-summary-description">
             @if(history.isEmpty) {
               @Messages("repository.changes.description.empty")
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html	2025-01-31 08:01:11.238738201 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html	2025-01-31 08:01:11.242738200 +0000
@@ -2,6 +2,7 @@
 
 @(baseUri: Uri
 )(activeUri: Option[Uri],
+  branches: Int,
   repositoryBaseUri: Uri,
   user: Option[Account] = None,
   vcsRepository: VcsRepository
@@ -17,6 +18,11 @@
     @defining(repositoryBaseUri.addSegment("history")) { uri =>
     <li class="pure-menu-item@if(activeUri.exists(_ === uri)){ pure-menu-active}else{}"><a class="pure-menu-link" href="@uri">@icon(baseUri)("list") @Messages("repository.menu.changes")</a></li>
     }
+    @if(branches > 0) {
+      @defining(repositoryBaseUri.addSegment("branches")) { uri =>
+      <li class="pure-menu-item@if(activeUri.exists(_ === uri)){ pure-menu-active}else{}"><a class="pure-menu-link" href="@uri">@icon(baseUri)("git-branch") @Messages("repository.menu.branches", branches)</a></li>
+      }
+    } else {}
     @if(activeUri.exists(uri => uri === repositoryBaseUri || uri === repositoryBaseUri.addSegment("edit") || uri === repositoryBaseUri.addSegment("delete"))) {
       @if(user.exists(_.uid === vcsRepository.owner.uid)) {
       @defining(repositoryBaseUri.addSegment("edit")) { uri =>
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html	2025-01-31 08:01:11.238738201 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html	2025-01-31 08:01:11.242738200 +0000
@@ -7,6 +7,7 @@
   title: Option[String] = None,
   user: Option[Account]
 )(vcsRepository: VcsRepository,
+  vcsRepositoryBranches: List[(Username, VcsRepositoryName)],
   vcsRepositoryHistory: List[VcsRepositoryPatchMetadata],
   vcsRepositoryParentFork: Option[VcsRepository] = None,
   vcsRepositoryReadme: Option[String] = None,
@@ -20,7 +21,7 @@
       <div class="pure-u-1">
         <div class="l-box-left-right">
           <h2><a href="@{baseUri.addSegment(s"~${vcsRepository.owner.name}")}">~@vcsRepository.owner.name</a>/@vcsRepository.name</h2>
-          @showRepositoryMenu(baseUri)(actionBaseUri.some, actionBaseUri, user, vcsRepository)
+          @showRepositoryMenu(baseUri)(actionBaseUri.some, vcsRepositoryBranches.size, actionBaseUri, user, vcsRepository)
           <div class="repo-summary-description">
             <strong>@Messages("repository.description.title")</strong> @vcsRepository.description
           </div>
@@ -80,7 +81,8 @@
                   <fieldset>
                     @csrfToken(csrf)
                     <div class="pure-controls">
-                      <button type="submit" class="pure-button">@Messages("form.fork.button.submit")</button>
+                      <button type="submit" class="pure-button" @if(user.exists(_.validatedEmail)){}else{title="@Messages("form.fork.button.submit.not-validated")" disabled=""}>@Messages("form.fork.button.submit")</button>
+                      <small class="pure-form-message" id="fork.help">@Messages("form.fork.help")</small>
                     </div>
                   </fieldset>
                 </form>
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryPatch.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryPatch.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryPatch.scala.html	2025-01-31 08:01:11.238738201 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryPatch.scala.html	2025-01-31 08:01:11.242738200 +0000
@@ -8,7 +8,8 @@
   user: Option[Account]
 )(patch: Option[VcsRepositoryPatchMetadata],
   patchDiff: String,
-  vcsRepository: VcsRepository
+  vcsRepository: VcsRepository,
+  vcsRepositoryBranches: List[(Username, VcsRepositoryName)],
 )
 @main(baseUri, lang)()(csrf, title, user) {
 @defining(lang.toLocale) { implicit locale =>
@@ -17,7 +18,7 @@
       <div class="pure-u-1-1 pure-u-md-1-1">
         <div class="l-box-left-right">
           <h2><a href="@{baseUri.addSegment(s"~${vcsRepository.owner.name}")}">~@vcsRepository.owner.name</a>/@vcsRepository.name</h2>
-          @showRepositoryMenu(baseUri)(actionBaseUri.addSegment("history").some, actionBaseUri, user, vcsRepository)
+          @showRepositoryMenu(baseUri)(actionBaseUri.addSegment("history").some, vcsRepositoryBranches.size, actionBaseUri, user, vcsRepository)
           <div class="repo-summary-description">
             @for(patch <- patch) {
               @Messages("repository.changes.patch.description", patch.hash.toString)