diff --git a/models/user/user.go b/models/user/user.go index 8a39eca634..956e69edcf 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -1140,9 +1140,16 @@ func GetUsersBySource(ctx context.Context, s *auth.Source) ([]*User, error) { return users, err } +// CoAuthorUser represents a co-author parsed from a commit trailer, with optional Gitea user. +type CoAuthorUser struct { + GiteaUser *User + TrailerSignature *git.Signature +} + // UserCommit represents a commit with validation of user. type UserCommit struct { //revive:disable-line:exported - User *User + User *User + CoAuthors []*CoAuthorUser *git.Commit } @@ -1158,6 +1165,27 @@ func ValidateCommitWithEmail(ctx context.Context, c *git.Commit) *User { return u } +// CoAuthorsFromCommit resolves co-author signatures from a commit into CoAuthorUser values. +func CoAuthorsFromCommit(ctx context.Context, c *git.Commit) ([]*CoAuthorUser, error) { + sigs := c.CoAuthorSignatures() + if len(sigs) == 0 { + return nil, nil + } + emails := make([]string, len(sigs)) + for i, sig := range sigs { + emails[i] = sig.Email + } + emailUserMap, err := GetUsersByEmails(ctx, emails) + if err != nil { + return nil, err + } + coAuthors := make([]*CoAuthorUser, len(sigs)) + for i, sig := range sigs { + coAuthors[i] = &CoAuthorUser{GiteaUser: emailUserMap.GetByEmail(sig.Email), TrailerSignature: sig} + } + return coAuthors, nil +} + // ValidateCommitsWithEmails checks if authors' e-mails of commits are corresponding to users. func ValidateCommitsWithEmails(ctx context.Context, oldCommits []*git.Commit) ([]*UserCommit, error) { var ( @@ -1168,6 +1196,9 @@ func ValidateCommitsWithEmails(ctx context.Context, oldCommits []*git.Commit) ([ if c.Author != nil { emailSet.Add(c.Author.Email) } + for _, sig := range c.CoAuthorSignatures() { + emailSet.Add(sig.Email) + } } emailUserMap, err := GetUsersByEmails(ctx, emailSet.Values()) @@ -1177,9 +1208,21 @@ func ValidateCommitsWithEmails(ctx context.Context, oldCommits []*git.Commit) ([ for _, c := range oldCommits { user := emailUserMap.GetByEmail(c.Author.Email) // FIXME: why ValidateCommitsWithEmails uses "Author", but ParseCommitsWithSignature uses "Committer"? + coAuthorSigs := c.CoAuthorSignatures() + var coAuthors []*CoAuthorUser + if len(coAuthorSigs) > 0 { + coAuthors = make([]*CoAuthorUser, 0, len(coAuthorSigs)) + for _, sig := range coAuthorSigs { + coAuthors = append(coAuthors, &CoAuthorUser{ + GiteaUser: emailUserMap.GetByEmail(sig.Email), + TrailerSignature: sig, + }) + } + } newCommits = append(newCommits, &UserCommit{ - User: user, - Commit: c, + User: user, + CoAuthors: coAuthors, + Commit: c, }) } return newCommits, nil diff --git a/modules/git/commit.go b/modules/git/commit.go index b576451db8..4a23533d2f 100644 --- a/modules/git/commit.go +++ b/modules/git/commit.go @@ -17,10 +17,11 @@ import ( ) type CommitMessage struct { - MessageRaw string - messageUTF8 *string - messageTitle *string - messageBody *string + MessageRaw string + messageUTF8 *string + messageTitle *string + messageBody *string + messageCoAuthors *[]*Signature } // Commit represents a git commit. @@ -68,6 +69,113 @@ func (c *CommitMessage) MessageBody() string { return *c.messageBody } +// cutTrailerPrefix matches ":" case-insensitively at the start of line and +// returns the trailing value plus whether a match was found. `git interpret-trailers` +// matches trailer tokens case-insensitively, so e.g. "Co-Authored-By:" is valid. +func cutTrailerPrefix(line, token string) (string, bool) { + if len(line) < len(token)+1 || line[len(token)] != ':' { + return "", false + } + if !strings.EqualFold(line[:len(token)], token) { + return "", false + } + return line[len(token)+1:], true +} + +func isTrailerLine(line string) bool { + token, rest, ok := strings.Cut(line, ":") + if !ok || strings.TrimSpace(rest) == "" { + return false + } + for _, r := range token { + if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + continue + } + return false + } + return token != "" +} + +// CoAuthorSignatures parses "Co-authored-by:" and "Co-committed-by:" trailers +// from the trailing block of the commit message and returns deduplicated +// Signature values. Only the last paragraph of the body is scanned so that +// quoted or in-body occurrences (e.g. inside a revert/cherry-pick description) +// are not misinterpreted as trailers. The trailing paragraph must contain only +// trailer-shaped lines. +// Token matching is case-insensitive to match git's behaviour. +func (c *CommitMessage) CoAuthorSignatures() []*Signature { + if c.messageCoAuthors != nil { + return *c.messageCoAuthors + } + var sigs []*Signature + seen := make(map[string]struct{}) + body := strings.TrimRight(c.MessageBody(), "\r\n") + if idx := strings.LastIndex(body, "\r\n\r\n"); idx >= 0 { + body = body[idx+4:] + } else if idx := strings.LastIndex(body, "\n\n"); idx >= 0 { + body = body[idx+2:] + } + lines := strings.Split(body, "\n") + for i, line := range lines { + lines[i] = strings.TrimRight(line, "\r") + if !isTrailerLine(lines[i]) { + c.messageCoAuthors = &sigs + return sigs + } + } + for _, line := range lines { + rest, ok := cutTrailerPrefix(line, "Co-authored-by") + if !ok { + rest, ok = cutTrailerPrefix(line, "Co-committed-by") + if !ok { + continue + } + } + rest = strings.TrimSpace(rest) + name, emailWithBracket, ok := strings.Cut(rest, " <") + if !ok { + continue + } + email, _, ok := strings.Cut(emailWithBracket, ">") + if !ok { + continue + } + email = strings.ToLower(strings.TrimSpace(email)) + if _, exists := seen[email]; exists { + continue + } + seen[email] = struct{}{} + sigs = append(sigs, &Signature{Name: strings.TrimSpace(name), Email: email}) + } + c.messageCoAuthors = &sigs + return sigs +} + +// CoAuthorSignatures returns the commit's co-author trailers with the commit's +// own author and committer emails filtered out, so a contributor who copies +// themselves into a Co-authored-by line is not duplicated in the avatar stack. +func (c *Commit) CoAuthorSignatures() []*Signature { + raw := c.CommitMessage.CoAuthorSignatures() + if len(raw) == 0 { + return raw + } + exclude := make(map[string]struct{}, 2) + if c.Author != nil { + exclude[strings.ToLower(strings.TrimSpace(c.Author.Email))] = struct{}{} + } + if c.Committer != nil { + exclude[strings.ToLower(strings.TrimSpace(c.Committer.Email))] = struct{}{} + } + out := make([]*Signature, 0, len(raw)) + for _, sig := range raw { + if _, skip := exclude[sig.Email]; skip { + continue + } + out = append(out, sig) + } + return out +} + // ParentID returns oid of n-th parent (0-based index). // It returns nil if no such parent exists. func (c *Commit) ParentID(n int) (ObjectID, error) { diff --git a/modules/git/commit_test.go b/modules/git/commit_test.go index a7668e4deb..08116b0199 100644 --- a/modules/git/commit_test.go +++ b/modules/git/commit_test.go @@ -208,3 +208,92 @@ func Test_GetCommitBranchStart(t *testing.T) { assert.NotEmpty(t, startCommitID) assert.Equal(t, "95bb4d39648ee7e325106df01a621c530863a653", startCommitID) } + +func TestCoAuthorSignatures(t *testing.T) { + cases := []struct { + name string + body string + want []Signature + }{ + { + name: "empty", + body: "title", + want: nil, + }, + { + name: "single co-author", + body: "title\n\nbody text\n\nCo-authored-by: Jane ", + want: []Signature{{Name: "Jane", Email: "jane@example.com"}}, + }, + { + name: "case insensitive token", + body: "title\n\nCo-Authored-By: Jane \nco-committed-by: Bob ", + want: []Signature{ + {Name: "Jane", Email: "jane@example.com"}, + {Name: "Bob", Email: "bob@example.com"}, + }, + }, + { + name: "dedup by lowercased email", + body: "title\n\nCo-authored-by: Jane \nCo-authored-by: Janey ", + want: []Signature{{Name: "Jane", Email: "jane@example.com"}}, + }, + { + name: "in-body trailer ignored, only last paragraph counts", + body: "title\n\nCo-authored-by: Mallory \n\nactual body explaining revert\n\nCo-authored-by: Jane ", + want: []Signature{{Name: "Jane", Email: "jane@example.com"}}, + }, + { + name: "body text in trailing paragraph rejects co-author line", + body: "title\n\nbody text\nCo-authored-by: Jane ", + want: nil, + }, + { + name: "missing brackets is ignored", + body: "title\n\nCo-authored-by: Jane jane@example.com", + want: nil, + }, + { + name: "CRLF line endings", + body: "title\r\n\r\nCo-authored-by: Jane \r\nCo-committed-by: Bob ", + want: []Signature{ + {Name: "Jane", Email: "jane@example.com"}, + {Name: "Bob", Email: "bob@example.com"}, + }, + }, + { + name: "non-trailer line", + body: "title\n\nSigned-off-by: Jane ", + want: nil, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cm := CommitMessage{MessageRaw: tc.body} + got := cm.CoAuthorSignatures() + assert.Len(t, got, len(tc.want)) + for i, w := range tc.want { + if i >= len(got) { + break + } + assert.Equal(t, w.Name, got[i].Name, "name[%d]", i) + assert.Equal(t, w.Email, got[i].Email, "email[%d]", i) + } + }) + } +} + +func TestCommitCoAuthorSignaturesFiltersAuthorAndCommitter(t *testing.T) { + c := &Commit{ + Author: &Signature{Name: "Jane", Email: "jane@example.com"}, + Committer: &Signature{Name: "Bob", Email: "bob@example.com"}, + CommitMessage: CommitMessage{MessageRaw: "title\n\n" + + "Co-authored-by: Jane Self \n" + + "Co-authored-by: Bob Self \n" + + "Co-authored-by: Carol "}, + } + got := c.CoAuthorSignatures() + if assert.Len(t, got, 1) { + assert.Equal(t, "carol@example.com", got[0].Email) + } +} diff --git a/modules/repository/commits.go b/modules/repository/commits.go index 32550a9f03..83ae10d8f5 100644 --- a/modules/repository/commits.go +++ b/modules/repository/commits.go @@ -29,9 +29,16 @@ type PushCommit struct { AuthorName string CommitterEmail string CommitterName string + CoAuthors []*PushCommitCoAuthor `json:",omitempty"` Timestamp time.Time } +// PushCommitCoAuthor represents a co-author in a push commit payload. +type PushCommitCoAuthor struct { + Name string + Email string +} + // PushCommits represents list of commits in a push operation. type PushCommits struct { Commits []*PushCommit @@ -148,6 +155,17 @@ func (pc *PushCommits) AvatarLink(ctx context.Context, email string) string { return v } +func pushCommitCoAuthorsFromSignatures(sigs []*git.Signature) []*PushCommitCoAuthor { + if len(sigs) == 0 { + return nil + } + coAuthors := make([]*PushCommitCoAuthor, len(sigs)) + for i, sig := range sigs { + coAuthors[i] = &PushCommitCoAuthor{Name: sig.Name, Email: sig.Email} + } + return coAuthors +} + // CommitToPushCommit transforms a git.Commit to PushCommit type. func CommitToPushCommit(commit *git.Commit) *PushCommit { return &PushCommit{ @@ -157,10 +175,34 @@ func CommitToPushCommit(commit *git.Commit) *PushCommit { AuthorName: commit.Author.Name, CommitterEmail: commit.Committer.Email, CommitterName: commit.Committer.Name, + CoAuthors: pushCommitCoAuthorsFromSignatures(commit.CoAuthorSignatures()), Timestamp: commit.Author.When, } } +// AuthorSignature returns the push commit author as a git signature. +func (pc *PushCommit) AuthorSignature() *git.Signature { + return &git.Signature{ + Email: pc.AuthorEmail, + Name: pc.AuthorName, + } +} + +// CoAuthorUsers returns co-authors in the template view shape. +func (pc *PushCommit) CoAuthorUsers() []*user_model.CoAuthorUser { + if len(pc.CoAuthors) == 0 { + return nil + } + coAuthors := make([]*user_model.CoAuthorUser, len(pc.CoAuthors)) + for i, coAuthor := range pc.CoAuthors { + coAuthors[i] = &user_model.CoAuthorUser{TrailerSignature: &git.Signature{ + Name: coAuthor.Name, + Email: coAuthor.Email, + }} + } + return coAuthors +} + // GitToPushCommits transforms a list of git.Commits to PushCommits type. func GitToPushCommits(gitCommits []*git.Commit) *PushCommits { commits := make([]*PushCommit, 0, len(gitCommits)) diff --git a/modules/repository/commits_test.go b/modules/repository/commits_test.go index 46db7b028b..6228ae61a7 100644 --- a/modules/repository/commits_test.go +++ b/modules/repository/commits_test.go @@ -145,14 +145,23 @@ func TestCommitToPushCommit(t *testing.T) { ID: sha1, Author: sig, Committer: sig, - CommitMessage: git.CommitMessage{MessageRaw: "Commit Message"}, + CommitMessage: git.CommitMessage{MessageRaw: "Commit Message\n\nCo-authored-by: Jane Doe "}, }) assert.Equal(t, hexString, pushCommit.Sha1) - assert.Equal(t, "Commit Message", pushCommit.Message) + assert.Equal(t, "Commit Message\n\nCo-authored-by: Jane Doe ", pushCommit.Message) assert.Equal(t, "example@example.com", pushCommit.AuthorEmail) assert.Equal(t, "John Doe", pushCommit.AuthorName) assert.Equal(t, "example@example.com", pushCommit.CommitterEmail) assert.Equal(t, "John Doe", pushCommit.CommitterName) + if assert.Len(t, pushCommit.CoAuthors, 1) { + assert.Equal(t, "jane@example.com", pushCommit.CoAuthors[0].Email) + assert.Equal(t, "Jane Doe", pushCommit.CoAuthors[0].Name) + } + assert.Equal(t, &git.Signature{Email: "example@example.com", Name: "John Doe"}, pushCommit.AuthorSignature()) + if assert.Len(t, pushCommit.CoAuthorUsers(), 1) { + assert.Equal(t, &git.Signature{Email: "jane@example.com", Name: "Jane Doe"}, pushCommit.CoAuthorUsers()[0].TrailerSignature) + assert.Nil(t, pushCommit.CoAuthorUsers()[0].GiteaUser) + } assert.Equal(t, now, pushCommit.Timestamp) } diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index c7ec133e57..b5ae421266 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2589,6 +2589,11 @@ "repo.diff.review.reject": "Request changes", "repo.diff.review.self_approve": "Pull request authors can't approve their own pull request", "repo.diff.committed_by": "committed by", + "repo.diff.coauthored_by": "co-authored by", + "repo.commits.coauthor_and": "and", + "repo.commits.coauthor_people": "%d people", + "repo.commits.coauthor_others_1": "%d other", + "repo.commits.coauthor_others_n": "%d others", "repo.diff.protected": "Protected", "repo.diff.image.side_by_side": "Side by Side", "repo.diff.image.swipe": "Swipe", diff --git a/routers/web/repo/blame.go b/routers/web/repo/blame.go index 803397f33f..62697d708c 100644 --- a/routers/web/repo/blame.go +++ b/routers/web/repo/blame.go @@ -29,12 +29,14 @@ import ( type blameRow struct { RowNumber int - Avatar template.HTML PreviousSha string PreviousShaURL string CommitURL string CommitMessage string CommitSince template.HTML + AuthorUser *user_model.User + CoAuthors []*user_model.CoAuthorUser + Author *git.Signature Code template.HTML EscapeStatus *charset.EscapeStatus @@ -221,13 +223,10 @@ func processBlameParts(ctx *context.Context, blameParts []*gitrepo.BlamePart) ma return commitNames } -func renderBlameFillFirstBlameRow(repoLink string, avatarUtils *templates.AvatarUtils, part *gitrepo.BlamePart, commit *user_model.UserCommit, br *blameRow) { - if commit.User != nil { - br.Avatar = avatarUtils.Avatar(commit.User, 18) - } else { - br.Avatar = avatarUtils.AvatarByEmail(commit.Author.Email, commit.Author.Name, 18) - } - +func renderBlameFillFirstBlameRow(repoLink string, part *gitrepo.BlamePart, commit *user_model.UserCommit, br *blameRow) { + br.AuthorUser = commit.User + br.CoAuthors = commit.CoAuthors + br.Author = commit.Author br.PreviousSha = part.PreviousSha br.PreviousShaURL = fmt.Sprintf("%s/blame/commit/%s/%s", repoLink, url.PathEscape(part.PreviousSha), util.PathEscapeSegments(part.PreviousPath)) br.CommitURL = fmt.Sprintf("%s/commit/%s", repoLink, url.PathEscape(part.Sha)) @@ -243,7 +242,6 @@ func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNa buf := &bytes.Buffer{} rows := make([]*blameRow, 0) - avatarUtils := templates.NewAvatarUtils(ctx) rowNumber := 0 // will be 1-based for _, part := range blameParts { for partLineIdx, line := range part.Lines { @@ -258,7 +256,7 @@ func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNa } if partLineIdx == 0 { - renderBlameFillFirstBlameRow(ctx.Repo.RepoLink, avatarUtils, part, commitNames[part.Sha], br) + renderBlameFillFirstBlameRow(ctx.Repo.RepoLink, part, commitNames[part.Sha], br) } } } diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 6c973696ff..b54a1dbf9b 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -382,6 +382,11 @@ func Diff(ctx *context.Context) { verification := asymkey_service.ParseCommitWithSignature(ctx, commit) ctx.Data["Verification"] = verification ctx.Data["Author"] = user_model.ValidateCommitWithEmail(ctx, commit) + if coAuthors, err := user_model.CoAuthorsFromCommit(ctx, commit); err != nil { + log.Error("CoAuthorsFromCommit: %v", err) + } else { + ctx.Data["CoAuthors"] = coAuthors + } ctx.Data["Parents"] = parents ctx.Data["DiffNotAvailable"] = diffShortStat.NumFiles == 0 diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 2d95d5233e..d0cf7d6f6f 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -135,6 +135,12 @@ func loadLatestCommitData(ctx *context.Context, latestCommit *git.Commit) bool { ctx.Data["LatestCommitVerification"] = verification ctx.Data["LatestCommitUser"] = user_model.ValidateCommitWithEmail(ctx, latestCommit) + if coAuthors, err := user_model.CoAuthorsFromCommit(ctx, latestCommit); err != nil { + log.Error("CoAuthorsFromCommit: %v", err) + } else { + ctx.Data["LatestCommitCoAuthors"] = coAuthors + } + statuses, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, latestCommit.ID.String(), db.ListOptionsAll) if err != nil { log.Error("GetLatestCommitStatus: %v", err) diff --git a/services/repository/gitgraph/graph_models.go b/services/repository/gitgraph/graph_models.go index fc2eb85b87..fb33d3c0a0 100644 --- a/services/repository/gitgraph/graph_models.go +++ b/services/repository/gitgraph/graph_models.go @@ -93,9 +93,7 @@ func (graph *Graph) AddCommit(row, column int, flowID int64, data []byte) error // before finally retrieving the latest status func (graph *Graph) LoadAndProcessCommits(ctx context.Context, repository *repo_model.Repository, gitRepo *git.Repository) error { var err error - var ok bool - - emails := map[string]*user_model.User{} + emailSet := map[string]struct{}{} keyMap := map[string]bool{} for _, c := range graph.Commits { @@ -106,12 +104,45 @@ func (graph *Graph) LoadAndProcessCommits(ctx context.Context, repository *repo_ if err != nil { return fmt.Errorf("GetCommit: %s Error: %w", c.Rev, err) } - if c.Commit.Author != nil { - email := c.Commit.Author.Email - if c.User, ok = emails[email]; !ok { - c.User, _ = user_model.GetUserByEmail(ctx, email) - emails[email] = c.User + emailSet[c.Commit.Author.Email] = struct{}{} + } + for _, sig := range c.Commit.CoAuthorSignatures() { + emailSet[sig.Email] = struct{}{} + } + } + + allEmails := make([]string, 0, len(emailSet)) + for email := range emailSet { + allEmails = append(allEmails, email) + } + var emailUserMap *user_model.EmailUserMap + if len(allEmails) > 0 { + emailUserMap, err = user_model.GetUsersByEmails(ctx, allEmails) + if err != nil { + log.Error("GetUsersByEmails: %v", err) + } + } + + for _, c := range graph.Commits { + if c.Commit == nil { + continue + } + if c.Commit.Author != nil && emailUserMap != nil { + c.User = emailUserMap.GetByEmail(c.Commit.Author.Email) + } + coAuthorSigs := c.Commit.CoAuthorSignatures() + if len(coAuthorSigs) > 0 { + c.CoAuthors = make([]*user_model.CoAuthorUser, 0, len(coAuthorSigs)) + for _, sig := range coAuthorSigs { + var giteaUser *user_model.User + if emailUserMap != nil { + giteaUser = emailUserMap.GetByEmail(sig.Email) + } + c.CoAuthors = append(c.CoAuthors, &user_model.CoAuthorUser{ + GiteaUser: giteaUser, + TrailerSignature: sig, + }) } } @@ -248,6 +279,7 @@ func newRefsFromRefNames(refNames []byte) []git.Reference { type Commit struct { Commit *git.Commit User *user_model.User + CoAuthors []*user_model.CoAuthorUser Verification *asymkey_model.CommitVerification Status *git_model.CommitStatus Flow int64 diff --git a/templates/repo/blame.tmpl b/templates/repo/blame.tmpl index 8bdefa5d43..e6ae31891d 100644 --- a/templates/repo/blame.tmpl +++ b/templates/repo/blame.tmpl @@ -43,7 +43,7 @@
- {{$row.Avatar}} + {{template "repo/commit_coauthor_avatar_stack" dict "AuthorUser" $row.AuthorUser "AuthorSignature" $row.Author "CoAuthors" $row.CoAuthors}}
+ {{if .CoAuthors}} +
+ {{ctx.Locale.Tr "repo.diff.coauthored_by"}} + {{range .CoAuthors}} + {{if .GiteaUser}} + {{ctx.AvatarUtils.Avatar .GiteaUser 20}} + {{.GiteaUser.GetDisplayName}} + {{else}} + {{ctx.AvatarUtils.AvatarByEmail .TrailerSignature.Email .TrailerSignature.Name 20}} + {{.TrailerSignature.Name}} + {{end}} + {{end}} +
+ {{end}} + {{if .Verification}} {{template "repo/commit_sign_badge" dict "CommitSignVerification" .Verification}} {{end}} diff --git a/templates/repo/commit_sign_badge.tmpl b/templates/repo/commit_sign_badge.tmpl index f63e4ec899..bf8185fd0b 100644 --- a/templates/repo/commit_sign_badge.tmpl +++ b/templates/repo/commit_sign_badge.tmpl @@ -64,10 +64,10 @@ so this template should be kept as small as possible, DO NOT put large component {{- if $verified -}} {{- if and $signingUser $signingUser.ID -}} {{svg "gitea-lock"}} - {{ctx.AvatarUtils.Avatar $signingUser 16}} + {{ctx.AvatarUtils.Avatar $signingUser 20}} {{- else -}} {{svg "gitea-lock-cog"}} - {{ctx.AvatarUtils.AvatarByEmail $signingEmail "" 16}} + {{ctx.AvatarUtils.AvatarByEmail $signingEmail "" 20}} {{- end -}} {{- else -}} {{svg "gitea-unlock"}} diff --git a/templates/repo/commits_list.tmpl b/templates/repo/commits_list.tmpl index e79d189b8d..e80b6cdc24 100644 --- a/templates/repo/commits_list.tmpl +++ b/templates/repo/commits_list.tmpl @@ -2,9 +2,9 @@ - + - + @@ -14,15 +14,7 @@ {{range $commit := .Commits}}
{{ctx.Locale.Tr "repo.commits.author"}}{{ctx.Locale.Tr "repo.commits.author"}} {{StringUtils.ToUpper $.Repository.ObjectFormatName}}{{ctx.Locale.Tr "repo.commits.message"}}{{ctx.Locale.Tr "repo.commits.message"}} {{ctx.Locale.Tr "repo.commits.date"}}
- - {{- if .User -}} - {{- ctx.AvatarUtils.Avatar .User 20 "tw-mr-2" -}} - {{- .User.GetShortDisplayNameLinkHTML -}} - {{- else -}} - {{- ctx.AvatarUtils.AvatarByEmail .Author.Email .Author.Name 20 "tw-mr-2" -}} - {{- .Author.Name -}} - {{- end -}} - + {{template "repo/commit_coauthor_avatars" dict "AuthorUser" .User "AuthorSignature" .Author "CoAuthors" .CoAuthors}} {{$commitBaseLink := ""}} diff --git a/templates/repo/commits_list_small.tmpl b/templates/repo/commits_list_small.tmpl index c5f0d5b590..c1cadf74ba 100644 --- a/templates/repo/commits_list_small.tmpl +++ b/templates/repo/commits_list_small.tmpl @@ -5,11 +5,7 @@ {{$index = Eval $index "+" 1}}
{{/*singular-commit*/}} {{svg "octicon-git-commit"}} - {{if .User}} - {{ctx.AvatarUtils.Avatar .User 20}} - {{else}} - {{ctx.AvatarUtils.AvatarByEmail .Author.Email .Author.Name 20}} - {{end}} + {{template "repo/commit_coauthor_avatar_stack" dict "AuthorUser" .User "AuthorSignature" .Author "CoAuthors" .CoAuthors}} {{$commitBaseLink := printf "%s/commit" $.comment.Issue.PullRequest.BaseRepo.Link}} {{$commitLink:= printf "%s/%s" $commitBaseLink (PathEscape .ID.String)}} diff --git a/templates/repo/graph/commits.tmpl b/templates/repo/graph/commits.tmpl index d86f73fe65..7b834d2be7 100644 --- a/templates/repo/graph/commits.tmpl +++ b/templates/repo/graph/commits.tmpl @@ -41,14 +41,7 @@ - {{if $commit.User}} - {{ctx.AvatarUtils.Avatar $commit.User 18}} - {{$commit.User.GetShortDisplayNameLinkHTML}} - {{else}} - {{$gitUserName := $commit.Commit.Author.Name}} - {{ctx.AvatarUtils.AvatarByEmail $commit.Commit.Author.Email $gitUserName 18}} - {{$gitUserName}} - {{end}} + {{template "repo/commit_coauthor_avatars" dict "AuthorUser" $commit.User "AuthorSignature" $commit.Commit.Author "CoAuthors" $commit.CoAuthors}} {{DateUtils.FullTime $commit.Date}} diff --git a/templates/repo/latest_commit.tmpl b/templates/repo/latest_commit.tmpl index c0518189b8..3a3b162ba5 100644 --- a/templates/repo/latest_commit.tmpl +++ b/templates/repo/latest_commit.tmpl @@ -2,15 +2,7 @@ {{if not .LatestCommit}} … {{else}} - - {{- if .LatestCommitUser -}} - {{- ctx.AvatarUtils.Avatar .LatestCommitUser 20 "tw-mr-2" -}} - {{.LatestCommitUser.GetShortDisplayNameLinkHTML}} - {{- else if .LatestCommit.Author -}} - {{- ctx.AvatarUtils.AvatarByEmail .LatestCommit.Author.Email .LatestCommit.Author.Name 20 "tw-mr-2" -}} - {{.LatestCommit.Author.Name}} - {{- end -}} - + {{template "repo/commit_coauthor_avatars" dict "AuthorUser" .LatestCommitUser "AuthorSignature" .LatestCommit.Author "CoAuthors" .LatestCommitCoAuthors}} {{template "repo/commit_sign_badge" dict "Commit" .LatestCommit "CommitBaseLink" (print .RepoLink "/commit") "CommitSignVerification" .LatestCommitVerification}} diff --git a/templates/user/dashboard/feeds.tmpl b/templates/user/dashboard/feeds.tmpl index baf077863c..0981fea8f1 100644 --- a/templates/user/dashboard/feeds.tmpl +++ b/templates/user/dashboard/feeds.tmpl @@ -91,7 +91,7 @@ {{range $pushCommit := $push.Commits}} {{$commitLink := printf "%s/commit/%s" $repoLink .Sha1}}
- + {{template "repo/commit_coauthor_avatar_stack" dict "AuthorSignature" .AuthorSignature "CoAuthors" .CoAuthorUsers}} {{ShortSha .Sha1}} {{ctx.RenderUtils.RenderCommitMessage $pushCommit.Message $repo}} diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 3eb016650f..0a30cf6ce5 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -1403,11 +1403,77 @@ tbody.commit-list { } .author-wrapper { - max-width: 180px; + max-width: 240px; align-self: center; white-space: nowrap; } +.coauthor-avatar-stack-wrapper { + display: inline-flex; + align-items: center; + vertical-align: middle; + margin-right: 0.25rem; +} + +.coauthor-avatar-stack { + display: inline-flex; + align-items: center; +} + +/* each direct child of the stack is either (linked user) or (no account) */ +.coauthor-avatar-stack > * { + margin-left: -12px; + transition: transform 0.15s ease; + position: relative; + z-index: 0; + display: inline-flex; + border-radius: 50%; +} + +.coauthor-avatar-stack > *:first-child { + margin-left: 0; +} + +.coauthor-avatar-stack .avatar { + border: 2px solid var(--color-body); + border-radius: 50%; +} + +.coauthor-overflow-chip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + font-size: 10px; + font-weight: var(--font-weight-semibold); + border: 2px solid var(--color-body); + border-radius: 50%; + background: var(--color-secondary); + color: var(--color-text); +} + +.coauthor-avatar-stack > *:nth-child(1) { z-index: 20; } +.coauthor-avatar-stack > *:nth-child(2) { z-index: 19; } +.coauthor-avatar-stack > *:nth-child(3) { z-index: 18; } +.coauthor-avatar-stack > *:nth-child(4) { z-index: 17; } +.coauthor-avatar-stack > *:nth-child(5) { z-index: 16; } +.coauthor-avatar-stack > *:nth-child(6) { z-index: 15; } +.coauthor-avatar-stack > *:nth-child(7) { z-index: 14; } +.coauthor-avatar-stack > *:nth-child(8) { z-index: 13; } +.coauthor-avatar-stack > *:nth-child(9) { z-index: 12; } +.coauthor-avatar-stack > *:nth-child(10) { z-index: 11; } + +.coauthor-avatar-stack:hover > *:nth-child(2) { transform: translateX(12px); } +.coauthor-avatar-stack:hover > *:nth-child(3) { transform: translateX(24px); } +.coauthor-avatar-stack:hover > *:nth-child(4) { transform: translateX(36px); } +.coauthor-avatar-stack:hover > *:nth-child(5) { transform: translateX(48px); } +.coauthor-avatar-stack:hover > *:nth-child(6) { transform: translateX(60px); } +.coauthor-avatar-stack:hover > *:nth-child(7) { transform: translateX(72px); } +.coauthor-avatar-stack:hover > *:nth-child(8) { transform: translateX(84px); } +.coauthor-avatar-stack:hover > *:nth-child(9) { transform: translateX(96px); } +.coauthor-avatar-stack:hover > *:nth-child(10) { transform: translateX(108px); } + .latest-commit .message-wrapper { max-width: calc(100% - 2.5rem); }