diff --git a/docs/guidelines-backend.md b/docs/guidelines-backend.md
index 9bef45da09..8fca9cb1a0 100644
--- a/docs/guidelines-backend.md
+++ b/docs/guidelines-backend.md
@@ -83,6 +83,24 @@ be touched by pull requests whose sole purpose is updating dependencies. Run
Any `go.mod` / `go.sum` update must be justified in the PR description and must be
verified by reviewers and the merger to reference an existing upstream commit.
+## Golang HTML template
+
+Gitea uses Go's built-in HTML template engine which is dynamically & weakly typed.
+To make template code maintainable:
+
+- Go code should take over complex logic and prepare template data as much as possible, templates only render the data.
+- Prefer struct types provided by Go code instead of map types for template data.
+- Avoid using single world names for non-local variables.
+- Avoid passing `"root" $` or `"." .` to sub-templates, instead pass the specific data needed by the sub-template.
+- Use explicit variable names instead of `.` to access data: ``{{range $item := $.TargetItems}}{{ $item.Name }}{{end}}``
+- Use Go code to implement render helpers if the render logic is too complex.
+
+Using a modern and statically & strongly typed template engine to replace Golang HTML template might be good,
+but the challenge is huge, the requisites are:
+
+- A working and maintainable proof-of-concept for the most complex templates (like "PR View" and "PR Diff").
+- A firm commitment of sufficient engineering resources.
+
## API v1
The API is documented with [Swagger](https://gitea.com/api/swagger) and is modelled
@@ -120,7 +138,7 @@ In general, choose HTTP methods as follows:
- **POST** creates a new object (e.g. a user) and returns **201 Created** with the
created object.
- **PUT** adds or assigns an existing object (e.g. a user to a team) and returns
- **204 No Content** with no body.
+ **204 No Content** with no response body.
- **PATCH** edits an existing object and returns the changed object with **200 OK**.
- **DELETE** removes an object and returns **204 No Content** with no body.
diff --git a/modelmigration/migrations.go b/modelmigration/migrations.go
index d7eda29fb1..76cd1649f6 100644
--- a/modelmigration/migrations.go
+++ b/modelmigration/migrations.go
@@ -426,6 +426,7 @@ func prepareMigrationTasks() []*migration {
newMigration(343, "Add max_parallel column to action_run_job", v1_28.AddMaxParallelToActionRunJob),
newMigration(344, "Add deferred-matrix columns to ActionRunJob", v1_28.AddDeferredMatrixColumnsToActionRunJob),
newMigration(345, "Add block on CODEOWNERS reviews branch protection", v1_28.AddBlockOnCodeownerReviews),
+ newMigration(346, "Add license_path column to repo_license and backfill", v1_28.AddLicensePathToRepoLicense),
}
return preparedMigrations
}
diff --git a/modelmigration/v1_28/v346.go b/modelmigration/v1_28/v346.go
new file mode 100644
index 0000000000..c551ab4d56
--- /dev/null
+++ b/modelmigration/v1_28/v346.go
@@ -0,0 +1,52 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package v1_28
+
+import (
+ "context"
+
+ "gitea.dev/modelmigration/base"
+
+ "xorm.io/xorm"
+ "xorm.io/xorm/schemas"
+)
+
+func AddLicensePathToRepoLicense(ctx context.Context, x base.EngineMigration) error {
+ // Drop the old 2-column UNIQUE(s) index on (repo_id, license) and, on
+ // re-runs, the index created under the new name.
+ // xorm Sync cannot reliably update an index when its column set changes.
+ indexes, err := x.Dialect().GetIndexes(x.DB(), ctx, "repo_license")
+ if err != nil {
+ return err
+ }
+ for _, idx := range indexes {
+ if idx.Name == "s" || idx.Name == "path" {
+ if _, err := x.Exec(x.Dialect().DropIndexSQL("repo_license", idx)); err != nil {
+ return err
+ }
+ }
+ }
+
+ // Add license_path column. The DEFAULT backfills existing rows: all repos
+ // created before this migration used the single LICENSE file convention.
+ type RepoLicense struct {
+ LicensePath string `xorm:"VARCHAR(255) NOT NULL DEFAULT 'LICENSE'"`
+ }
+ if _, err := x.SyncWithOptions(xorm.SyncOptions{
+ IgnoreDropIndices: true,
+ IgnoreConstrains: true,
+ }, new(RepoLicense)); err != nil {
+ return err
+ }
+
+ // Create new 3-column UNIQUE(path) index on (repo_id, license, license_path);
+ // xorm prefixes the name to UQE_repo_license_path.
+ newIndex := schemas.NewIndex("path", schemas.UniqueType)
+ newIndex.AddColumn("repo_id", "license", "license_path")
+ if _, err := x.Exec(x.Dialect().CreateIndexSQL("repo_license", newIndex)); err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/modelmigration/v1_28/v346_test.go b/modelmigration/v1_28/v346_test.go
new file mode 100644
index 0000000000..faa91c9286
--- /dev/null
+++ b/modelmigration/v1_28/v346_test.go
@@ -0,0 +1,80 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package v1_28
+
+import (
+ "context"
+ "testing"
+
+ "gitea.dev/modelmigration/migrationtest"
+ "gitea.dev/modules/timeutil"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "xorm.io/xorm/schemas"
+)
+
+// repoLicenseBeforeV345 mirrors the pre-migration repo_license table: no
+// license_path column and a 2-column UNIQUE(s) index on (repo_id, license).
+type repoLicenseBeforeV345 struct {
+ ID int64 `xorm:"pk autoincr"`
+ RepoID int64 `xorm:"UNIQUE(s) NOT NULL"`
+ CommitID string
+ License string `xorm:"VARCHAR(255) UNIQUE(s) NOT NULL"`
+ CreatedUnix timeutil.TimeStamp `xorm:"created"`
+ UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
+}
+
+func (repoLicenseBeforeV345) TableName() string { return "repo_license" }
+
+func Test_AddLicensePathToRepoLicense(t *testing.T) {
+ x, deferable := migrationtest.PrepareTestEnv(t, 0, new(repoLicenseBeforeV345))
+ defer deferable()
+
+ _, err := x.Insert(&repoLicenseBeforeV345{RepoID: 1, CommitID: "c1", License: "MIT"})
+ require.NoError(t, err)
+ _, err = x.Insert(&repoLicenseBeforeV345{RepoID: 1, CommitID: "c1", License: "Apache-2.0"})
+ require.NoError(t, err)
+ _, err = x.Insert(&repoLicenseBeforeV345{RepoID: 2, CommitID: "c2", License: "MIT"})
+ require.NoError(t, err)
+
+ indexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "repo_license")
+ require.NoError(t, err)
+ oldIdx, ok := indexes["s"] // GetIndexes strips the UQE_repo_license_ prefix
+ require.True(t, ok, "old 2-column unique s index should exist before migration")
+ assert.Equal(t, schemas.UniqueType, oldIdx.Type)
+ assert.Equal(t, []string{"repo_id", "license"}, oldIdx.Cols)
+
+ require.NoError(t, AddLicensePathToRepoLicense(t.Context(), x))
+ require.NoError(t, AddLicensePathToRepoLicense(t.Context(), x)) // idempotent
+
+ indexes, err = x.Dialect().GetIndexes(x.DB(), context.Background(), "repo_license")
+ require.NoError(t, err)
+ assert.NotContains(t, indexes, "s", "old 2-column unique s index should be gone after migration")
+ newIdx, ok := indexes["path"] // GetIndexes strips the UQE_repo_license_ prefix
+ require.True(t, ok, "new index must be named path (UQE_repo_license_path)")
+ assert.Equal(t, schemas.UniqueType, newIdx.Type)
+ assert.Equal(t, []string{"repo_id", "license", "license_path"}, newIdx.Cols)
+
+ // pre-existing rows must default to the LICENSE path
+ type licenseRow struct {
+ RepoID int64
+ License string
+ LicensePath string
+ }
+ var rows []licenseRow
+ require.NoError(t, x.SQL("SELECT repo_id, license, license_path FROM repo_license ORDER BY id").Find(&rows))
+ require.Len(t, rows, 3)
+ for _, r := range rows {
+ assert.Equal(t, "LICENSE", r.LicensePath)
+ }
+
+ // the new index is unique per (repo_id, license, license_path): the exact
+ // duplicate must be rejected, while a second path for the same license
+ // in the same repo is allowed
+ _, err = x.Exec("INSERT INTO repo_license (repo_id, commit_id, license, license_path) VALUES (1, 'c1', 'MIT', 'LICENSE')")
+ require.Error(t, err)
+ _, err = x.Exec("INSERT INTO repo_license (repo_id, commit_id, license, license_path) VALUES (1, 'c1', 'MIT', 'LICENSES/MIT.txt')")
+ require.NoError(t, err)
+}
diff --git a/models/repo/license.go b/models/repo/license.go
index 3ab721a9de..781fd2a17d 100644
--- a/models/repo/license.go
+++ b/models/repo/license.go
@@ -16,9 +16,10 @@ func init() {
type RepoLicense struct { //revive:disable-line:exported
ID int64 `xorm:"pk autoincr"`
- RepoID int64 `xorm:"UNIQUE(s) NOT NULL"`
+ RepoID int64 `xorm:"UNIQUE(path) NOT NULL"`
CommitID string
- License string `xorm:"VARCHAR(255) UNIQUE(s) NOT NULL"`
+ License string `xorm:"VARCHAR(255) UNIQUE(path) NOT NULL"`
+ LicensePath string `xorm:"VARCHAR(255) UNIQUE(path) NOT NULL DEFAULT 'LICENSE'"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX UPDATED"`
}
@@ -34,6 +35,13 @@ func (rll RepoLicenseList) StringList() []string {
return licenses
}
+type DetectedLicense struct {
+ // SPDXID of the license
+ SPDXID string
+ // LicensePath is in repo path to the license
+ LicensePath string
+}
+
// GetRepoLicenses returns the license statistics for a repository
func GetRepoLicenses(ctx context.Context, repo *Repository) (RepoLicenseList, error) {
licenses := make(RepoLicenseList, 0)
@@ -43,8 +51,19 @@ func GetRepoLicenses(ctx context.Context, repo *Repository) (RepoLicenseList, er
return licenses, nil
}
+func GetUniqueRepoLicenses(ctx context.Context, repo *Repository) (RepoLicenseList, error) {
+ licenses := make(RepoLicenseList, 0)
+ if err := db.GetEngine(ctx).Select("MAX(`id`) AS `id`, `license`, MAX(`license_path`) AS `license_path`, MAX(`commit_id`) AS `commit_id`").
+ Where("`repo_id` = ?", repo.ID).
+ GroupBy("`license`").
+ Find(&licenses); err != nil {
+ return nil, err
+ }
+ return licenses, nil
+}
+
// UpdateRepoLicenses updates the license statistics for repository
-func UpdateRepoLicenses(ctx context.Context, repo *Repository, commitID string, licenses []string) error {
+func UpdateRepoLicenses(ctx context.Context, repo *Repository, commitID string, licenses []DetectedLicense) error {
oldLicenses, err := GetRepoLicenses(ctx, repo)
if err != nil {
return err
@@ -53,9 +72,10 @@ func UpdateRepoLicenses(ctx context.Context, repo *Repository, commitID string,
upd := false
for _, o := range oldLicenses {
// Update already existing license
- if o.License == license {
+ if o.License == license.SPDXID {
o.CommitID = commitID
- if _, err := db.GetEngine(ctx).ID(o.ID).Cols("`commit_id`").Update(o); err != nil {
+ o.LicensePath = license.LicensePath
+ if _, err := db.GetEngine(ctx).ID(o.ID).Cols("`commit_id`", "`license_path`").Update(o); err != nil {
return err
}
upd = true
@@ -65,9 +85,10 @@ func UpdateRepoLicenses(ctx context.Context, repo *Repository, commitID string,
// Insert new license
if !upd {
if err := db.Insert(ctx, &RepoLicense{
- RepoID: repo.ID,
- CommitID: commitID,
- License: license,
+ RepoID: repo.ID,
+ CommitID: commitID,
+ License: license.SPDXID,
+ LicensePath: license.LicensePath,
}); err != nil {
return err
}
@@ -100,9 +121,10 @@ func CopyLicense(ctx context.Context, originalRepo, destRepo *Repository) error
for _, rl := range repoLicenses {
newRepoLicense := &RepoLicense{
- RepoID: destRepo.ID,
- CommitID: rl.CommitID,
- License: rl.License,
+ RepoID: destRepo.ID,
+ CommitID: rl.CommitID,
+ License: rl.License,
+ LicensePath: rl.LicensePath,
}
newRepoLicenses = append(newRepoLicenses, newRepoLicense)
}
diff --git a/routers/web/repo/view_home.go b/routers/web/repo/view_home.go
index fb3e27294e..e829520ce6 100644
--- a/routers/web/repo/view_home.go
+++ b/routers/web/repo/view_home.go
@@ -123,14 +123,36 @@ func prepareHomeSidebarCitationFile(entry *git.TreeEntry) func(ctx *context.Cont
}
}
+type licenseGroup struct {
+ LicensePath string
+ LicenseNames []string
+}
+
func prepareHomeSidebarLicenses(ctx *context.Context) {
- repoLicenses, err := repo_model.GetRepoLicenses(ctx, ctx.Repo.Repository)
+ repoLicenses, err := repo_model.GetUniqueRepoLicenses(ctx, ctx.Repo.Repository)
if err != nil {
ctx.ServerError("GetRepoLicenses", err)
return
}
- ctx.Data["DetectedRepoLicenses"] = repoLicenses.StringList()
- ctx.Data["LicenseFileName"] = repo_service.LicenseFileName
+ if len(repoLicenses) == 0 {
+ return
+ }
+
+ order := make([]string, 0)
+ groups := make(map[string]*licenseGroup)
+ for _, rl := range repoLicenses {
+ if _, ok := groups[rl.LicensePath]; !ok {
+ groups[rl.LicensePath] = &licenseGroup{LicensePath: rl.LicensePath}
+ order = append(order, rl.LicensePath)
+ }
+ groups[rl.LicensePath].LicenseNames = append(groups[rl.LicensePath].LicenseNames, rl.License)
+ }
+
+ result := make([]licenseGroup, 0, len(order))
+ for _, path := range order {
+ result = append(result, *groups[path])
+ }
+ ctx.Data["LicenseGroups"] = result
}
func prepareToRenderDirectory(ctx *context.Context) {
diff --git a/services/repository/create.go b/services/repository/create.go
index cefde90f05..b33c6047d0 100644
--- a/services/repository/create.go
+++ b/services/repository/create.go
@@ -308,16 +308,15 @@ func CreateRepositoryDirectly(ctx context.Context, doer, owner *user_model.User,
}
// 6 - update licenses
- var licenses []string
if len(opts.License) > 0 {
- licenses = append(licenses, opts.License)
-
+ licenses := make([]repo_model.DetectedLicense, 0, 1)
var stdout string
stdout, _, err = gitcmd.NewCommand("rev-parse", "HEAD").WithRepo(repo).RunStdString(ctx)
if err != nil {
log.Error("CreateRepository(git rev-parse HEAD) in %v: Stdout: %s\nError: %v", repo, stdout, err)
return nil, fmt.Errorf("CreateRepository(git rev-parse HEAD): %w", err)
}
+ licenses = append(licenses, repo_model.DetectedLicense{SPDXID: opts.License, LicensePath: LicenseLegacyFile})
if err = repo_model.UpdateRepoLicenses(ctx, repo, stdout, licenses); err != nil {
return nil, err
}
diff --git a/services/repository/license.go b/services/repository/license.go
index 3315cbbe30..428f6347ca 100644
--- a/services/repository/license.go
+++ b/services/repository/license.go
@@ -7,6 +7,8 @@ import (
"context"
"fmt"
"io"
+ "path"
+ "strings"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
@@ -16,13 +18,20 @@ import (
"gitea.dev/modules/log"
"gitea.dev/modules/options"
"gitea.dev/modules/queue"
+ "gitea.dev/modules/util"
licenseclassifier "github.com/google/licenseclassifier/v2"
)
+const (
+ LicenseLegacyFile = "LICENSE"
+ // LicenseReuseDir is for REUSE license spec - see https://reuse.software/spec-3.3/
+ // TODO: Surface this version in repo creation
+ LicenseReuseDir = "LICENSES"
+)
+
var (
- classifier *licenseclassifier.Classifier
- LicenseFileName = "LICENSE"
+ classifier *licenseclassifier.Classifier
// licenseUpdaterQueue represents a queue to handle update repo licenses
licenseUpdaterQueue *queue.WorkerPoolQueue[*LicenseUpdaterOptions]
@@ -71,21 +80,23 @@ func repoLicenseUpdater(items ...*LicenseUpdaterOptions) []*LicenseUpdaterOption
continue
}
- gitRepo, err := git.OpenRepository(ctx, repo)
- if err != nil {
- log.Error("repoLicenseUpdater [%d] failed: OpenRepository: %v", opts.RepoID, err)
- continue
- }
- defer gitRepo.Close()
+ func() {
+ gitRepo, err := git.OpenRepository(ctx, repo)
+ if err != nil {
+ log.Error("repoLicenseUpdater [%d] failed: OpenRepository: %v", opts.RepoID, err)
+ return
+ }
+ defer gitRepo.Close()
- commit, err := gitRepo.GetBranchCommit(ctx, repo.DefaultBranch)
- if err != nil {
- log.Error("repoLicenseUpdater [%d] failed: GetBranchCommit: %v", opts.RepoID, err)
- continue
- }
- if err = UpdateRepoLicenses(ctx, repo, gitRepo, commit); err != nil {
- log.Error("repoLicenseUpdater [%d] failed: updateRepoLicenses: %v", opts.RepoID, err)
- }
+ commit, err := gitRepo.GetBranchCommit(ctx, repo.DefaultBranch)
+ if err != nil {
+ log.Error("repoLicenseUpdater [%d] failed: GetBranchCommit: %v", opts.RepoID, err)
+ return
+ }
+ if err = UpdateRepoLicenses(ctx, repo, gitRepo, commit); err != nil {
+ log.Error("repoLicenseUpdater [%d] failed: updateRepoLicenses: %v", opts.RepoID, err)
+ }
+ }()
}
return nil
}
@@ -113,43 +124,94 @@ func SyncRepoLicenses(ctx context.Context) error {
return nil
}
+// resolveReuseLicenses gathers all licenses in a subtree (assumed to be LicenseReuseDir as per REUSE specification)
+func resolveReuseLicenses(ctx context.Context, gitrepo *git.Repository, parentPath string, tree *git.Tree) ([]repo_model.DetectedLicense, error) {
+ entries, err := tree.ListEntries(ctx, gitrepo)
+ if err != nil {
+ return nil, fmt.Errorf("ListEntries: %w", err)
+ }
+ licenses := make([]repo_model.DetectedLicense, 0)
+ for _, entry := range entries {
+ if entry.IsRegular() {
+ spdxID := util.PathBaseStem(entry.Name())
+ licenses = append(licenses, repo_model.DetectedLicense{SPDXID: spdxID, LicensePath: path.Join(parentPath, entry.Name())})
+ }
+ }
+
+ return licenses, nil
+}
+
+// isLicenseFile checks the prefix of the file and determines if it could plausibly be a license one
+// it's checking well-known ones: license, licence and copying
+// allowed extensions are: md, lesser and txt
+func isLicenseFile(name string) bool {
+ lower := strings.ToLower(name)
+ stem := util.PathBaseStem(lower)
+ ext := path.Ext(lower)
+ // exact match (e.g. "LICENSE") or at most one allowed extension (e.g. "LICENSE.md")
+ return (stem == "license" || stem == "licence" || stem == "copying") &&
+ (ext == "" || ext == ".md" || ext == ".lesser" || ext == ".txt")
+}
+
+func resolveLicenses(ctx context.Context, gitRepo *git.Repository, commit *git.Commit) ([]repo_model.DetectedLicense, error) {
+ tree, err := commit.SubTree(ctx, gitRepo, LicenseReuseDir)
+ if err != nil && !git.IsErrNotExist(err) {
+ return nil, fmt.Errorf("SubTree: %w", err)
+ }
+
+ // handle REUSE license spec first
+ if !git.IsErrNotExist(err) {
+ return resolveReuseLicenses(ctx, gitRepo, LicenseReuseDir, tree)
+ }
+
+ tree, err = commit.SubTree(ctx, gitRepo, "")
+ if err != nil && !git.IsErrNotExist(err) {
+ return nil, fmt.Errorf("SubTree: %w", err)
+ }
+
+ entries, err := tree.ListEntries(ctx, gitRepo)
+ if err != nil {
+ return nil, fmt.Errorf("ListEntries: %w", err)
+ }
+ licenses := make([]repo_model.DetectedLicense, 0)
+ for _, entry := range entries {
+ if !entry.IsRegular() {
+ continue
+ }
+ if !isLicenseFile(entry.Name()) {
+ continue
+ }
+ r, err := entry.Blob(gitRepo).DataAsync(ctx)
+ if err != nil {
+ continue
+ }
+ found, err := detectLicense(r)
+ _ = r.Close()
+ if err != nil {
+ continue
+ }
+ for _, license := range found {
+ licenses = append(licenses, repo_model.DetectedLicense{SPDXID: license, LicensePath: entry.Name()})
+ }
+ }
+ return licenses, nil
+}
+
// UpdateRepoLicenses will update repository licenses col if license file exists
func UpdateRepoLicenses(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, commit *git.Commit) error {
- if commit == nil {
- return nil
+ licenses, err := resolveLicenses(ctx, gitRepo, commit)
+ if err != nil {
+ return err
}
-
- b, err := commit.GetBlobByPath(ctx, gitRepo, LicenseFileName)
- if err != nil && !git.IsErrNotExist(err) {
- return fmt.Errorf("GetBlobByPath: %w", err)
- }
-
- if git.IsErrNotExist(err) {
+ if len(licenses) == 0 {
return repo_model.CleanRepoLicenses(ctx, repo)
}
- licenses := make([]string, 0)
- if b != nil {
- r, err := b.DataAsync(ctx)
- if err != nil {
- return err
- }
- defer r.Close()
-
- licenses, err = detectLicense(r)
- if err != nil {
- return fmt.Errorf("detectLicense: %w", err)
- }
- }
return repo_model.UpdateRepoLicenses(ctx, repo, commit.ID.String(), licenses)
}
// detectLicense returns the licenses detected by the given content buff
func detectLicense(r io.Reader) ([]string, error) {
- if r == nil {
- return nil, nil
- }
-
matches, err := classifier.MatchFrom(r)
if err != nil {
return nil, err
diff --git a/services/repository/license_test.go b/services/repository/license_test.go
index a8a28cdf07..3cea2f7c53 100644
--- a/services/repository/license_test.go
+++ b/services/repository/license_test.go
@@ -4,9 +4,12 @@
package repository
import (
+ "path/filepath"
"strings"
"testing"
+ repo_model "gitea.dev/models/repo"
+ "gitea.dev/modules/git"
repo_module "gitea.dev/modules/repository"
"github.com/stretchr/testify/assert"
@@ -41,7 +44,7 @@ func Test_detectLicense(t *testing.T) {
Repo: "gitea",
Year: "2024",
})
- assert.NoError(t, err)
+ require.NoError(t, err)
tests = append(tests, DetectLicenseTest{
name: "single license test: " + licenseName,
@@ -59,12 +62,161 @@ func Test_detectLicense(t *testing.T) {
})
}
- result, err := detectLicense(strings.NewReader(tests[2].arg + tests[3].arg + tests[4].arg))
- assert.NoError(t, err)
- t.Run("multiple licenses test", func(t *testing.T) {
- assert.Len(t, result, 3)
- assert.Contains(t, result, tests[2].want[0])
- assert.Contains(t, result, tests[3].want[0])
- assert.Contains(t, result, tests[4].want[0])
+ // Build multi-license content from the first 3 real license entries.
+ require.GreaterOrEqual(t, len(repo_module.Licenses), 3, "need at least 3 licenses for multi-license test")
+ var multiContent strings.Builder
+ var multiWant []string
+ for _, name := range repo_module.Licenses[:3] {
+ lic, err := repo_module.GetLicense(name, &repo_module.LicenseValues{
+ Owner: "Gitea", Email: "teabot@gitea.io", Repo: "gitea", Year: "2024",
+ })
+ require.NoError(t, err)
+ multiContent.Write(lic)
+ multiWant = append(multiWant, name)
+ }
+ t.Run("multiple licenses", func(t *testing.T) {
+ result, err := detectLicense(strings.NewReader(multiContent.String()))
+ assert.NoError(t, err)
+ assert.ElementsMatch(t, multiWant, result)
+ })
+}
+
+func detectedLicenseIDs(licenses []repo_model.DetectedLicense) []string {
+ ids := make([]string, 0, len(licenses))
+ for _, l := range licenses {
+ ids = append(ids, l.SPDXID)
+ }
+ return ids
+}
+
+func Test_resolveLicenses(t *testing.T) {
+ require.NoError(t, repo_module.LoadRepoConfig())
+ require.NoError(t, InitLicenseClassifier())
+
+ mitLicense, err := repo_module.GetLicense("MIT", &repo_module.LicenseValues{
+ Owner: "Test",
+ Email: "test@test.com",
+ Repo: "test",
+ Year: "2024",
+ })
+ require.NoError(t, err)
+
+ repoDir := filepath.Join(t.TempDir(), "repo.git")
+ require.NoError(t, git.InitRepositoryLocal(t.Context(), repoDir, true, "sha1"))
+ gitRepo, err := git.OpenRepositoryLocal(t.Context(), repoDir)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ // 1. repo with no license at all
+ require.NoError(t, git.ForceFastImport(t.Context(), gitRepo, []git.FastImportCommit{{
+ Ref: "refs/heads/master",
+ Message: "empty",
+ }}))
+ commit, err := gitRepo.GetBranchCommit(t.Context(), "master")
+ require.NoError(t, err)
+
+ licenses, err := resolveLicenses(t.Context(), gitRepo, commit)
+ assert.Empty(t, licenses)
+ assert.NoError(t, err)
+
+ // 2. repo with a plain LICENSE file — classifier should detect MIT
+ require.NoError(t, git.ForceFastImport(t.Context(), gitRepo, []git.FastImportCommit{{
+ Ref: "refs/heads/master",
+ Message: "add LICENSE",
+ Files: []git.FastImportFile{
+ {Mode: git.EntryModeBlob, Path: "LICENSE", Content: string(mitLicense)},
+ },
+ }}))
+ commit, err = gitRepo.GetBranchCommit(t.Context(), "master")
+ require.NoError(t, err)
+
+ licenses, err = resolveLicenses(t.Context(), gitRepo, commit)
+ assert.NoError(t, err)
+ assert.Len(t, licenses, 1)
+ assert.Equal(t, "MIT", licenses[0].SPDXID)
+ assert.Equal(t, "LICENSE", licenses[0].LicensePath)
+
+ // 3. repo with REUSE LICENSES/ dir — should take priority over root LICENSE
+ require.NoError(t, git.ForceFastImport(t.Context(), gitRepo, []git.FastImportCommit{{
+ Ref: "refs/heads/master",
+ Message: "add LICENSES dir",
+ Files: []git.FastImportFile{
+ {Mode: git.EntryModeBlob, Path: "LICENSE", Content: string(mitLicense)},
+ {Mode: git.EntryModeBlob, Path: "LICENSES/MIT.txt", Content: "MIT license text"},
+ {Mode: git.EntryModeBlob, Path: "LICENSES/Apache-2.0.txt", Content: "Apache license text"},
+ },
+ }}))
+ commit, err = gitRepo.GetBranchCommit(t.Context(), "master")
+ require.NoError(t, err)
+
+ licenses, err = resolveLicenses(t.Context(), gitRepo, commit)
+ assert.NoError(t, err)
+ assert.Len(t, licenses, 2)
+
+ ids := detectedLicenseIDs(licenses)
+ assert.ElementsMatch(t, []string{"Apache-2.0", "MIT"}, ids)
+ // REUSE entries carry full path including LICENSES/ prefix
+ for _, l := range licenses {
+ assert.True(t, strings.HasPrefix(l.LicensePath, "LICENSES/"))
+ }
+
+ // 4. remove all licenses — should return not exist
+ require.NoError(t, git.ForceFastImport(t.Context(), gitRepo, []git.FastImportCommit{{
+ Ref: "refs/heads/master",
+ Message: "remove licenses",
+ }}))
+ commit, err = gitRepo.GetBranchCommit(t.Context(), "master")
+ require.NoError(t, err)
+
+ licenses, err = resolveLicenses(t.Context(), gitRepo, commit)
+ assert.Empty(t, licenses)
+ assert.NoError(t, err)
+}
+
+func Test_isLicenseFile(t *testing.T) {
+ shouldMatch := []string{
+ "LICENSE",
+ "LICENCE",
+ "COPYING",
+ "License",
+ "Licence",
+ "copying",
+ "LICENSE.txt",
+ "LICENSE.md",
+ "LICENCE.txt",
+ "LICENCE.md",
+ "COPYING.txt",
+ "COPYING.md",
+ "LICENSE.TXT",
+ "LICENSE.MD",
+ "license.txt",
+ "licence.md",
+ "copying.TXT",
+ "COPYING.LESSER",
+ }
+
+ shouldNotMatch := []string{
+ "LICENSE.",
+ "README",
+ "README.md",
+ "NOTICE",
+ "AUTHORS",
+ "LICENSING",
+ "COPYLEFT",
+ "LICENSE.a.b",
+ "LICENSE.a.",
+ "LICENSE.a.md",
+ "LICENSE.md.a",
+ }
+ t.Run("match", func(t *testing.T) {
+ for _, name := range shouldMatch {
+ assert.True(t, isLicenseFile(name))
+ }
+ })
+
+ t.Run("nomatch", func(t *testing.T) {
+ for _, name := range shouldNotMatch {
+ assert.False(t, isLicenseFile(name))
+ }
})
}
diff --git a/templates/repo/home_sidebar_top.tmpl b/templates/repo/home_sidebar_top.tmpl
index 690db11f80..431c507920 100644
--- a/templates/repo/home_sidebar_top.tmpl
+++ b/templates/repo/home_sidebar_top.tmpl
@@ -51,9 +51,10 @@
{{end}}
- {{if .DetectedRepoLicenses}}
-
- {{svg "octicon-law"}} {{if eq (len .DetectedRepoLicenses) 1}}{{index .DetectedRepoLicenses 0}}{{else}}{{ctx.Locale.Tr "repo.multiple_licenses"}}{{end}}
+ {{range $licenseGroup := .LicenseGroups}}
+ {{$licenseDisplayName := StringUtils.Join $licenseGroup.LicenseNames ", "}}
+
+ {{svg "octicon-law"}} {{$licenseDisplayName}}
{{end}}