vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Add file history and blame views
67a308badf4a88298639271a1201ff45cc3737a6 · Keenan Nemetz · 15d ago · parent f204e7ee
Add file history and blame views A blob's toolbar now links to the commits that touched its path and to per-line attribution. File history uses git log for the path, following renames for blobs; blame groups consecutive lines by commit and shows the author and age in a left column. Both are read-only and reuse the existing commit-link and escaping rules.
core/git/git.go +69
| @@ -641,6 +641,75 @@ func (r *Repo) Grep(rev, pattern string, maxBytes int) ([]byte, bool, error) { | |||
| 641 | 641 | return out, truncated, nil | |
| 642 | 642 | } | |
| 643 | 643 | ||
| 644 | // LogPath lists commits that touched one path. follow asks git to follow a | ||
| 645 | // rename; it is only valid for a single file, so directory history uses false. | ||
| 646 | func (r *Repo) LogPath(rev, path string, n int, follow bool) ([]Commit, error) { | ||
| 647 | args := []string{"log", "--format=" + commitFormat, "-n", strconv.Itoa(n)} | ||
| 648 | if follow { | ||
| 649 | args = append(args, "--follow") | ||
| 650 | } | ||
| 651 | args = append(args, rev, "--", path) | ||
| 652 | out, errs, err := r.run(nil, args...) | ||
| 653 | if err != nil { | ||
| 654 | return nil, fmt.Errorf("log %q -- %q: %w: %s", rev, path, err, errs) | ||
| 655 | } | ||
| 656 | return parseCommits(out), nil | ||
| 657 | } | ||
| 658 | |||
| 659 | // BlameLine is one line of `git blame` output with the commit that last touched | ||
| 660 | // it. Line is the line number in the blamed revision. | ||
| 661 | type BlameLine struct { | ||
| 662 | SHA string | ||
| 663 | Author string | ||
| 664 | When int64 | ||
| 665 | Line int | ||
| 666 | Text string | ||
| 667 | } | ||
| 668 | |||
| 669 | // Blame reads line attribution for one file, bounded by maxBytes. A cut result | ||
| 670 | // is reported rather than silently shown as the whole file. | ||
| 671 | func (r *Repo) Blame(rev, path string, maxBytes int) ([]BlameLine, bool, error) { | ||
| 672 | if maxBytes < 1 { | ||
| 673 | maxBytes = 1 | ||
| 674 | } | ||
| 675 | out, _, err := r.runHead(maxBytes+1, | ||
| 676 | "blame", "--line-porcelain", rev, "--", path) | ||
| 677 | if err != nil { | ||
| 678 | return nil, false, fmt.Errorf("blame %q -- %q: %w", rev, path, err) | ||
| 679 | } | ||
| 680 | truncated := len(out) > maxBytes | ||
| 681 | if truncated { | ||
| 682 | out = out[:maxBytes] | ||
| 683 | } | ||
| 684 | var lines []BlameLine | ||
| 685 | var cur BlameLine | ||
| 686 | have := false | ||
| 687 | for _, line := range strings.Split(string(out), "\n") { | ||
| 688 | switch { | ||
| 689 | case strings.HasPrefix(line, "\t"): | ||
| 690 | if have { | ||
| 691 | cur.Text = line[1:] | ||
| 692 | lines = append(lines, cur) | ||
| 693 | have = false | ||
| 694 | } | ||
| 695 | case strings.HasPrefix(line, "author "): | ||
| 696 | cur.Author = strings.TrimPrefix(line, "author ") | ||
| 697 | case strings.HasPrefix(line, "author-time "): | ||
| 698 | cur.When, _ = strconv.ParseInt(strings.TrimPrefix(line, "author-time "), 10, 64) | ||
| 699 | default: | ||
| 700 | fields := strings.Fields(line) | ||
| 701 | if len(fields) >= 3 { | ||
| 702 | sha := strings.TrimPrefix(fields[0], "^") | ||
| 703 | if n, err := strconv.Atoi(fields[2]); err == nil && len(sha) == 40 { | ||
| 704 | cur = BlameLine{SHA: sha, Line: n} | ||
| 705 | have = true | ||
| 706 | } | ||
| 707 | } | ||
| 708 | } | ||
| 709 | } | ||
| 710 | return lines, truncated, nil | ||
| 711 | } | ||
| 712 | |||
| 644 | 713 | func (r *Repo) CountCommits(rev string) (int, error) { | |
| 645 | 714 | out, _, err := r.run(nil, "rev-list", "--count", rev) | |
| 646 | 715 | if err != nil { | |