forgejo/services/repository/gitgraph/graph_models.go
Bram Hagens 60fb59a7a0
Some checks are pending
Integration tests for the release process / release-simulation (push) Waiting to run
/ release (push) Waiting to run
testing-integration / test-unit (push) Waiting to run
testing-integration / test-sqlite (push) Waiting to run
testing-integration / test-mariadb (v10.6) (push) Waiting to run
testing-integration / test-mariadb (v11.8) (push) Waiting to run
testing / backend-checks (push) Waiting to run
testing / frontend-checks (push) Waiting to run
testing / test-unit (push) Blocked by required conditions
testing / test-e2e (push) Blocked by required conditions
testing / test-remote-cacher (redis) (push) Blocked by required conditions
testing / test-remote-cacher (valkey) (push) Blocked by required conditions
testing / test-remote-cacher (garnet) (push) Blocked by required conditions
testing / test-remote-cacher (redict) (push) Blocked by required conditions
testing / test-mysql (push) Blocked by required conditions
testing / test-pgsql (push) Blocked by required conditions
testing / test-sqlite (push) Blocked by required conditions
testing / security-check (push) Blocked by required conditions
fix: display orphan branches separately in commit graph (#10484)
Fixes #2705.
Fixes #7635.

This PR fixes the commit graph showing false connections for orphan/root commits. Connection lines are now shown only when a parent/child relationship exists. Visible relationships are determined using `git log`'s `%P` output by the new `ComputeGlyphConnectivity` function. The SVG template is adapted to render vertical lines conditionally.

Unit tests for `ComputeGlyphConnectivity` cover regular linear commit history, orphan commits, merge commits, and non-commit glyphs (`|`, `/`, `\`). Unit tests also cover the changes to the `git log` parsing. The SVG template was verified manually.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10484
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: Bram Hagens <bram@bramh.me>
Co-committed-by: Bram Hagens <bram@bramh.me>
2025-12-29 02:53:54 +01:00

339 lines
8.5 KiB
Go

// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitgraph
import (
"bytes"
"context"
"fmt"
"strings"
"time"
asymkey_model "forgejo.org/models/asymkey"
"forgejo.org/models/db"
git_model "forgejo.org/models/git"
repo_model "forgejo.org/models/repo"
user_model "forgejo.org/models/user"
"forgejo.org/modules/container"
"forgejo.org/modules/git"
"forgejo.org/modules/log"
)
// NewGraph creates a basic graph
func NewGraph() *Graph {
graph := &Graph{}
graph.relationCommit = &Commit{
Row: -1,
Column: -1,
}
graph.Flows = map[int64]*Flow{}
graph.continuationAbove = map[[2]int]bool{}
return graph
}
// Graph represents a collection of flows
type Graph struct {
Flows map[int64]*Flow
Commits []*Commit
MinRow int
MinColumn int
MaxRow int
MaxColumn int
relationCommit *Commit
continuationAbove map[[2]int]bool
}
// Width returns the width of the graph
func (graph *Graph) Width() int {
return graph.MaxColumn - graph.MinColumn + 1
}
// Height returns the height of the graph
func (graph *Graph) Height() int {
return graph.MaxRow - graph.MinRow + 1
}
// ComputeGlyphConnectivity sets ConnectsUp/ConnectsDown for commit glyphs based on parent/child relationships
func (graph *Graph) ComputeGlyphConnectivity() {
revs := make(container.Set[string])
revByPos := make(map[[2]int]string)
for _, c := range graph.Commits {
if c.Rev != "" {
revs.Add(c.Rev)
revByPos[[2]int{c.Row, c.Column}] = c.Rev
}
}
connectsDown := make(container.Set[string])
connectsUp := make(container.Set[string])
// Commits with parents connect down (even if parent is on another page)
// Commits with visible children connect up
for _, c := range graph.Commits {
if len(c.ParentHashes) > 0 {
connectsDown.Add(c.Rev)
}
for _, parentHash := range c.ParentHashes {
if revs.Contains(parentHash) {
connectsUp.Add(parentHash)
}
}
}
// Commits with a non-commit glyph above also connect up
for _, flow := range graph.Flows {
for _, g := range flow.Glyphs {
if g.Glyph != '*' {
pos := [2]int{g.Row + 1, g.Column}
if rev, exists := revByPos[pos]; exists {
connectsUp.Add(rev)
}
}
}
}
// Commits with continuation from previous page connect up
for pos := range graph.continuationAbove {
if rev, exists := revByPos[pos]; exists {
connectsUp.Add(rev)
}
}
for _, flow := range graph.Flows {
for i := range flow.Glyphs {
glyph := &flow.Glyphs[i]
if glyph.Glyph == '*' {
pos := [2]int{glyph.Row, glyph.Column}
if rev, exists := revByPos[pos]; exists {
glyph.ConnectsUp = connectsUp.Contains(rev)
glyph.ConnectsDown = connectsDown.Contains(rev)
}
} else {
glyph.ConnectsUp = true
glyph.ConnectsDown = true
}
}
}
}
// AddGlyph adds glyph to flows
func (graph *Graph) AddGlyph(row, column int, flowID int64, color int, glyph byte) {
flow, ok := graph.Flows[flowID]
if !ok {
flow = NewFlow(flowID, color, row, column)
graph.Flows[flowID] = flow
}
flow.AddGlyph(row, column, glyph)
if row < graph.MinRow {
graph.MinRow = row
}
if row > graph.MaxRow {
graph.MaxRow = row
}
if column < graph.MinColumn {
graph.MinColumn = column
}
if column > graph.MaxColumn {
graph.MaxColumn = column
}
}
// AddCommit adds a commit at row, column on flowID with the provided data
func (graph *Graph) AddCommit(row, column int, flowID int64, data []byte) error {
commit, err := NewCommit(row, column, data)
if err != nil {
return err
}
commit.Flow = flowID
graph.Commits = append(graph.Commits, commit)
graph.Flows[flowID].Commits = append(graph.Flows[flowID].Commits, commit)
return nil
}
// LoadAndProcessCommits will load the git.Commits for each commit in the graph,
// the associate the commit with the user author, and check the commit verification
// 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{}
keyMap := map[string]bool{}
for _, c := range graph.Commits {
if len(c.Rev) == 0 {
continue
}
c.Commit, err = gitRepo.GetCommit(c.Rev)
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
}
}
c.Verification = asymkey_model.ParseCommitWithSignature(ctx, c.Commit)
_ = asymkey_model.CalculateTrustStatus(c.Verification, repository.GetTrustModel(), func(user *user_model.User) (bool, error) {
return repo_model.IsOwnerMemberCollaborator(ctx, repository, user.ID)
}, &keyMap)
statuses, _, err := git_model.GetLatestCommitStatus(ctx, repository.ID, c.Commit.ID.String(), db.ListOptions{})
if err != nil {
log.Error("GetLatestCommitStatus: %v", err)
} else {
c.Status = git_model.CalcCommitStatus(statuses)
}
}
return nil
}
// NewFlow creates a new flow
func NewFlow(flowID int64, color, row, column int) *Flow {
return &Flow{
ID: flowID,
ColorNumber: color,
MinRow: row,
MinColumn: column,
MaxRow: row,
MaxColumn: column,
}
}
// Flow represents a series of glyphs
type Flow struct {
ID int64
ColorNumber int
Glyphs []Glyph
Commits []*Commit
MinRow int
MinColumn int
MaxRow int
MaxColumn int
}
// Color16 wraps the color numbers around mod 16
func (flow *Flow) Color16() int {
return flow.ColorNumber % 16
}
// AddGlyph adds glyph at row and column
func (flow *Flow) AddGlyph(row, column int, glyph byte) {
if row < flow.MinRow {
flow.MinRow = row
}
if row > flow.MaxRow {
flow.MaxRow = row
}
if column < flow.MinColumn {
flow.MinColumn = column
}
if column > flow.MaxColumn {
flow.MaxColumn = column
}
flow.Glyphs = append(flow.Glyphs, Glyph{
Row: row,
Column: column,
Glyph: glyph,
})
}
// Glyph represents a coordinate and glyph
type Glyph struct {
Row int
Column int
Glyph byte
ConnectsUp bool
ConnectsDown bool
}
// RelationCommit represents an empty relation commit
var RelationCommit = &Commit{
Row: -1,
}
// NewCommit creates a new commit from a provided line
func NewCommit(row, column int, line []byte) (*Commit, error) {
data := bytes.SplitN(line, []byte("|"), 6)
if len(data) < 6 {
return nil, fmt.Errorf("malformed data section on line %d with commit: %s", row, string(line))
}
// Format is a slight modification from RFC1123Z
t, err := time.Parse("Mon, _2 Jan 2006 15:04:05 -0700", string(data[3]))
if err != nil {
return nil, fmt.Errorf("could not parse date of commit: %w", err)
}
var parents []string
for p := range bytes.FieldsSeq(data[0]) {
parents = append(parents, string(p))
}
return &Commit{
Row: row,
Column: column,
// 0 matches git log --pretty=format:%P => parent hashes
ParentHashes: parents,
// 1 matches git log --pretty=format:%d => ref names, like the --decorate option of git-log(1)
Refs: newRefsFromRefNames(data[1]),
// 2 matches git log --pretty=format:%H => commit hash
Rev: string(data[2]),
// 3 matches git log --pretty=format:%aD => author date, RFC2822 style
Date: t,
// 4 matches git log --pretty=format:%h => abbreviated commit hash
ShortRev: string(data[4]),
// 5 matches git log --pretty=format:%s => subject
Subject: string(data[5]),
}, nil
}
func newRefsFromRefNames(refNames []byte) []git.Reference {
refBytes := bytes.Split(refNames, []byte{',', ' '})
refs := make([]git.Reference, 0, len(refBytes))
for _, refNameBytes := range refBytes {
if len(refNameBytes) == 0 {
continue
}
refName := string(refNameBytes)
if strings.HasPrefix(refName, "tag: ") {
refName = strings.TrimPrefix(refName, "tag: ")
} else {
refName = strings.TrimPrefix(refName, "HEAD -> ")
}
refs = append(refs, git.Reference{
Name: refName,
})
}
return refs
}
// Commit represents a commit at coordinate X, Y with the data
type Commit struct {
Commit *git.Commit
User *user_model.User
Verification *asymkey_model.ObjectVerification
Status *git_model.CommitStatus
Flow int64
Row int
Column int
Refs []git.Reference
Rev string
Date time.Time
ShortRev string
Subject string
ParentHashes []string
}
// OnlyRelation returns whether this a relation only commit
func (c *Commit) OnlyRelation() bool {
return c.Row == -1
}