feat(web): home rows show commits, last 30 days, latest tag and last activity (#62)
- commit
- 333c71081622ec84d1a304246618ab0f31ccc1f2
- author
- brice · · 2026-10-18 09:52 UTC
- committer
- bnema · · 2026-10-18 09:52 UTC
- parent
- 4c072b1
- signature
- SSH Unverified
SHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivAThe key is unknown - refs
- tag: v0.23.0
32 files · +1328 -572
M internal/adapters/ermitegit/ermitegit.go +11 -0
| @@ -415,3 +415,14 @@ func (a *Adapter) ListCommits(ctx context.Context, repo domain.RepoName, q ports | |||
| 415 | 415 | func (a *Adapter) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) { | |
| 416 | 416 | return a.cli.GetCommit(ctx, repo, rev, limits) | |
| 417 | 417 | } | |
| 418 | + | ||
| 419 | + | // CountCommits is served by the wrapped reader: git counts from the commit | |
| 420 | + | // graph. | |
| 421 | + | func (a *Adapter) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error) { | |
| 422 | + | return a.cli.CountCommits(ctx, repo, head, since) | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | // LatestTag is served by the wrapped reader. | |
| 426 | + | func (a *Adapter) LatestTag(ctx context.Context, repo domain.RepoName) (domain.TagInfo, bool, error) { | |
| 427 | + | return a.cli.LatestTag(ctx, repo) | |
| 428 | + | } | |
| @@ -415,3 +415,14 @@ func (a *Adapter) ListCommits(ctx context.Context, repo domain.RepoName, q ports | |||||
| 415 | func (a *Adapter) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) { | 415 | func (a *Adapter) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) { | ||
| 416 | return a.cli.GetCommit(ctx, repo, rev, limits) | 416 | return a.cli.GetCommit(ctx, repo, rev, limits) | ||
| 417 | } | 417 | } | ||
| 418 | + | ||||
| 419 | + | // CountCommits is served by the wrapped reader: git counts from the commit | |||
| 420 | + | // graph. | |||
| 421 | + | func (a *Adapter) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error) { | |||
| 422 | + | return a.cli.CountCommits(ctx, repo, head, since) | |||
| 423 | + | } | |||
| 424 | + | ||||
| 425 | + | // LatestTag is served by the wrapped reader. | |||
| 426 | + | func (a *Adapter) LatestTag(ctx context.Context, repo domain.RepoName) (domain.TagInfo, bool, error) { | |||
| 427 | + | return a.cli.LatestTag(ctx, repo) | |||
| 428 | + | } | |||
A internal/adapters/gitcli/stats.go +88 -0
| @@ -0,0 +1,88 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package gitcli | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "strconv" | |
| 9 | + | "strings" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // CountCommits runs `git rev-list --count`, from the commit graph when the | |
| 16 | + | // repository has one. A bound uses --since-as-filter, which looks at every | |
| 17 | + | // reachable commit: --since stops the walk at the first older one, and | |
| 18 | + | // misses newer commits behind it when dates are out of order. | |
| 19 | + | // | |
| 20 | + | //hexcheck:ignore suspicious-business-logic-in-adapter reads the count git prints | |
| 21 | + | func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error) { | |
| 22 | + | if head.IsZero() { | |
| 23 | + | return 0, fmt.Errorf("count commits: %w: no head", domain.ErrInvalid) | |
| 24 | + | } | |
| 25 | + | dir, err := g.repoDir(repo) | |
| 26 | + | if err != nil { | |
| 27 | + | return 0, err | |
| 28 | + | } | |
| 29 | + | args := []string{"rev-list", "--count"} | |
| 30 | + | if !since.IsZero() { | |
| 31 | + | args = append(args, "--since-as-filter=@"+strconv.FormatInt(since.Unix(), 10)) | |
| 32 | + | } | |
| 33 | + | out, err := g.run(ctx, readArgs(dir, append(args, "--end-of-options", head.String())...)...) | |
| 34 | + | if err != nil { | |
| 35 | + | if exitCode(err) == 128 { | |
| 36 | + | return 0, fmt.Errorf("count commits of %q: %w", repo, domain.ErrNotFound) | |
| 37 | + | } | |
| 38 | + | return 0, fmt.Errorf("count commits of %q: %w", repo, err) | |
| 39 | + | } | |
| 40 | + | n, err := strconv.Atoi(strings.TrimSpace(out)) | |
| 41 | + | if err != nil { | |
| 42 | + | return 0, fmt.Errorf("count commits of %q: git printed %q", repo, strings.TrimSpace(out)) | |
| 43 | + | } | |
| 44 | + | return n, nil | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // LatestTag runs `git for-each-ref --sort=-creatordate --count=1` on the tags. | |
| 48 | + | // | |
| 49 | + | //hexcheck:ignore suspicious-business-logic-in-adapter reads the line git prints | |
| 50 | + | func (g *Git) LatestTag(ctx context.Context, repo domain.RepoName) (domain.TagInfo, bool, error) { | |
| 51 | + | dir, err := g.repoDir(repo) | |
| 52 | + | if err != nil { | |
| 53 | + | return domain.TagInfo{}, false, err | |
| 54 | + | } | |
| 55 | + | out, err := g.run(ctx, readArgs(dir, "for-each-ref", "--sort=-creatordate", "--count=1", | |
| 56 | + | "--format=%(refname:strip=2)%00%(creatordate:unix)%00%(objecttype)%00%(objectname)%00%(*objecttype)%00%(*objectname)", tagsRefPrefix)...) | |
| 57 | + | if err != nil { | |
| 58 | + | return domain.TagInfo{}, false, fmt.Errorf("latest tag of %q: %w", repo, err) | |
| 59 | + | } | |
| 60 | + | line := strings.TrimSuffix(out, "\n") | |
| 61 | + | if line == "" { | |
| 62 | + | return domain.TagInfo{}, false, nil | |
| 63 | + | } | |
| 64 | + | f := strings.Split(line, "\x00") | |
| 65 | + | if len(f) != 6 || f[0] == "" { | |
| 66 | + | return domain.TagInfo{}, false, fmt.Errorf("latest tag of %q: unexpected line %q", repo, truncateText(line)) | |
| 67 | + | } | |
| 68 | + | sec, err := strconv.ParseInt(f[1], 10, 64) | |
| 69 | + | if err != nil { | |
| 70 | + | return domain.TagInfo{}, false, fmt.Errorf("latest tag of %q: unexpected line %q", repo, truncateText(line)) | |
| 71 | + | } | |
| 72 | + | tag := domain.TagInfo{Name: domain.ValidGitText(f[0]), At: time.Unix(sec, 0).UTC()} | |
| 73 | + | // A lightweight tag names its commit; an annotated one is peeled to the | |
| 74 | + | // end of its chain of tags. | |
| 75 | + | commit := "" | |
| 76 | + | switch { | |
| 77 | + | case f[2] == "commit": | |
| 78 | + | commit = f[3] | |
| 79 | + | case f[4] == "commit": | |
| 80 | + | commit = f[5] | |
| 81 | + | } | |
| 82 | + | if commit != "" { | |
| 83 | + | if tag.CommitID, err = domain.ParseObjectID(commit); err != nil { | |
| 84 | + | return domain.TagInfo{}, false, fmt.Errorf("latest tag of %q: %w", repo, err) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | return tag, true, nil | |
| 88 | + | } | |
| @@ -0,0 +1,88 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package gitcli | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "fmt" | |||
| 8 | + | "strconv" | |||
| 9 | + | "strings" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "bnema.dev/ermite/internal/domain" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // CountCommits runs `git rev-list --count`, from the commit graph when the | |||
| 16 | + | // repository has one. A bound uses --since-as-filter, which looks at every | |||
| 17 | + | // reachable commit: --since stops the walk at the first older one, and | |||
| 18 | + | // misses newer commits behind it when dates are out of order. | |||
| 19 | + | // | |||
| 20 | + | //hexcheck:ignore suspicious-business-logic-in-adapter reads the count git prints | |||
| 21 | + | func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error) { | |||
| 22 | + | if head.IsZero() { | |||
| 23 | + | return 0, fmt.Errorf("count commits: %w: no head", domain.ErrInvalid) | |||
| 24 | + | } | |||
| 25 | + | dir, err := g.repoDir(repo) | |||
| 26 | + | if err != nil { | |||
| 27 | + | return 0, err | |||
| 28 | + | } | |||
| 29 | + | args := []string{"rev-list", "--count"} | |||
| 30 | + | if !since.IsZero() { | |||
| 31 | + | args = append(args, "--since-as-filter=@"+strconv.FormatInt(since.Unix(), 10)) | |||
| 32 | + | } | |||
| 33 | + | out, err := g.run(ctx, readArgs(dir, append(args, "--end-of-options", head.String())...)...) | |||
| 34 | + | if err != nil { | |||
| 35 | + | if exitCode(err) == 128 { | |||
| 36 | + | return 0, fmt.Errorf("count commits of %q: %w", repo, domain.ErrNotFound) | |||
| 37 | + | } | |||
| 38 | + | return 0, fmt.Errorf("count commits of %q: %w", repo, err) | |||
| 39 | + | } | |||
| 40 | + | n, err := strconv.Atoi(strings.TrimSpace(out)) | |||
| 41 | + | if err != nil { | |||
| 42 | + | return 0, fmt.Errorf("count commits of %q: git printed %q", repo, strings.TrimSpace(out)) | |||
| 43 | + | } | |||
| 44 | + | return n, nil | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // LatestTag runs `git for-each-ref --sort=-creatordate --count=1` on the tags. | |||
| 48 | + | // | |||
| 49 | + | //hexcheck:ignore suspicious-business-logic-in-adapter reads the line git prints | |||
| 50 | + | func (g *Git) LatestTag(ctx context.Context, repo domain.RepoName) (domain.TagInfo, bool, error) { | |||
| 51 | + | dir, err := g.repoDir(repo) | |||
| 52 | + | if err != nil { | |||
| 53 | + | return domain.TagInfo{}, false, err | |||
| 54 | + | } | |||
| 55 | + | out, err := g.run(ctx, readArgs(dir, "for-each-ref", "--sort=-creatordate", "--count=1", | |||
| 56 | + | "--format=%(refname:strip=2)%00%(creatordate:unix)%00%(objecttype)%00%(objectname)%00%(*objecttype)%00%(*objectname)", tagsRefPrefix)...) | |||
| 57 | + | if err != nil { | |||
| 58 | + | return domain.TagInfo{}, false, fmt.Errorf("latest tag of %q: %w", repo, err) | |||
| 59 | + | } | |||
| 60 | + | line := strings.TrimSuffix(out, "\n") | |||
| 61 | + | if line == "" { | |||
| 62 | + | return domain.TagInfo{}, false, nil | |||
| 63 | + | } | |||
| 64 | + | f := strings.Split(line, "\x00") | |||
| 65 | + | if len(f) != 6 || f[0] == "" { | |||
| 66 | + | return domain.TagInfo{}, false, fmt.Errorf("latest tag of %q: unexpected line %q", repo, truncateText(line)) | |||
| 67 | + | } | |||
| 68 | + | sec, err := strconv.ParseInt(f[1], 10, 64) | |||
| 69 | + | if err != nil { | |||
| 70 | + | return domain.TagInfo{}, false, fmt.Errorf("latest tag of %q: unexpected line %q", repo, truncateText(line)) | |||
| 71 | + | } | |||
| 72 | + | tag := domain.TagInfo{Name: domain.ValidGitText(f[0]), At: time.Unix(sec, 0).UTC()} | |||
| 73 | + | // A lightweight tag names its commit; an annotated one is peeled to the | |||
| 74 | + | // end of its chain of tags. | |||
| 75 | + | commit := "" | |||
| 76 | + | switch { | |||
| 77 | + | case f[2] == "commit": | |||
| 78 | + | commit = f[3] | |||
| 79 | + | case f[4] == "commit": | |||
| 80 | + | commit = f[5] | |||
| 81 | + | } | |||
| 82 | + | if commit != "" { | |||
| 83 | + | if tag.CommitID, err = domain.ParseObjectID(commit); err != nil { | |||
| 84 | + | return domain.TagInfo{}, false, fmt.Errorf("latest tag of %q: %w", repo, err) | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | return tag, true, nil | |||
| 88 | + | } | |||
M internal/adapters/web/components.templ +23 -10
| @@ -52,16 +52,30 @@ templ HomeRowView(r HomeRow) { | |||
| 52 | 52 | } else { | |
| 53 | 53 | <p class="repo-desc mute">no description</p> | |
| 54 | 54 | } | |
| 55 | - | <p class="repo-meta"> | |
| 56 | - | if r.Branch != "" { | |
| 57 | - | <span class="repo-branch"><span class="ico i-branch" aria-hidden="true"></span>{ r.Branch }</span> | |
| 58 | - | } | |
| 59 | - | if r.PushedAt != "" { | |
| 60 | - | <span>pushed <time datetime={ r.PushedAt } title={ r.PushedAt }>{ r.Pushed }</time></span> | |
| 61 | - | } else { | |
| 62 | - | <span>never pushed</span> | |
| 55 | + | <dl class="repo-stats" aria-label={ "Figures of " + r.Name }> | |
| 56 | + | if s := r.Stats; s != nil { | |
| 57 | + | <div><dt>commits</dt><dd>{ s.Commits }</dd></div> | |
| 58 | + | <div><dt>last 30 days</dt><dd>{ s.Recent }</dd></div> | |
| 59 | + | if s.Tag != "" { | |
| 60 | + | <div> | |
| 61 | + | <dt>latest tag</dt> | |
| 62 | + | if s.TagHref != "" { | |
| 63 | + | <dd><a href={ templ.SafeURL(s.TagHref) } title={ "made " + s.TagWhen }>{ s.Tag }</a></dd> | |
| 64 | + | } else { | |
| 65 | + | <dd title={ "made " + s.TagWhen }>{ s.Tag }</dd> | |
| 66 | + | } | |
| 67 | + | </div> | |
| 68 | + | } | |
| 63 | 69 | } | |
| 64 | - | </p> | |
| 70 | + | <div> | |
| 71 | + | <dt>last activity</dt> | |
| 72 | + | if r.ActiveAt != "" { | |
| 73 | + | <dd><time datetime={ r.ActiveAt } title={ r.ActiveAt }>{ r.Active }</time></dd> | |
| 74 | + | } else { | |
| 75 | + | <dd class="mute">never</dd> | |
| 76 | + | } | |
| 77 | + | </div> | |
| 78 | + | </dl> | |
| 65 | 79 | </div> | |
| 66 | 80 | if r.CI != nil { | |
| 67 | 81 | <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }> | |
| @@ -131,7 +145,6 @@ templ CommitRows(rows []CommitRow) { | |||
| 131 | 145 | } | |
| 132 | 146 | data-cursor?={ c.Cursor } | |
| 133 | 147 | > | |
| 134 | - | <td class="cur" aria-hidden="true"></td> | |
| 135 | 148 | <td class="c-sha mute">{ c.Short }</td> | |
| 136 | 149 | <td class="c-subject"> | |
| 137 | 150 | <a href={ templ.SafeURL(c.CHref) }>{ c.Subject }</a> | |
| @@ -52,16 +52,30 @@ templ HomeRowView(r HomeRow) { | |||||
| 52 | } else { | 52 | } else { | ||
| 53 | <p class="repo-desc mute">no description</p> | 53 | <p class="repo-desc mute">no description</p> | ||
| 54 | } | 54 | } | ||
| 55 | - | <p class="repo-meta"> | 55 | + | <dl class="repo-stats" aria-label={ "Figures of " + r.Name }> |
| 56 | - | if r.Branch != "" { | 56 | + | if s := r.Stats; s != nil { |
| 57 | - | <span class="repo-branch"><span class="ico i-branch" aria-hidden="true"></span>{ r.Branch }</span> | 57 | + | <div><dt>commits</dt><dd>{ s.Commits }</dd></div> |
| 58 | - | } | 58 | + | <div><dt>last 30 days</dt><dd>{ s.Recent }</dd></div> |
| 59 | - | if r.PushedAt != "" { | 59 | + | if s.Tag != "" { |
| 60 | - | <span>pushed <time datetime={ r.PushedAt } title={ r.PushedAt }>{ r.Pushed }</time></span> | 60 | + | <div> |
| 61 | - | } else { | 61 | + | <dt>latest tag</dt> |
| 62 | - | <span>never pushed</span> | 62 | + | if s.TagHref != "" { |
| 63 | + | <dd><a href={ templ.SafeURL(s.TagHref) } title={ "made " + s.TagWhen }>{ s.Tag }</a></dd> | |||
| 64 | + | } else { | |||
| 65 | + | <dd title={ "made " + s.TagWhen }>{ s.Tag }</dd> | |||
| 66 | + | } | |||
| 67 | + | </div> | |||
| 68 | + | } | |||
| 63 | } | 69 | } | ||
| 64 | - | </p> | 70 | + | <div> |
| 71 | + | <dt>last activity</dt> | |||
| 72 | + | if r.ActiveAt != "" { | |||
| 73 | + | <dd><time datetime={ r.ActiveAt } title={ r.ActiveAt }>{ r.Active }</time></dd> | |||
| 74 | + | } else { | |||
| 75 | + | <dd class="mute">never</dd> | |||
| 76 | + | } | |||
| 77 | + | </div> | |||
| 78 | + | </dl> | |||
| 65 | </div> | 79 | </div> | ||
| 66 | if r.CI != nil { | 80 | if r.CI != nil { | ||
| 67 | <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }> | 81 | <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }> | ||
| @@ -131,7 +145,6 @@ templ CommitRows(rows []CommitRow) { | |||||
| 131 | } | 145 | } | ||
| 132 | data-cursor?={ c.Cursor } | 146 | data-cursor?={ c.Cursor } | ||
| 133 | > | 147 | > | ||
| 134 | - | <td class="cur" aria-hidden="true"></td> | |||
| 135 | <td class="c-sha mute">{ c.Short }</td> | 148 | <td class="c-sha mute">{ c.Short }</td> | ||
| 136 | <td class="c-subject"> | 149 | <td class="c-subject"> | ||
| 137 | <a href={ templ.SafeURL(c.CHref) }>{ c.Subject }</a> | 150 | <a href={ templ.SafeURL(c.CHref) }>{ c.Subject }</a> | ||
M internal/adapters/web/components_templ.go +325 -209 generated file: collapsed
M internal/adapters/web/format.go +16 -0
| @@ -45,6 +45,22 @@ func rfc3339(t time.Time) string { | |||
| 45 | 45 | return t.UTC().Format(time.RFC3339) | |
| 46 | 46 | } | |
| 47 | 47 | ||
| 48 | + | // grouped writes a count with thousands separated by a thin space: 12 345. | |
| 49 | + | func grouped(n int) string { | |
| 50 | + | s := itoa(n) | |
| 51 | + | if n < 0 || len(s) <= 3 { | |
| 52 | + | return s | |
| 53 | + | } | |
| 54 | + | var b []byte | |
| 55 | + | for i := range len(s) { | |
| 56 | + | if i > 0 && (len(s)-i)%3 == 0 { | |
| 57 | + | b = append(b, "\u202f"...) | |
| 58 | + | } | |
| 59 | + | b = append(b, s[i]) | |
| 60 | + | } | |
| 61 | + | return string(b) | |
| 62 | + | } | |
| 63 | + | ||
| 48 | 64 | func plural(n int, one, many string) string { | |
| 49 | 65 | if n == 1 { | |
| 50 | 66 | return "1 " + one | |
| @@ -45,6 +45,22 @@ func rfc3339(t time.Time) string { | |||||
| 45 | return t.UTC().Format(time.RFC3339) | 45 | return t.UTC().Format(time.RFC3339) | ||
| 46 | } | 46 | } | ||
| 47 | 47 | ||||
| 48 | + | // grouped writes a count with thousands separated by a thin space: 12 345. | |||
| 49 | + | func grouped(n int) string { | |||
| 50 | + | s := itoa(n) | |||
| 51 | + | if n < 0 || len(s) <= 3 { | |||
| 52 | + | return s | |||
| 53 | + | } | |||
| 54 | + | var b []byte | |||
| 55 | + | for i := range len(s) { | |||
| 56 | + | if i > 0 && (len(s)-i)%3 == 0 { | |||
| 57 | + | b = append(b, "\u202f"...) | |||
| 58 | + | } | |||
| 59 | + | b = append(b, s[i]) | |||
| 60 | + | } | |||
| 61 | + | return string(b) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 48 | func plural(n int, one, many string) string { | 64 | func plural(n int, one, many string) string { | ||
| 49 | if n == 1 { | 65 | if n == 1 { | ||
| 50 | return "1 " + one | 66 | return "1 " + one | ||
M internal/adapters/web/home.go +30 -2
| @@ -168,6 +168,7 @@ func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time, | |||
| 168 | 168 | row.Commits, head, row.NoCommits = s.homeCommits(ctx, r, now) | |
| 169 | 169 | if head != "" { | |
| 170 | 170 | row.CI = s.homeCI(ctx, r, head) | |
| 171 | + | row.Stats = s.homeStats(ctx, r, head, now) | |
| 171 | 172 | } | |
| 172 | 173 | return row | |
| 173 | 174 | } | |
| @@ -195,6 +196,33 @@ func (s *Server) homeCommits(ctx context.Context, r ports.RepoView, now time.Tim | |||
| 195 | 196 | return rows, head, len(rows) == 0 | |
| 196 | 197 | } | |
| 197 | 198 | ||
| 199 | + | // homeStats are the figures of a row; nil when the read fails (logged), since | |
| 200 | + | // the row does not depend on them. | |
| 201 | + | func (s *Server) homeStats(ctx context.Context, r ports.RepoView, head string, now time.Time) *HomeStats { | |
| 202 | + | id, err := domain.ParseObjectID(head) | |
| 203 | + | if err != nil { | |
| 204 | + | return nil | |
| 205 | + | } | |
| 206 | + | st, err := s.cfg.Browse.RepoStats(ctx, principal(ctx), r.Name, id, now) | |
| 207 | + | if err != nil { | |
| 208 | + | if !errors.Is(err, domain.ErrNotFound) { | |
| 209 | + | log := zerowrap.FromCtx(ctx) | |
| 210 | + | log.Warn().Err(err).Str("repo", r.Name.String()).Msg("read the figures of the home page") | |
| 211 | + | } | |
| 212 | + | return nil | |
| 213 | + | } | |
| 214 | + | hs := &HomeStats{Commits: grouped(st.Commits), Recent: grouped(st.Recent)} | |
| 215 | + | if t := st.LatestTag; t != nil { | |
| 216 | + | hs.Tag, hs.TagWhen = t.Name, ago(now, t.At) | |
| 217 | + | if !t.CommitID.IsZero() { | |
| 218 | + | // By its commit: a branch of the same name would win over the | |
| 219 | + | // tag in a tree path. | |
| 220 | + | hs.TagHref = repoHref(r.Name.String(), "tree", t.CommitID.String()) | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | return hs | |
| 224 | + | } | |
| 225 | + | ||
| 198 | 226 | // homeCI is the state of the CI on the head of the default branch, as the | |
| 199 | 227 | // caller of the request sees it: the combined checks of that commit, read by | |
| 200 | 228 | // its id so the cost does not grow with the CI history. nil when the server | |
| @@ -231,11 +259,11 @@ func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow { | |||
| 231 | 259 | name := r.Name.String() | |
| 232 | 260 | row := HomeRow{ | |
| 233 | 261 | ID: "repo-" + name, Name: name, Href: repoHref(name), | |
| 234 | - | Desc: r.Description, Branch: r.DefaultBranch, Cursor: cursor, | |
| 262 | + | Desc: r.Description, Cursor: cursor, | |
| 235 | 263 | Private: signed && r.Visibility == domain.VisibilityPrivate, | |
| 236 | 264 | } | |
| 237 | 265 | if !r.PushedAt.IsZero() { | |
| 238 | - | row.Pushed, row.PushedAt = ago(now, r.PushedAt), rfc3339(r.PushedAt) | |
| 266 | + | row.Active, row.ActiveAt = ago(now, r.PushedAt), rfc3339(r.PushedAt) | |
| 239 | 267 | } | |
| 240 | 268 | return row | |
| 241 | 269 | } | |
| @@ -168,6 +168,7 @@ func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time, | |||||
| 168 | row.Commits, head, row.NoCommits = s.homeCommits(ctx, r, now) | 168 | row.Commits, head, row.NoCommits = s.homeCommits(ctx, r, now) | ||
| 169 | if head != "" { | 169 | if head != "" { | ||
| 170 | row.CI = s.homeCI(ctx, r, head) | 170 | row.CI = s.homeCI(ctx, r, head) | ||
| 171 | + | row.Stats = s.homeStats(ctx, r, head, now) | |||
| 171 | } | 172 | } | ||
| 172 | return row | 173 | return row | ||
| 173 | } | 174 | } | ||
| @@ -195,6 +196,33 @@ func (s *Server) homeCommits(ctx context.Context, r ports.RepoView, now time.Tim | |||||
| 195 | return rows, head, len(rows) == 0 | 196 | return rows, head, len(rows) == 0 | ||
| 196 | } | 197 | } | ||
| 197 | 198 | ||||
| 199 | + | // homeStats are the figures of a row; nil when the read fails (logged), since | |||
| 200 | + | // the row does not depend on them. | |||
| 201 | + | func (s *Server) homeStats(ctx context.Context, r ports.RepoView, head string, now time.Time) *HomeStats { | |||
| 202 | + | id, err := domain.ParseObjectID(head) | |||
| 203 | + | if err != nil { | |||
| 204 | + | return nil | |||
| 205 | + | } | |||
| 206 | + | st, err := s.cfg.Browse.RepoStats(ctx, principal(ctx), r.Name, id, now) | |||
| 207 | + | if err != nil { | |||
| 208 | + | if !errors.Is(err, domain.ErrNotFound) { | |||
| 209 | + | log := zerowrap.FromCtx(ctx) | |||
| 210 | + | log.Warn().Err(err).Str("repo", r.Name.String()).Msg("read the figures of the home page") | |||
| 211 | + | } | |||
| 212 | + | return nil | |||
| 213 | + | } | |||
| 214 | + | hs := &HomeStats{Commits: grouped(st.Commits), Recent: grouped(st.Recent)} | |||
| 215 | + | if t := st.LatestTag; t != nil { | |||
| 216 | + | hs.Tag, hs.TagWhen = t.Name, ago(now, t.At) | |||
| 217 | + | if !t.CommitID.IsZero() { | |||
| 218 | + | // By its commit: a branch of the same name would win over the | |||
| 219 | + | // tag in a tree path. | |||
| 220 | + | hs.TagHref = repoHref(r.Name.String(), "tree", t.CommitID.String()) | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | return hs | |||
| 224 | + | } | |||
| 225 | + | ||||
| 198 | // homeCI is the state of the CI on the head of the default branch, as the | 226 | // homeCI is the state of the CI on the head of the default branch, as the | ||
| 199 | // caller of the request sees it: the combined checks of that commit, read by | 227 | // caller of the request sees it: the combined checks of that commit, read by | ||
| 200 | // its id so the cost does not grow with the CI history. nil when the server | 228 | // its id so the cost does not grow with the CI history. nil when the server | ||
| @@ -231,11 +259,11 @@ func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow { | |||||
| 231 | name := r.Name.String() | 259 | name := r.Name.String() | ||
| 232 | row := HomeRow{ | 260 | row := HomeRow{ | ||
| 233 | ID: "repo-" + name, Name: name, Href: repoHref(name), | 261 | ID: "repo-" + name, Name: name, Href: repoHref(name), | ||
| 234 | - | Desc: r.Description, Branch: r.DefaultBranch, Cursor: cursor, | 262 | + | Desc: r.Description, Cursor: cursor, |
| 235 | Private: signed && r.Visibility == domain.VisibilityPrivate, | 263 | Private: signed && r.Visibility == domain.VisibilityPrivate, | ||
| 236 | } | 264 | } | ||
| 237 | if !r.PushedAt.IsZero() { | 265 | if !r.PushedAt.IsZero() { | ||
| 238 | - | row.Pushed, row.PushedAt = ago(now, r.PushedAt), rfc3339(r.PushedAt) | 266 | + | row.Active, row.ActiveAt = ago(now, r.PushedAt), rfc3339(r.PushedAt) |
| 239 | } | 267 | } | ||
| 240 | return row | 268 | return row | ||
| 241 | } | 269 | } | ||
M internal/adapters/web/home_test.go +57 -7
| @@ -45,6 +45,55 @@ func homeCommits(t testing.TB, n int) []domain.Commit { | |||
| 45 | 45 | return out | |
| 46 | 46 | } | |
| 47 | 47 | ||
| 48 | + | // homeBrowse is a BrowseService whose figures are always there: 1234 | |
| 49 | + | // commits, 7 of them recent, no tag. Tests that care set their own. | |
| 50 | + | func homeBrowse(t *testing.T) *mocks.BrowseService { | |
| 51 | + | t.Helper() | |
| 52 | + | b := mocks.NewBrowseService(t) | |
| 53 | + | b.EXPECT().RepoStats(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). | |
| 54 | + | Return(ports.RepoStats{Commits: 1234, Recent: 7}, nil).Maybe() | |
| 55 | + | return b | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // Each row shows the figures of its repository: commits, recent commits, | |
| 59 | + | // latest tag and last activity; no branch name. | |
| 60 | + | func TestHomeRowFigures(t *testing.T) { | |
| 61 | + | t.Parallel() | |
| 62 | + | repo := repoName(t, "proj") | |
| 63 | + | head := objectID(t, fmt.Sprintf("%040x", 2)) | |
| 64 | + | now := time.Now() | |
| 65 | + | browse := mocks.NewBrowseService(t) | |
| 66 | + | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil) | |
| 67 | + | browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{ | |
| 68 | + | Commits: 12345, Recent: 42, LatestTag: &domain.TagInfo{Name: "v1.2.0", At: now.Add(-3 * time.Hour), CommitID: head}, | |
| 69 | + | }, nil).Once() | |
| 70 | + | srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse}) | |
| 71 | + | ||
| 72 | + | row := srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false) | |
| 73 | + | require.NotNil(t, row.Stats) | |
| 74 | + | out := renderRow(t, row) | |
| 75 | + | assert.Contains(t, out, "<dt>commits</dt><dd>12\u202f345</dd>") | |
| 76 | + | assert.Contains(t, out, "<dt>last 30 days</dt><dd>42</dd>") | |
| 77 | + | assert.Contains(t, out, `<a href="/proj/tree/`+head.String()+`" title="made 3h ago">v1.2.0</a>`, "by its commit: a branch may share its name") | |
| 78 | + | assert.Contains(t, out, "<dt>last activity</dt>") | |
| 79 | + | assert.NotContains(t, out, "i-branch", "the default branch is not shown") | |
| 80 | + | assert.NotContains(t, out, "pushed") | |
| 81 | + | ||
| 82 | + | // A failed read leaves the figures out, not the row. | |
| 83 | + | browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{}, errors.New("boom")).Once() | |
| 84 | + | row = srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false) | |
| 85 | + | assert.Nil(t, row.Stats) | |
| 86 | + | assert.Len(t, row.Commits, 2) | |
| 87 | + | assert.NotContains(t, renderRow(t, row), "<dt>commits</dt>") | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestGrouped(t *testing.T) { | |
| 91 | + | t.Parallel() | |
| 92 | + | for n, want := range map[int]string{0: "0", 999: "999", 1000: "1\u202f000", 1234567: "1\u202f234\u202f567"} { | |
| 93 | + | assert.Equal(t, want, grouped(n), n) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 48 | 97 | func renderRow(t testing.TB, r HomeRow) string { | |
| 49 | 98 | t.Helper() | |
| 50 | 99 | var b bytes.Buffer | |
| @@ -63,7 +112,7 @@ func mustRevision(t testing.TB, s string) domain.Revision { | |||
| 63 | 112 | // name of the branch. | |
| 64 | 113 | func TestHomeRowShowsFiveCommits(t *testing.T) { | |
| 65 | 114 | t.Parallel() | |
| 66 | - | browse := mocks.NewBrowseService(t) | |
| 115 | + | browse := homeBrowse(t) | |
| 67 | 116 | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListCommitsParams{ | |
| 68 | 117 | Revision: mustRevision(t, "refs/heads/main"), PageSize: homeRecent, | |
| 69 | 118 | }).Return(ports.HistoryPage{Commits: homeCommits(t, 7)}, nil).Once() | |
| @@ -90,7 +139,8 @@ func TestHomeRowEmptyRepository(t *testing.T) { | |||
| 90 | 139 | assert.True(t, row.NoCommits) | |
| 91 | 140 | out := renderRow(t, row) | |
| 92 | 141 | assert.Contains(t, out, "no commits yet") | |
| 93 | - | assert.Contains(t, out, "never pushed") | |
| 142 | + | assert.Contains(t, out, `<dt>last activity</dt><dd class="mute">never</dd>`) | |
| 143 | + | assert.NotContains(t, out, "<dt>commits</dt>", "no figures without a commit") | |
| 94 | 144 | assert.NotContains(t, out, "repo-commits\" aria-label") | |
| 95 | 145 | } | |
| 96 | 146 | ||
| @@ -119,7 +169,7 @@ func TestHomeRowCI(t *testing.T) { | |||
| 119 | 169 | for _, tt := range tests { | |
| 120 | 170 | t.Run(tt.name, func(t *testing.T) { | |
| 121 | 171 | t.Parallel() | |
| 122 | - | browse := mocks.NewBrowseService(t) | |
| 172 | + | browse := homeBrowse(t) | |
| 123 | 173 | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil) | |
| 124 | 174 | runs := mocks.NewRunService(t) | |
| 125 | 175 | runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head). | |
| @@ -147,7 +197,7 @@ func TestHomeRowPrivateBadge(t *testing.T) { | |||
| 147 | 197 | t.Parallel() | |
| 148 | 198 | priv := pushedRepo(t, "secret") | |
| 149 | 199 | priv.Visibility = domain.VisibilityPrivate | |
| 150 | - | browse := mocks.NewBrowseService(t) | |
| 200 | + | browse := homeBrowse(t) | |
| 151 | 201 | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.HistoryPage{}, nil).Maybe() | |
| 152 | 202 | srv := newTestServer(t, Config{Repos: reposServing(t, priv, pushedRepo(t, "open")), Browse: browse}) | |
| 153 | 203 | ||
| @@ -162,7 +212,7 @@ func TestRunUpdatedRefreshesTheHomeRow(t *testing.T) { | |||
| 162 | 212 | t.Parallel() | |
| 163 | 213 | repo := repoName(t, "proj") | |
| 164 | 214 | head := fmt.Sprintf("%040x", 2) | |
| 165 | - | browse := mocks.NewBrowseService(t) | |
| 215 | + | browse := homeBrowse(t) | |
| 166 | 216 | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil) | |
| 167 | 217 | runs := mocks.NewRunService(t) | |
| 168 | 218 | runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: []ports.CheckView{ | |
| @@ -196,7 +246,7 @@ func TestRunUpdatedRefreshesTheHomeRow(t *testing.T) { | |||
| 196 | 246 | // served; the others are not affected. | |
| 197 | 247 | func TestHomeSurvivesABrokenRepository(t *testing.T) { | |
| 198 | 248 | t.Parallel() | |
| 199 | - | browse := mocks.NewBrowseService(t) | |
| 249 | + | browse := homeBrowse(t) | |
| 200 | 250 | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "broken"), mock.Anything). | |
| 201 | 251 | Return(ports.HistoryPage{}, errors.New("git exploded")).Once() | |
| 202 | 252 | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "fine"), mock.Anything). | |
| @@ -222,7 +272,7 @@ func TestHomeRowsAreBoundedAndOrdered(t *testing.T) { | |||
| 222 | 272 | views = append(views, pushedRepo(t, fmt.Sprintf("r%02d", i))) | |
| 223 | 273 | } | |
| 224 | 274 | var running, peak atomic.Int32 | |
| 225 | - | browse := mocks.NewBrowseService(t) | |
| 275 | + | browse := homeBrowse(t) | |
| 226 | 276 | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 227 | 277 | func(context.Context, domain.Principal, domain.RepoName, ports.ListCommitsParams) (ports.HistoryPage, error) { | |
| 228 | 278 | cur := running.Add(1) | |
| @@ -45,6 +45,55 @@ func homeCommits(t testing.TB, n int) []domain.Commit { | |||||
| 45 | return out | 45 | return out | ||
| 46 | } | 46 | } | ||
| 47 | 47 | ||||
| 48 | + | // homeBrowse is a BrowseService whose figures are always there: 1234 | |||
| 49 | + | // commits, 7 of them recent, no tag. Tests that care set their own. | |||
| 50 | + | func homeBrowse(t *testing.T) *mocks.BrowseService { | |||
| 51 | + | t.Helper() | |||
| 52 | + | b := mocks.NewBrowseService(t) | |||
| 53 | + | b.EXPECT().RepoStats(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). | |||
| 54 | + | Return(ports.RepoStats{Commits: 1234, Recent: 7}, nil).Maybe() | |||
| 55 | + | return b | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // Each row shows the figures of its repository: commits, recent commits, | |||
| 59 | + | // latest tag and last activity; no branch name. | |||
| 60 | + | func TestHomeRowFigures(t *testing.T) { | |||
| 61 | + | t.Parallel() | |||
| 62 | + | repo := repoName(t, "proj") | |||
| 63 | + | head := objectID(t, fmt.Sprintf("%040x", 2)) | |||
| 64 | + | now := time.Now() | |||
| 65 | + | browse := mocks.NewBrowseService(t) | |||
| 66 | + | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil) | |||
| 67 | + | browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{ | |||
| 68 | + | Commits: 12345, Recent: 42, LatestTag: &domain.TagInfo{Name: "v1.2.0", At: now.Add(-3 * time.Hour), CommitID: head}, | |||
| 69 | + | }, nil).Once() | |||
| 70 | + | srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse}) | |||
| 71 | + | ||||
| 72 | + | row := srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false) | |||
| 73 | + | require.NotNil(t, row.Stats) | |||
| 74 | + | out := renderRow(t, row) | |||
| 75 | + | assert.Contains(t, out, "<dt>commits</dt><dd>12\u202f345</dd>") | |||
| 76 | + | assert.Contains(t, out, "<dt>last 30 days</dt><dd>42</dd>") | |||
| 77 | + | assert.Contains(t, out, `<a href="/proj/tree/`+head.String()+`" title="made 3h ago">v1.2.0</a>`, "by its commit: a branch may share its name") | |||
| 78 | + | assert.Contains(t, out, "<dt>last activity</dt>") | |||
| 79 | + | assert.NotContains(t, out, "i-branch", "the default branch is not shown") | |||
| 80 | + | assert.NotContains(t, out, "pushed") | |||
| 81 | + | ||||
| 82 | + | // A failed read leaves the figures out, not the row. | |||
| 83 | + | browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{}, errors.New("boom")).Once() | |||
| 84 | + | row = srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false) | |||
| 85 | + | assert.Nil(t, row.Stats) | |||
| 86 | + | assert.Len(t, row.Commits, 2) | |||
| 87 | + | assert.NotContains(t, renderRow(t, row), "<dt>commits</dt>") | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestGrouped(t *testing.T) { | |||
| 91 | + | t.Parallel() | |||
| 92 | + | for n, want := range map[int]string{0: "0", 999: "999", 1000: "1\u202f000", 1234567: "1\u202f234\u202f567"} { | |||
| 93 | + | assert.Equal(t, want, grouped(n), n) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 48 | func renderRow(t testing.TB, r HomeRow) string { | 97 | func renderRow(t testing.TB, r HomeRow) string { | ||
| 49 | t.Helper() | 98 | t.Helper() | ||
| 50 | var b bytes.Buffer | 99 | var b bytes.Buffer | ||
| @@ -63,7 +112,7 @@ func mustRevision(t testing.TB, s string) domain.Revision { | |||||
| 63 | // name of the branch. | 112 | // name of the branch. | ||
| 64 | func TestHomeRowShowsFiveCommits(t *testing.T) { | 113 | func TestHomeRowShowsFiveCommits(t *testing.T) { | ||
| 65 | t.Parallel() | 114 | t.Parallel() | ||
| 66 | - | browse := mocks.NewBrowseService(t) | 115 | + | browse := homeBrowse(t) |
| 67 | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListCommitsParams{ | 116 | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListCommitsParams{ | ||
| 68 | Revision: mustRevision(t, "refs/heads/main"), PageSize: homeRecent, | 117 | Revision: mustRevision(t, "refs/heads/main"), PageSize: homeRecent, | ||
| 69 | }).Return(ports.HistoryPage{Commits: homeCommits(t, 7)}, nil).Once() | 118 | }).Return(ports.HistoryPage{Commits: homeCommits(t, 7)}, nil).Once() | ||
| @@ -90,7 +139,8 @@ func TestHomeRowEmptyRepository(t *testing.T) { | |||||
| 90 | assert.True(t, row.NoCommits) | 139 | assert.True(t, row.NoCommits) | ||
| 91 | out := renderRow(t, row) | 140 | out := renderRow(t, row) | ||
| 92 | assert.Contains(t, out, "no commits yet") | 141 | assert.Contains(t, out, "no commits yet") | ||
| 93 | - | assert.Contains(t, out, "never pushed") | 142 | + | assert.Contains(t, out, `<dt>last activity</dt><dd class="mute">never</dd>`) |
| 143 | + | assert.NotContains(t, out, "<dt>commits</dt>", "no figures without a commit") | |||
| 94 | assert.NotContains(t, out, "repo-commits\" aria-label") | 144 | assert.NotContains(t, out, "repo-commits\" aria-label") | ||
| 95 | } | 145 | } | ||
| 96 | 146 | ||||
| @@ -119,7 +169,7 @@ func TestHomeRowCI(t *testing.T) { | |||||
| 119 | for _, tt := range tests { | 169 | for _, tt := range tests { | ||
| 120 | t.Run(tt.name, func(t *testing.T) { | 170 | t.Run(tt.name, func(t *testing.T) { | ||
| 121 | t.Parallel() | 171 | t.Parallel() | ||
| 122 | - | browse := mocks.NewBrowseService(t) | 172 | + | browse := homeBrowse(t) |
| 123 | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil) | 173 | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil) | ||
| 124 | runs := mocks.NewRunService(t) | 174 | runs := mocks.NewRunService(t) | ||
| 125 | runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head). | 175 | runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head). | ||
| @@ -147,7 +197,7 @@ func TestHomeRowPrivateBadge(t *testing.T) { | |||||
| 147 | t.Parallel() | 197 | t.Parallel() | ||
| 148 | priv := pushedRepo(t, "secret") | 198 | priv := pushedRepo(t, "secret") | ||
| 149 | priv.Visibility = domain.VisibilityPrivate | 199 | priv.Visibility = domain.VisibilityPrivate | ||
| 150 | - | browse := mocks.NewBrowseService(t) | 200 | + | browse := homeBrowse(t) |
| 151 | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.HistoryPage{}, nil).Maybe() | 201 | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.HistoryPage{}, nil).Maybe() | ||
| 152 | srv := newTestServer(t, Config{Repos: reposServing(t, priv, pushedRepo(t, "open")), Browse: browse}) | 202 | srv := newTestServer(t, Config{Repos: reposServing(t, priv, pushedRepo(t, "open")), Browse: browse}) | ||
| 153 | 203 | ||||
| @@ -162,7 +212,7 @@ func TestRunUpdatedRefreshesTheHomeRow(t *testing.T) { | |||||
| 162 | t.Parallel() | 212 | t.Parallel() | ||
| 163 | repo := repoName(t, "proj") | 213 | repo := repoName(t, "proj") | ||
| 164 | head := fmt.Sprintf("%040x", 2) | 214 | head := fmt.Sprintf("%040x", 2) | ||
| 165 | - | browse := mocks.NewBrowseService(t) | 215 | + | browse := homeBrowse(t) |
| 166 | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil) | 216 | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil) | ||
| 167 | runs := mocks.NewRunService(t) | 217 | runs := mocks.NewRunService(t) | ||
| 168 | runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: []ports.CheckView{ | 218 | runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: []ports.CheckView{ | ||
| @@ -196,7 +246,7 @@ func TestRunUpdatedRefreshesTheHomeRow(t *testing.T) { | |||||
| 196 | // served; the others are not affected. | 246 | // served; the others are not affected. | ||
| 197 | func TestHomeSurvivesABrokenRepository(t *testing.T) { | 247 | func TestHomeSurvivesABrokenRepository(t *testing.T) { | ||
| 198 | t.Parallel() | 248 | t.Parallel() | ||
| 199 | - | browse := mocks.NewBrowseService(t) | 249 | + | browse := homeBrowse(t) |
| 200 | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "broken"), mock.Anything). | 250 | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "broken"), mock.Anything). | ||
| 201 | Return(ports.HistoryPage{}, errors.New("git exploded")).Once() | 251 | Return(ports.HistoryPage{}, errors.New("git exploded")).Once() | ||
| 202 | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "fine"), mock.Anything). | 252 | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "fine"), mock.Anything). | ||
| @@ -222,7 +272,7 @@ func TestHomeRowsAreBoundedAndOrdered(t *testing.T) { | |||||
| 222 | views = append(views, pushedRepo(t, fmt.Sprintf("r%02d", i))) | 272 | views = append(views, pushedRepo(t, fmt.Sprintf("r%02d", i))) | ||
| 223 | } | 273 | } | ||
| 224 | var running, peak atomic.Int32 | 274 | var running, peak atomic.Int32 | ||
| 225 | - | browse := mocks.NewBrowseService(t) | 275 | + | browse := homeBrowse(t) |
| 226 | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | 276 | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | ||
| 227 | func(context.Context, domain.Principal, domain.RepoName, ports.ListCommitsParams) (ports.HistoryPage, error) { | 277 | func(context.Context, domain.Principal, domain.RepoName, ports.ListCommitsParams) (ports.HistoryPage, error) { | ||
| 228 | cur := running.Add(1) | 278 | cur := running.Add(1) | ||
M internal/adapters/web/mr.go +6 -6
| @@ -199,12 +199,12 @@ type MRPage struct { | |||
| 199 | 199 | UpdatedAt string | |
| 200 | 200 | // Desc is the description rendered as Markdown (as a README: no raw | |
| 201 | 201 | // HTML, filtered URLs), "" when it is empty. | |
| 202 | - | Desc template.HTML | |
| 203 | - | Head string | |
| 204 | - | HeadShort string | |
| 205 | - | MergedShort string | |
| 206 | - | MergedHref string | |
| 207 | - | MergeMode string | |
| 202 | + | Desc template.HTML | |
| 203 | + | Head string | |
| 204 | + | HeadShort string | |
| 205 | + | MergedShort string | |
| 206 | + | MergedHref string | |
| 207 | + | MergeMode string | |
| 208 | 208 | ||
| 209 | 209 | Since *SinceNav | |
| 210 | 210 | SinceNote string | |
| @@ -199,12 +199,12 @@ type MRPage struct { | |||||
| 199 | UpdatedAt string | 199 | UpdatedAt string | ||
| 200 | // Desc is the description rendered as Markdown (as a README: no raw | 200 | // Desc is the description rendered as Markdown (as a README: no raw | ||
| 201 | // HTML, filtered URLs), "" when it is empty. | 201 | // HTML, filtered URLs), "" when it is empty. | ||
| 202 | - | Desc template.HTML | 202 | + | Desc template.HTML |
| 203 | - | Head string | 203 | + | Head string |
| 204 | - | HeadShort string | 204 | + | HeadShort string |
| 205 | - | MergedShort string | 205 | + | MergedShort string |
| 206 | - | MergedHref string | 206 | + | MergedHref string |
| 207 | - | MergeMode string | 207 | + | MergeMode string |
| 208 | 208 | ||||
| 209 | Since *SinceNav | 209 | Since *SinceNav | ||
| 210 | SinceNote string | 210 | SinceNote string | ||
M internal/adapters/web/mr.templ +0 -1
| @@ -60,7 +60,6 @@ templ mrRows(rows []MRRow, label string) { | |||
| 60 | 60 | <tbody data-rows> | |
| 61 | 61 | for _, m := range rows { | |
| 62 | 62 | <tr data-cursor?={ m.Cursor } data-state={ m.State }> | |
| 63 | - | <td class="cur" aria-hidden="true"></td> | |
| 64 | 63 | <td class="c-n mute">{ m.Number }</td> | |
| 65 | 64 | <td class="c-state">{ m.State }</td> | |
| 66 | 65 | <td class="c-subject"><a href={ templ.SafeURL(m.Href) }>{ m.Title }</a> <span class="mute">{ m.Branches }</span></td> | |
| @@ -60,7 +60,6 @@ templ mrRows(rows []MRRow, label string) { | |||||
| 60 | <tbody data-rows> | 60 | <tbody data-rows> | ||
| 61 | for _, m := range rows { | 61 | for _, m := range rows { | ||
| 62 | <tr data-cursor?={ m.Cursor } data-state={ m.State }> | 62 | <tr data-cursor?={ m.Cursor } data-state={ m.State }> | ||
| 63 | - | <td class="cur" aria-hidden="true"></td> | |||
| 64 | <td class="c-n mute">{ m.Number }</td> | 63 | <td class="c-n mute">{ m.Number }</td> | ||
| 65 | <td class="c-state">{ m.State }</td> | 64 | <td class="c-state">{ m.State }</td> | ||
| 66 | <td class="c-subject"><a href={ templ.SafeURL(m.Href) }>{ m.Title }</a> <span class="mute">{ m.Branches }</span></td> | 65 | <td class="c-subject"><a href={ templ.SafeURL(m.Href) }>{ m.Title }</a> <span class="mute">{ m.Branches }</span></td> | ||
M internal/adapters/web/mr_templ.go +124 -124 generated file: collapsed
M internal/adapters/web/pages.templ +1 -4
| @@ -122,7 +122,7 @@ templ Refs(l Layout, p RefsPage) { | |||
| 122 | 122 | </div> | |
| 123 | 123 | <table class="list refs" aria-label="Branches and tags"> | |
| 124 | 124 | <thead> | |
| 125 | - | <tr><th class="cur"></th><th class="c-kind">kind</th><th class="c-name">name</th><th class="c-sha">commit</th><th class="c-sub">subject</th><th class="c-when">date</th></tr> | |
| 125 | + | <tr><th class="c-kind">kind</th><th class="c-name">name</th><th class="c-sha">commit</th><th class="c-sub">subject</th><th class="c-when">date</th></tr> | |
| 126 | 126 | </thead> | |
| 127 | 127 | @RefsBody(p) | |
| 128 | 128 | </table> | |
| @@ -134,7 +134,6 @@ templ Refs(l Layout, p RefsPage) { | |||
| 134 | 134 | ||
| 135 | 135 | templ refRow(kind string, r RefRow) { | |
| 136 | 136 | <tr data-cursor?={ r.Cursor }> | |
| 137 | - | <td class="cur" aria-hidden="true"></td> | |
| 138 | 137 | <td class="c-kind mute">{ kind }</td> | |
| 139 | 138 | <td class="c-name"> | |
| 140 | 139 | <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a> | |
| @@ -234,7 +233,6 @@ templ Passkeys(l Layout, p PasskeysPage) { | |||
| 234 | 233 | <table class="list passkeys" aria-label="Passkeys"> | |
| 235 | 234 | <thead> | |
| 236 | 235 | <tr> | |
| 237 | - | <th class="cur"></th> | |
| 238 | 236 | <th class="c-name">Name</th> | |
| 239 | 237 | <th class="c-when">Created</th> | |
| 240 | 238 | <th class="c-when">Last used</th> | |
| @@ -245,7 +243,6 @@ templ Passkeys(l Layout, p PasskeysPage) { | |||
| 245 | 243 | <tbody> | |
| 246 | 244 | for _, r := range p.Rows { | |
| 247 | 245 | <tr> | |
| 248 | - | <td class="cur" aria-hidden="true"></td> | |
| 249 | 246 | <td class="c-name">{ r.Name }</td> | |
| 250 | 247 | <td class="c-when nowrap"> | |
| 251 | 248 | <time | |
| @@ -122,7 +122,7 @@ templ Refs(l Layout, p RefsPage) { | |||||
| 122 | </div> | 122 | </div> | ||
| 123 | <table class="list refs" aria-label="Branches and tags"> | 123 | <table class="list refs" aria-label="Branches and tags"> | ||
| 124 | <thead> | 124 | <thead> | ||
| 125 | - | <tr><th class="cur"></th><th class="c-kind">kind</th><th class="c-name">name</th><th class="c-sha">commit</th><th class="c-sub">subject</th><th class="c-when">date</th></tr> | 125 | + | <tr><th class="c-kind">kind</th><th class="c-name">name</th><th class="c-sha">commit</th><th class="c-sub">subject</th><th class="c-when">date</th></tr> |
| 126 | </thead> | 126 | </thead> | ||
| 127 | @RefsBody(p) | 127 | @RefsBody(p) | ||
| 128 | </table> | 128 | </table> | ||
| @@ -134,7 +134,6 @@ templ Refs(l Layout, p RefsPage) { | |||||
| 134 | 134 | ||||
| 135 | templ refRow(kind string, r RefRow) { | 135 | templ refRow(kind string, r RefRow) { | ||
| 136 | <tr data-cursor?={ r.Cursor }> | 136 | <tr data-cursor?={ r.Cursor }> | ||
| 137 | - | <td class="cur" aria-hidden="true"></td> | |||
| 138 | <td class="c-kind mute">{ kind }</td> | 137 | <td class="c-kind mute">{ kind }</td> | ||
| 139 | <td class="c-name"> | 138 | <td class="c-name"> | ||
| 140 | <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a> | 139 | <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a> | ||
| @@ -234,7 +233,6 @@ templ Passkeys(l Layout, p PasskeysPage) { | |||||
| 234 | <table class="list passkeys" aria-label="Passkeys"> | 233 | <table class="list passkeys" aria-label="Passkeys"> | ||
| 235 | <thead> | 234 | <thead> | ||
| 236 | <tr> | 235 | <tr> | ||
| 237 | - | <th class="cur"></th> | |||
| 238 | <th class="c-name">Name</th> | 236 | <th class="c-name">Name</th> | ||
| 239 | <th class="c-when">Created</th> | 237 | <th class="c-when">Created</th> | ||
| 240 | <th class="c-when">Last used</th> | 238 | <th class="c-when">Last used</th> | ||
| @@ -245,7 +243,6 @@ templ Passkeys(l Layout, p PasskeysPage) { | |||||
| 245 | <tbody> | 243 | <tbody> | ||
| 246 | for _, r := range p.Rows { | 244 | for _, r := range p.Rows { | ||
| 247 | <tr> | 245 | <tr> | ||
| 248 | - | <td class="cur" aria-hidden="true"></td> | |||
| 249 | <td class="c-name">{ r.Name }</td> | 246 | <td class="c-name">{ r.Name }</td> | ||
| 250 | <td class="c-when nowrap"> | 247 | <td class="c-when nowrap"> | ||
| 251 | <time | 248 | <time | ||
M internal/adapters/web/pages_templ.go +39 -39 generated file: collapsed
M internal/adapters/web/static/css/30-list.css +12 -8
| @@ -14,10 +14,9 @@ | |||
| 14 | 14 | & th[aria-sort="ascending"], & th[aria-sort="descending"] { color: var(--ink-hi); } | |
| 15 | 15 | & td { height: 40px; padding: 0 var(--s3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-top: 1px solid var(--border); } | |
| 16 | 16 | & tbody tr:first-child td { border-top: 0; } | |
| 17 | - | & th.cur, & td.cur { width: 3px; padding: 0; } | |
| 18 | 17 | & tbody tr:hover { background: var(--bg-raised); } | |
| 19 | - | & tbody tr[data-cursor] { background: var(--bg-raised); } | |
| 20 | - | & tbody tr[data-cursor] .cur { background: var(--ink-hi); } | |
| 18 | + | /* the cursor: the row raised, framed by one even line */ | |
| 19 | + | & tbody tr[data-cursor] { background: var(--bg-raised); outline: 1px solid var(--ink-mute); outline-offset: -1px; } | |
| 21 | 20 | & tbody tr[hidden] { display: none; } | |
| 22 | 21 | & td > a { text-decoration: none; } | |
| 23 | 22 | & td > a:first-child { color: var(--ink-hi); } | |
| @@ -54,7 +53,7 @@ | |||
| 54 | 53 | padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); | |
| 55 | 54 | } | |
| 56 | 55 | & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); } | |
| 57 | - | & .repo[data-cursor] { background: var(--bg-raised); border-color: var(--ink-mute); box-shadow: inset 3px 0 0 var(--ink-hi); } | |
| 56 | + | & .repo[data-cursor] { background: var(--bg-raised); border-color: var(--ink-mute); } | |
| 58 | 57 | & .repo[hidden] { display: none; } | |
| 59 | 58 | & .repo-main { grid-area: main; display: flex; flex-direction: column; gap: var(--s2); min-width: 0; } | |
| 60 | 59 | & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-family: var(--mono); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; } | |
| @@ -66,9 +65,14 @@ | |||
| 66 | 65 | display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; | |
| 67 | 66 | } | |
| 68 | 67 | & .repo-desc.mute { color: var(--ink-mute); font-style: italic; } | |
| 69 | - | & .repo-meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1) var(--s4); font-size: var(--fs-s); color: var(--ink-mute); } | |
| 70 | - | & .repo-meta time { color: var(--ink-dim); } | |
| 71 | - | & .repo-branch { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); color: var(--ink-dim); } | |
| 68 | + | /* the figures: a small label over a mono value, side by side */ | |
| 69 | + | & .repo-stats { display: flex; flex-wrap: wrap; gap: var(--s2) var(--s5); margin: var(--s1) 0 0; } | |
| 70 | + | & .repo-stats div { display: flex; flex-direction: column; gap: 2px; min-width: 0; } | |
| 71 | + | & .repo-stats dt { font-size: 11px; line-height: 14px; letter-spacing: .04em; text-transform: uppercase; color: var(--ink-mute); } | |
| 72 | + | & .repo-stats dd { margin: 0; font-family: var(--mono); font-size: 15px; line-height: 20px; color: var(--ink-hi); font-variant-numeric: tabular-nums; white-space: nowrap; } | |
| 73 | + | & .repo-stats dd.mute { color: var(--ink-mute); } | |
| 74 | + | & .repo-stats dd a { color: inherit; text-decoration: none; } | |
| 75 | + | & .repo-stats dd a:hover { text-decoration: underline; } | |
| 72 | 76 | & .repo-ci { | |
| 73 | 77 | grid-area: ci; justify-self: end; display: inline-flex; align-items: center; gap: var(--s1); margin-bottom: var(--s2); | |
| 74 | 78 | font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none; | |
| @@ -168,7 +172,7 @@ | |||
| 168 | 172 | min-height: var(--rowh); padding-right: var(--s4); font-size: var(--fs-s); | |
| 169 | 173 | } | |
| 170 | 174 | & > li:hover, & > li[data-cursor] { background: var(--bg-raised); } | |
| 171 | - | & > li[data-cursor] { box-shadow: inset 3px 0 0 var(--ink-hi); } | |
| 175 | + | & > li[data-cursor] { outline: 1px solid var(--ink-mute); outline-offset: -1px; } | |
| 172 | 176 | & > li:focus-visible { outline-offset: -2px; border-radius: 0; } | |
| 173 | 177 | & .t-main { display: flex; align-items: stretch; min-width: 0; min-height: var(--rowh); padding-left: var(--s3); } | |
| 174 | 178 | & .t-twig { | |
| @@ -14,10 +14,9 @@ | |||||
| 14 | & th[aria-sort="ascending"], & th[aria-sort="descending"] { color: var(--ink-hi); } | 14 | & th[aria-sort="ascending"], & th[aria-sort="descending"] { color: var(--ink-hi); } | ||
| 15 | & td { height: 40px; padding: 0 var(--s3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-top: 1px solid var(--border); } | 15 | & td { height: 40px; padding: 0 var(--s3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-top: 1px solid var(--border); } | ||
| 16 | & tbody tr:first-child td { border-top: 0; } | 16 | & tbody tr:first-child td { border-top: 0; } | ||
| 17 | - | & th.cur, & td.cur { width: 3px; padding: 0; } | |||
| 18 | & tbody tr:hover { background: var(--bg-raised); } | 17 | & tbody tr:hover { background: var(--bg-raised); } | ||
| 19 | - | & tbody tr[data-cursor] { background: var(--bg-raised); } | 18 | + | /* the cursor: the row raised, framed by one even line */ |
| 20 | - | & tbody tr[data-cursor] .cur { background: var(--ink-hi); } | 19 | + | & tbody tr[data-cursor] { background: var(--bg-raised); outline: 1px solid var(--ink-mute); outline-offset: -1px; } |
| 21 | & tbody tr[hidden] { display: none; } | 20 | & tbody tr[hidden] { display: none; } | ||
| 22 | & td > a { text-decoration: none; } | 21 | & td > a { text-decoration: none; } | ||
| 23 | & td > a:first-child { color: var(--ink-hi); } | 22 | & td > a:first-child { color: var(--ink-hi); } | ||
| @@ -54,7 +53,7 @@ | |||||
| 54 | padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); | 53 | padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); | ||
| 55 | } | 54 | } | ||
| 56 | & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); } | 55 | & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); } | ||
| 57 | - | & .repo[data-cursor] { background: var(--bg-raised); border-color: var(--ink-mute); box-shadow: inset 3px 0 0 var(--ink-hi); } | 56 | + | & .repo[data-cursor] { background: var(--bg-raised); border-color: var(--ink-mute); } |
| 58 | & .repo[hidden] { display: none; } | 57 | & .repo[hidden] { display: none; } | ||
| 59 | & .repo-main { grid-area: main; display: flex; flex-direction: column; gap: var(--s2); min-width: 0; } | 58 | & .repo-main { grid-area: main; display: flex; flex-direction: column; gap: var(--s2); min-width: 0; } | ||
| 60 | & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-family: var(--mono); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; } | 59 | & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-family: var(--mono); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; } | ||
| @@ -66,9 +65,14 @@ | |||||
| 66 | display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; | 65 | display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; | ||
| 67 | } | 66 | } | ||
| 68 | & .repo-desc.mute { color: var(--ink-mute); font-style: italic; } | 67 | & .repo-desc.mute { color: var(--ink-mute); font-style: italic; } | ||
| 69 | - | & .repo-meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1) var(--s4); font-size: var(--fs-s); color: var(--ink-mute); } | 68 | + | /* the figures: a small label over a mono value, side by side */ |
| 70 | - | & .repo-meta time { color: var(--ink-dim); } | 69 | + | & .repo-stats { display: flex; flex-wrap: wrap; gap: var(--s2) var(--s5); margin: var(--s1) 0 0; } |
| 71 | - | & .repo-branch { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); color: var(--ink-dim); } | 70 | + | & .repo-stats div { display: flex; flex-direction: column; gap: 2px; min-width: 0; } |
| 71 | + | & .repo-stats dt { font-size: 11px; line-height: 14px; letter-spacing: .04em; text-transform: uppercase; color: var(--ink-mute); } | |||
| 72 | + | & .repo-stats dd { margin: 0; font-family: var(--mono); font-size: 15px; line-height: 20px; color: var(--ink-hi); font-variant-numeric: tabular-nums; white-space: nowrap; } | |||
| 73 | + | & .repo-stats dd.mute { color: var(--ink-mute); } | |||
| 74 | + | & .repo-stats dd a { color: inherit; text-decoration: none; } | |||
| 75 | + | & .repo-stats dd a:hover { text-decoration: underline; } | |||
| 72 | & .repo-ci { | 76 | & .repo-ci { | ||
| 73 | grid-area: ci; justify-self: end; display: inline-flex; align-items: center; gap: var(--s1); margin-bottom: var(--s2); | 77 | grid-area: ci; justify-self: end; display: inline-flex; align-items: center; gap: var(--s1); margin-bottom: var(--s2); | ||
| 74 | font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none; | 78 | font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none; | ||
| @@ -168,7 +172,7 @@ | |||||
| 168 | min-height: var(--rowh); padding-right: var(--s4); font-size: var(--fs-s); | 172 | min-height: var(--rowh); padding-right: var(--s4); font-size: var(--fs-s); | ||
| 169 | } | 173 | } | ||
| 170 | & > li:hover, & > li[data-cursor] { background: var(--bg-raised); } | 174 | & > li:hover, & > li[data-cursor] { background: var(--bg-raised); } | ||
| 171 | - | & > li[data-cursor] { box-shadow: inset 3px 0 0 var(--ink-hi); } | 175 | + | & > li[data-cursor] { outline: 1px solid var(--ink-mute); outline-offset: -1px; } |
| 172 | & > li:focus-visible { outline-offset: -2px; border-radius: 0; } | 176 | & > li:focus-visible { outline-offset: -2px; border-radius: 0; } | ||
| 173 | & .t-main { display: flex; align-items: stretch; min-width: 0; min-height: var(--rowh); padding-left: var(--s3); } | 177 | & .t-main { display: flex; align-items: stretch; min-width: 0; min-height: var(--rowh); padding-left: var(--s3); } | ||
| 174 | & .t-twig { | 178 | & .t-twig { | ||
M internal/adapters/web/static/css/50-code.css +3 -4
| @@ -119,11 +119,11 @@ | |||
| 119 | 119 | a pixel below the row, enough to give the list a scroll bar */ | |
| 120 | 120 | display: grid; grid-template-columns: 1.5ch minmax(0, 1fr) auto auto; column-gap: var(--s2); align-items: center; | |
| 121 | 121 | height: 26px; line-height: 26px; padding: 0 var(--s2); margin: 0 calc(-1 * var(--s2)); | |
| 122 | - | border-radius: 4px; border-left: 2px solid transparent; text-decoration: none; font-family: var(--mono); color: var(--ink); | |
| 122 | + | border-radius: 4px; border: 1px solid transparent; text-decoration: none; font-family: var(--mono); color: var(--ink); | |
| 123 | 123 | } | |
| 124 | 124 | & a:hover { background: var(--bg-raised); } | |
| 125 | 125 | & a:hover .p { text-decoration: underline; } | |
| 126 | - | & a[aria-current="true"] { background: var(--row); border-left-color: var(--ink-hi); } | |
| 126 | + | & a[aria-current="true"] { background: var(--row); border-color: var(--ink-mute); } | |
| 127 | 127 | & a[aria-current="true"] .p { font-weight: bold; color: var(--ink-hi); } | |
| 128 | 128 | & .st { color: var(--ink-mute); font-weight: bold; } | |
| 129 | 129 | & .st[data-st="A"] { color: var(--teal); } | |
| @@ -158,8 +158,7 @@ | |||
| 158 | 158 | .rail .list.commits { | |
| 159 | 159 | border: 0; border-radius: 0; background: none; table-layout: auto; | |
| 160 | 160 | & td { height: 34px; padding: 0 var(--s2); width: 1%; white-space: nowrap; } | |
| 161 | - | & td.cur { width: 3px; padding: 0; } | |
| 162 | - | & td:nth-child(2) { padding-left: var(--s3); } | |
| 161 | + | & td:first-child { padding-left: var(--s3); } | |
| 163 | 162 | & td:last-child { padding-right: var(--s4); } | |
| 164 | 163 | & .c-who { display: none; } | |
| 165 | 164 | & .c-subject { width: auto; max-width: 0; font-size: var(--fs-s); } | |
| @@ -119,11 +119,11 @@ | |||||
| 119 | a pixel below the row, enough to give the list a scroll bar */ | 119 | a pixel below the row, enough to give the list a scroll bar */ | ||
| 120 | display: grid; grid-template-columns: 1.5ch minmax(0, 1fr) auto auto; column-gap: var(--s2); align-items: center; | 120 | display: grid; grid-template-columns: 1.5ch minmax(0, 1fr) auto auto; column-gap: var(--s2); align-items: center; | ||
| 121 | height: 26px; line-height: 26px; padding: 0 var(--s2); margin: 0 calc(-1 * var(--s2)); | 121 | height: 26px; line-height: 26px; padding: 0 var(--s2); margin: 0 calc(-1 * var(--s2)); | ||
| 122 | - | border-radius: 4px; border-left: 2px solid transparent; text-decoration: none; font-family: var(--mono); color: var(--ink); | 122 | + | border-radius: 4px; border: 1px solid transparent; text-decoration: none; font-family: var(--mono); color: var(--ink); |
| 123 | } | 123 | } | ||
| 124 | & a:hover { background: var(--bg-raised); } | 124 | & a:hover { background: var(--bg-raised); } | ||
| 125 | & a:hover .p { text-decoration: underline; } | 125 | & a:hover .p { text-decoration: underline; } | ||
| 126 | - | & a[aria-current="true"] { background: var(--row); border-left-color: var(--ink-hi); } | 126 | + | & a[aria-current="true"] { background: var(--row); border-color: var(--ink-mute); } |
| 127 | & a[aria-current="true"] .p { font-weight: bold; color: var(--ink-hi); } | 127 | & a[aria-current="true"] .p { font-weight: bold; color: var(--ink-hi); } | ||
| 128 | & .st { color: var(--ink-mute); font-weight: bold; } | 128 | & .st { color: var(--ink-mute); font-weight: bold; } | ||
| 129 | & .st[data-st="A"] { color: var(--teal); } | 129 | & .st[data-st="A"] { color: var(--teal); } | ||
| @@ -158,8 +158,7 @@ | |||||
| 158 | .rail .list.commits { | 158 | .rail .list.commits { | ||
| 159 | border: 0; border-radius: 0; background: none; table-layout: auto; | 159 | border: 0; border-radius: 0; background: none; table-layout: auto; | ||
| 160 | & td { height: 34px; padding: 0 var(--s2); width: 1%; white-space: nowrap; } | 160 | & td { height: 34px; padding: 0 var(--s2); width: 1%; white-space: nowrap; } | ||
| 161 | - | & td.cur { width: 3px; padding: 0; } | 161 | + | & td:first-child { padding-left: var(--s3); } |
| 162 | - | & td:nth-child(2) { padding-left: var(--s3); } | |||
| 163 | & td:last-child { padding-right: var(--s4); } | 162 | & td:last-child { padding-right: var(--s4); } | ||
| 164 | & .c-who { display: none; } | 163 | & .c-who { display: none; } | ||
| 165 | & .c-subject { width: auto; max-width: 0; font-size: var(--fs-s); } | 164 | & .c-subject { width: auto; max-width: 0; font-size: var(--fs-s); } | ||
M internal/adapters/web/view.go +19 -7
| @@ -76,14 +76,17 @@ type Link struct{ Label, Href string } | |||
| 76 | 76 | ||
| 77 | 77 | // HomeRow is one repository in the home list. | |
| 78 | 78 | type HomeRow struct { | |
| 79 | - | ID string | |
| 80 | - | Name string | |
| 81 | - | Href string | |
| 82 | - | Desc string | |
| 83 | - | Pushed string | |
| 84 | - | PushedAt string // RFC 3339, "" when never pushed | |
| 85 | - | Branch string | |
| 79 | + | ID string | |
| 80 | + | Name string | |
| 81 | + | Href string | |
| 82 | + | Desc string | |
| 83 | + | // Active is when the repository last changed (its last push or merge), | |
| 84 | + | // ActiveAt its RFC 3339 form, "" when it never got a commit. | |
| 85 | + | Active string | |
| 86 | + | ActiveAt string | |
| 86 | 87 | Cursor bool | |
| 88 | + | // Stats are the figures of the row; nil when they could not be read. | |
| 89 | + | Stats *HomeStats | |
| 87 | 90 | // Private marks a private repository; only set for a signed-in visitor. | |
| 88 | 91 | Private bool | |
| 89 | 92 | // Fresh marks a row that a push just updated (live updates only). | |
| @@ -97,6 +100,15 @@ type HomeRow struct { | |||
| 97 | 100 | CI *HomeCI | |
| 98 | 101 | } | |
| 99 | 102 | ||
| 103 | + | // HomeStats are the figures of a home row: commits of the default branch, | |
| 104 | + | // those of the last 30 days, and the latest tag. | |
| 105 | + | type HomeStats struct { | |
| 106 | + | Commits, Recent string | |
| 107 | + | // Tag is the latest tag, "" without tags; TagHref links its tree, TagWhen | |
| 108 | + | // tells when it was made. | |
| 109 | + | Tag, TagHref, TagWhen string | |
| 110 | + | } | |
| 111 | + | ||
| 100 | 112 | // HomeCI is the state of the latest run of the default branch of a repository. | |
| 101 | 113 | type HomeCI struct { | |
| 102 | 114 | Href string | |
| @@ -76,14 +76,17 @@ type Link struct{ Label, Href string } | |||||
| 76 | 76 | ||||
| 77 | // HomeRow is one repository in the home list. | 77 | // HomeRow is one repository in the home list. | ||
| 78 | type HomeRow struct { | 78 | type HomeRow struct { | ||
| 79 | - | ID string | 79 | + | ID string |
| 80 | - | Name string | 80 | + | Name string |
| 81 | - | Href string | 81 | + | Href string |
| 82 | - | Desc string | 82 | + | Desc string |
| 83 | - | Pushed string | 83 | + | // Active is when the repository last changed (its last push or merge), |
| 84 | - | PushedAt string // RFC 3339, "" when never pushed | 84 | + | // ActiveAt its RFC 3339 form, "" when it never got a commit. |
| 85 | - | Branch string | 85 | + | Active string |
| 86 | + | ActiveAt string | |||
| 86 | Cursor bool | 87 | Cursor bool | ||
| 88 | + | // Stats are the figures of the row; nil when they could not be read. | |||
| 89 | + | Stats *HomeStats | |||
| 87 | // Private marks a private repository; only set for a signed-in visitor. | 90 | // Private marks a private repository; only set for a signed-in visitor. | ||
| 88 | Private bool | 91 | Private bool | ||
| 89 | // Fresh marks a row that a push just updated (live updates only). | 92 | // Fresh marks a row that a push just updated (live updates only). | ||
| @@ -97,6 +100,15 @@ type HomeRow struct { | |||||
| 97 | CI *HomeCI | 100 | CI *HomeCI | ||
| 98 | } | 101 | } | ||
| 99 | 102 | ||||
| 103 | + | // HomeStats are the figures of a home row: commits of the default branch, | |||
| 104 | + | // those of the last 30 days, and the latest tag. | |||
| 105 | + | type HomeStats struct { | |||
| 106 | + | Commits, Recent string | |||
| 107 | + | // Tag is the latest tag, "" without tags; TagHref links its tree, TagWhen | |||
| 108 | + | // tells when it was made. | |||
| 109 | + | Tag, TagHref, TagWhen string | |||
| 110 | + | } | |||
| 111 | + | ||||
| 100 | // HomeCI is the state of the latest run of the default branch of a repository. | 112 | // HomeCI is the state of the latest run of the default branch of a repository. | ||
| 101 | type HomeCI struct { | 113 | type HomeCI struct { | ||
| 102 | Href string | 114 | Href string | ||
M internal/app/testdata/web/commits-page2.html +0 -4
| @@ -108,8 +108,6 @@ | |||
| 108 | 108 | <table class="list commits" aria-label="Commits"> | |
| 109 | 109 | <tbody data-rows> | |
| 110 | 110 | <tr id="c-SHA7" data-cursor> | |
| 111 | - | <td class="cur" aria-hidden="true"> | |
| 112 | - | </td> | |
| 113 | 111 | <td class="c-sha mute">SHA7</td> | |
| 114 | 112 | <td class="c-subject"> | |
| 115 | 113 | <a href="/long/commit/SHA40">commit number 1</a> | |
| @@ -122,8 +120,6 @@ | |||
| 122 | 120 | </td> | |
| 123 | 121 | </tr> | |
| 124 | 122 | <tr id="c-SHA7"> | |
| 125 | - | <td class="cur" aria-hidden="true"> | |
| 126 | - | </td> | |
| 127 | 123 | <td class="c-sha mute">SHA7</td> | |
| 128 | 124 | <td class="c-subject"> | |
| 129 | 125 | <a href="/long/commit/SHA40">commit number 0</a> | |
| @@ -108,8 +108,6 @@ | |||||
| 108 | <table class="list commits" aria-label="Commits"> | 108 | <table class="list commits" aria-label="Commits"> | ||
| 109 | <tbody data-rows> | 109 | <tbody data-rows> | ||
| 110 | <tr id="c-SHA7" data-cursor> | 110 | <tr id="c-SHA7" data-cursor> | ||
| 111 | - | <td class="cur" aria-hidden="true"> | |||
| 112 | - | </td> | |||
| 113 | <td class="c-sha mute">SHA7</td> | 111 | <td class="c-sha mute">SHA7</td> | ||
| 114 | <td class="c-subject"> | 112 | <td class="c-subject"> | ||
| 115 | <a href="/long/commit/SHA40">commit number 1</a> | 113 | <a href="/long/commit/SHA40">commit number 1</a> | ||
| @@ -122,8 +120,6 @@ | |||||
| 122 | </td> | 120 | </td> | ||
| 123 | </tr> | 121 | </tr> | ||
| 124 | <tr id="c-SHA7"> | 122 | <tr id="c-SHA7"> | ||
| 125 | - | <td class="cur" aria-hidden="true"> | |||
| 126 | - | </td> | |||
| 127 | <td class="c-sha mute">SHA7</td> | 123 | <td class="c-sha mute">SHA7</td> | ||
| 128 | <td class="c-subject"> | 124 | <td class="c-subject"> | ||
| 129 | <a href="/long/commit/SHA40">commit number 0</a> | 125 | <a href="/long/commit/SHA40">commit number 0</a> | ||
M internal/app/testdata/web/commits-path.html +0 -4
| @@ -112,8 +112,6 @@ | |||
| 112 | 112 | <table class="list commits" aria-label="Commits"> | |
| 113 | 113 | <tbody data-rows> | |
| 114 | 114 | <tr id="c-SHA7" data-cursor> | |
| 115 | - | <td class="cur" aria-hidden="true"> | |
| 116 | - | </td> | |
| 117 | 115 | <td class="c-sha mute">SHA7</td> | |
| 118 | 116 | <td class="c-subject"> | |
| 119 | 117 | <a href="/long/commit/SHA40">commit number 30</a> | |
| @@ -135,8 +133,6 @@ | |||
| 135 | 133 | <table class="list commits" aria-label="Commits"> | |
| 136 | 134 | <tbody data-rows> | |
| 137 | 135 | <tr id="c-SHA7"> | |
| 138 | - | <td class="cur" aria-hidden="true"> | |
| 139 | - | </td> | |
| 140 | 136 | <td class="c-sha mute">SHA7</td> | |
| 141 | 137 | <td class="c-subject"> | |
| 142 | 138 | <a href="/long/commit/SHA40">commit number 3</a> | |
| @@ -112,8 +112,6 @@ | |||||
| 112 | <table class="list commits" aria-label="Commits"> | 112 | <table class="list commits" aria-label="Commits"> | ||
| 113 | <tbody data-rows> | 113 | <tbody data-rows> | ||
| 114 | <tr id="c-SHA7" data-cursor> | 114 | <tr id="c-SHA7" data-cursor> | ||
| 115 | - | <td class="cur" aria-hidden="true"> | |||
| 116 | - | </td> | |||
| 117 | <td class="c-sha mute">SHA7</td> | 115 | <td class="c-sha mute">SHA7</td> | ||
| 118 | <td class="c-subject"> | 116 | <td class="c-subject"> | ||
| 119 | <a href="/long/commit/SHA40">commit number 30</a> | 117 | <a href="/long/commit/SHA40">commit number 30</a> | ||
| @@ -135,8 +133,6 @@ | |||||
| 135 | <table class="list commits" aria-label="Commits"> | 133 | <table class="list commits" aria-label="Commits"> | ||
| 136 | <tbody data-rows> | 134 | <tbody data-rows> | ||
| 137 | <tr id="c-SHA7"> | 135 | <tr id="c-SHA7"> | ||
| 138 | - | <td class="cur" aria-hidden="true"> | |||
| 139 | - | </td> | |||
| 140 | <td class="c-sha mute">SHA7</td> | 136 | <td class="c-sha mute">SHA7</td> | ||
| 141 | <td class="c-subject"> | 137 | <td class="c-subject"> | ||
| 142 | <a href="/long/commit/SHA40">commit number 3</a> | 138 | <a href="/long/commit/SHA40">commit number 3</a> | ||
M internal/app/testdata/web/commits-root.html +0 -100
| @@ -110,8 +110,6 @@ | |||
| 110 | 110 | <table class="list commits" aria-label="Commits"> | |
| 111 | 111 | <tbody data-rows> | |
| 112 | 112 | <tr id="c-SHA7" data-cursor> | |
| 113 | - | <td class="cur" aria-hidden="true"> | |
| 114 | - | </td> | |
| 115 | 113 | <td class="c-sha mute">SHA7</td> | |
| 116 | 114 | <td class="c-subject"> | |
| 117 | 115 | <a href="/long/commit/SHA40">commit number 51</a> | |
| @@ -125,8 +123,6 @@ | |||
| 125 | 123 | </td> | |
| 126 | 124 | </tr> | |
| 127 | 125 | <tr id="c-SHA7"> | |
| 128 | - | <td class="cur" aria-hidden="true"> | |
| 129 | - | </td> | |
| 130 | 126 | <td class="c-sha mute">SHA7</td> | |
| 131 | 127 | <td class="c-subject"> | |
| 132 | 128 | <a href="/long/commit/SHA40">commit number 50</a> | |
| @@ -148,8 +144,6 @@ | |||
| 148 | 144 | <table class="list commits" aria-label="Commits"> | |
| 149 | 145 | <tbody data-rows> | |
| 150 | 146 | <tr id="c-SHA7"> | |
| 151 | - | <td class="cur" aria-hidden="true"> | |
| 152 | - | </td> | |
| 153 | 147 | <td class="c-sha mute">SHA7</td> | |
| 154 | 148 | <td class="c-subject"> | |
| 155 | 149 | <a href="/long/commit/SHA40">commit number 49</a> | |
| @@ -162,8 +156,6 @@ | |||
| 162 | 156 | </td> | |
| 163 | 157 | </tr> | |
| 164 | 158 | <tr id="c-SHA7"> | |
| 165 | - | <td class="cur" aria-hidden="true"> | |
| 166 | - | </td> | |
| 167 | 159 | <td class="c-sha mute">SHA7</td> | |
| 168 | 160 | <td class="c-subject"> | |
| 169 | 161 | <a href="/long/commit/SHA40">commit number 48</a> | |
| @@ -185,8 +177,6 @@ | |||
| 185 | 177 | <table class="list commits" aria-label="Commits"> | |
| 186 | 178 | <tbody data-rows> | |
| 187 | 179 | <tr id="c-SHA7"> | |
| 188 | - | <td class="cur" aria-hidden="true"> | |
| 189 | - | </td> | |
| 190 | 180 | <td class="c-sha mute">SHA7</td> | |
| 191 | 181 | <td class="c-subject"> | |
| 192 | 182 | <a href="/long/commit/SHA40">commit number 47</a> | |
| @@ -199,8 +189,6 @@ | |||
| 199 | 189 | </td> | |
| 200 | 190 | </tr> | |
| 201 | 191 | <tr id="c-SHA7"> | |
| 202 | - | <td class="cur" aria-hidden="true"> | |
| 203 | - | </td> | |
| 204 | 192 | <td class="c-sha mute">SHA7</td> | |
| 205 | 193 | <td class="c-subject"> | |
| 206 | 194 | <a href="/long/commit/SHA40">commit number 46</a> | |
| @@ -222,8 +210,6 @@ | |||
| 222 | 210 | <table class="list commits" aria-label="Commits"> | |
| 223 | 211 | <tbody data-rows> | |
| 224 | 212 | <tr id="c-SHA7"> | |
| 225 | - | <td class="cur" aria-hidden="true"> | |
| 226 | - | </td> | |
| 227 | 213 | <td class="c-sha mute">SHA7</td> | |
| 228 | 214 | <td class="c-subject"> | |
| 229 | 215 | <a href="/long/commit/SHA40">commit number 45</a> | |
| @@ -236,8 +222,6 @@ | |||
| 236 | 222 | </td> | |
| 237 | 223 | </tr> | |
| 238 | 224 | <tr id="c-SHA7"> | |
| 239 | - | <td class="cur" aria-hidden="true"> | |
| 240 | - | </td> | |
| 241 | 225 | <td class="c-sha mute">SHA7</td> | |
| 242 | 226 | <td class="c-subject"> | |
| 243 | 227 | <a href="/long/commit/SHA40">commit number 44</a> | |
| @@ -259,8 +243,6 @@ | |||
| 259 | 243 | <table class="list commits" aria-label="Commits"> | |
| 260 | 244 | <tbody data-rows> | |
| 261 | 245 | <tr id="c-SHA7"> | |
| 262 | - | <td class="cur" aria-hidden="true"> | |
| 263 | - | </td> | |
| 264 | 246 | <td class="c-sha mute">SHA7</td> | |
| 265 | 247 | <td class="c-subject"> | |
| 266 | 248 | <a href="/long/commit/SHA40">commit number 43</a> | |
| @@ -273,8 +255,6 @@ | |||
| 273 | 255 | </td> | |
| 274 | 256 | </tr> | |
| 275 | 257 | <tr id="c-SHA7"> | |
| 276 | - | <td class="cur" aria-hidden="true"> | |
| 277 | - | </td> | |
| 278 | 258 | <td class="c-sha mute">SHA7</td> | |
| 279 | 259 | <td class="c-subject"> | |
| 280 | 260 | <a href="/long/commit/SHA40">commit number 42</a> | |
| @@ -296,8 +276,6 @@ | |||
| 296 | 276 | <table class="list commits" aria-label="Commits"> | |
| 297 | 277 | <tbody data-rows> | |
| 298 | 278 | <tr id="c-SHA7"> | |
| 299 | - | <td class="cur" aria-hidden="true"> | |
| 300 | - | </td> | |
| 301 | 279 | <td class="c-sha mute">SHA7</td> | |
| 302 | 280 | <td class="c-subject"> | |
| 303 | 281 | <a href="/long/commit/SHA40">commit number 41</a> | |
| @@ -310,8 +288,6 @@ | |||
| 310 | 288 | </td> | |
| 311 | 289 | </tr> | |
| 312 | 290 | <tr id="c-SHA7"> | |
| 313 | - | <td class="cur" aria-hidden="true"> | |
| 314 | - | </td> | |
| 315 | 291 | <td class="c-sha mute">SHA7</td> | |
| 316 | 292 | <td class="c-subject"> | |
| 317 | 293 | <a href="/long/commit/SHA40">commit number 40</a> | |
| @@ -333,8 +309,6 @@ | |||
| 333 | 309 | <table class="list commits" aria-label="Commits"> | |
| 334 | 310 | <tbody data-rows> | |
| 335 | 311 | <tr id="c-SHA7"> | |
| 336 | - | <td class="cur" aria-hidden="true"> | |
| 337 | - | </td> | |
| 338 | 312 | <td class="c-sha mute">SHA7</td> | |
| 339 | 313 | <td class="c-subject"> | |
| 340 | 314 | <a href="/long/commit/SHA40">commit number 39</a> | |
| @@ -347,8 +321,6 @@ | |||
| 347 | 321 | </td> | |
| 348 | 322 | </tr> | |
| 349 | 323 | <tr id="c-SHA7"> | |
| 350 | - | <td class="cur" aria-hidden="true"> | |
| 351 | - | </td> | |
| 352 | 324 | <td class="c-sha mute">SHA7</td> | |
| 353 | 325 | <td class="c-subject"> | |
| 354 | 326 | <a href="/long/commit/SHA40">commit number 38</a> | |
| @@ -370,8 +342,6 @@ | |||
| 370 | 342 | <table class="list commits" aria-label="Commits"> | |
| 371 | 343 | <tbody data-rows> | |
| 372 | 344 | <tr id="c-SHA7"> | |
| 373 | - | <td class="cur" aria-hidden="true"> | |
| 374 | - | </td> | |
| 375 | 345 | <td class="c-sha mute">SHA7</td> | |
| 376 | 346 | <td class="c-subject"> | |
| 377 | 347 | <a href="/long/commit/SHA40">commit number 37</a> | |
| @@ -384,8 +354,6 @@ | |||
| 384 | 354 | </td> | |
| 385 | 355 | </tr> | |
| 386 | 356 | <tr id="c-SHA7"> | |
| 387 | - | <td class="cur" aria-hidden="true"> | |
| 388 | - | </td> | |
| 389 | 357 | <td class="c-sha mute">SHA7</td> | |
| 390 | 358 | <td class="c-subject"> | |
| 391 | 359 | <a href="/long/commit/SHA40">commit number 36</a> | |
| @@ -407,8 +375,6 @@ | |||
| 407 | 375 | <table class="list commits" aria-label="Commits"> | |
| 408 | 376 | <tbody data-rows> | |
| 409 | 377 | <tr id="c-SHA7"> | |
| 410 | - | <td class="cur" aria-hidden="true"> | |
| 411 | - | </td> | |
| 412 | 378 | <td class="c-sha mute">SHA7</td> | |
| 413 | 379 | <td class="c-subject"> | |
| 414 | 380 | <a href="/long/commit/SHA40">commit number 35</a> | |
| @@ -421,8 +387,6 @@ | |||
| 421 | 387 | </td> | |
| 422 | 388 | </tr> | |
| 423 | 389 | <tr id="c-SHA7"> | |
| 424 | - | <td class="cur" aria-hidden="true"> | |
| 425 | - | </td> | |
| 426 | 390 | <td class="c-sha mute">SHA7</td> | |
| 427 | 391 | <td class="c-subject"> | |
| 428 | 392 | <a href="/long/commit/SHA40">commit number 34</a> | |
| @@ -444,8 +408,6 @@ | |||
| 444 | 408 | <table class="list commits" aria-label="Commits"> | |
| 445 | 409 | <tbody data-rows> | |
| 446 | 410 | <tr id="c-SHA7"> | |
| 447 | - | <td class="cur" aria-hidden="true"> | |
| 448 | - | </td> | |
| 449 | 411 | <td class="c-sha mute">SHA7</td> | |
| 450 | 412 | <td class="c-subject"> | |
| 451 | 413 | <a href="/long/commit/SHA40">commit number 33</a> | |
| @@ -458,8 +420,6 @@ | |||
| 458 | 420 | </td> | |
| 459 | 421 | </tr> | |
| 460 | 422 | <tr id="c-SHA7"> | |
| 461 | - | <td class="cur" aria-hidden="true"> | |
| 462 | - | </td> | |
| 463 | 423 | <td class="c-sha mute">SHA7</td> | |
| 464 | 424 | <td class="c-subject"> | |
| 465 | 425 | <a href="/long/commit/SHA40">commit number 32</a> | |
| @@ -481,8 +441,6 @@ | |||
| 481 | 441 | <table class="list commits" aria-label="Commits"> | |
| 482 | 442 | <tbody data-rows> | |
| 483 | 443 | <tr id="c-SHA7"> | |
| 484 | - | <td class="cur" aria-hidden="true"> | |
| 485 | - | </td> | |
| 486 | 444 | <td class="c-sha mute">SHA7</td> | |
| 487 | 445 | <td class="c-subject"> | |
| 488 | 446 | <a href="/long/commit/SHA40">commit number 31</a> | |
| @@ -495,8 +453,6 @@ | |||
| 495 | 453 | </td> | |
| 496 | 454 | </tr> | |
| 497 | 455 | <tr id="c-SHA7"> | |
| 498 | - | <td class="cur" aria-hidden="true"> | |
| 499 | - | </td> | |
| 500 | 456 | <td class="c-sha mute">SHA7</td> | |
| 501 | 457 | <td class="c-subject"> | |
| 502 | 458 | <a href="/long/commit/SHA40">commit number 30</a> | |
| @@ -518,8 +474,6 @@ | |||
| 518 | 474 | <table class="list commits" aria-label="Commits"> | |
| 519 | 475 | <tbody data-rows> | |
| 520 | 476 | <tr id="c-SHA7"> | |
| 521 | - | <td class="cur" aria-hidden="true"> | |
| 522 | - | </td> | |
| 523 | 477 | <td class="c-sha mute">SHA7</td> | |
| 524 | 478 | <td class="c-subject"> | |
| 525 | 479 | <a href="/long/commit/SHA40">commit number 29</a> | |
| @@ -532,8 +486,6 @@ | |||
| 532 | 486 | </td> | |
| 533 | 487 | </tr> | |
| 534 | 488 | <tr id="c-SHA7"> | |
| 535 | - | <td class="cur" aria-hidden="true"> | |
| 536 | - | </td> | |
| 537 | 489 | <td class="c-sha mute">SHA7</td> | |
| 538 | 490 | <td class="c-subject"> | |
| 539 | 491 | <a href="/long/commit/SHA40">commit number 28</a> | |
| @@ -555,8 +507,6 @@ | |||
| 555 | 507 | <table class="list commits" aria-label="Commits"> | |
| 556 | 508 | <tbody data-rows> | |
| 557 | 509 | <tr id="c-SHA7"> | |
| 558 | - | <td class="cur" aria-hidden="true"> | |
| 559 | - | </td> | |
| 560 | 510 | <td class="c-sha mute">SHA7</td> | |
| 561 | 511 | <td class="c-subject"> | |
| 562 | 512 | <a href="/long/commit/SHA40">commit number 27</a> | |
| @@ -569,8 +519,6 @@ | |||
| 569 | 519 | </td> | |
| 570 | 520 | </tr> | |
| 571 | 521 | <tr id="c-SHA7"> | |
| 572 | - | <td class="cur" aria-hidden="true"> | |
| 573 | - | </td> | |
| 574 | 522 | <td class="c-sha mute">SHA7</td> | |
| 575 | 523 | <td class="c-subject"> | |
| 576 | 524 | <a href="/long/commit/SHA40">commit number 26</a> | |
| @@ -592,8 +540,6 @@ | |||
| 592 | 540 | <table class="list commits" aria-label="Commits"> | |
| 593 | 541 | <tbody data-rows> | |
| 594 | 542 | <tr id="c-SHA7"> | |
| 595 | - | <td class="cur" aria-hidden="true"> | |
| 596 | - | </td> | |
| 597 | 543 | <td class="c-sha mute">SHA7</td> | |
| 598 | 544 | <td class="c-subject"> | |
| 599 | 545 | <a href="/long/commit/SHA40">commit number 25</a> | |
| @@ -606,8 +552,6 @@ | |||
| 606 | 552 | </td> | |
| 607 | 553 | </tr> | |
| 608 | 554 | <tr id="c-SHA7"> | |
| 609 | - | <td class="cur" aria-hidden="true"> | |
| 610 | - | </td> | |
| 611 | 555 | <td class="c-sha mute">SHA7</td> | |
| 612 | 556 | <td class="c-subject"> | |
| 613 | 557 | <a href="/long/commit/SHA40">commit number 24</a> | |
| @@ -629,8 +573,6 @@ | |||
| 629 | 573 | <table class="list commits" aria-label="Commits"> | |
| 630 | 574 | <tbody data-rows> | |
| 631 | 575 | <tr id="c-SHA7"> | |
| 632 | - | <td class="cur" aria-hidden="true"> | |
| 633 | - | </td> | |
| 634 | 576 | <td class="c-sha mute">SHA7</td> | |
| 635 | 577 | <td class="c-subject"> | |
| 636 | 578 | <a href="/long/commit/SHA40">commit number 23</a> | |
| @@ -643,8 +585,6 @@ | |||
| 643 | 585 | </td> | |
| 644 | 586 | </tr> | |
| 645 | 587 | <tr id="c-SHA7"> | |
| 646 | - | <td class="cur" aria-hidden="true"> | |
| 647 | - | </td> | |
| 648 | 588 | <td class="c-sha mute">SHA7</td> | |
| 649 | 589 | <td class="c-subject"> | |
| 650 | 590 | <a href="/long/commit/SHA40">commit number 22</a> | |
| @@ -666,8 +606,6 @@ | |||
| 666 | 606 | <table class="list commits" aria-label="Commits"> | |
| 667 | 607 | <tbody data-rows> | |
| 668 | 608 | <tr id="c-SHA7"> | |
| 669 | - | <td class="cur" aria-hidden="true"> | |
| 670 | - | </td> | |
| 671 | 609 | <td class="c-sha mute">SHA7</td> | |
| 672 | 610 | <td class="c-subject"> | |
| 673 | 611 | <a href="/long/commit/SHA40">commit number 21</a> | |
| @@ -680,8 +618,6 @@ | |||
| 680 | 618 | </td> | |
| 681 | 619 | </tr> | |
| 682 | 620 | <tr id="c-SHA7"> | |
| 683 | - | <td class="cur" aria-hidden="true"> | |
| 684 | - | </td> | |
| 685 | 621 | <td class="c-sha mute">SHA7</td> | |
| 686 | 622 | <td class="c-subject"> | |
| 687 | 623 | <a href="/long/commit/SHA40">commit number 20</a> | |
| @@ -703,8 +639,6 @@ | |||
| 703 | 639 | <table class="list commits" aria-label="Commits"> | |
| 704 | 640 | <tbody data-rows> | |
| 705 | 641 | <tr id="c-SHA7"> | |
| 706 | - | <td class="cur" aria-hidden="true"> | |
| 707 | - | </td> | |
| 708 | 642 | <td class="c-sha mute">SHA7</td> | |
| 709 | 643 | <td class="c-subject"> | |
| 710 | 644 | <a href="/long/commit/SHA40">commit number 19</a> | |
| @@ -717,8 +651,6 @@ | |||
| 717 | 651 | </td> | |
| 718 | 652 | </tr> | |
| 719 | 653 | <tr id="c-SHA7"> | |
| 720 | - | <td class="cur" aria-hidden="true"> | |
| 721 | - | </td> | |
| 722 | 654 | <td class="c-sha mute">SHA7</td> | |
| 723 | 655 | <td class="c-subject"> | |
| 724 | 656 | <a href="/long/commit/SHA40">commit number 18</a> | |
| @@ -740,8 +672,6 @@ | |||
| 740 | 672 | <table class="list commits" aria-label="Commits"> | |
| 741 | 673 | <tbody data-rows> | |
| 742 | 674 | <tr id="c-SHA7"> | |
| 743 | - | <td class="cur" aria-hidden="true"> | |
| 744 | - | </td> | |
| 745 | 675 | <td class="c-sha mute">SHA7</td> | |
| 746 | 676 | <td class="c-subject"> | |
| 747 | 677 | <a href="/long/commit/SHA40">commit number 17</a> | |
| @@ -754,8 +684,6 @@ | |||
| 754 | 684 | </td> | |
| 755 | 685 | </tr> | |
| 756 | 686 | <tr id="c-SHA7"> | |
| 757 | - | <td class="cur" aria-hidden="true"> | |
| 758 | - | </td> | |
| 759 | 687 | <td class="c-sha mute">SHA7</td> | |
| 760 | 688 | <td class="c-subject"> | |
| 761 | 689 | <a href="/long/commit/SHA40">commit number 16</a> | |
| @@ -777,8 +705,6 @@ | |||
| 777 | 705 | <table class="list commits" aria-label="Commits"> | |
| 778 | 706 | <tbody data-rows> | |
| 779 | 707 | <tr id="c-SHA7"> | |
| 780 | - | <td class="cur" aria-hidden="true"> | |
| 781 | - | </td> | |
| 782 | 708 | <td class="c-sha mute">SHA7</td> | |
| 783 | 709 | <td class="c-subject"> | |
| 784 | 710 | <a href="/long/commit/SHA40">commit number 15</a> | |
| @@ -791,8 +717,6 @@ | |||
| 791 | 717 | </td> | |
| 792 | 718 | </tr> | |
| 793 | 719 | <tr id="c-SHA7"> | |
| 794 | - | <td class="cur" aria-hidden="true"> | |
| 795 | - | </td> | |
| 796 | 720 | <td class="c-sha mute">SHA7</td> | |
| 797 | 721 | <td class="c-subject"> | |
| 798 | 722 | <a href="/long/commit/SHA40">commit number 14</a> | |
| @@ -814,8 +738,6 @@ | |||
| 814 | 738 | <table class="list commits" aria-label="Commits"> | |
| 815 | 739 | <tbody data-rows> | |
| 816 | 740 | <tr id="c-SHA7"> | |
| 817 | - | <td class="cur" aria-hidden="true"> | |
| 818 | - | </td> | |
| 819 | 741 | <td class="c-sha mute">SHA7</td> | |
| 820 | 742 | <td class="c-subject"> | |
| 821 | 743 | <a href="/long/commit/SHA40">commit number 13</a> | |
| @@ -828,8 +750,6 @@ | |||
| 828 | 750 | </td> | |
| 829 | 751 | </tr> | |
| 830 | 752 | <tr id="c-SHA7"> | |
| 831 | - | <td class="cur" aria-hidden="true"> | |
| 832 | - | </td> | |
| 833 | 753 | <td class="c-sha mute">SHA7</td> | |
| 834 | 754 | <td class="c-subject"> | |
| 835 | 755 | <a href="/long/commit/SHA40">commit number 12</a> | |
| @@ -851,8 +771,6 @@ | |||
| 851 | 771 | <table class="list commits" aria-label="Commits"> | |
| 852 | 772 | <tbody data-rows> | |
| 853 | 773 | <tr id="c-SHA7"> | |
| 854 | - | <td class="cur" aria-hidden="true"> | |
| 855 | - | </td> | |
| 856 | 774 | <td class="c-sha mute">SHA7</td> | |
| 857 | 775 | <td class="c-subject"> | |
| 858 | 776 | <a href="/long/commit/SHA40">commit number 11</a> | |
| @@ -865,8 +783,6 @@ | |||
| 865 | 783 | </td> | |
| 866 | 784 | </tr> | |
| 867 | 785 | <tr id="c-SHA7"> | |
| 868 | - | <td class="cur" aria-hidden="true"> | |
| 869 | - | </td> | |
| 870 | 786 | <td class="c-sha mute">SHA7</td> | |
| 871 | 787 | <td class="c-subject"> | |
| 872 | 788 | <a href="/long/commit/SHA40">commit number 10</a> | |
| @@ -888,8 +804,6 @@ | |||
| 888 | 804 | <table class="list commits" aria-label="Commits"> | |
| 889 | 805 | <tbody data-rows> | |
| 890 | 806 | <tr id="c-SHA7"> | |
| 891 | - | <td class="cur" aria-hidden="true"> | |
| 892 | - | </td> | |
| 893 | 807 | <td class="c-sha mute">SHA7</td> | |
| 894 | 808 | <td class="c-subject"> | |
| 895 | 809 | <a href="/long/commit/SHA40">commit number 9</a> | |
| @@ -902,8 +816,6 @@ | |||
| 902 | 816 | </td> | |
| 903 | 817 | </tr> | |
| 904 | 818 | <tr id="c-SHA7"> | |
| 905 | - | <td class="cur" aria-hidden="true"> | |
| 906 | - | </td> | |
| 907 | 819 | <td class="c-sha mute">SHA7</td> | |
| 908 | 820 | <td class="c-subject"> | |
| 909 | 821 | <a href="/long/commit/SHA40">commit number 8</a> | |
| @@ -925,8 +837,6 @@ | |||
| 925 | 837 | <table class="list commits" aria-label="Commits"> | |
| 926 | 838 | <tbody data-rows> | |
| 927 | 839 | <tr id="c-SHA7"> | |
| 928 | - | <td class="cur" aria-hidden="true"> | |
| 929 | - | </td> | |
| 930 | 840 | <td class="c-sha mute">SHA7</td> | |
| 931 | 841 | <td class="c-subject"> | |
| 932 | 842 | <a href="/long/commit/SHA40">commit number 7</a> | |
| @@ -939,8 +849,6 @@ | |||
| 939 | 849 | </td> | |
| 940 | 850 | </tr> | |
| 941 | 851 | <tr id="c-SHA7"> | |
| 942 | - | <td class="cur" aria-hidden="true"> | |
| 943 | - | </td> | |
| 944 | 852 | <td class="c-sha mute">SHA7</td> | |
| 945 | 853 | <td class="c-subject"> | |
| 946 | 854 | <a href="/long/commit/SHA40">commit number 6</a> | |
| @@ -962,8 +870,6 @@ | |||
| 962 | 870 | <table class="list commits" aria-label="Commits"> | |
| 963 | 871 | <tbody data-rows> | |
| 964 | 872 | <tr id="c-SHA7"> | |
| 965 | - | <td class="cur" aria-hidden="true"> | |
| 966 | - | </td> | |
| 967 | 873 | <td class="c-sha mute">SHA7</td> | |
| 968 | 874 | <td class="c-subject"> | |
| 969 | 875 | <a href="/long/commit/SHA40">commit number 5</a> | |
| @@ -976,8 +882,6 @@ | |||
| 976 | 882 | </td> | |
| 977 | 883 | </tr> | |
| 978 | 884 | <tr id="c-SHA7"> | |
| 979 | - | <td class="cur" aria-hidden="true"> | |
| 980 | - | </td> | |
| 981 | 885 | <td class="c-sha mute">SHA7</td> | |
| 982 | 886 | <td class="c-subject"> | |
| 983 | 887 | <a href="/long/commit/SHA40">commit number 4</a> | |
| @@ -999,8 +903,6 @@ | |||
| 999 | 903 | <table class="list commits" aria-label="Commits"> | |
| 1000 | 904 | <tbody data-rows> | |
| 1001 | 905 | <tr id="c-SHA7"> | |
| 1002 | - | <td class="cur" aria-hidden="true"> | |
| 1003 | - | </td> | |
| 1004 | 906 | <td class="c-sha mute">SHA7</td> | |
| 1005 | 907 | <td class="c-subject"> | |
| 1006 | 908 | <a href="/long/commit/SHA40">commit number 3</a> | |
| @@ -1013,8 +915,6 @@ | |||
| 1013 | 915 | </td> | |
| 1014 | 916 | </tr> | |
| 1015 | 917 | <tr id="c-SHA7"> | |
| 1016 | - | <td class="cur" aria-hidden="true"> | |
| 1017 | - | </td> | |
| 1018 | 918 | <td class="c-sha mute">SHA7</td> | |
| 1019 | 919 | <td class="c-subject"> | |
| 1020 | 920 | <a href="/long/commit/SHA40">commit number 2</a> | |
| @@ -110,8 +110,6 @@ | |||||
| 110 | <table class="list commits" aria-label="Commits"> | 110 | <table class="list commits" aria-label="Commits"> | ||
| 111 | <tbody data-rows> | 111 | <tbody data-rows> | ||
| 112 | <tr id="c-SHA7" data-cursor> | 112 | <tr id="c-SHA7" data-cursor> | ||
| 113 | - | <td class="cur" aria-hidden="true"> | |||
| 114 | - | </td> | |||
| 115 | <td class="c-sha mute">SHA7</td> | 113 | <td class="c-sha mute">SHA7</td> | ||
| 116 | <td class="c-subject"> | 114 | <td class="c-subject"> | ||
| 117 | <a href="/long/commit/SHA40">commit number 51</a> | 115 | <a href="/long/commit/SHA40">commit number 51</a> | ||
| @@ -125,8 +123,6 @@ | |||||
| 125 | </td> | 123 | </td> | ||
| 126 | </tr> | 124 | </tr> | ||
| 127 | <tr id="c-SHA7"> | 125 | <tr id="c-SHA7"> | ||
| 128 | - | <td class="cur" aria-hidden="true"> | |||
| 129 | - | </td> | |||
| 130 | <td class="c-sha mute">SHA7</td> | 126 | <td class="c-sha mute">SHA7</td> | ||
| 131 | <td class="c-subject"> | 127 | <td class="c-subject"> | ||
| 132 | <a href="/long/commit/SHA40">commit number 50</a> | 128 | <a href="/long/commit/SHA40">commit number 50</a> | ||
| @@ -148,8 +144,6 @@ | |||||
| 148 | <table class="list commits" aria-label="Commits"> | 144 | <table class="list commits" aria-label="Commits"> | ||
| 149 | <tbody data-rows> | 145 | <tbody data-rows> | ||
| 150 | <tr id="c-SHA7"> | 146 | <tr id="c-SHA7"> | ||
| 151 | - | <td class="cur" aria-hidden="true"> | |||
| 152 | - | </td> | |||
| 153 | <td class="c-sha mute">SHA7</td> | 147 | <td class="c-sha mute">SHA7</td> | ||
| 154 | <td class="c-subject"> | 148 | <td class="c-subject"> | ||
| 155 | <a href="/long/commit/SHA40">commit number 49</a> | 149 | <a href="/long/commit/SHA40">commit number 49</a> | ||
| @@ -162,8 +156,6 @@ | |||||
| 162 | </td> | 156 | </td> | ||
| 163 | </tr> | 157 | </tr> | ||
| 164 | <tr id="c-SHA7"> | 158 | <tr id="c-SHA7"> | ||
| 165 | - | <td class="cur" aria-hidden="true"> | |||
| 166 | - | </td> | |||
| 167 | <td class="c-sha mute">SHA7</td> | 159 | <td class="c-sha mute">SHA7</td> | ||
| 168 | <td class="c-subject"> | 160 | <td class="c-subject"> | ||
| 169 | <a href="/long/commit/SHA40">commit number 48</a> | 161 | <a href="/long/commit/SHA40">commit number 48</a> | ||
| @@ -185,8 +177,6 @@ | |||||
| 185 | <table class="list commits" aria-label="Commits"> | 177 | <table class="list commits" aria-label="Commits"> | ||
| 186 | <tbody data-rows> | 178 | <tbody data-rows> | ||
| 187 | <tr id="c-SHA7"> | 179 | <tr id="c-SHA7"> | ||
| 188 | - | <td class="cur" aria-hidden="true"> | |||
| 189 | - | </td> | |||
| 190 | <td class="c-sha mute">SHA7</td> | 180 | <td class="c-sha mute">SHA7</td> | ||
| 191 | <td class="c-subject"> | 181 | <td class="c-subject"> | ||
| 192 | <a href="/long/commit/SHA40">commit number 47</a> | 182 | <a href="/long/commit/SHA40">commit number 47</a> | ||
| @@ -199,8 +189,6 @@ | |||||
| 199 | </td> | 189 | </td> | ||
| 200 | </tr> | 190 | </tr> | ||
| 201 | <tr id="c-SHA7"> | 191 | <tr id="c-SHA7"> | ||
| 202 | - | <td class="cur" aria-hidden="true"> | |||
| 203 | - | </td> | |||
| 204 | <td class="c-sha mute">SHA7</td> | 192 | <td class="c-sha mute">SHA7</td> | ||
| 205 | <td class="c-subject"> | 193 | <td class="c-subject"> | ||
| 206 | <a href="/long/commit/SHA40">commit number 46</a> | 194 | <a href="/long/commit/SHA40">commit number 46</a> | ||
| @@ -222,8 +210,6 @@ | |||||
| 222 | <table class="list commits" aria-label="Commits"> | 210 | <table class="list commits" aria-label="Commits"> | ||
| 223 | <tbody data-rows> | 211 | <tbody data-rows> | ||
| 224 | <tr id="c-SHA7"> | 212 | <tr id="c-SHA7"> | ||
| 225 | - | <td class="cur" aria-hidden="true"> | |||
| 226 | - | </td> | |||
| 227 | <td class="c-sha mute">SHA7</td> | 213 | <td class="c-sha mute">SHA7</td> | ||
| 228 | <td class="c-subject"> | 214 | <td class="c-subject"> | ||
| 229 | <a href="/long/commit/SHA40">commit number 45</a> | 215 | <a href="/long/commit/SHA40">commit number 45</a> | ||
| @@ -236,8 +222,6 @@ | |||||
| 236 | </td> | 222 | </td> | ||
| 237 | </tr> | 223 | </tr> | ||
| 238 | <tr id="c-SHA7"> | 224 | <tr id="c-SHA7"> | ||
| 239 | - | <td class="cur" aria-hidden="true"> | |||
| 240 | - | </td> | |||
| 241 | <td class="c-sha mute">SHA7</td> | 225 | <td class="c-sha mute">SHA7</td> | ||
| 242 | <td class="c-subject"> | 226 | <td class="c-subject"> | ||
| 243 | <a href="/long/commit/SHA40">commit number 44</a> | 227 | <a href="/long/commit/SHA40">commit number 44</a> | ||
| @@ -259,8 +243,6 @@ | |||||
| 259 | <table class="list commits" aria-label="Commits"> | 243 | <table class="list commits" aria-label="Commits"> | ||
| 260 | <tbody data-rows> | 244 | <tbody data-rows> | ||
| 261 | <tr id="c-SHA7"> | 245 | <tr id="c-SHA7"> | ||
| 262 | - | <td class="cur" aria-hidden="true"> | |||
| 263 | - | </td> | |||
| 264 | <td class="c-sha mute">SHA7</td> | 246 | <td class="c-sha mute">SHA7</td> | ||
| 265 | <td class="c-subject"> | 247 | <td class="c-subject"> | ||
| 266 | <a href="/long/commit/SHA40">commit number 43</a> | 248 | <a href="/long/commit/SHA40">commit number 43</a> | ||
| @@ -273,8 +255,6 @@ | |||||
| 273 | </td> | 255 | </td> | ||
| 274 | </tr> | 256 | </tr> | ||
| 275 | <tr id="c-SHA7"> | 257 | <tr id="c-SHA7"> | ||
| 276 | - | <td class="cur" aria-hidden="true"> | |||
| 277 | - | </td> | |||
| 278 | <td class="c-sha mute">SHA7</td> | 258 | <td class="c-sha mute">SHA7</td> | ||
| 279 | <td class="c-subject"> | 259 | <td class="c-subject"> | ||
| 280 | <a href="/long/commit/SHA40">commit number 42</a> | 260 | <a href="/long/commit/SHA40">commit number 42</a> | ||
| @@ -296,8 +276,6 @@ | |||||
| 296 | <table class="list commits" aria-label="Commits"> | 276 | <table class="list commits" aria-label="Commits"> | ||
| 297 | <tbody data-rows> | 277 | <tbody data-rows> | ||
| 298 | <tr id="c-SHA7"> | 278 | <tr id="c-SHA7"> | ||
| 299 | - | <td class="cur" aria-hidden="true"> | |||
| 300 | - | </td> | |||
| 301 | <td class="c-sha mute">SHA7</td> | 279 | <td class="c-sha mute">SHA7</td> | ||
| 302 | <td class="c-subject"> | 280 | <td class="c-subject"> | ||
| 303 | <a href="/long/commit/SHA40">commit number 41</a> | 281 | <a href="/long/commit/SHA40">commit number 41</a> | ||
| @@ -310,8 +288,6 @@ | |||||
| 310 | </td> | 288 | </td> | ||
| 311 | </tr> | 289 | </tr> | ||
| 312 | <tr id="c-SHA7"> | 290 | <tr id="c-SHA7"> | ||
| 313 | - | <td class="cur" aria-hidden="true"> | |||
| 314 | - | </td> | |||
| 315 | <td class="c-sha mute">SHA7</td> | 291 | <td class="c-sha mute">SHA7</td> | ||
| 316 | <td class="c-subject"> | 292 | <td class="c-subject"> | ||
| 317 | <a href="/long/commit/SHA40">commit number 40</a> | 293 | <a href="/long/commit/SHA40">commit number 40</a> | ||
| @@ -333,8 +309,6 @@ | |||||
| 333 | <table class="list commits" aria-label="Commits"> | 309 | <table class="list commits" aria-label="Commits"> | ||
| 334 | <tbody data-rows> | 310 | <tbody data-rows> | ||
| 335 | <tr id="c-SHA7"> | 311 | <tr id="c-SHA7"> | ||
| 336 | - | <td class="cur" aria-hidden="true"> | |||
| 337 | - | </td> | |||
| 338 | <td class="c-sha mute">SHA7</td> | 312 | <td class="c-sha mute">SHA7</td> | ||
| 339 | <td class="c-subject"> | 313 | <td class="c-subject"> | ||
| 340 | <a href="/long/commit/SHA40">commit number 39</a> | 314 | <a href="/long/commit/SHA40">commit number 39</a> | ||
| @@ -347,8 +321,6 @@ | |||||
| 347 | </td> | 321 | </td> | ||
| 348 | </tr> | 322 | </tr> | ||
| 349 | <tr id="c-SHA7"> | 323 | <tr id="c-SHA7"> | ||
| 350 | - | <td class="cur" aria-hidden="true"> | |||
| 351 | - | </td> | |||
| 352 | <td class="c-sha mute">SHA7</td> | 324 | <td class="c-sha mute">SHA7</td> | ||
| 353 | <td class="c-subject"> | 325 | <td class="c-subject"> | ||
| 354 | <a href="/long/commit/SHA40">commit number 38</a> | 326 | <a href="/long/commit/SHA40">commit number 38</a> | ||
| @@ -370,8 +342,6 @@ | |||||
| 370 | <table class="list commits" aria-label="Commits"> | 342 | <table class="list commits" aria-label="Commits"> | ||
| 371 | <tbody data-rows> | 343 | <tbody data-rows> | ||
| 372 | <tr id="c-SHA7"> | 344 | <tr id="c-SHA7"> | ||
| 373 | - | <td class="cur" aria-hidden="true"> | |||
| 374 | - | </td> | |||
| 375 | <td class="c-sha mute">SHA7</td> | 345 | <td class="c-sha mute">SHA7</td> | ||
| 376 | <td class="c-subject"> | 346 | <td class="c-subject"> | ||
| 377 | <a href="/long/commit/SHA40">commit number 37</a> | 347 | <a href="/long/commit/SHA40">commit number 37</a> | ||
| @@ -384,8 +354,6 @@ | |||||
| 384 | </td> | 354 | </td> | ||
| 385 | </tr> | 355 | </tr> | ||
| 386 | <tr id="c-SHA7"> | 356 | <tr id="c-SHA7"> | ||
| 387 | - | <td class="cur" aria-hidden="true"> | |||
| 388 | - | </td> | |||
| 389 | <td class="c-sha mute">SHA7</td> | 357 | <td class="c-sha mute">SHA7</td> | ||
| 390 | <td class="c-subject"> | 358 | <td class="c-subject"> | ||
| 391 | <a href="/long/commit/SHA40">commit number 36</a> | 359 | <a href="/long/commit/SHA40">commit number 36</a> | ||
| @@ -407,8 +375,6 @@ | |||||
| 407 | <table class="list commits" aria-label="Commits"> | 375 | <table class="list commits" aria-label="Commits"> | ||
| 408 | <tbody data-rows> | 376 | <tbody data-rows> | ||
| 409 | <tr id="c-SHA7"> | 377 | <tr id="c-SHA7"> | ||
| 410 | - | <td class="cur" aria-hidden="true"> | |||
| 411 | - | </td> | |||
| 412 | <td class="c-sha mute">SHA7</td> | 378 | <td class="c-sha mute">SHA7</td> | ||
| 413 | <td class="c-subject"> | 379 | <td class="c-subject"> | ||
| 414 | <a href="/long/commit/SHA40">commit number 35</a> | 380 | <a href="/long/commit/SHA40">commit number 35</a> | ||
| @@ -421,8 +387,6 @@ | |||||
| 421 | </td> | 387 | </td> | ||
| 422 | </tr> | 388 | </tr> | ||
| 423 | <tr id="c-SHA7"> | 389 | <tr id="c-SHA7"> | ||
| 424 | - | <td class="cur" aria-hidden="true"> | |||
| 425 | - | </td> | |||
| 426 | <td class="c-sha mute">SHA7</td> | 390 | <td class="c-sha mute">SHA7</td> | ||
| 427 | <td class="c-subject"> | 391 | <td class="c-subject"> | ||
| 428 | <a href="/long/commit/SHA40">commit number 34</a> | 392 | <a href="/long/commit/SHA40">commit number 34</a> | ||
| @@ -444,8 +408,6 @@ | |||||
| 444 | <table class="list commits" aria-label="Commits"> | 408 | <table class="list commits" aria-label="Commits"> | ||
| 445 | <tbody data-rows> | 409 | <tbody data-rows> | ||
| 446 | <tr id="c-SHA7"> | 410 | <tr id="c-SHA7"> | ||
| 447 | - | <td class="cur" aria-hidden="true"> | |||
| 448 | - | </td> | |||
| 449 | <td class="c-sha mute">SHA7</td> | 411 | <td class="c-sha mute">SHA7</td> | ||
| 450 | <td class="c-subject"> | 412 | <td class="c-subject"> | ||
| 451 | <a href="/long/commit/SHA40">commit number 33</a> | 413 | <a href="/long/commit/SHA40">commit number 33</a> | ||
| @@ -458,8 +420,6 @@ | |||||
| 458 | </td> | 420 | </td> | ||
| 459 | </tr> | 421 | </tr> | ||
| 460 | <tr id="c-SHA7"> | 422 | <tr id="c-SHA7"> | ||
| 461 | - | <td class="cur" aria-hidden="true"> | |||
| 462 | - | </td> | |||
| 463 | <td class="c-sha mute">SHA7</td> | 423 | <td class="c-sha mute">SHA7</td> | ||
| 464 | <td class="c-subject"> | 424 | <td class="c-subject"> | ||
| 465 | <a href="/long/commit/SHA40">commit number 32</a> | 425 | <a href="/long/commit/SHA40">commit number 32</a> | ||
| @@ -481,8 +441,6 @@ | |||||
| 481 | <table class="list commits" aria-label="Commits"> | 441 | <table class="list commits" aria-label="Commits"> | ||
| 482 | <tbody data-rows> | 442 | <tbody data-rows> | ||
| 483 | <tr id="c-SHA7"> | 443 | <tr id="c-SHA7"> | ||
| 484 | - | <td class="cur" aria-hidden="true"> | |||
| 485 | - | </td> | |||
| 486 | <td class="c-sha mute">SHA7</td> | 444 | <td class="c-sha mute">SHA7</td> | ||
| 487 | <td class="c-subject"> | 445 | <td class="c-subject"> | ||
| 488 | <a href="/long/commit/SHA40">commit number 31</a> | 446 | <a href="/long/commit/SHA40">commit number 31</a> | ||
| @@ -495,8 +453,6 @@ | |||||
| 495 | </td> | 453 | </td> | ||
| 496 | </tr> | 454 | </tr> | ||
| 497 | <tr id="c-SHA7"> | 455 | <tr id="c-SHA7"> | ||
| 498 | - | <td class="cur" aria-hidden="true"> | |||
| 499 | - | </td> | |||
| 500 | <td class="c-sha mute">SHA7</td> | 456 | <td class="c-sha mute">SHA7</td> | ||
| 501 | <td class="c-subject"> | 457 | <td class="c-subject"> | ||
| 502 | <a href="/long/commit/SHA40">commit number 30</a> | 458 | <a href="/long/commit/SHA40">commit number 30</a> | ||
| @@ -518,8 +474,6 @@ | |||||
| 518 | <table class="list commits" aria-label="Commits"> | 474 | <table class="list commits" aria-label="Commits"> | ||
| 519 | <tbody data-rows> | 475 | <tbody data-rows> | ||
| 520 | <tr id="c-SHA7"> | 476 | <tr id="c-SHA7"> | ||
| 521 | - | <td class="cur" aria-hidden="true"> | |||
| 522 | - | </td> | |||
| 523 | <td class="c-sha mute">SHA7</td> | 477 | <td class="c-sha mute">SHA7</td> | ||
| 524 | <td class="c-subject"> | 478 | <td class="c-subject"> | ||
| 525 | <a href="/long/commit/SHA40">commit number 29</a> | 479 | <a href="/long/commit/SHA40">commit number 29</a> | ||
| @@ -532,8 +486,6 @@ | |||||
| 532 | </td> | 486 | </td> | ||
| 533 | </tr> | 487 | </tr> | ||
| 534 | <tr id="c-SHA7"> | 488 | <tr id="c-SHA7"> | ||
| 535 | - | <td class="cur" aria-hidden="true"> | |||
| 536 | - | </td> | |||
| 537 | <td class="c-sha mute">SHA7</td> | 489 | <td class="c-sha mute">SHA7</td> | ||
| 538 | <td class="c-subject"> | 490 | <td class="c-subject"> | ||
| 539 | <a href="/long/commit/SHA40">commit number 28</a> | 491 | <a href="/long/commit/SHA40">commit number 28</a> | ||
| @@ -555,8 +507,6 @@ | |||||
| 555 | <table class="list commits" aria-label="Commits"> | 507 | <table class="list commits" aria-label="Commits"> | ||
| 556 | <tbody data-rows> | 508 | <tbody data-rows> | ||
| 557 | <tr id="c-SHA7"> | 509 | <tr id="c-SHA7"> | ||
| 558 | - | <td class="cur" aria-hidden="true"> | |||
| 559 | - | </td> | |||
| 560 | <td class="c-sha mute">SHA7</td> | 510 | <td class="c-sha mute">SHA7</td> | ||
| 561 | <td class="c-subject"> | 511 | <td class="c-subject"> | ||
| 562 | <a href="/long/commit/SHA40">commit number 27</a> | 512 | <a href="/long/commit/SHA40">commit number 27</a> | ||
| @@ -569,8 +519,6 @@ | |||||
| 569 | </td> | 519 | </td> | ||
| 570 | </tr> | 520 | </tr> | ||
| 571 | <tr id="c-SHA7"> | 521 | <tr id="c-SHA7"> | ||
| 572 | - | <td class="cur" aria-hidden="true"> | |||
| 573 | - | </td> | |||
| 574 | <td class="c-sha mute">SHA7</td> | 522 | <td class="c-sha mute">SHA7</td> | ||
| 575 | <td class="c-subject"> | 523 | <td class="c-subject"> | ||
| 576 | <a href="/long/commit/SHA40">commit number 26</a> | 524 | <a href="/long/commit/SHA40">commit number 26</a> | ||
| @@ -592,8 +540,6 @@ | |||||
| 592 | <table class="list commits" aria-label="Commits"> | 540 | <table class="list commits" aria-label="Commits"> | ||
| 593 | <tbody data-rows> | 541 | <tbody data-rows> | ||
| 594 | <tr id="c-SHA7"> | 542 | <tr id="c-SHA7"> | ||
| 595 | - | <td class="cur" aria-hidden="true"> | |||
| 596 | - | </td> | |||
| 597 | <td class="c-sha mute">SHA7</td> | 543 | <td class="c-sha mute">SHA7</td> | ||
| 598 | <td class="c-subject"> | 544 | <td class="c-subject"> | ||
| 599 | <a href="/long/commit/SHA40">commit number 25</a> | 545 | <a href="/long/commit/SHA40">commit number 25</a> | ||
| @@ -606,8 +552,6 @@ | |||||
| 606 | </td> | 552 | </td> | ||
| 607 | </tr> | 553 | </tr> | ||
| 608 | <tr id="c-SHA7"> | 554 | <tr id="c-SHA7"> | ||
| 609 | - | <td class="cur" aria-hidden="true"> | |||
| 610 | - | </td> | |||
| 611 | <td class="c-sha mute">SHA7</td> | 555 | <td class="c-sha mute">SHA7</td> | ||
| 612 | <td class="c-subject"> | 556 | <td class="c-subject"> | ||
| 613 | <a href="/long/commit/SHA40">commit number 24</a> | 557 | <a href="/long/commit/SHA40">commit number 24</a> | ||
| @@ -629,8 +573,6 @@ | |||||
| 629 | <table class="list commits" aria-label="Commits"> | 573 | <table class="list commits" aria-label="Commits"> | ||
| 630 | <tbody data-rows> | 574 | <tbody data-rows> | ||
| 631 | <tr id="c-SHA7"> | 575 | <tr id="c-SHA7"> | ||
| 632 | - | <td class="cur" aria-hidden="true"> | |||
| 633 | - | </td> | |||
| 634 | <td class="c-sha mute">SHA7</td> | 576 | <td class="c-sha mute">SHA7</td> | ||
| 635 | <td class="c-subject"> | 577 | <td class="c-subject"> | ||
| 636 | <a href="/long/commit/SHA40">commit number 23</a> | 578 | <a href="/long/commit/SHA40">commit number 23</a> | ||
| @@ -643,8 +585,6 @@ | |||||
| 643 | </td> | 585 | </td> | ||
| 644 | </tr> | 586 | </tr> | ||
| 645 | <tr id="c-SHA7"> | 587 | <tr id="c-SHA7"> | ||
| 646 | - | <td class="cur" aria-hidden="true"> | |||
| 647 | - | </td> | |||
| 648 | <td class="c-sha mute">SHA7</td> | 588 | <td class="c-sha mute">SHA7</td> | ||
| 649 | <td class="c-subject"> | 589 | <td class="c-subject"> | ||
| 650 | <a href="/long/commit/SHA40">commit number 22</a> | 590 | <a href="/long/commit/SHA40">commit number 22</a> | ||
| @@ -666,8 +606,6 @@ | |||||
| 666 | <table class="list commits" aria-label="Commits"> | 606 | <table class="list commits" aria-label="Commits"> | ||
| 667 | <tbody data-rows> | 607 | <tbody data-rows> | ||
| 668 | <tr id="c-SHA7"> | 608 | <tr id="c-SHA7"> | ||
| 669 | - | <td class="cur" aria-hidden="true"> | |||
| 670 | - | </td> | |||
| 671 | <td class="c-sha mute">SHA7</td> | 609 | <td class="c-sha mute">SHA7</td> | ||
| 672 | <td class="c-subject"> | 610 | <td class="c-subject"> | ||
| 673 | <a href="/long/commit/SHA40">commit number 21</a> | 611 | <a href="/long/commit/SHA40">commit number 21</a> | ||
| @@ -680,8 +618,6 @@ | |||||
| 680 | </td> | 618 | </td> | ||
| 681 | </tr> | 619 | </tr> | ||
| 682 | <tr id="c-SHA7"> | 620 | <tr id="c-SHA7"> | ||
| 683 | - | <td class="cur" aria-hidden="true"> | |||
| 684 | - | </td> | |||
| 685 | <td class="c-sha mute">SHA7</td> | 621 | <td class="c-sha mute">SHA7</td> | ||
| 686 | <td class="c-subject"> | 622 | <td class="c-subject"> | ||
| 687 | <a href="/long/commit/SHA40">commit number 20</a> | 623 | <a href="/long/commit/SHA40">commit number 20</a> | ||
| @@ -703,8 +639,6 @@ | |||||
| 703 | <table class="list commits" aria-label="Commits"> | 639 | <table class="list commits" aria-label="Commits"> | ||
| 704 | <tbody data-rows> | 640 | <tbody data-rows> | ||
| 705 | <tr id="c-SHA7"> | 641 | <tr id="c-SHA7"> | ||
| 706 | - | <td class="cur" aria-hidden="true"> | |||
| 707 | - | </td> | |||
| 708 | <td class="c-sha mute">SHA7</td> | 642 | <td class="c-sha mute">SHA7</td> | ||
| 709 | <td class="c-subject"> | 643 | <td class="c-subject"> | ||
| 710 | <a href="/long/commit/SHA40">commit number 19</a> | 644 | <a href="/long/commit/SHA40">commit number 19</a> | ||
| @@ -717,8 +651,6 @@ | |||||
| 717 | </td> | 651 | </td> | ||
| 718 | </tr> | 652 | </tr> | ||
| 719 | <tr id="c-SHA7"> | 653 | <tr id="c-SHA7"> | ||
| 720 | - | <td class="cur" aria-hidden="true"> | |||
| 721 | - | </td> | |||
| 722 | <td class="c-sha mute">SHA7</td> | 654 | <td class="c-sha mute">SHA7</td> | ||
| 723 | <td class="c-subject"> | 655 | <td class="c-subject"> | ||
| 724 | <a href="/long/commit/SHA40">commit number 18</a> | 656 | <a href="/long/commit/SHA40">commit number 18</a> | ||
| @@ -740,8 +672,6 @@ | |||||
| 740 | <table class="list commits" aria-label="Commits"> | 672 | <table class="list commits" aria-label="Commits"> | ||
| 741 | <tbody data-rows> | 673 | <tbody data-rows> | ||
| 742 | <tr id="c-SHA7"> | 674 | <tr id="c-SHA7"> | ||
| 743 | - | <td class="cur" aria-hidden="true"> | |||
| 744 | - | </td> | |||
| 745 | <td class="c-sha mute">SHA7</td> | 675 | <td class="c-sha mute">SHA7</td> | ||
| 746 | <td class="c-subject"> | 676 | <td class="c-subject"> | ||
| 747 | <a href="/long/commit/SHA40">commit number 17</a> | 677 | <a href="/long/commit/SHA40">commit number 17</a> | ||
| @@ -754,8 +684,6 @@ | |||||
| 754 | </td> | 684 | </td> | ||
| 755 | </tr> | 685 | </tr> | ||
| 756 | <tr id="c-SHA7"> | 686 | <tr id="c-SHA7"> | ||
| 757 | - | <td class="cur" aria-hidden="true"> | |||
| 758 | - | </td> | |||
| 759 | <td class="c-sha mute">SHA7</td> | 687 | <td class="c-sha mute">SHA7</td> | ||
| 760 | <td class="c-subject"> | 688 | <td class="c-subject"> | ||
| 761 | <a href="/long/commit/SHA40">commit number 16</a> | 689 | <a href="/long/commit/SHA40">commit number 16</a> | ||
| @@ -777,8 +705,6 @@ | |||||
| 777 | <table class="list commits" aria-label="Commits"> | 705 | <table class="list commits" aria-label="Commits"> | ||
| 778 | <tbody data-rows> | 706 | <tbody data-rows> | ||
| 779 | <tr id="c-SHA7"> | 707 | <tr id="c-SHA7"> | ||
| 780 | - | <td class="cur" aria-hidden="true"> | |||
| 781 | - | </td> | |||
| 782 | <td class="c-sha mute">SHA7</td> | 708 | <td class="c-sha mute">SHA7</td> | ||
| 783 | <td class="c-subject"> | 709 | <td class="c-subject"> | ||
| 784 | <a href="/long/commit/SHA40">commit number 15</a> | 710 | <a href="/long/commit/SHA40">commit number 15</a> | ||
| @@ -791,8 +717,6 @@ | |||||
| 791 | </td> | 717 | </td> | ||
| 792 | </tr> | 718 | </tr> | ||
| 793 | <tr id="c-SHA7"> | 719 | <tr id="c-SHA7"> | ||
| 794 | - | <td class="cur" aria-hidden="true"> | |||
| 795 | - | </td> | |||
| 796 | <td class="c-sha mute">SHA7</td> | 720 | <td class="c-sha mute">SHA7</td> | ||
| 797 | <td class="c-subject"> | 721 | <td class="c-subject"> | ||
| 798 | <a href="/long/commit/SHA40">commit number 14</a> | 722 | <a href="/long/commit/SHA40">commit number 14</a> | ||
| @@ -814,8 +738,6 @@ | |||||
| 814 | <table class="list commits" aria-label="Commits"> | 738 | <table class="list commits" aria-label="Commits"> | ||
| 815 | <tbody data-rows> | 739 | <tbody data-rows> | ||
| 816 | <tr id="c-SHA7"> | 740 | <tr id="c-SHA7"> | ||
| 817 | - | <td class="cur" aria-hidden="true"> | |||
| 818 | - | </td> | |||
| 819 | <td class="c-sha mute">SHA7</td> | 741 | <td class="c-sha mute">SHA7</td> | ||
| 820 | <td class="c-subject"> | 742 | <td class="c-subject"> | ||
| 821 | <a href="/long/commit/SHA40">commit number 13</a> | 743 | <a href="/long/commit/SHA40">commit number 13</a> | ||
| @@ -828,8 +750,6 @@ | |||||
| 828 | </td> | 750 | </td> | ||
| 829 | </tr> | 751 | </tr> | ||
| 830 | <tr id="c-SHA7"> | 752 | <tr id="c-SHA7"> | ||
| 831 | - | <td class="cur" aria-hidden="true"> | |||
| 832 | - | </td> | |||
| 833 | <td class="c-sha mute">SHA7</td> | 753 | <td class="c-sha mute">SHA7</td> | ||
| 834 | <td class="c-subject"> | 754 | <td class="c-subject"> | ||
| 835 | <a href="/long/commit/SHA40">commit number 12</a> | 755 | <a href="/long/commit/SHA40">commit number 12</a> | ||
| @@ -851,8 +771,6 @@ | |||||
| 851 | <table class="list commits" aria-label="Commits"> | 771 | <table class="list commits" aria-label="Commits"> | ||
| 852 | <tbody data-rows> | 772 | <tbody data-rows> | ||
| 853 | <tr id="c-SHA7"> | 773 | <tr id="c-SHA7"> | ||
| 854 | - | <td class="cur" aria-hidden="true"> | |||
| 855 | - | </td> | |||
| 856 | <td class="c-sha mute">SHA7</td> | 774 | <td class="c-sha mute">SHA7</td> | ||
| 857 | <td class="c-subject"> | 775 | <td class="c-subject"> | ||
| 858 | <a href="/long/commit/SHA40">commit number 11</a> | 776 | <a href="/long/commit/SHA40">commit number 11</a> | ||
| @@ -865,8 +783,6 @@ | |||||
| 865 | </td> | 783 | </td> | ||
| 866 | </tr> | 784 | </tr> | ||
| 867 | <tr id="c-SHA7"> | 785 | <tr id="c-SHA7"> | ||
| 868 | - | <td class="cur" aria-hidden="true"> | |||
| 869 | - | </td> | |||
| 870 | <td class="c-sha mute">SHA7</td> | 786 | <td class="c-sha mute">SHA7</td> | ||
| 871 | <td class="c-subject"> | 787 | <td class="c-subject"> | ||
| 872 | <a href="/long/commit/SHA40">commit number 10</a> | 788 | <a href="/long/commit/SHA40">commit number 10</a> | ||
| @@ -888,8 +804,6 @@ | |||||
| 888 | <table class="list commits" aria-label="Commits"> | 804 | <table class="list commits" aria-label="Commits"> | ||
| 889 | <tbody data-rows> | 805 | <tbody data-rows> | ||
| 890 | <tr id="c-SHA7"> | 806 | <tr id="c-SHA7"> | ||
| 891 | - | <td class="cur" aria-hidden="true"> | |||
| 892 | - | </td> | |||
| 893 | <td class="c-sha mute">SHA7</td> | 807 | <td class="c-sha mute">SHA7</td> | ||
| 894 | <td class="c-subject"> | 808 | <td class="c-subject"> | ||
| 895 | <a href="/long/commit/SHA40">commit number 9</a> | 809 | <a href="/long/commit/SHA40">commit number 9</a> | ||
| @@ -902,8 +816,6 @@ | |||||
| 902 | </td> | 816 | </td> | ||
| 903 | </tr> | 817 | </tr> | ||
| 904 | <tr id="c-SHA7"> | 818 | <tr id="c-SHA7"> | ||
| 905 | - | <td class="cur" aria-hidden="true"> | |||
| 906 | - | </td> | |||
| 907 | <td class="c-sha mute">SHA7</td> | 819 | <td class="c-sha mute">SHA7</td> | ||
| 908 | <td class="c-subject"> | 820 | <td class="c-subject"> | ||
| 909 | <a href="/long/commit/SHA40">commit number 8</a> | 821 | <a href="/long/commit/SHA40">commit number 8</a> | ||
| @@ -925,8 +837,6 @@ | |||||
| 925 | <table class="list commits" aria-label="Commits"> | 837 | <table class="list commits" aria-label="Commits"> | ||
| 926 | <tbody data-rows> | 838 | <tbody data-rows> | ||
| 927 | <tr id="c-SHA7"> | 839 | <tr id="c-SHA7"> | ||
| 928 | - | <td class="cur" aria-hidden="true"> | |||
| 929 | - | </td> | |||
| 930 | <td class="c-sha mute">SHA7</td> | 840 | <td class="c-sha mute">SHA7</td> | ||
| 931 | <td class="c-subject"> | 841 | <td class="c-subject"> | ||
| 932 | <a href="/long/commit/SHA40">commit number 7</a> | 842 | <a href="/long/commit/SHA40">commit number 7</a> | ||
| @@ -939,8 +849,6 @@ | |||||
| 939 | </td> | 849 | </td> | ||
| 940 | </tr> | 850 | </tr> | ||
| 941 | <tr id="c-SHA7"> | 851 | <tr id="c-SHA7"> | ||
| 942 | - | <td class="cur" aria-hidden="true"> | |||
| 943 | - | </td> | |||
| 944 | <td class="c-sha mute">SHA7</td> | 852 | <td class="c-sha mute">SHA7</td> | ||
| 945 | <td class="c-subject"> | 853 | <td class="c-subject"> | ||
| 946 | <a href="/long/commit/SHA40">commit number 6</a> | 854 | <a href="/long/commit/SHA40">commit number 6</a> | ||
| @@ -962,8 +870,6 @@ | |||||
| 962 | <table class="list commits" aria-label="Commits"> | 870 | <table class="list commits" aria-label="Commits"> | ||
| 963 | <tbody data-rows> | 871 | <tbody data-rows> | ||
| 964 | <tr id="c-SHA7"> | 872 | <tr id="c-SHA7"> | ||
| 965 | - | <td class="cur" aria-hidden="true"> | |||
| 966 | - | </td> | |||
| 967 | <td class="c-sha mute">SHA7</td> | 873 | <td class="c-sha mute">SHA7</td> | ||
| 968 | <td class="c-subject"> | 874 | <td class="c-subject"> | ||
| 969 | <a href="/long/commit/SHA40">commit number 5</a> | 875 | <a href="/long/commit/SHA40">commit number 5</a> | ||
| @@ -976,8 +882,6 @@ | |||||
| 976 | </td> | 882 | </td> | ||
| 977 | </tr> | 883 | </tr> | ||
| 978 | <tr id="c-SHA7"> | 884 | <tr id="c-SHA7"> | ||
| 979 | - | <td class="cur" aria-hidden="true"> | |||
| 980 | - | </td> | |||
| 981 | <td class="c-sha mute">SHA7</td> | 885 | <td class="c-sha mute">SHA7</td> | ||
| 982 | <td class="c-subject"> | 886 | <td class="c-subject"> | ||
| 983 | <a href="/long/commit/SHA40">commit number 4</a> | 887 | <a href="/long/commit/SHA40">commit number 4</a> | ||
| @@ -999,8 +903,6 @@ | |||||
| 999 | <table class="list commits" aria-label="Commits"> | 903 | <table class="list commits" aria-label="Commits"> | ||
| 1000 | <tbody data-rows> | 904 | <tbody data-rows> | ||
| 1001 | <tr id="c-SHA7"> | 905 | <tr id="c-SHA7"> | ||
| 1002 | - | <td class="cur" aria-hidden="true"> | |||
| 1003 | - | </td> | |||
| 1004 | <td class="c-sha mute">SHA7</td> | 906 | <td class="c-sha mute">SHA7</td> | ||
| 1005 | <td class="c-subject"> | 907 | <td class="c-subject"> | ||
| 1006 | <a href="/long/commit/SHA40">commit number 3</a> | 908 | <a href="/long/commit/SHA40">commit number 3</a> | ||
| @@ -1013,8 +915,6 @@ | |||||
| 1013 | </td> | 915 | </td> | ||
| 1014 | </tr> | 916 | </tr> | ||
| 1015 | <tr id="c-SHA7"> | 917 | <tr id="c-SHA7"> | ||
| 1016 | - | <td class="cur" aria-hidden="true"> | |||
| 1017 | - | </td> | |||
| 1018 | <td class="c-sha mute">SHA7</td> | 918 | <td class="c-sha mute">SHA7</td> | ||
| 1019 | <td class="c-subject"> | 919 | <td class="c-subject"> | ||
| 1020 | <a href="/long/commit/SHA40">commit number 2</a> | 920 | <a href="/long/commit/SHA40">commit number 2</a> | ||
M internal/app/testdata/web/commits-small.html +0 -16
| @@ -110,8 +110,6 @@ | |||
| 110 | 110 | <table class="list commits" aria-label="Commits"> | |
| 111 | 111 | <tbody data-rows> | |
| 112 | 112 | <tr id="c-SHA7" data-cursor> | |
| 113 | - | <td class="cur" aria-hidden="true"> | |
| 114 | - | </td> | |
| 115 | 113 | <td class="c-sha mute">SHA7</td> | |
| 116 | 114 | <td class="c-subject"> | |
| 117 | 115 | <a href="/diffs/commit/SHA40">add a very large file</a> | |
| @@ -134,8 +132,6 @@ | |||
| 134 | 132 | <table class="list commits" aria-label="Commits"> | |
| 135 | 133 | <tbody data-rows> | |
| 136 | 134 | <tr id="c-SHA7"> | |
| 137 | - | <td class="cur" aria-hidden="true"> | |
| 138 | - | </td> | |
| 139 | 135 | <td class="c-sha mute">SHA7</td> | |
| 140 | 136 | <td class="c-subject"> | |
| 141 | 137 | <a href="/diffs/commit/SHA40">add evil.go</a> | |
| @@ -157,8 +153,6 @@ | |||
| 157 | 153 | <table class="list commits" aria-label="Commits"> | |
| 158 | 154 | <tbody data-rows> | |
| 159 | 155 | <tr id="c-SHA7"> | |
| 160 | - | <td class="cur" aria-hidden="true"> | |
| 161 | - | </td> | |
| 162 | 156 | <td class="c-sha mute">SHA7</td> | |
| 163 | 157 | <td class="c-subject"> | |
| 164 | 158 | <a href="/diffs/commit/SHA40">merge the side branch</a> | |
| @@ -181,8 +175,6 @@ | |||
| 181 | 175 | <table class="list commits" aria-label="Commits"> | |
| 182 | 176 | <tbody data-rows> | |
| 183 | 177 | <tr id="c-SHA7"> | |
| 184 | - | <td class="cur" aria-hidden="true"> | |
| 185 | - | </td> | |
| 186 | 178 | <td class="c-sha mute">SHA7</td> | |
| 187 | 179 | <td class="c-subject"> | |
| 188 | 180 | <a href="/diffs/commit/SHA40">work on the side</a> | |
| @@ -205,8 +197,6 @@ | |||
| 205 | 197 | <table class="list commits" aria-label="Commits"> | |
| 206 | 198 | <tbody data-rows> | |
| 207 | 199 | <tr id="c-SHA7"> | |
| 208 | - | <td class="cur" aria-hidden="true"> | |
| 209 | - | </td> | |
| 210 | 200 | <td class="c-sha mute">SHA7</td> | |
| 211 | 201 | <td class="c-subject"> | |
| 212 | 202 | <a href="/diffs/commit/SHA40">change a binary file</a> | |
| @@ -228,8 +218,6 @@ | |||
| 228 | 218 | <table class="list commits" aria-label="Commits"> | |
| 229 | 219 | <tbody data-rows> | |
| 230 | 220 | <tr id="c-SHA7"> | |
| 231 | - | <td class="cur" aria-hidden="true"> | |
| 232 | - | </td> | |
| 233 | 221 | <td class="c-sha mute">SHA7</td> | |
| 234 | 222 | <td class="c-subject"> | |
| 235 | 223 | <a href="/diffs/commit/SHA40">rename a file and delete another</a> | |
| @@ -251,8 +239,6 @@ | |||
| 251 | 239 | <table class="list commits" aria-label="Commits"> | |
| 252 | 240 | <tbody data-rows> | |
| 253 | 241 | <tr id="c-SHA7"> | |
| 254 | - | <td class="cur" aria-hidden="true"> | |
| 255 | - | </td> | |
| 256 | 242 | <td class="c-sha mute">SHA7</td> | |
| 257 | 243 | <td class="c-subject"> | |
| 258 | 244 | <a href="/diffs/commit/SHA40">modify a file and make a script executable</a> | |
| @@ -276,8 +262,6 @@ | |||
| 276 | 262 | <table class="list commits" aria-label="Commits"> | |
| 277 | 263 | <tbody data-rows> | |
| 278 | 264 | <tr id="c-SHA7"> | |
| 279 | - | <td class="cur" aria-hidden="true"> | |
| 280 | - | </td> | |
| 281 | 265 | <td class="c-sha mute">SHA7</td> | |
| 282 | 266 | <td class="c-subject"> | |
| 283 | 267 | <a href="/diffs/commit/SHA40">root: the first files</a> | |
| @@ -110,8 +110,6 @@ | |||||
| 110 | <table class="list commits" aria-label="Commits"> | 110 | <table class="list commits" aria-label="Commits"> | ||
| 111 | <tbody data-rows> | 111 | <tbody data-rows> | ||
| 112 | <tr id="c-SHA7" data-cursor> | 112 | <tr id="c-SHA7" data-cursor> | ||
| 113 | - | <td class="cur" aria-hidden="true"> | |||
| 114 | - | </td> | |||
| 115 | <td class="c-sha mute">SHA7</td> | 113 | <td class="c-sha mute">SHA7</td> | ||
| 116 | <td class="c-subject"> | 114 | <td class="c-subject"> | ||
| 117 | <a href="/diffs/commit/SHA40">add a very large file</a> | 115 | <a href="/diffs/commit/SHA40">add a very large file</a> | ||
| @@ -134,8 +132,6 @@ | |||||
| 134 | <table class="list commits" aria-label="Commits"> | 132 | <table class="list commits" aria-label="Commits"> | ||
| 135 | <tbody data-rows> | 133 | <tbody data-rows> | ||
| 136 | <tr id="c-SHA7"> | 134 | <tr id="c-SHA7"> | ||
| 137 | - | <td class="cur" aria-hidden="true"> | |||
| 138 | - | </td> | |||
| 139 | <td class="c-sha mute">SHA7</td> | 135 | <td class="c-sha mute">SHA7</td> | ||
| 140 | <td class="c-subject"> | 136 | <td class="c-subject"> | ||
| 141 | <a href="/diffs/commit/SHA40">add evil.go</a> | 137 | <a href="/diffs/commit/SHA40">add evil.go</a> | ||
| @@ -157,8 +153,6 @@ | |||||
| 157 | <table class="list commits" aria-label="Commits"> | 153 | <table class="list commits" aria-label="Commits"> | ||
| 158 | <tbody data-rows> | 154 | <tbody data-rows> | ||
| 159 | <tr id="c-SHA7"> | 155 | <tr id="c-SHA7"> | ||
| 160 | - | <td class="cur" aria-hidden="true"> | |||
| 161 | - | </td> | |||
| 162 | <td class="c-sha mute">SHA7</td> | 156 | <td class="c-sha mute">SHA7</td> | ||
| 163 | <td class="c-subject"> | 157 | <td class="c-subject"> | ||
| 164 | <a href="/diffs/commit/SHA40">merge the side branch</a> | 158 | <a href="/diffs/commit/SHA40">merge the side branch</a> | ||
| @@ -181,8 +175,6 @@ | |||||
| 181 | <table class="list commits" aria-label="Commits"> | 175 | <table class="list commits" aria-label="Commits"> | ||
| 182 | <tbody data-rows> | 176 | <tbody data-rows> | ||
| 183 | <tr id="c-SHA7"> | 177 | <tr id="c-SHA7"> | ||
| 184 | - | <td class="cur" aria-hidden="true"> | |||
| 185 | - | </td> | |||
| 186 | <td class="c-sha mute">SHA7</td> | 178 | <td class="c-sha mute">SHA7</td> | ||
| 187 | <td class="c-subject"> | 179 | <td class="c-subject"> | ||
| 188 | <a href="/diffs/commit/SHA40">work on the side</a> | 180 | <a href="/diffs/commit/SHA40">work on the side</a> | ||
| @@ -205,8 +197,6 @@ | |||||
| 205 | <table class="list commits" aria-label="Commits"> | 197 | <table class="list commits" aria-label="Commits"> | ||
| 206 | <tbody data-rows> | 198 | <tbody data-rows> | ||
| 207 | <tr id="c-SHA7"> | 199 | <tr id="c-SHA7"> | ||
| 208 | - | <td class="cur" aria-hidden="true"> | |||
| 209 | - | </td> | |||
| 210 | <td class="c-sha mute">SHA7</td> | 200 | <td class="c-sha mute">SHA7</td> | ||
| 211 | <td class="c-subject"> | 201 | <td class="c-subject"> | ||
| 212 | <a href="/diffs/commit/SHA40">change a binary file</a> | 202 | <a href="/diffs/commit/SHA40">change a binary file</a> | ||
| @@ -228,8 +218,6 @@ | |||||
| 228 | <table class="list commits" aria-label="Commits"> | 218 | <table class="list commits" aria-label="Commits"> | ||
| 229 | <tbody data-rows> | 219 | <tbody data-rows> | ||
| 230 | <tr id="c-SHA7"> | 220 | <tr id="c-SHA7"> | ||
| 231 | - | <td class="cur" aria-hidden="true"> | |||
| 232 | - | </td> | |||
| 233 | <td class="c-sha mute">SHA7</td> | 221 | <td class="c-sha mute">SHA7</td> | ||
| 234 | <td class="c-subject"> | 222 | <td class="c-subject"> | ||
| 235 | <a href="/diffs/commit/SHA40">rename a file and delete another</a> | 223 | <a href="/diffs/commit/SHA40">rename a file and delete another</a> | ||
| @@ -251,8 +239,6 @@ | |||||
| 251 | <table class="list commits" aria-label="Commits"> | 239 | <table class="list commits" aria-label="Commits"> | ||
| 252 | <tbody data-rows> | 240 | <tbody data-rows> | ||
| 253 | <tr id="c-SHA7"> | 241 | <tr id="c-SHA7"> | ||
| 254 | - | <td class="cur" aria-hidden="true"> | |||
| 255 | - | </td> | |||
| 256 | <td class="c-sha mute">SHA7</td> | 242 | <td class="c-sha mute">SHA7</td> | ||
| 257 | <td class="c-subject"> | 243 | <td class="c-subject"> | ||
| 258 | <a href="/diffs/commit/SHA40">modify a file and make a script executable</a> | 244 | <a href="/diffs/commit/SHA40">modify a file and make a script executable</a> | ||
| @@ -276,8 +262,6 @@ | |||||
| 276 | <table class="list commits" aria-label="Commits"> | 262 | <table class="list commits" aria-label="Commits"> | ||
| 277 | <tbody data-rows> | 263 | <tbody data-rows> | ||
| 278 | <tr id="c-SHA7"> | 264 | <tr id="c-SHA7"> | ||
| 279 | - | <td class="cur" aria-hidden="true"> | |||
| 280 | - | </td> | |||
| 281 | <td class="c-sha mute">SHA7</td> | 265 | <td class="c-sha mute">SHA7</td> | ||
| 282 | <td class="c-subject"> | 266 | <td class="c-subject"> | ||
| 283 | <a href="/diffs/commit/SHA40">root: the first files</a> | 267 | <a href="/diffs/commit/SHA40">root: the first files</a> | ||
M internal/app/testdata/web/home.html +28 -13
| @@ -72,13 +72,28 @@ | |||
| 72 | 72 | <a href="/demo">demo</a> | |
| 73 | 73 | </h2> | |
| 74 | 74 | <p class="repo-desc">A demo repository</p> | |
| 75 | - | <p class="repo-meta"> | |
| 76 | - | <span class="repo-branch"> | |
| 77 | - | <span class="ico i-branch" aria-hidden="true"> | |
| 78 | - | </span>main</span> | |
| 79 | - | <span>pushed <time datetime="STAMP" title="STAMP">AGO</time> | |
| 80 | - | </span> | |
| 81 | - | </p> | |
| 75 | + | <dl class="repo-stats" aria-label="Figures of demo"> | |
| 76 | + | <div> | |
| 77 | + | <dt>commits</dt> | |
| 78 | + | <dd>2</dd> | |
| 79 | + | </div> | |
| 80 | + | <div> | |
| 81 | + | <dt>last 30 days</dt> | |
| 82 | + | <dd>0</dd> | |
| 83 | + | </div> | |
| 84 | + | <div> | |
| 85 | + | <dt>latest tag</dt> | |
| 86 | + | <dd> | |
| 87 | + | <a href="/demo/tree/SHA40" title="made AGO">v0.2.0</a> | |
| 88 | + | </dd> | |
| 89 | + | </div> | |
| 90 | + | <div> | |
| 91 | + | <dt>last activity</dt> | |
| 92 | + | <dd> | |
| 93 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 94 | + | </dd> | |
| 95 | + | </div> | |
| 96 | + | </dl> | |
| 82 | 97 | </div> | |
| 83 | 98 | <ul class="repo-commits" aria-label="Recent commits of demo"> | |
| 84 | 99 | <li> | |
| @@ -99,12 +114,12 @@ | |||
| 99 | 114 | <a href="/quiet">quiet</a> | |
| 100 | 115 | </h2> | |
| 101 | 116 | <p class="repo-desc mute">no description</p> | |
| 102 | - | <p class="repo-meta"> | |
| 103 | - | <span class="repo-branch"> | |
| 104 | - | <span class="ico i-branch" aria-hidden="true"> | |
| 105 | - | </span>main</span> | |
| 106 | - | <span>never pushed</span> | |
| 107 | - | </p> | |
| 117 | + | <dl class="repo-stats" aria-label="Figures of quiet"> | |
| 118 | + | <div> | |
| 119 | + | <dt>last activity</dt> | |
| 120 | + | <dd class="mute">never</dd> | |
| 121 | + | </div> | |
| 122 | + | </dl> | |
| 108 | 123 | </div> | |
| 109 | 124 | <p class="repo-commits mute">no commits yet</p> | |
| 110 | 125 | </li> | |
| @@ -72,13 +72,28 @@ | |||||
| 72 | <a href="/demo">demo</a> | 72 | <a href="/demo">demo</a> | ||
| 73 | </h2> | 73 | </h2> | ||
| 74 | <p class="repo-desc">A demo repository</p> | 74 | <p class="repo-desc">A demo repository</p> | ||
| 75 | - | <p class="repo-meta"> | 75 | + | <dl class="repo-stats" aria-label="Figures of demo"> |
| 76 | - | <span class="repo-branch"> | 76 | + | <div> |
| 77 | - | <span class="ico i-branch" aria-hidden="true"> | 77 | + | <dt>commits</dt> |
| 78 | - | </span>main</span> | 78 | + | <dd>2</dd> |
| 79 | - | <span>pushed <time datetime="STAMP" title="STAMP">AGO</time> | 79 | + | </div> |
| 80 | - | </span> | 80 | + | <div> |
| 81 | - | </p> | 81 | + | <dt>last 30 days</dt> |
| 82 | + | <dd>0</dd> | |||
| 83 | + | </div> | |||
| 84 | + | <div> | |||
| 85 | + | <dt>latest tag</dt> | |||
| 86 | + | <dd> | |||
| 87 | + | <a href="/demo/tree/SHA40" title="made AGO">v0.2.0</a> | |||
| 88 | + | </dd> | |||
| 89 | + | </div> | |||
| 90 | + | <div> | |||
| 91 | + | <dt>last activity</dt> | |||
| 92 | + | <dd> | |||
| 93 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 94 | + | </dd> | |||
| 95 | + | </div> | |||
| 96 | + | </dl> | |||
| 82 | </div> | 97 | </div> | ||
| 83 | <ul class="repo-commits" aria-label="Recent commits of demo"> | 98 | <ul class="repo-commits" aria-label="Recent commits of demo"> | ||
| 84 | <li> | 99 | <li> | ||
| @@ -99,12 +114,12 @@ | |||||
| 99 | <a href="/quiet">quiet</a> | 114 | <a href="/quiet">quiet</a> | ||
| 100 | </h2> | 115 | </h2> | ||
| 101 | <p class="repo-desc mute">no description</p> | 116 | <p class="repo-desc mute">no description</p> | ||
| 102 | - | <p class="repo-meta"> | 117 | + | <dl class="repo-stats" aria-label="Figures of quiet"> |
| 103 | - | <span class="repo-branch"> | 118 | + | <div> |
| 104 | - | <span class="ico i-branch" aria-hidden="true"> | 119 | + | <dt>last activity</dt> |
| 105 | - | </span>main</span> | 120 | + | <dd class="mute">never</dd> |
| 106 | - | <span>never pushed</span> | 121 | + | </div> |
| 107 | - | </p> | 122 | + | </dl> |
| 108 | </div> | 123 | </div> | ||
| 109 | <p class="repo-commits mute">no commits yet</p> | 124 | <p class="repo-commits mute">no commits yet</p> | ||
| 110 | </li> | 125 | </li> | ||
M internal/app/testdata/web/refs.html +0 -10
| @@ -100,8 +100,6 @@ | |||
| 100 | 100 | <table class="list refs" aria-label="Branches and tags"> | |
| 101 | 101 | <thead> | |
| 102 | 102 | <tr> | |
| 103 | - | <th class="cur"> | |
| 104 | - | </th> | |
| 105 | 103 | <th class="c-kind">kind</th> | |
| 106 | 104 | <th class="c-name">name</th> | |
| 107 | 105 | <th class="c-sha">commit</th> | |
| @@ -111,8 +109,6 @@ | |||
| 111 | 109 | </thead> | |
| 112 | 110 | <tbody id="ref-rows" data-rows> | |
| 113 | 111 | <tr data-cursor> | |
| 114 | - | <td class="cur" aria-hidden="true"> | |
| 115 | - | </td> | |
| 116 | 112 | <td class="c-kind mute">branch</td> | |
| 117 | 113 | <td class="c-name"> | |
| 118 | 114 | <a href="/demo/tree/main">main</a> | |
| @@ -127,8 +123,6 @@ | |||
| 127 | 123 | </td> | |
| 128 | 124 | </tr> | |
| 129 | 125 | <tr> | |
| 130 | - | <td class="cur" aria-hidden="true"> | |
| 131 | - | </td> | |
| 132 | 126 | <td class="c-kind mute">branch</td> | |
| 133 | 127 | <td class="c-name"> | |
| 134 | 128 | <a href="/demo/tree/feature/x">feature/x</a> | |
| @@ -142,8 +136,6 @@ | |||
| 142 | 136 | </td> | |
| 143 | 137 | </tr> | |
| 144 | 138 | <tr> | |
| 145 | - | <td class="cur" aria-hidden="true"> | |
| 146 | - | </td> | |
| 147 | 139 | <td class="c-kind mute">tag</td> | |
| 148 | 140 | <td class="c-name"> | |
| 149 | 141 | <a href="/demo/tree/v0.2.0">v0.2.0</a> | |
| @@ -157,8 +149,6 @@ | |||
| 157 | 149 | </td> | |
| 158 | 150 | </tr> | |
| 159 | 151 | <tr> | |
| 160 | - | <td class="cur" aria-hidden="true"> | |
| 161 | - | </td> | |
| 162 | 152 | <td class="c-kind mute">tag</td> | |
| 163 | 153 | <td class="c-name"> | |
| 164 | 154 | <a href="/demo/tree/v0.1.0">v0.1.0</a> | |
| @@ -100,8 +100,6 @@ | |||||
| 100 | <table class="list refs" aria-label="Branches and tags"> | 100 | <table class="list refs" aria-label="Branches and tags"> | ||
| 101 | <thead> | 101 | <thead> | ||
| 102 | <tr> | 102 | <tr> | ||
| 103 | - | <th class="cur"> | |||
| 104 | - | </th> | |||
| 105 | <th class="c-kind">kind</th> | 103 | <th class="c-kind">kind</th> | ||
| 106 | <th class="c-name">name</th> | 104 | <th class="c-name">name</th> | ||
| 107 | <th class="c-sha">commit</th> | 105 | <th class="c-sha">commit</th> | ||
| @@ -111,8 +109,6 @@ | |||||
| 111 | </thead> | 109 | </thead> | ||
| 112 | <tbody id="ref-rows" data-rows> | 110 | <tbody id="ref-rows" data-rows> | ||
| 113 | <tr data-cursor> | 111 | <tr data-cursor> | ||
| 114 | - | <td class="cur" aria-hidden="true"> | |||
| 115 | - | </td> | |||
| 116 | <td class="c-kind mute">branch</td> | 112 | <td class="c-kind mute">branch</td> | ||
| 117 | <td class="c-name"> | 113 | <td class="c-name"> | ||
| 118 | <a href="/demo/tree/main">main</a> | 114 | <a href="/demo/tree/main">main</a> | ||
| @@ -127,8 +123,6 @@ | |||||
| 127 | </td> | 123 | </td> | ||
| 128 | </tr> | 124 | </tr> | ||
| 129 | <tr> | 125 | <tr> | ||
| 130 | - | <td class="cur" aria-hidden="true"> | |||
| 131 | - | </td> | |||
| 132 | <td class="c-kind mute">branch</td> | 126 | <td class="c-kind mute">branch</td> | ||
| 133 | <td class="c-name"> | 127 | <td class="c-name"> | ||
| 134 | <a href="/demo/tree/feature/x">feature/x</a> | 128 | <a href="/demo/tree/feature/x">feature/x</a> | ||
| @@ -142,8 +136,6 @@ | |||||
| 142 | </td> | 136 | </td> | ||
| 143 | </tr> | 137 | </tr> | ||
| 144 | <tr> | 138 | <tr> | ||
| 145 | - | <td class="cur" aria-hidden="true"> | |||
| 146 | - | </td> | |||
| 147 | <td class="c-kind mute">tag</td> | 139 | <td class="c-kind mute">tag</td> | ||
| 148 | <td class="c-name"> | 140 | <td class="c-name"> | ||
| 149 | <a href="/demo/tree/v0.2.0">v0.2.0</a> | 141 | <a href="/demo/tree/v0.2.0">v0.2.0</a> | ||
| @@ -157,8 +149,6 @@ | |||||
| 157 | </td> | 149 | </td> | ||
| 158 | </tr> | 150 | </tr> | ||
| 159 | <tr> | 151 | <tr> | ||
| 160 | - | <td class="cur" aria-hidden="true"> | |||
| 161 | - | </td> | |||
| 162 | <td class="c-kind mute">tag</td> | 152 | <td class="c-kind mute">tag</td> | ||
| 163 | <td class="c-name"> | 153 | <td class="c-name"> | ||
| 164 | <a href="/demo/tree/v0.1.0">v0.1.0</a> | 154 | <a href="/demo/tree/v0.1.0">v0.1.0</a> | ||
M internal/app/testdata/web/summary.html +0 -4
| @@ -159,8 +159,6 @@ | |||
| 159 | 159 | <table class="list commits" aria-label="Commits"> | |
| 160 | 160 | <tbody data-rows> | |
| 161 | 161 | <tr data-cursor> | |
| 162 | - | <td class="cur" aria-hidden="true"> | |
| 163 | - | </td> | |
| 164 | 162 | <td class="c-sha mute">SHA7</td> | |
| 165 | 163 | <td class="c-subject"> | |
| 166 | 164 | <a href="/demo/commit/SHA40">add main.go</a> | |
| @@ -176,8 +174,6 @@ | |||
| 176 | 174 | </td> | |
| 177 | 175 | </tr> | |
| 178 | 176 | <tr> | |
| 179 | - | <td class="cur" aria-hidden="true"> | |
| 180 | - | </td> | |
| 181 | 177 | <td class="c-sha mute">SHA7</td> | |
| 182 | 178 | <td class="c-subject"> | |
| 183 | 179 | <a href="/demo/commit/SHA40">first commit</a> | |
| @@ -159,8 +159,6 @@ | |||||
| 159 | <table class="list commits" aria-label="Commits"> | 159 | <table class="list commits" aria-label="Commits"> | ||
| 160 | <tbody data-rows> | 160 | <tbody data-rows> | ||
| 161 | <tr data-cursor> | 161 | <tr data-cursor> | ||
| 162 | - | <td class="cur" aria-hidden="true"> | |||
| 163 | - | </td> | |||
| 164 | <td class="c-sha mute">SHA7</td> | 162 | <td class="c-sha mute">SHA7</td> | ||
| 165 | <td class="c-subject"> | 163 | <td class="c-subject"> | ||
| 166 | <a href="/demo/commit/SHA40">add main.go</a> | 164 | <a href="/demo/commit/SHA40">add main.go</a> | ||
| @@ -176,8 +174,6 @@ | |||||
| 176 | </td> | 174 | </td> | ||
| 177 | </tr> | 175 | </tr> | ||
| 178 | <tr> | 176 | <tr> | ||
| 179 | - | <td class="cur" aria-hidden="true"> | |||
| 180 | - | </td> | |||
| 181 | <td class="c-sha mute">SHA7</td> | 177 | <td class="c-sha mute">SHA7</td> | ||
| 182 | <td class="c-subject"> | 178 | <td class="c-subject"> | ||
| 183 | <a href="/demo/commit/SHA40">first commit</a> | 179 | <a href="/demo/commit/SHA40">first commit</a> | ||
M internal/domain/git.go +10 -0
| @@ -253,6 +253,16 @@ type Ref struct { | |||
| 253 | 253 | CommitID ObjectID | |
| 254 | 254 | } | |
| 255 | 255 | ||
| 256 | + | // TagInfo is a tag and when it was made: the tagger date of an annotated | |
| 257 | + | // tag, the committer date of the commit of a lightweight one. | |
| 258 | + | type TagInfo struct { | |
| 259 | + | Name string // short name, without refs/tags/ | |
| 260 | + | At time.Time | |
| 261 | + | // CommitID is the commit the tag leads to; zero for a tag of a tree or | |
| 262 | + | // a blob. | |
| 263 | + | CommitID ObjectID | |
| 264 | + | } | |
| 265 | + | ||
| 256 | 266 | // ResolvedRevision is the result of resolving a Revision. | |
| 257 | 267 | type ResolvedRevision struct { | |
| 258 | 268 | // CommitID is the commit the revision designates, tags followed. | |
| @@ -253,6 +253,16 @@ type Ref struct { | |||||
| 253 | CommitID ObjectID | 253 | CommitID ObjectID | ||
| 254 | } | 254 | } | ||
| 255 | 255 | ||||
| 256 | + | // TagInfo is a tag and when it was made: the tagger date of an annotated | |||
| 257 | + | // tag, the committer date of the commit of a lightweight one. | |||
| 258 | + | type TagInfo struct { | |||
| 259 | + | Name string // short name, without refs/tags/ | |||
| 260 | + | At time.Time | |||
| 261 | + | // CommitID is the commit the tag leads to; zero for a tag of a tree or | |||
| 262 | + | // a blob. | |||
| 263 | + | CommitID ObjectID | |||
| 264 | + | } | |||
| 265 | + | ||||
| 256 | // ResolvedRevision is the result of resolving a Revision. | 266 | // ResolvedRevision is the result of resolving a Revision. | ||
| 257 | type ResolvedRevision struct { | 267 | type ResolvedRevision struct { | ||
| 258 | // CommitID is the commit the revision designates, tags followed. | 268 | // CommitID is the commit the revision designates, tags followed. | ||
M internal/ports/dto.go +12 -0
| @@ -114,6 +114,18 @@ type HistoryPage struct { | |||
| 114 | 114 | NextPageToken string | |
| 115 | 115 | } | |
| 116 | 116 | ||
| 117 | + | // RepoStats are the figures of a repository the home page shows. | |
| 118 | + | type RepoStats struct { | |
| 119 | + | // Commits is the number of commits of the default branch; Recent of them | |
| 120 | + | // were committed in the last RecentDays days. | |
| 121 | + | Commits, Recent int | |
| 122 | + | // LatestTag is the most recently created tag; nil without tags. | |
| 123 | + | LatestTag *domain.TagInfo | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // RecentDays is the window of RepoStats.Recent. | |
| 127 | + | const RecentDays = 30 | |
| 128 | + | ||
| 117 | 129 | // WatchParams select the events of a watch. | |
| 118 | 130 | type WatchParams struct { | |
| 119 | 131 | // Topics are TopicHome or "repo:<name>"; empty means TopicHome. | |
| @@ -114,6 +114,18 @@ type HistoryPage struct { | |||||
| 114 | NextPageToken string | 114 | NextPageToken string | ||
| 115 | } | 115 | } | ||
| 116 | 116 | ||||
| 117 | + | // RepoStats are the figures of a repository the home page shows. | |||
| 118 | + | type RepoStats struct { | |||
| 119 | + | // Commits is the number of commits of the default branch; Recent of them | |||
| 120 | + | // were committed in the last RecentDays days. | |||
| 121 | + | Commits, Recent int | |||
| 122 | + | // LatestTag is the most recently created tag; nil without tags. | |||
| 123 | + | LatestTag *domain.TagInfo | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // RecentDays is the window of RepoStats.Recent. | |||
| 127 | + | const RecentDays = 30 | |||
| 128 | + | ||||
| 117 | // WatchParams select the events of a watch. | 129 | // WatchParams select the events of a watch. | ||
| 118 | type WatchParams struct { | 130 | type WatchParams struct { | ||
| 119 | // Topics are TopicHome or "repo:<name>"; empty means TopicHome. | 131 | // Topics are TopicHome or "repo:<name>"; empty means TopicHome. | ||
M internal/ports/mocks/BrowseService.go +85 -0
| @@ -7,6 +7,7 @@ package mocks | |||
| 7 | 7 | import ( | |
| 8 | 8 | "context" | |
| 9 | 9 | "io" | |
| 10 | + | "time" | |
| 10 | 11 | ||
| 11 | 12 | "bnema.dev/ermite/internal/domain" | |
| 12 | 13 | "bnema.dev/ermite/internal/ports" | |
| @@ -543,6 +544,90 @@ func (_c *BrowseService_OpenBlob_Call) RunAndReturn(run func(ctx context.Context | |||
| 543 | 544 | return _c | |
| 544 | 545 | } | |
| 545 | 546 | ||
| 547 | + | // RepoStats provides a mock function for the type BrowseService | |
| 548 | + | func (_mock *BrowseService) RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (ports.RepoStats, error) { | |
| 549 | + | ret := _mock.Called(ctx, p, repo, head, now) | |
| 550 | + | ||
| 551 | + | if len(ret) == 0 { | |
| 552 | + | panic("no return value specified for RepoStats") | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | var r0 ports.RepoStats | |
| 556 | + | var r1 error | |
| 557 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, time.Time) (ports.RepoStats, error)); ok { | |
| 558 | + | return returnFunc(ctx, p, repo, head, now) | |
| 559 | + | } | |
| 560 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, time.Time) ports.RepoStats); ok { | |
| 561 | + | r0 = returnFunc(ctx, p, repo, head, now) | |
| 562 | + | } else { | |
| 563 | + | r0 = ret.Get(0).(ports.RepoStats) | |
| 564 | + | } | |
| 565 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, time.Time) error); ok { | |
| 566 | + | r1 = returnFunc(ctx, p, repo, head, now) | |
| 567 | + | } else { | |
| 568 | + | r1 = ret.Error(1) | |
| 569 | + | } | |
| 570 | + | return r0, r1 | |
| 571 | + | } | |
| 572 | + | ||
| 573 | + | // BrowseService_RepoStats_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RepoStats' | |
| 574 | + | type BrowseService_RepoStats_Call struct { | |
| 575 | + | *mock.Call | |
| 576 | + | } | |
| 577 | + | ||
| 578 | + | // RepoStats is a helper method to define mock.On call | |
| 579 | + | // - ctx context.Context | |
| 580 | + | // - p domain.Principal | |
| 581 | + | // - repo domain.RepoName | |
| 582 | + | // - head domain.ObjectID | |
| 583 | + | // - now time.Time | |
| 584 | + | func (_e *BrowseService_Expecter) RepoStats(ctx any, p any, repo any, head any, now any) *BrowseService_RepoStats_Call { | |
| 585 | + | return &BrowseService_RepoStats_Call{Call: _e.mock.On("RepoStats", ctx, p, repo, head, now)} | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | func (_c *BrowseService_RepoStats_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time)) *BrowseService_RepoStats_Call { | |
| 589 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 590 | + | var arg0 context.Context | |
| 591 | + | if args[0] != nil { | |
| 592 | + | arg0 = args[0].(context.Context) | |
| 593 | + | } | |
| 594 | + | var arg1 domain.Principal | |
| 595 | + | if args[1] != nil { | |
| 596 | + | arg1 = args[1].(domain.Principal) | |
| 597 | + | } | |
| 598 | + | var arg2 domain.RepoName | |
| 599 | + | if args[2] != nil { | |
| 600 | + | arg2 = args[2].(domain.RepoName) | |
| 601 | + | } | |
| 602 | + | var arg3 domain.ObjectID | |
| 603 | + | if args[3] != nil { | |
| 604 | + | arg3 = args[3].(domain.ObjectID) | |
| 605 | + | } | |
| 606 | + | var arg4 time.Time | |
| 607 | + | if args[4] != nil { | |
| 608 | + | arg4 = args[4].(time.Time) | |
| 609 | + | } | |
| 610 | + | run( | |
| 611 | + | arg0, | |
| 612 | + | arg1, | |
| 613 | + | arg2, | |
| 614 | + | arg3, | |
| 615 | + | arg4, | |
| 616 | + | ) | |
| 617 | + | }) | |
| 618 | + | return _c | |
| 619 | + | } | |
| 620 | + | ||
| 621 | + | func (_c *BrowseService_RepoStats_Call) Return(repoStats ports.RepoStats, err error) *BrowseService_RepoStats_Call { | |
| 622 | + | _c.Call.Return(repoStats, err) | |
| 623 | + | return _c | |
| 624 | + | } | |
| 625 | + | ||
| 626 | + | func (_c *BrowseService_RepoStats_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (ports.RepoStats, error)) *BrowseService_RepoStats_Call { | |
| 627 | + | _c.Call.Return(run) | |
| 628 | + | return _c | |
| 629 | + | } | |
| 630 | + | ||
| 546 | 631 | // ResolveRevision provides a mock function for the type BrowseService | |
| 547 | 632 | func (_mock *BrowseService) ResolveRevision(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) { | |
| 548 | 633 | ret := _mock.Called(ctx, p, repo, rev) | |
| @@ -7,6 +7,7 @@ package mocks | |||||
| 7 | import ( | 7 | import ( | ||
| 8 | "context" | 8 | "context" | ||
| 9 | "io" | 9 | "io" | ||
| 10 | + | "time" | |||
| 10 | 11 | ||||
| 11 | "bnema.dev/ermite/internal/domain" | 12 | "bnema.dev/ermite/internal/domain" | ||
| 12 | "bnema.dev/ermite/internal/ports" | 13 | "bnema.dev/ermite/internal/ports" | ||
| @@ -543,6 +544,90 @@ func (_c *BrowseService_OpenBlob_Call) RunAndReturn(run func(ctx context.Context | |||||
| 543 | return _c | 544 | return _c | ||
| 544 | } | 545 | } | ||
| 545 | 546 | ||||
| 547 | + | // RepoStats provides a mock function for the type BrowseService | |||
| 548 | + | func (_mock *BrowseService) RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (ports.RepoStats, error) { | |||
| 549 | + | ret := _mock.Called(ctx, p, repo, head, now) | |||
| 550 | + | ||||
| 551 | + | if len(ret) == 0 { | |||
| 552 | + | panic("no return value specified for RepoStats") | |||
| 553 | + | } | |||
| 554 | + | ||||
| 555 | + | var r0 ports.RepoStats | |||
| 556 | + | var r1 error | |||
| 557 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, time.Time) (ports.RepoStats, error)); ok { | |||
| 558 | + | return returnFunc(ctx, p, repo, head, now) | |||
| 559 | + | } | |||
| 560 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, time.Time) ports.RepoStats); ok { | |||
| 561 | + | r0 = returnFunc(ctx, p, repo, head, now) | |||
| 562 | + | } else { | |||
| 563 | + | r0 = ret.Get(0).(ports.RepoStats) | |||
| 564 | + | } | |||
| 565 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, time.Time) error); ok { | |||
| 566 | + | r1 = returnFunc(ctx, p, repo, head, now) | |||
| 567 | + | } else { | |||
| 568 | + | r1 = ret.Error(1) | |||
| 569 | + | } | |||
| 570 | + | return r0, r1 | |||
| 571 | + | } | |||
| 572 | + | ||||
| 573 | + | // BrowseService_RepoStats_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RepoStats' | |||
| 574 | + | type BrowseService_RepoStats_Call struct { | |||
| 575 | + | *mock.Call | |||
| 576 | + | } | |||
| 577 | + | ||||
| 578 | + | // RepoStats is a helper method to define mock.On call | |||
| 579 | + | // - ctx context.Context | |||
| 580 | + | // - p domain.Principal | |||
| 581 | + | // - repo domain.RepoName | |||
| 582 | + | // - head domain.ObjectID | |||
| 583 | + | // - now time.Time | |||
| 584 | + | func (_e *BrowseService_Expecter) RepoStats(ctx any, p any, repo any, head any, now any) *BrowseService_RepoStats_Call { | |||
| 585 | + | return &BrowseService_RepoStats_Call{Call: _e.mock.On("RepoStats", ctx, p, repo, head, now)} | |||
| 586 | + | } | |||
| 587 | + | ||||
| 588 | + | func (_c *BrowseService_RepoStats_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time)) *BrowseService_RepoStats_Call { | |||
| 589 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 590 | + | var arg0 context.Context | |||
| 591 | + | if args[0] != nil { | |||
| 592 | + | arg0 = args[0].(context.Context) | |||
| 593 | + | } | |||
| 594 | + | var arg1 domain.Principal | |||
| 595 | + | if args[1] != nil { | |||
| 596 | + | arg1 = args[1].(domain.Principal) | |||
| 597 | + | } | |||
| 598 | + | var arg2 domain.RepoName | |||
| 599 | + | if args[2] != nil { | |||
| 600 | + | arg2 = args[2].(domain.RepoName) | |||
| 601 | + | } | |||
| 602 | + | var arg3 domain.ObjectID | |||
| 603 | + | if args[3] != nil { | |||
| 604 | + | arg3 = args[3].(domain.ObjectID) | |||
| 605 | + | } | |||
| 606 | + | var arg4 time.Time | |||
| 607 | + | if args[4] != nil { | |||
| 608 | + | arg4 = args[4].(time.Time) | |||
| 609 | + | } | |||
| 610 | + | run( | |||
| 611 | + | arg0, | |||
| 612 | + | arg1, | |||
| 613 | + | arg2, | |||
| 614 | + | arg3, | |||
| 615 | + | arg4, | |||
| 616 | + | ) | |||
| 617 | + | }) | |||
| 618 | + | return _c | |||
| 619 | + | } | |||
| 620 | + | ||||
| 621 | + | func (_c *BrowseService_RepoStats_Call) Return(repoStats ports.RepoStats, err error) *BrowseService_RepoStats_Call { | |||
| 622 | + | _c.Call.Return(repoStats, err) | |||
| 623 | + | return _c | |||
| 624 | + | } | |||
| 625 | + | ||||
| 626 | + | func (_c *BrowseService_RepoStats_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (ports.RepoStats, error)) *BrowseService_RepoStats_Call { | |||
| 627 | + | _c.Call.Return(run) | |||
| 628 | + | return _c | |||
| 629 | + | } | |||
| 630 | + | ||||
| 546 | // ResolveRevision provides a mock function for the type BrowseService | 631 | // ResolveRevision provides a mock function for the type BrowseService | ||
| 547 | func (_mock *BrowseService) ResolveRevision(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) { | 632 | func (_mock *BrowseService) ResolveRevision(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) { | ||
| 548 | ret := _mock.Called(ctx, p, repo, rev) | 633 | ret := _mock.Called(ctx, p, repo, rev) | ||
M internal/ports/mocks/RepoReader.go +151 -0
| @@ -7,6 +7,7 @@ package mocks | |||
| 7 | 7 | import ( | |
| 8 | 8 | "context" | |
| 9 | 9 | "io" | |
| 10 | + | "time" | |
| 10 | 11 | ||
| 11 | 12 | "bnema.dev/ermite/internal/domain" | |
| 12 | 13 | "bnema.dev/ermite/internal/ports" | |
| @@ -49,6 +50,84 @@ func (_m *RepoReader) EXPECT() *RepoReader_Expecter { | |||
| 49 | 50 | return &RepoReader_Expecter{mock: &_m.Mock} | |
| 50 | 51 | } | |
| 51 | 52 | ||
| 53 | + | // CountCommits provides a mock function for the type RepoReader | |
| 54 | + | func (_mock *RepoReader) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error) { | |
| 55 | + | ret := _mock.Called(ctx, repo, head, since) | |
| 56 | + | ||
| 57 | + | if len(ret) == 0 { | |
| 58 | + | panic("no return value specified for CountCommits") | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | var r0 int | |
| 62 | + | var r1 error | |
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) (int, error)); ok { | |
| 64 | + | return returnFunc(ctx, repo, head, since) | |
| 65 | + | } | |
| 66 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) int); ok { | |
| 67 | + | r0 = returnFunc(ctx, repo, head, since) | |
| 68 | + | } else { | |
| 69 | + | r0 = ret.Get(0).(int) | |
| 70 | + | } | |
| 71 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) error); ok { | |
| 72 | + | r1 = returnFunc(ctx, repo, head, since) | |
| 73 | + | } else { | |
| 74 | + | r1 = ret.Error(1) | |
| 75 | + | } | |
| 76 | + | return r0, r1 | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // RepoReader_CountCommits_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CountCommits' | |
| 80 | + | type RepoReader_CountCommits_Call struct { | |
| 81 | + | *mock.Call | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // CountCommits is a helper method to define mock.On call | |
| 85 | + | // - ctx context.Context | |
| 86 | + | // - repo domain.RepoName | |
| 87 | + | // - head domain.ObjectID | |
| 88 | + | // - since time.Time | |
| 89 | + | func (_e *RepoReader_Expecter) CountCommits(ctx any, repo any, head any, since any) *RepoReader_CountCommits_Call { | |
| 90 | + | return &RepoReader_CountCommits_Call{Call: _e.mock.On("CountCommits", ctx, repo, head, since)} | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func (_c *RepoReader_CountCommits_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time)) *RepoReader_CountCommits_Call { | |
| 94 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 95 | + | var arg0 context.Context | |
| 96 | + | if args[0] != nil { | |
| 97 | + | arg0 = args[0].(context.Context) | |
| 98 | + | } | |
| 99 | + | var arg1 domain.RepoName | |
| 100 | + | if args[1] != nil { | |
| 101 | + | arg1 = args[1].(domain.RepoName) | |
| 102 | + | } | |
| 103 | + | var arg2 domain.ObjectID | |
| 104 | + | if args[2] != nil { | |
| 105 | + | arg2 = args[2].(domain.ObjectID) | |
| 106 | + | } | |
| 107 | + | var arg3 time.Time | |
| 108 | + | if args[3] != nil { | |
| 109 | + | arg3 = args[3].(time.Time) | |
| 110 | + | } | |
| 111 | + | run( | |
| 112 | + | arg0, | |
| 113 | + | arg1, | |
| 114 | + | arg2, | |
| 115 | + | arg3, | |
| 116 | + | ) | |
| 117 | + | }) | |
| 118 | + | return _c | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func (_c *RepoReader_CountCommits_Call) Return(n int, err error) *RepoReader_CountCommits_Call { | |
| 122 | + | _c.Call.Return(n, err) | |
| 123 | + | return _c | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func (_c *RepoReader_CountCommits_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error)) *RepoReader_CountCommits_Call { | |
| 127 | + | _c.Call.Return(run) | |
| 128 | + | return _c | |
| 129 | + | } | |
| 130 | + | ||
| 52 | 131 | // GetCommit provides a mock function for the type RepoReader | |
| 53 | 132 | func (_mock *RepoReader) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) { | |
| 54 | 133 | ret := _mock.Called(ctx, repo, rev, limits) | |
| @@ -211,6 +290,78 @@ func (_c *RepoReader_GetTree_Call) RunAndReturn(run func(ctx context.Context, re | |||
| 211 | 290 | return _c | |
| 212 | 291 | } | |
| 213 | 292 | ||
| 293 | + | // LatestTag provides a mock function for the type RepoReader | |
| 294 | + | func (_mock *RepoReader) LatestTag(ctx context.Context, repo domain.RepoName) (domain.TagInfo, bool, error) { | |
| 295 | + | ret := _mock.Called(ctx, repo) | |
| 296 | + | ||
| 297 | + | if len(ret) == 0 { | |
| 298 | + | panic("no return value specified for LatestTag") | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | var r0 domain.TagInfo | |
| 302 | + | var r1 bool | |
| 303 | + | var r2 error | |
| 304 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) (domain.TagInfo, bool, error)); ok { | |
| 305 | + | return returnFunc(ctx, repo) | |
| 306 | + | } | |
| 307 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) domain.TagInfo); ok { | |
| 308 | + | r0 = returnFunc(ctx, repo) | |
| 309 | + | } else { | |
| 310 | + | r0 = ret.Get(0).(domain.TagInfo) | |
| 311 | + | } | |
| 312 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) bool); ok { | |
| 313 | + | r1 = returnFunc(ctx, repo) | |
| 314 | + | } else { | |
| 315 | + | r1 = ret.Get(1).(bool) | |
| 316 | + | } | |
| 317 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName) error); ok { | |
| 318 | + | r2 = returnFunc(ctx, repo) | |
| 319 | + | } else { | |
| 320 | + | r2 = ret.Error(2) | |
| 321 | + | } | |
| 322 | + | return r0, r1, r2 | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | // RepoReader_LatestTag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LatestTag' | |
| 326 | + | type RepoReader_LatestTag_Call struct { | |
| 327 | + | *mock.Call | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | // LatestTag is a helper method to define mock.On call | |
| 331 | + | // - ctx context.Context | |
| 332 | + | // - repo domain.RepoName | |
| 333 | + | func (_e *RepoReader_Expecter) LatestTag(ctx any, repo any) *RepoReader_LatestTag_Call { | |
| 334 | + | return &RepoReader_LatestTag_Call{Call: _e.mock.On("LatestTag", ctx, repo)} | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | func (_c *RepoReader_LatestTag_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *RepoReader_LatestTag_Call { | |
| 338 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 339 | + | var arg0 context.Context | |
| 340 | + | if args[0] != nil { | |
| 341 | + | arg0 = args[0].(context.Context) | |
| 342 | + | } | |
| 343 | + | var arg1 domain.RepoName | |
| 344 | + | if args[1] != nil { | |
| 345 | + | arg1 = args[1].(domain.RepoName) | |
| 346 | + | } | |
| 347 | + | run( | |
| 348 | + | arg0, | |
| 349 | + | arg1, | |
| 350 | + | ) | |
| 351 | + | }) | |
| 352 | + | return _c | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | func (_c *RepoReader_LatestTag_Call) Return(tag domain.TagInfo, ok bool, err error) *RepoReader_LatestTag_Call { | |
| 356 | + | _c.Call.Return(tag, ok, err) | |
| 357 | + | return _c | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | func (_c *RepoReader_LatestTag_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) (domain.TagInfo, bool, error)) *RepoReader_LatestTag_Call { | |
| 361 | + | _c.Call.Return(run) | |
| 362 | + | return _c | |
| 363 | + | } | |
| 364 | + | ||
| 214 | 365 | // ListCommits provides a mock function for the type RepoReader | |
| 215 | 366 | func (_mock *RepoReader) ListCommits(ctx context.Context, repo domain.RepoName, q ports.CommitQuery) (ports.CommitPage, error) { | |
| 216 | 367 | ret := _mock.Called(ctx, repo, q) | |
| @@ -7,6 +7,7 @@ package mocks | |||||
| 7 | import ( | 7 | import ( | ||
| 8 | "context" | 8 | "context" | ||
| 9 | "io" | 9 | "io" | ||
| 10 | + | "time" | |||
| 10 | 11 | ||||
| 11 | "bnema.dev/ermite/internal/domain" | 12 | "bnema.dev/ermite/internal/domain" | ||
| 12 | "bnema.dev/ermite/internal/ports" | 13 | "bnema.dev/ermite/internal/ports" | ||
| @@ -49,6 +50,84 @@ func (_m *RepoReader) EXPECT() *RepoReader_Expecter { | |||||
| 49 | return &RepoReader_Expecter{mock: &_m.Mock} | 50 | return &RepoReader_Expecter{mock: &_m.Mock} | ||
| 50 | } | 51 | } | ||
| 51 | 52 | ||||
| 53 | + | // CountCommits provides a mock function for the type RepoReader | |||
| 54 | + | func (_mock *RepoReader) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error) { | |||
| 55 | + | ret := _mock.Called(ctx, repo, head, since) | |||
| 56 | + | ||||
| 57 | + | if len(ret) == 0 { | |||
| 58 | + | panic("no return value specified for CountCommits") | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | var r0 int | |||
| 62 | + | var r1 error | |||
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) (int, error)); ok { | |||
| 64 | + | return returnFunc(ctx, repo, head, since) | |||
| 65 | + | } | |||
| 66 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) int); ok { | |||
| 67 | + | r0 = returnFunc(ctx, repo, head, since) | |||
| 68 | + | } else { | |||
| 69 | + | r0 = ret.Get(0).(int) | |||
| 70 | + | } | |||
| 71 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) error); ok { | |||
| 72 | + | r1 = returnFunc(ctx, repo, head, since) | |||
| 73 | + | } else { | |||
| 74 | + | r1 = ret.Error(1) | |||
| 75 | + | } | |||
| 76 | + | return r0, r1 | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // RepoReader_CountCommits_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CountCommits' | |||
| 80 | + | type RepoReader_CountCommits_Call struct { | |||
| 81 | + | *mock.Call | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // CountCommits is a helper method to define mock.On call | |||
| 85 | + | // - ctx context.Context | |||
| 86 | + | // - repo domain.RepoName | |||
| 87 | + | // - head domain.ObjectID | |||
| 88 | + | // - since time.Time | |||
| 89 | + | func (_e *RepoReader_Expecter) CountCommits(ctx any, repo any, head any, since any) *RepoReader_CountCommits_Call { | |||
| 90 | + | return &RepoReader_CountCommits_Call{Call: _e.mock.On("CountCommits", ctx, repo, head, since)} | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func (_c *RepoReader_CountCommits_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time)) *RepoReader_CountCommits_Call { | |||
| 94 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 95 | + | var arg0 context.Context | |||
| 96 | + | if args[0] != nil { | |||
| 97 | + | arg0 = args[0].(context.Context) | |||
| 98 | + | } | |||
| 99 | + | var arg1 domain.RepoName | |||
| 100 | + | if args[1] != nil { | |||
| 101 | + | arg1 = args[1].(domain.RepoName) | |||
| 102 | + | } | |||
| 103 | + | var arg2 domain.ObjectID | |||
| 104 | + | if args[2] != nil { | |||
| 105 | + | arg2 = args[2].(domain.ObjectID) | |||
| 106 | + | } | |||
| 107 | + | var arg3 time.Time | |||
| 108 | + | if args[3] != nil { | |||
| 109 | + | arg3 = args[3].(time.Time) | |||
| 110 | + | } | |||
| 111 | + | run( | |||
| 112 | + | arg0, | |||
| 113 | + | arg1, | |||
| 114 | + | arg2, | |||
| 115 | + | arg3, | |||
| 116 | + | ) | |||
| 117 | + | }) | |||
| 118 | + | return _c | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func (_c *RepoReader_CountCommits_Call) Return(n int, err error) *RepoReader_CountCommits_Call { | |||
| 122 | + | _c.Call.Return(n, err) | |||
| 123 | + | return _c | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func (_c *RepoReader_CountCommits_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error)) *RepoReader_CountCommits_Call { | |||
| 127 | + | _c.Call.Return(run) | |||
| 128 | + | return _c | |||
| 129 | + | } | |||
| 130 | + | ||||
| 52 | // GetCommit provides a mock function for the type RepoReader | 131 | // GetCommit provides a mock function for the type RepoReader | ||
| 53 | func (_mock *RepoReader) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) { | 132 | func (_mock *RepoReader) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) { | ||
| 54 | ret := _mock.Called(ctx, repo, rev, limits) | 133 | ret := _mock.Called(ctx, repo, rev, limits) | ||
| @@ -211,6 +290,78 @@ func (_c *RepoReader_GetTree_Call) RunAndReturn(run func(ctx context.Context, re | |||||
| 211 | return _c | 290 | return _c | ||
| 212 | } | 291 | } | ||
| 213 | 292 | ||||
| 293 | + | // LatestTag provides a mock function for the type RepoReader | |||
| 294 | + | func (_mock *RepoReader) LatestTag(ctx context.Context, repo domain.RepoName) (domain.TagInfo, bool, error) { | |||
| 295 | + | ret := _mock.Called(ctx, repo) | |||
| 296 | + | ||||
| 297 | + | if len(ret) == 0 { | |||
| 298 | + | panic("no return value specified for LatestTag") | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | var r0 domain.TagInfo | |||
| 302 | + | var r1 bool | |||
| 303 | + | var r2 error | |||
| 304 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) (domain.TagInfo, bool, error)); ok { | |||
| 305 | + | return returnFunc(ctx, repo) | |||
| 306 | + | } | |||
| 307 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) domain.TagInfo); ok { | |||
| 308 | + | r0 = returnFunc(ctx, repo) | |||
| 309 | + | } else { | |||
| 310 | + | r0 = ret.Get(0).(domain.TagInfo) | |||
| 311 | + | } | |||
| 312 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) bool); ok { | |||
| 313 | + | r1 = returnFunc(ctx, repo) | |||
| 314 | + | } else { | |||
| 315 | + | r1 = ret.Get(1).(bool) | |||
| 316 | + | } | |||
| 317 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName) error); ok { | |||
| 318 | + | r2 = returnFunc(ctx, repo) | |||
| 319 | + | } else { | |||
| 320 | + | r2 = ret.Error(2) | |||
| 321 | + | } | |||
| 322 | + | return r0, r1, r2 | |||
| 323 | + | } | |||
| 324 | + | ||||
| 325 | + | // RepoReader_LatestTag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LatestTag' | |||
| 326 | + | type RepoReader_LatestTag_Call struct { | |||
| 327 | + | *mock.Call | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | // LatestTag is a helper method to define mock.On call | |||
| 331 | + | // - ctx context.Context | |||
| 332 | + | // - repo domain.RepoName | |||
| 333 | + | func (_e *RepoReader_Expecter) LatestTag(ctx any, repo any) *RepoReader_LatestTag_Call { | |||
| 334 | + | return &RepoReader_LatestTag_Call{Call: _e.mock.On("LatestTag", ctx, repo)} | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | func (_c *RepoReader_LatestTag_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *RepoReader_LatestTag_Call { | |||
| 338 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 339 | + | var arg0 context.Context | |||
| 340 | + | if args[0] != nil { | |||
| 341 | + | arg0 = args[0].(context.Context) | |||
| 342 | + | } | |||
| 343 | + | var arg1 domain.RepoName | |||
| 344 | + | if args[1] != nil { | |||
| 345 | + | arg1 = args[1].(domain.RepoName) | |||
| 346 | + | } | |||
| 347 | + | run( | |||
| 348 | + | arg0, | |||
| 349 | + | arg1, | |||
| 350 | + | ) | |||
| 351 | + | }) | |||
| 352 | + | return _c | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | func (_c *RepoReader_LatestTag_Call) Return(tag domain.TagInfo, ok bool, err error) *RepoReader_LatestTag_Call { | |||
| 356 | + | _c.Call.Return(tag, ok, err) | |||
| 357 | + | return _c | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | func (_c *RepoReader_LatestTag_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) (domain.TagInfo, bool, error)) *RepoReader_LatestTag_Call { | |||
| 361 | + | _c.Call.Return(run) | |||
| 362 | + | return _c | |||
| 363 | + | } | |||
| 364 | + | ||||
| 214 | // ListCommits provides a mock function for the type RepoReader | 365 | // ListCommits provides a mock function for the type RepoReader | ||
| 215 | func (_mock *RepoReader) ListCommits(ctx context.Context, repo domain.RepoName, q ports.CommitQuery) (ports.CommitPage, error) { | 366 | func (_mock *RepoReader) ListCommits(ctx context.Context, repo domain.RepoName, q ports.CommitQuery) (ports.CommitPage, error) { | ||
| 216 | ret := _mock.Called(ctx, repo, q) | 367 | ret := _mock.Called(ctx, repo, q) | ||
M internal/ports/ports.go +6 -0
| @@ -178,6 +178,12 @@ type RepoReader interface { | |||
| 178 | 178 | // GetCommit returns a commit with its statistics and its diff against the | |
| 179 | 179 | // first parent, bounded by limits. | |
| 180 | 180 | GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) | |
| 181 | + | // CountCommits counts the commits reachable from head; with a non-zero | |
| 182 | + | // since, only those committed at or after it. | |
| 183 | + | CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error) | |
| 184 | + | // LatestTag is the most recently created tag (the commit date for a | |
| 185 | + | // lightweight tag); ok is false when the repository has no tag. | |
| 186 | + | LatestTag(ctx context.Context, repo domain.RepoName) (tag domain.TagInfo, ok bool, err error) | |
| 181 | 187 | } | |
| 182 | 188 | ||
| 183 | 189 | // HTTPBackend tells how to run `git http-backend` for one request. | |
| @@ -178,6 +178,12 @@ type RepoReader interface { | |||||
| 178 | // GetCommit returns a commit with its statistics and its diff against the | 178 | // GetCommit returns a commit with its statistics and its diff against the | ||
| 179 | // first parent, bounded by limits. | 179 | // first parent, bounded by limits. | ||
| 180 | GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) | 180 | GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) | ||
| 181 | + | // CountCommits counts the commits reachable from head; with a non-zero | |||
| 182 | + | // since, only those committed at or after it. | |||
| 183 | + | CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error) | |||
| 184 | + | // LatestTag is the most recently created tag (the commit date for a | |||
| 185 | + | // lightweight tag); ok is false when the repository has no tag. | |||
| 186 | + | LatestTag(ctx context.Context, repo domain.RepoName) (tag domain.TagInfo, ok bool, err error) | |||
| 181 | } | 187 | } | ||
| 182 | 188 | ||||
| 183 | // HTTPBackend tells how to run `git http-backend` for one request. | 189 | // HTTPBackend tells how to run `git http-backend` for one request. | ||
M internal/ports/services.go +3 -0
| @@ -96,6 +96,9 @@ type BrowseService interface { | |||
| 96 | 96 | OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) | |
| 97 | 97 | ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (HistoryPage, error) | |
| 98 | 98 | GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) | |
| 99 | + | // RepoStats counts the commits of the branch whose head is given, and | |
| 100 | + | // names the latest tag. | |
| 101 | + | RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (RepoStats, error) | |
| 99 | 102 | } | |
| 100 | 103 | ||
| 101 | 104 | // EventService streams the events of the forge. *usecase.Events implements it. | |
| @@ -96,6 +96,9 @@ type BrowseService interface { | |||||
| 96 | OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) | 96 | OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) | ||
| 97 | ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (HistoryPage, error) | 97 | ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (HistoryPage, error) | ||
| 98 | GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) | 98 | GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) | ||
| 99 | + | // RepoStats counts the commits of the branch whose head is given, and | |||
| 100 | + | // names the latest tag. | |||
| 101 | + | RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (RepoStats, error) | |||
| 99 | } | 102 | } | ||
| 100 | 103 | ||||
| 101 | // EventService streams the events of the forge. *usecase.Events implements it. | 104 | // EventService streams the events of the forge. *usecase.Events implements it. | ||
M internal/readertest/conformance.go +62 -0
| @@ -49,6 +49,7 @@ func Conformance(t *testing.T, mk Maker) { | |||
| 49 | 49 | {"GetTreeIsBounded", testGetTreeIsBounded}, | |
| 50 | 50 | {"GetTreeRefusesAGitlinkToATree", testGetTreeRefusesAGitlinkToATree}, | |
| 51 | 51 | {"ListPaths", testListPaths}, | |
| 52 | + | {"CountCommitsAndLatestTag", testCountCommitsAndLatestTag}, | |
| 52 | 53 | } { | |
| 53 | 54 | t.Run(tc.name, func(t *testing.T) { tc.fn(t, mk) }) | |
| 54 | 55 | } | |
| @@ -104,6 +105,67 @@ func testListRefs(t *testing.T, mk Maker) { | |||
| 104 | 105 | }, got) | |
| 105 | 106 | } | |
| 106 | 107 | ||
| 108 | + | func testCountCommitsAndLatestTag(t *testing.T, mk Maker) { | |
| 109 | + | ctx := context.Background() | |
| 110 | + | g, data := mk(t) | |
| 111 | + | f := NewFixture(t, data, "stats") | |
| 112 | + | ||
| 113 | + | _, ok, err := g.LatestTag(ctx, f.Name) | |
| 114 | + | require.NoError(t, err) | |
| 115 | + | assert.False(t, ok, "no tag yet") | |
| 116 | + | ||
| 117 | + | var ids []string | |
| 118 | + | for i := range 4 { // committed at 12:01, 12:02, 12:03, 12:04 | |
| 119 | + | ids = append(ids, f.Commit("c"+itoa(i))) | |
| 120 | + | } | |
| 121 | + | head := MustID(t, ids[3]) | |
| 122 | + | n, err := g.CountCommits(ctx, f.Name, head, time.Time{}) | |
| 123 | + | require.NoError(t, err) | |
| 124 | + | assert.Equal(t, 4, n) | |
| 125 | + | n, err = g.CountCommits(ctx, f.Name, head, f.When.Add(-90*time.Second)) | |
| 126 | + | require.NoError(t, err) | |
| 127 | + | assert.Equal(t, 2, n, "the commits of 12:03 and 12:04") | |
| 128 | + | n, err = g.CountCommits(ctx, f.Name, MustID(t, ids[1]), time.Time{}) | |
| 129 | + | require.NoError(t, err) | |
| 130 | + | assert.Equal(t, 2, n, "from another commit") | |
| 131 | + | _, err = g.CountCommits(ctx, f.Name, MustID(t, strings.Repeat("ab", 20)), time.Time{}) | |
| 132 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 133 | + | ||
| 134 | + | // Dates out of order: a commit dated in the past on top of recent ones | |
| 135 | + | // does not hide them. | |
| 136 | + | recent := f.When | |
| 137 | + | f.When = recent.Add(-48 * time.Hour) | |
| 138 | + | old := f.Commit("backdated") | |
| 139 | + | f.When = recent | |
| 140 | + | n, err = g.CountCommits(ctx, f.Name, MustID(t, old), recent.Add(-90*time.Second)) | |
| 141 | + | require.NoError(t, err) | |
| 142 | + | assert.Equal(t, 2, n, "the recent commits behind an old one count") | |
| 143 | + | ||
| 144 | + | // The latest tag is the one made last: an annotated tag by its own date, | |
| 145 | + | // a lightweight one by the date of its commit. | |
| 146 | + | f.Git("tag", "light-old", ids[0]) | |
| 147 | + | f.When = f.When.Add(time.Hour) | |
| 148 | + | f.Git("tag", "-a", "-m", "new", "v2", ids[0]) | |
| 149 | + | f.Git("tag", "light-new", ids[3]) | |
| 150 | + | tag, ok, err := g.LatestTag(ctx, f.Name) | |
| 151 | + | require.NoError(t, err) | |
| 152 | + | require.True(t, ok) | |
| 153 | + | assert.Equal(t, "v2", tag.Name) | |
| 154 | + | assert.True(t, tag.At.Equal(f.When), "%v", tag.At) | |
| 155 | + | assert.Equal(t, ids[0], tag.CommitID.String(), "an annotated tag is peeled to its commit") | |
| 156 | + | ||
| 157 | + | f.When = f.When.Add(time.Hour) | |
| 158 | + | f.Git("tag", "-a", "-m", "tree", "tree-tag", ids[0]+"^{tree}") | |
| 159 | + | tag, ok, err = g.LatestTag(ctx, f.Name) | |
| 160 | + | require.NoError(t, err) | |
| 161 | + | require.True(t, ok) | |
| 162 | + | assert.Equal(t, "tree-tag", tag.Name) | |
| 163 | + | assert.True(t, tag.CommitID.IsZero(), "a tag of a tree has no commit") | |
| 164 | + | ||
| 165 | + | _, _, err = g.LatestTag(ctx, RepoName(t, "nope")) | |
| 166 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 167 | + | } | |
| 168 | + | ||
| 107 | 169 | func testListRefsEmptyAndMissing(t *testing.T, mk Maker) { | |
| 108 | 170 | ctx := context.Background() | |
| 109 | 171 | g, data := mk(t) | |
| @@ -49,6 +49,7 @@ func Conformance(t *testing.T, mk Maker) { | |||||
| 49 | {"GetTreeIsBounded", testGetTreeIsBounded}, | 49 | {"GetTreeIsBounded", testGetTreeIsBounded}, | ||
| 50 | {"GetTreeRefusesAGitlinkToATree", testGetTreeRefusesAGitlinkToATree}, | 50 | {"GetTreeRefusesAGitlinkToATree", testGetTreeRefusesAGitlinkToATree}, | ||
| 51 | {"ListPaths", testListPaths}, | 51 | {"ListPaths", testListPaths}, | ||
| 52 | + | {"CountCommitsAndLatestTag", testCountCommitsAndLatestTag}, | |||
| 52 | } { | 53 | } { | ||
| 53 | t.Run(tc.name, func(t *testing.T) { tc.fn(t, mk) }) | 54 | t.Run(tc.name, func(t *testing.T) { tc.fn(t, mk) }) | ||
| 54 | } | 55 | } | ||
| @@ -104,6 +105,67 @@ func testListRefs(t *testing.T, mk Maker) { | |||||
| 104 | }, got) | 105 | }, got) | ||
| 105 | } | 106 | } | ||
| 106 | 107 | ||||
| 108 | + | func testCountCommitsAndLatestTag(t *testing.T, mk Maker) { | |||
| 109 | + | ctx := context.Background() | |||
| 110 | + | g, data := mk(t) | |||
| 111 | + | f := NewFixture(t, data, "stats") | |||
| 112 | + | ||||
| 113 | + | _, ok, err := g.LatestTag(ctx, f.Name) | |||
| 114 | + | require.NoError(t, err) | |||
| 115 | + | assert.False(t, ok, "no tag yet") | |||
| 116 | + | ||||
| 117 | + | var ids []string | |||
| 118 | + | for i := range 4 { // committed at 12:01, 12:02, 12:03, 12:04 | |||
| 119 | + | ids = append(ids, f.Commit("c"+itoa(i))) | |||
| 120 | + | } | |||
| 121 | + | head := MustID(t, ids[3]) | |||
| 122 | + | n, err := g.CountCommits(ctx, f.Name, head, time.Time{}) | |||
| 123 | + | require.NoError(t, err) | |||
| 124 | + | assert.Equal(t, 4, n) | |||
| 125 | + | n, err = g.CountCommits(ctx, f.Name, head, f.When.Add(-90*time.Second)) | |||
| 126 | + | require.NoError(t, err) | |||
| 127 | + | assert.Equal(t, 2, n, "the commits of 12:03 and 12:04") | |||
| 128 | + | n, err = g.CountCommits(ctx, f.Name, MustID(t, ids[1]), time.Time{}) | |||
| 129 | + | require.NoError(t, err) | |||
| 130 | + | assert.Equal(t, 2, n, "from another commit") | |||
| 131 | + | _, err = g.CountCommits(ctx, f.Name, MustID(t, strings.Repeat("ab", 20)), time.Time{}) | |||
| 132 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 133 | + | ||||
| 134 | + | // Dates out of order: a commit dated in the past on top of recent ones | |||
| 135 | + | // does not hide them. | |||
| 136 | + | recent := f.When | |||
| 137 | + | f.When = recent.Add(-48 * time.Hour) | |||
| 138 | + | old := f.Commit("backdated") | |||
| 139 | + | f.When = recent | |||
| 140 | + | n, err = g.CountCommits(ctx, f.Name, MustID(t, old), recent.Add(-90*time.Second)) | |||
| 141 | + | require.NoError(t, err) | |||
| 142 | + | assert.Equal(t, 2, n, "the recent commits behind an old one count") | |||
| 143 | + | ||||
| 144 | + | // The latest tag is the one made last: an annotated tag by its own date, | |||
| 145 | + | // a lightweight one by the date of its commit. | |||
| 146 | + | f.Git("tag", "light-old", ids[0]) | |||
| 147 | + | f.When = f.When.Add(time.Hour) | |||
| 148 | + | f.Git("tag", "-a", "-m", "new", "v2", ids[0]) | |||
| 149 | + | f.Git("tag", "light-new", ids[3]) | |||
| 150 | + | tag, ok, err := g.LatestTag(ctx, f.Name) | |||
| 151 | + | require.NoError(t, err) | |||
| 152 | + | require.True(t, ok) | |||
| 153 | + | assert.Equal(t, "v2", tag.Name) | |||
| 154 | + | assert.True(t, tag.At.Equal(f.When), "%v", tag.At) | |||
| 155 | + | assert.Equal(t, ids[0], tag.CommitID.String(), "an annotated tag is peeled to its commit") | |||
| 156 | + | ||||
| 157 | + | f.When = f.When.Add(time.Hour) | |||
| 158 | + | f.Git("tag", "-a", "-m", "tree", "tree-tag", ids[0]+"^{tree}") | |||
| 159 | + | tag, ok, err = g.LatestTag(ctx, f.Name) | |||
| 160 | + | require.NoError(t, err) | |||
| 161 | + | require.True(t, ok) | |||
| 162 | + | assert.Equal(t, "tree-tag", tag.Name) | |||
| 163 | + | assert.True(t, tag.CommitID.IsZero(), "a tag of a tree has no commit") | |||
| 164 | + | ||||
| 165 | + | _, _, err = g.LatestTag(ctx, RepoName(t, "nope")) | |||
| 166 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 167 | + | } | |||
| 168 | + | ||||
| 107 | func testListRefsEmptyAndMissing(t *testing.T, mk Maker) { | 169 | func testListRefsEmptyAndMissing(t *testing.T, mk Maker) { | ||
| 108 | ctx := context.Background() | 170 | ctx := context.Background() | ||
| 109 | g, data := mk(t) | 171 | g, data := mk(t) | ||
M internal/usecase/browse.go +2 -0
| @@ -29,6 +29,8 @@ type Browse struct { | |||
| 29 | 29 | // they never change, and computing them costs a history walk. Nil | |
| 30 | 30 | // disables it. | |
| 31 | 31 | history *historyCache | |
| 32 | + | // stats keeps the figures of the home page. | |
| 33 | + | stats statsCache | |
| 32 | 34 | } | |
| 33 | 35 | ||
| 34 | 36 | var _ ports.BrowseService = (*Browse)(nil) | |
| @@ -29,6 +29,8 @@ type Browse struct { | |||||
| 29 | // they never change, and computing them costs a history walk. Nil | 29 | // they never change, and computing them costs a history walk. Nil | ||
| 30 | // disables it. | 30 | // disables it. | ||
| 31 | history *historyCache | 31 | history *historyCache | ||
| 32 | + | // stats keeps the figures of the home page. | |||
| 33 | + | stats statsCache | |||
| 32 | } | 34 | } | ||
| 33 | 35 | ||||
| 34 | var _ ports.BrowseService = (*Browse)(nil) | 36 | var _ ports.BrowseService = (*Browse)(nil) | ||
A internal/usecase/browse_stats.go +122 -0
| @@ -0,0 +1,122 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "sync" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | const ( | |
| 15 | + | // maxStatsEntries bounds the cache of figures; when full it is emptied, | |
| 16 | + | // and the entries of the current heads come back on the next read. | |
| 17 | + | maxStatsEntries = 1024 | |
| 18 | + | // statsTTL is how long figures are served: the 30-day window moves | |
| 19 | + | // with time even when the repository does not. | |
| 20 | + | statsTTL = 10 * time.Minute | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // statsKey names the figures of one state of a repository: its life (the | |
| 24 | + | // creation time), its last push or merge (which every ref change, tags | |
| 25 | + | // included, moves) and the head counted from. | |
| 26 | + | type statsKey struct { | |
| 27 | + | repo domain.RepoName | |
| 28 | + | created int64 | |
| 29 | + | pushed int64 | |
| 30 | + | head domain.ObjectID | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | type statsEntry struct { | |
| 34 | + | st ports.RepoStats | |
| 35 | + | at time.Time | |
| 36 | + | err error | |
| 37 | + | // done is closed when st and err are set; until then the entry is the | |
| 38 | + | // read in flight that the other callers wait for. | |
| 39 | + | done chan struct{} | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // statsCache keeps the figures of the home page, so that a request reads Git | |
| 43 | + | // once per repository state and per statsTTL, and concurrent requests share | |
| 44 | + | // one read. | |
| 45 | + | type statsCache struct { | |
| 46 | + | mu sync.Mutex | |
| 47 | + | m map[statsKey]*statsEntry | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // get returns the figures of key, computing them with read when they are | |
| 51 | + | // missing, failed or older than statsTTL. | |
| 52 | + | func (c *statsCache) get(ctx context.Context, key statsKey, now time.Time, read func() (ports.RepoStats, error)) (ports.RepoStats, error) { | |
| 53 | + | c.mu.Lock() | |
| 54 | + | e, ok := c.m[key] | |
| 55 | + | if ok { | |
| 56 | + | select { | |
| 57 | + | case <-e.done: | |
| 58 | + | if e.err != nil || now.Sub(e.at) >= statsTTL || now.Before(e.at) { | |
| 59 | + | ok = false | |
| 60 | + | } | |
| 61 | + | default: // in flight | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | if !ok { | |
| 65 | + | if c.m == nil || len(c.m) >= maxStatsEntries { | |
| 66 | + | c.m = make(map[statsKey]*statsEntry) | |
| 67 | + | } | |
| 68 | + | e = &statsEntry{at: now, done: make(chan struct{})} | |
| 69 | + | c.m[key] = e | |
| 70 | + | c.mu.Unlock() | |
| 71 | + | e.st, e.err = read() | |
| 72 | + | close(e.done) | |
| 73 | + | return cloneStats(e.st), e.err | |
| 74 | + | } | |
| 75 | + | c.mu.Unlock() | |
| 76 | + | select { | |
| 77 | + | case <-e.done: | |
| 78 | + | return cloneStats(e.st), e.err | |
| 79 | + | case <-ctx.Done(): | |
| 80 | + | return ports.RepoStats{}, ctx.Err() | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func cloneStats(st ports.RepoStats) ports.RepoStats { | |
| 85 | + | if st.LatestTag != nil { | |
| 86 | + | t := *st.LatestTag | |
| 87 | + | st.LatestTag = &t | |
| 88 | + | } | |
| 89 | + | return st | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // RepoStats counts the commits reachable from head, all of them and those of | |
| 93 | + | // the last ports.RecentDays days before now, and names the latest tag. The | |
| 94 | + | // answer is kept for statsTTL, or until the repository is pushed to. | |
| 95 | + | func (b *Browse) RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (ports.RepoStats, error) { | |
| 96 | + | r, err := b.authorize(ctx, p, repo) | |
| 97 | + | if err != nil { | |
| 98 | + | return ports.RepoStats{}, err | |
| 99 | + | } | |
| 100 | + | key := statsKey{repo: r.Name, created: repoEpoch(r), pushed: r.PushedAt.UnixNano(), head: head} | |
| 101 | + | return b.stats.get(ctx, key, now, func() (ports.RepoStats, error) { | |
| 102 | + | // The read outlives a caller that gives up: the others wait for it. | |
| 103 | + | rctx := context.WithoutCancel(ctx) | |
| 104 | + | var st ports.RepoStats | |
| 105 | + | var err error | |
| 106 | + | if st.Commits, err = b.reader.CountCommits(rctx, repo, head, time.Time{}); err != nil { | |
| 107 | + | return ports.RepoStats{}, err | |
| 108 | + | } | |
| 109 | + | since := now.AddDate(0, 0, -ports.RecentDays) | |
| 110 | + | if st.Recent, err = b.reader.CountCommits(rctx, repo, head, since); err != nil { | |
| 111 | + | return ports.RepoStats{}, err | |
| 112 | + | } | |
| 113 | + | tag, ok, err := b.reader.LatestTag(rctx, repo) | |
| 114 | + | if err != nil { | |
| 115 | + | return ports.RepoStats{}, err | |
| 116 | + | } | |
| 117 | + | if ok { | |
| 118 | + | st.LatestTag = &tag | |
| 119 | + | } | |
| 120 | + | return st, nil | |
| 121 | + | }) | |
| 122 | + | } | |
| @@ -0,0 +1,122 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "sync" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | "bnema.dev/ermite/internal/ports" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | const ( | |||
| 15 | + | // maxStatsEntries bounds the cache of figures; when full it is emptied, | |||
| 16 | + | // and the entries of the current heads come back on the next read. | |||
| 17 | + | maxStatsEntries = 1024 | |||
| 18 | + | // statsTTL is how long figures are served: the 30-day window moves | |||
| 19 | + | // with time even when the repository does not. | |||
| 20 | + | statsTTL = 10 * time.Minute | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // statsKey names the figures of one state of a repository: its life (the | |||
| 24 | + | // creation time), its last push or merge (which every ref change, tags | |||
| 25 | + | // included, moves) and the head counted from. | |||
| 26 | + | type statsKey struct { | |||
| 27 | + | repo domain.RepoName | |||
| 28 | + | created int64 | |||
| 29 | + | pushed int64 | |||
| 30 | + | head domain.ObjectID | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | type statsEntry struct { | |||
| 34 | + | st ports.RepoStats | |||
| 35 | + | at time.Time | |||
| 36 | + | err error | |||
| 37 | + | // done is closed when st and err are set; until then the entry is the | |||
| 38 | + | // read in flight that the other callers wait for. | |||
| 39 | + | done chan struct{} | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // statsCache keeps the figures of the home page, so that a request reads Git | |||
| 43 | + | // once per repository state and per statsTTL, and concurrent requests share | |||
| 44 | + | // one read. | |||
| 45 | + | type statsCache struct { | |||
| 46 | + | mu sync.Mutex | |||
| 47 | + | m map[statsKey]*statsEntry | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // get returns the figures of key, computing them with read when they are | |||
| 51 | + | // missing, failed or older than statsTTL. | |||
| 52 | + | func (c *statsCache) get(ctx context.Context, key statsKey, now time.Time, read func() (ports.RepoStats, error)) (ports.RepoStats, error) { | |||
| 53 | + | c.mu.Lock() | |||
| 54 | + | e, ok := c.m[key] | |||
| 55 | + | if ok { | |||
| 56 | + | select { | |||
| 57 | + | case <-e.done: | |||
| 58 | + | if e.err != nil || now.Sub(e.at) >= statsTTL || now.Before(e.at) { | |||
| 59 | + | ok = false | |||
| 60 | + | } | |||
| 61 | + | default: // in flight | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | if !ok { | |||
| 65 | + | if c.m == nil || len(c.m) >= maxStatsEntries { | |||
| 66 | + | c.m = make(map[statsKey]*statsEntry) | |||
| 67 | + | } | |||
| 68 | + | e = &statsEntry{at: now, done: make(chan struct{})} | |||
| 69 | + | c.m[key] = e | |||
| 70 | + | c.mu.Unlock() | |||
| 71 | + | e.st, e.err = read() | |||
| 72 | + | close(e.done) | |||
| 73 | + | return cloneStats(e.st), e.err | |||
| 74 | + | } | |||
| 75 | + | c.mu.Unlock() | |||
| 76 | + | select { | |||
| 77 | + | case <-e.done: | |||
| 78 | + | return cloneStats(e.st), e.err | |||
| 79 | + | case <-ctx.Done(): | |||
| 80 | + | return ports.RepoStats{}, ctx.Err() | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func cloneStats(st ports.RepoStats) ports.RepoStats { | |||
| 85 | + | if st.LatestTag != nil { | |||
| 86 | + | t := *st.LatestTag | |||
| 87 | + | st.LatestTag = &t | |||
| 88 | + | } | |||
| 89 | + | return st | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // RepoStats counts the commits reachable from head, all of them and those of | |||
| 93 | + | // the last ports.RecentDays days before now, and names the latest tag. The | |||
| 94 | + | // answer is kept for statsTTL, or until the repository is pushed to. | |||
| 95 | + | func (b *Browse) RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (ports.RepoStats, error) { | |||
| 96 | + | r, err := b.authorize(ctx, p, repo) | |||
| 97 | + | if err != nil { | |||
| 98 | + | return ports.RepoStats{}, err | |||
| 99 | + | } | |||
| 100 | + | key := statsKey{repo: r.Name, created: repoEpoch(r), pushed: r.PushedAt.UnixNano(), head: head} | |||
| 101 | + | return b.stats.get(ctx, key, now, func() (ports.RepoStats, error) { | |||
| 102 | + | // The read outlives a caller that gives up: the others wait for it. | |||
| 103 | + | rctx := context.WithoutCancel(ctx) | |||
| 104 | + | var st ports.RepoStats | |||
| 105 | + | var err error | |||
| 106 | + | if st.Commits, err = b.reader.CountCommits(rctx, repo, head, time.Time{}); err != nil { | |||
| 107 | + | return ports.RepoStats{}, err | |||
| 108 | + | } | |||
| 109 | + | since := now.AddDate(0, 0, -ports.RecentDays) | |||
| 110 | + | if st.Recent, err = b.reader.CountCommits(rctx, repo, head, since); err != nil { | |||
| 111 | + | return ports.RepoStats{}, err | |||
| 112 | + | } | |||
| 113 | + | tag, ok, err := b.reader.LatestTag(rctx, repo) | |||
| 114 | + | if err != nil { | |||
| 115 | + | return ports.RepoStats{}, err | |||
| 116 | + | } | |||
| 117 | + | if ok { | |||
| 118 | + | st.LatestTag = &tag | |||
| 119 | + | } | |||
| 120 | + | return st, nil | |||
| 121 | + | }) | |||
| 122 | + | } | |||
M internal/usecase/browse_test.go +93 -0
| @@ -5,11 +5,16 @@ package usecase_test | |||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | 7 | "encoding/base64" | |
| 8 | + | "errors" | |
| 8 | 9 | "fmt" | |
| 9 | 10 | "io" | |
| 11 | + | "runtime" | |
| 10 | 12 | "strconv" | |
| 11 | 13 | "strings" | |
| 14 | + | "sync" | |
| 15 | + | "sync/atomic" | |
| 12 | 16 | "testing" | |
| 17 | + | "time" | |
| 13 | 18 | ||
| 14 | 19 | "github.com/stretchr/testify/assert" | |
| 15 | 20 | "github.com/stretchr/testify/mock" | |
| @@ -104,7 +109,95 @@ func browseOps() []browseOp { | |||
| 104 | 109 | }, func(r *mocks.RepoReader) { | |
| 105 | 110 | r.EXPECT().GetCommit(mock.Anything, mock.Anything, mock.Anything, domain.DefaultDiffLimits()).Return(domain.CommitDetail{}, nil) | |
| 106 | 111 | }}, | |
| 112 | + | {"RepoStats", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 113 | + | _, err := b.RepoStats(ctx, p, repo, oid("a"), time.Now()) | |
| 114 | + | return err | |
| 115 | + | }, func(r *mocks.RepoReader) { | |
| 116 | + | r.EXPECT().CountCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(1, nil) | |
| 117 | + | r.EXPECT().LatestTag(mock.Anything, mock.Anything).Return(domain.TagInfo{}, false, nil) | |
| 118 | + | }}, | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // RepoStats reads Git once per state of the repository (head and last push) | |
| 123 | + | // and per 10 minutes; concurrent callers share the read, and a failure is | |
| 124 | + | // not kept. | |
| 125 | + | func TestRepoStatsCache(t *testing.T) { | |
| 126 | + | t.Parallel() | |
| 127 | + | ctx := context.Background() | |
| 128 | + | f := newBrowseFixture(t) | |
| 129 | + | repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic, CreatedAt: time.Unix(100, 0), PushedAt: time.Unix(200, 0)} | |
| 130 | + | var mu sync.Mutex | |
| 131 | + | f.meta.EXPECT().Get(mock.Anything, repo.Name).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) { | |
| 132 | + | mu.Lock() | |
| 133 | + | defer mu.Unlock() | |
| 134 | + | return repo, nil | |
| 135 | + | }) | |
| 136 | + | now := time.Date(2026, 10, 18, 0, 0, 0, 0, time.UTC) | |
| 137 | + | var reads atomic.Int32 | |
| 138 | + | gate := make(chan struct{}) | |
| 139 | + | f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, mock.Anything, time.Time{}).RunAndReturn( | |
| 140 | + | func(context.Context, domain.RepoName, domain.ObjectID, time.Time) (int, error) { | |
| 141 | + | reads.Add(1) | |
| 142 | + | <-gate | |
| 143 | + | return 900, nil | |
| 144 | + | }) | |
| 145 | + | f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, mock.Anything, mock.MatchedBy(func(s time.Time) bool { return !s.IsZero() })).Return(12, nil) | |
| 146 | + | tag := domain.TagInfo{Name: "v1", At: now, CommitID: oid("c")} | |
| 147 | + | f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(tag, true, nil) | |
| 148 | + | ||
| 149 | + | stats := func(head string, at time.Time) ports.RepoStats { | |
| 150 | + | st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid(head), at) | |
| 151 | + | require.NoError(t, err) | |
| 152 | + | return st | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // Eight concurrent requests: one read. | |
| 156 | + | var wg sync.WaitGroup | |
| 157 | + | for range 8 { | |
| 158 | + | wg.Go(func() { | |
| 159 | + | st := stats("a", now) | |
| 160 | + | assert.Equal(t, 900, st.Commits) | |
| 161 | + | assert.Equal(t, 12, st.Recent) | |
| 162 | + | assert.Equal(t, tag, *st.LatestTag) | |
| 163 | + | }) | |
| 164 | + | } | |
| 165 | + | for reads.Load() == 0 { | |
| 166 | + | runtime.Gosched() | |
| 107 | 167 | } | |
| 168 | + | close(gate) | |
| 169 | + | wg.Wait() | |
| 170 | + | assert.EqualValues(t, 1, reads.Load(), "concurrent callers share the read") | |
| 171 | + | ||
| 172 | + | stats("a", now.Add(9*time.Minute)) | |
| 173 | + | assert.EqualValues(t, 1, reads.Load(), "served from the cache") | |
| 174 | + | stats("a", now.Add(10*time.Minute)) | |
| 175 | + | assert.EqualValues(t, 2, reads.Load(), "expired") | |
| 176 | + | stats("b", now.Add(10*time.Minute)) | |
| 177 | + | assert.EqualValues(t, 3, reads.Load(), "another head") | |
| 178 | + | mu.Lock() | |
| 179 | + | repo.PushedAt = time.Unix(300, 0) // a push, a tag-only one included | |
| 180 | + | mu.Unlock() | |
| 181 | + | stats("b", now.Add(10*time.Minute)) | |
| 182 | + | assert.EqualValues(t, 4, reads.Load(), "a push forgets the figures") | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func TestRepoStatsDoesNotKeepAFailure(t *testing.T) { | |
| 186 | + | t.Parallel() | |
| 187 | + | ctx := context.Background() | |
| 188 | + | f := newBrowseFixture(t) | |
| 189 | + | repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic} | |
| 190 | + | f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil) | |
| 191 | + | now := time.Now() | |
| 192 | + | f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a"), time.Time{}).Return(0, errors.New("boom")).Once() | |
| 193 | + | _, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now) | |
| 194 | + | require.Error(t, err) | |
| 195 | + | f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a"), mock.Anything).Return(3, nil).Twice() | |
| 196 | + | f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(domain.TagInfo{}, false, nil).Once() | |
| 197 | + | st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now) | |
| 198 | + | require.NoError(t, err) | |
| 199 | + | assert.Equal(t, 3, st.Commits) | |
| 200 | + | assert.Nil(t, st.LatestTag) | |
| 108 | 201 | } | |
| 109 | 202 | ||
| 110 | 203 | // TestBrowseAccess is the access rule over every read use case and every | |
| @@ -5,11 +5,16 @@ package usecase_test | |||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | "encoding/base64" | 7 | "encoding/base64" | ||
| 8 | + | "errors" | |||
| 8 | "fmt" | 9 | "fmt" | ||
| 9 | "io" | 10 | "io" | ||
| 11 | + | "runtime" | |||
| 10 | "strconv" | 12 | "strconv" | ||
| 11 | "strings" | 13 | "strings" | ||
| 14 | + | "sync" | |||
| 15 | + | "sync/atomic" | |||
| 12 | "testing" | 16 | "testing" | ||
| 17 | + | "time" | |||
| 13 | 18 | ||||
| 14 | "github.com/stretchr/testify/assert" | 19 | "github.com/stretchr/testify/assert" | ||
| 15 | "github.com/stretchr/testify/mock" | 20 | "github.com/stretchr/testify/mock" | ||
| @@ -104,7 +109,95 @@ func browseOps() []browseOp { | |||||
| 104 | }, func(r *mocks.RepoReader) { | 109 | }, func(r *mocks.RepoReader) { | ||
| 105 | r.EXPECT().GetCommit(mock.Anything, mock.Anything, mock.Anything, domain.DefaultDiffLimits()).Return(domain.CommitDetail{}, nil) | 110 | r.EXPECT().GetCommit(mock.Anything, mock.Anything, mock.Anything, domain.DefaultDiffLimits()).Return(domain.CommitDetail{}, nil) | ||
| 106 | }}, | 111 | }}, | ||
| 112 | + | {"RepoStats", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |||
| 113 | + | _, err := b.RepoStats(ctx, p, repo, oid("a"), time.Now()) | |||
| 114 | + | return err | |||
| 115 | + | }, func(r *mocks.RepoReader) { | |||
| 116 | + | r.EXPECT().CountCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(1, nil) | |||
| 117 | + | r.EXPECT().LatestTag(mock.Anything, mock.Anything).Return(domain.TagInfo{}, false, nil) | |||
| 118 | + | }}, | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // RepoStats reads Git once per state of the repository (head and last push) | |||
| 123 | + | // and per 10 minutes; concurrent callers share the read, and a failure is | |||
| 124 | + | // not kept. | |||
| 125 | + | func TestRepoStatsCache(t *testing.T) { | |||
| 126 | + | t.Parallel() | |||
| 127 | + | ctx := context.Background() | |||
| 128 | + | f := newBrowseFixture(t) | |||
| 129 | + | repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic, CreatedAt: time.Unix(100, 0), PushedAt: time.Unix(200, 0)} | |||
| 130 | + | var mu sync.Mutex | |||
| 131 | + | f.meta.EXPECT().Get(mock.Anything, repo.Name).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) { | |||
| 132 | + | mu.Lock() | |||
| 133 | + | defer mu.Unlock() | |||
| 134 | + | return repo, nil | |||
| 135 | + | }) | |||
| 136 | + | now := time.Date(2026, 10, 18, 0, 0, 0, 0, time.UTC) | |||
| 137 | + | var reads atomic.Int32 | |||
| 138 | + | gate := make(chan struct{}) | |||
| 139 | + | f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, mock.Anything, time.Time{}).RunAndReturn( | |||
| 140 | + | func(context.Context, domain.RepoName, domain.ObjectID, time.Time) (int, error) { | |||
| 141 | + | reads.Add(1) | |||
| 142 | + | <-gate | |||
| 143 | + | return 900, nil | |||
| 144 | + | }) | |||
| 145 | + | f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, mock.Anything, mock.MatchedBy(func(s time.Time) bool { return !s.IsZero() })).Return(12, nil) | |||
| 146 | + | tag := domain.TagInfo{Name: "v1", At: now, CommitID: oid("c")} | |||
| 147 | + | f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(tag, true, nil) | |||
| 148 | + | ||||
| 149 | + | stats := func(head string, at time.Time) ports.RepoStats { | |||
| 150 | + | st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid(head), at) | |||
| 151 | + | require.NoError(t, err) | |||
| 152 | + | return st | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // Eight concurrent requests: one read. | |||
| 156 | + | var wg sync.WaitGroup | |||
| 157 | + | for range 8 { | |||
| 158 | + | wg.Go(func() { | |||
| 159 | + | st := stats("a", now) | |||
| 160 | + | assert.Equal(t, 900, st.Commits) | |||
| 161 | + | assert.Equal(t, 12, st.Recent) | |||
| 162 | + | assert.Equal(t, tag, *st.LatestTag) | |||
| 163 | + | }) | |||
| 164 | + | } | |||
| 165 | + | for reads.Load() == 0 { | |||
| 166 | + | runtime.Gosched() | |||
| 107 | } | 167 | } | ||
| 168 | + | close(gate) | |||
| 169 | + | wg.Wait() | |||
| 170 | + | assert.EqualValues(t, 1, reads.Load(), "concurrent callers share the read") | |||
| 171 | + | ||||
| 172 | + | stats("a", now.Add(9*time.Minute)) | |||
| 173 | + | assert.EqualValues(t, 1, reads.Load(), "served from the cache") | |||
| 174 | + | stats("a", now.Add(10*time.Minute)) | |||
| 175 | + | assert.EqualValues(t, 2, reads.Load(), "expired") | |||
| 176 | + | stats("b", now.Add(10*time.Minute)) | |||
| 177 | + | assert.EqualValues(t, 3, reads.Load(), "another head") | |||
| 178 | + | mu.Lock() | |||
| 179 | + | repo.PushedAt = time.Unix(300, 0) // a push, a tag-only one included | |||
| 180 | + | mu.Unlock() | |||
| 181 | + | stats("b", now.Add(10*time.Minute)) | |||
| 182 | + | assert.EqualValues(t, 4, reads.Load(), "a push forgets the figures") | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | func TestRepoStatsDoesNotKeepAFailure(t *testing.T) { | |||
| 186 | + | t.Parallel() | |||
| 187 | + | ctx := context.Background() | |||
| 188 | + | f := newBrowseFixture(t) | |||
| 189 | + | repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic} | |||
| 190 | + | f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil) | |||
| 191 | + | now := time.Now() | |||
| 192 | + | f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a"), time.Time{}).Return(0, errors.New("boom")).Once() | |||
| 193 | + | _, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now) | |||
| 194 | + | require.Error(t, err) | |||
| 195 | + | f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a"), mock.Anything).Return(3, nil).Twice() | |||
| 196 | + | f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(domain.TagInfo{}, false, nil).Once() | |||
| 197 | + | st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now) | |||
| 198 | + | require.NoError(t, err) | |||
| 199 | + | assert.Equal(t, 3, st.Commits) | |||
| 200 | + | assert.Nil(t, st.LatestTag) | |||
| 108 | } | 201 | } | ||
| 109 | 202 | ||||
| 110 | // TestBrowseAccess is the access rule over every read use case and every | 203 | // TestBrowseAccess is the access rule over every read use case and every | ||