Add commit detail view: full message, changed files, colored diff
Each row in the commit history is now a link to /repo-commit/{repo} showing the full message, author/date/hash/parent, a per-file change summary (status badge, +/- counts), and the unified diff colored by line type. Diffs are capped at 4000 lines to bound memory use. Also fixes ShowCommit/CommitDiff returning a 500 instead of a 404 for an unresolvable commit hash — git's "fatal: bad object" error wasn't in the not-found error list.
11 files changed
+467 −3
M
CHANGELOG.md
+4 −0
M
cmd/gitfed-web/handlers_repo_commits.go
+193 −2
M
cmd/gitfed-web/render.go
+30 −1
M
cmd/gitfed-web/routes.go
+1 −0
M
internal/admin/admin.go
+18 −0
M
internal/adminrpc/client.go
+12 −0
M
internal/adminrpc/protocol.go
+17 −0
M
internal/adminrpc/server.go
+16 −0
M
internal/gitexec/gitexec.go
+168 −0
M
internal/i18n/strings_en.go
+4 −0
M
internal/i18n/strings_fr.go
+4 −0
CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog
+## 0.9.6
+
+- Commits in the history view are now clickable: each opens a detail page with the full commit message, author/date/hash/parent, the list of changed files (with a status badge and +/− counts), and a full colored diff. Very large diffs are capped at 4000 lines with a notice instead of being loaded in full.
+
## 0.9.5
- The repo page no longer dumps the full LICENSE text inline — it's a lot of legal boilerplate pushing the actual content (README, files) further down. Now shows a compact "View file" button linking to the license blob instead.
cmd/gitfed-web/handlers_repo_commits.go
@@ -4,6 +4,7 @@ import (
"bytes"
"html/template"
"net/http"
+ "strings"
"gitfed/internal/gitexec"
"gitfed/internal/i18n"
@@ -23,13 +24,13 @@ var repoCommitsTpl = newTpl("repo-commits", `
<div class="gf-card gf-commit-list">
{{range .Commits}}
- <div class="gf-commit-row">
+ <a class="gf-commit-row" href="/repo-commit/{{$.Repo.Name}}?hash={{.Hash}}">
<div class="gf-commit-main">
<div class="subject">{{.Subject}}</div>
<div class="meta">{{.Author}} · {{.Date.Local.Format "2006-01-02 15:04"}}</div>
</div>
<code class="gf-commit-hash">{{.ShortHash}}</code>
- </div>
+ </a>
{{else}}
<div class="gf-commit-row"><span class="muted">{{t .Lang "repo.commits_empty"}}</span></div>
{{end}}
@@ -61,3 +62,193 @@ func (s *server) handleRepoCommits(w http.ResponseWriter, r *http.Request) {
}{repo, string(lang), commits, flash(r)})
s.render(w, r, name+" — "+i18n.T(lang, "repo.commits_title"), "home", template.HTML(buf.String()))
}
+
+// diffFileView adds display-only fields (badge class, status letter) to a
+// gitexec.DiffFile.
+type diffFileView struct {
+ gitexec.DiffFile
+ Letter string
+ Class string
+}
+
+func newDiffFileView(f gitexec.DiffFile) diffFileView {
+ letter, class := "M", "plain"
+ switch f.Status {
+ case "added":
+ letter, class = "A", "trusted"
+ case "deleted":
+ letter, class = "D", "danger"
+ case "renamed":
+ letter, class = "R", "pending"
+ case "copied":
+ letter, class = "C", "pending"
+ }
+ return diffFileView{DiffFile: f, Letter: letter, Class: class}
+}
+
+// diffLine is one line of a unified diff, classified for CSS coloring.
+type diffLine struct {
+ Class string
+ Text string
+}
+
+// diffFileSection groups a diff's lines under the file path they belong to,
+// so the detail page can show a small header before each file's hunks.
+type diffFileSection struct {
+ Path string
+ Lines []diffLine
+}
+
+func classifyDiff(diff string) []diffFileSection {
+ var sections []diffFileSection
+ var path string
+ var lines []diffLine
+ started := false
+ flush := func() {
+ if started {
+ sections = append(sections, diffFileSection{Path: path, Lines: lines})
+ }
+ }
+ for _, l := range strings.Split(diff, "\n") {
+ if strings.HasPrefix(l, "diff --git ") {
+ flush()
+ path = diffFilePath(l)
+ lines = nil
+ started = true
+ }
+ lines = append(lines, diffLine{Class: classifyDiffLine(l), Text: l})
+ }
+ flush()
+ return sections
+}
+
+func diffFilePath(header string) string {
+ header = strings.TrimPrefix(header, "diff --git ")
+ if idx := strings.Index(header, " b/"); idx >= 0 {
+ return strings.TrimPrefix(header[:idx], "a/")
+ }
+ return header
+}
+
+func classifyDiffLine(l string) string {
+ switch {
+ case strings.HasPrefix(l, "diff --git"), strings.HasPrefix(l, "index "),
+ strings.HasPrefix(l, "--- "), strings.HasPrefix(l, "+++ "),
+ strings.HasPrefix(l, "new file mode"), strings.HasPrefix(l, "deleted file mode"),
+ strings.HasPrefix(l, "similarity index"), strings.HasPrefix(l, "rename from"),
+ strings.HasPrefix(l, "rename to"), strings.HasPrefix(l, "old mode"),
+ strings.HasPrefix(l, "new mode"), strings.HasPrefix(l, "Binary files"):
+ return "meta"
+ case strings.HasPrefix(l, "@@"):
+ return "hunk"
+ case strings.HasPrefix(l, "+") && !strings.HasPrefix(l, "+++"):
+ return "add"
+ case strings.HasPrefix(l, "-") && !strings.HasPrefix(l, "---"):
+ return "del"
+ default:
+ return "ctx"
+ }
+}
+
+var repoCommitTpl = newTpl("repo-commit", `
+{{.Flash}}
+<div class="gf-crumbs">
+ <a href="/r/{{.Repo.Name}}">{{.Repo.Name}}</a><span class="sep">/</span>
+ <a href="/repo-commits/{{.Repo.Name}}">{{t .Lang "repo.commits_title"}}</a><span class="sep">/</span>
+ <code>{{.Detail.ShortHash}}</code>
+</div>
+
+<div class="gf-card">
+ <div class="gf-commit-detail-head">
+ <p class="subject">{{.Detail.Subject}}</p>
+ {{if .Detail.Body}}<p class="body">{{.Detail.Body}}</p>{{end}}
+ <div class="meta-row">
+ <span class="field">{{.Detail.Author}}</span>
+ <span class="field">{{.Detail.Date.Local.Format "2006-01-02 15:04"}}</span>
+ <span class="field">{{t .Lang "repo.commit_label"}} <code class="hash">{{.Detail.Hash}}</code></span>
+ {{range .Detail.Parents}}<span class="field">{{t $.Lang "repo.parent_label"}} <a href="/repo-commit/{{$.Repo.Name}}?hash={{.}}"><code class="hash">{{.}}</code></a></span>{{end}}
+ </div>
+ </div>
+
+ <div class="gf-diffstat-head">
+ <span>{{len .Detail.Files}} {{t .Lang "repo.files_changed"}}</span>
+ <span class="totals"><span class="add">+{{.TotalAdd}}</span> <span class="del">−{{.TotalDel}}</span></span>
+ </div>
+ <div>
+ {{range .Files}}
+ <div class="gf-diffstat-row">
+ <span class="badge {{.Class}}">{{.Letter}}</span>
+ <span class="path">{{.Path}}</span>
+ {{if .Binary}}<span class="stat muted">{{t $.Lang "repo.binary_file"}}</span>{{else}}<span class="stat"><span class="add">+{{.Insertions}}</span> <span class="del">−{{.Deletions}}</span></span>{{end}}
+ </div>
+ {{end}}
+ </div>
+</div>
+
+{{if .DiffSections}}
+<div class="gf-card">
+{{range .DiffSections}}
+ <div class="gf-diff-file">
+ <div class="gf-diff-file-head">{{.Path}}</div>
+ <div class="gf-diff">{{range .Lines}}<span class="line {{.Class}}">{{.Text}}</span>
+{{end}}</div>
+ </div>
+{{end}}
+</div>
+{{end}}
+{{if .Truncated}}<p class="muted" style="text-align:center;">{{t .Lang "repo.diff_truncated"}}</p>{{end}}
+`)
+
+func (s *server) handleRepoCommitDetail(w http.ResponseWriter, r *http.Request) {
+ name := r.PathValue("repo")
+ hash := strings.TrimSpace(r.URL.Query().Get("hash"))
+ lang := s.lang(r)
+ if hash == "" {
+ http.NotFound(w, r)
+ return
+ }
+
+ repo, err := s.ops.GetRepo(name)
+ if err != nil || !s.canView(r, repo) {
+ http.NotFound(w, r)
+ return
+ }
+
+ detail, found, err := s.ops.ShowCommit(name, hash)
+ if err != nil {
+ s.serverError(w, r, err)
+ return
+ }
+ if !found {
+ http.NotFound(w, r)
+ return
+ }
+
+ diff, truncated, err := s.ops.CommitDiff(name, hash)
+ if err != nil {
+ s.serverError(w, r, err)
+ return
+ }
+
+ files := make([]diffFileView, len(detail.Files))
+ var totalAdd, totalDel int
+ for i, f := range detail.Files {
+ files[i] = newDiffFileView(f)
+ totalAdd += f.Insertions
+ totalDel += f.Deletions
+ }
+
+ var buf bytes.Buffer
+ _ = repoCommitTpl.Execute(&buf, struct {
+ Repo store.Repo
+ Lang string
+ Detail gitexec.CommitDetail
+ Files []diffFileView
+ TotalAdd int
+ TotalDel int
+ DiffSections []diffFileSection
+ Truncated bool
+ Flash template.HTML
+ }{repo, string(lang), detail, files, totalAdd, totalDel, classifyDiff(diff), truncated, flash(r)})
+ s.render(w, r, detail.ShortHash+" — "+name, "home", template.HTML(buf.String()))
+}
cmd/gitfed-web/render.go
@@ -377,7 +377,7 @@ const shellHeadSrc = `<!doctype html>
.gf-repo-main .topics a:hover { color: var(--text-dim); }
.gf-commit-list { display: flex; flex-direction: column; }
- .gf-commit-row { display: flex; align-items: center; gap: 0.9rem; padding: 0.8rem 1.1rem; border-bottom: 1px solid var(--border); }
+ .gf-commit-row { display: flex; align-items: center; gap: 0.9rem; padding: 0.8rem 1.1rem; border-bottom: 1px solid var(--border); text-decoration: none; color: inherit; }
.gf-commit-row:last-child { border-bottom: none; }
.gf-commit-row:hover { background: var(--surface-2); }
.gf-commit-main { flex: 1; min-width: 0; }
@@ -385,6 +385,34 @@ const shellHeadSrc = `<!doctype html>
.gf-commit-main .meta { font-size: 0.78rem; color: var(--text-faint); margin-top: 0.2rem; }
.gf-commit-hash { flex-shrink: 0; font-size: 0.78rem; color: var(--text-dim); background: var(--surface-2); padding: 0.15rem 0.5rem; border-radius: 5px; }
+ .gf-commit-detail-head { padding: 1.1rem 1.2rem; border-bottom: 1px solid var(--border); }
+ .gf-commit-detail-head .subject { font-size: 1.08rem; font-weight: 650; margin: 0 0 0.5rem; }
+ .gf-commit-detail-head .body { white-space: pre-wrap; color: var(--text-dim); font-size: 0.87rem; margin: 0 0 0.9rem; }
+ .gf-commit-detail-head .meta-row { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 1.2rem; font-size: 0.82rem; color: var(--text-faint); }
+ .gf-commit-detail-head code.hash { font-family: var(--mono); background: var(--surface-2); padding: 0.15rem 0.5rem; border-radius: 5px; color: var(--text-dim); }
+ .gf-commit-detail-head a:hover code.hash { color: var(--accent); }
+
+ .gf-diffstat-head { display: flex; align-items: center; justify-content: space-between; padding: 0.8rem 1.2rem; border-bottom: 1px solid var(--border); font-size: 0.85rem; color: var(--text-dim); }
+ .gf-diffstat-head .totals .add { color: var(--ok-fg); }
+ .gf-diffstat-head .totals .del { color: var(--danger-fg); }
+ .gf-diffstat-row { display: flex; align-items: center; gap: 0.7rem; padding: 0.55rem 1.2rem; border-bottom: 1px solid var(--border); font-size: 0.85rem; }
+ .gf-diffstat-row:last-child { border-bottom: none; }
+ .gf-diffstat-row:hover { background: var(--surface-2); }
+ .gf-diffstat-row .path { font-family: var(--mono); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .gf-diffstat-row .stat { font-family: var(--mono); font-size: 0.78rem; color: var(--text-faint); flex-shrink: 0; }
+ .gf-diffstat-row .stat .add { color: var(--ok-fg); }
+ .gf-diffstat-row .stat .del { color: var(--danger-fg); }
+
+ .gf-diff-file { border-bottom: 1px solid var(--border); }
+ .gf-diff-file:last-child { border-bottom: none; }
+ .gf-diff-file-head { display: flex; align-items: center; gap: 0.6rem; padding: 0.7rem 1.2rem; background: var(--surface-2); font-family: var(--mono); font-size: 0.82rem; }
+ .gf-diff { font-family: var(--mono); font-size: 0.8rem; line-height: 1.55; overflow-x: auto; }
+ .gf-diff .line { display: block; white-space: pre; padding: 0 1.2rem; }
+ .gf-diff .line.add { background: rgba(123,214,168,0.09); color: #a8e6c4; }
+ .gf-diff .line.del { background: rgba(242,139,130,0.09); color: #f5aca6; }
+ .gf-diff .line.hunk { color: var(--accent); background: var(--surface-2); }
+ .gf-diff .line.ctx { color: var(--text-dim); }
+
.gf-new-repo summary { list-style: none; cursor: pointer; }
.gf-new-repo summary::-webkit-details-marker { display: none; }
.gf-new-repo[open] summary { margin-bottom: 0.5rem; }
@@ -481,6 +509,7 @@ const shellHeadSrc = `<!doctype html>
.badge.pending { background: var(--pending-bg); color: var(--pending-fg); }
.badge.trusted { background: var(--ok-bg); color: var(--ok-fg); }
.badge.plain { background: var(--surface-2); border: 1px solid var(--border); color: var(--text-dim); margin-right: 0.3rem; }
+ .badge.danger { background: var(--danger-bg); color: var(--danger-fg); }
.muted { color: var(--text-dim); font-size: 0.85rem; }
code, pre { background: var(--surface-2); border-radius: 6px; font-family: var(--mono); font-size: 0.86em; }
code { padding: 0.15rem 0.4rem; }
cmd/gitfed-web/routes.go
@@ -8,6 +8,7 @@ func (s *server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /r/{repo...}", s.handleRepoView)
mux.HandleFunc("GET /repo-blob/{repo...}", s.handleRepoBlob)
mux.HandleFunc("GET /repo-commits/{repo...}", s.handleRepoCommits)
+ mux.HandleFunc("GET /repo-commit/{repo...}", s.handleRepoCommitDetail)
mux.HandleFunc("GET /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLogin)
mux.HandleFunc("POST /logout", s.handleLogout)
internal/admin/admin.go
@@ -54,6 +54,8 @@ type Ops interface {
GetRepoFile(name, path string) (content string, found bool, err error)
GetRepoBranch(name string) (branch string, found bool, err error)
ListCommits(name string, limit int) (commits []gitexec.Commit, found bool, err error)
+ ShowCommit(name, hash string) (detail gitexec.CommitDetail, found bool, err error)
+ CommitDiff(name, hash string) (diff string, truncated bool, err error)
GetACL(repoName string) (store.ACL, error)
GrantCollaborator(repoName, principal, actor string, role store.Role) error
@@ -344,6 +346,22 @@ func (a *Admin) ListCommits(name string, limit int) ([]gitexec.Commit, bool, err
return gitexec.ListCommits(repo.Path, limit)
}
+func (a *Admin) ShowCommit(name, hash string) (gitexec.CommitDetail, bool, error) {
+ repo, err := a.Store.GetRepo(name)
+ if err != nil {
+ return gitexec.CommitDetail{}, false, err
+ }
+ return gitexec.ShowCommit(repo.Path, hash)
+}
+
+func (a *Admin) CommitDiff(name, hash string) (string, bool, error) {
+ repo, err := a.Store.GetRepo(name)
+ if err != nil {
+ return "", false, err
+ }
+ return gitexec.CommitDiff(repo.Path, hash)
+}
+
// GrantCollaborator adds/updates a collaborator's role on a repo. If the
// principal belongs to a remote domain, it first resolves trust for that
// domain (§5.2/§6); for the whitelist policy this leaves the domain pending
internal/adminrpc/client.go
@@ -238,6 +238,18 @@ func (c *Client) ListCommits(name string, limit int) ([]gitexec.Commit, bool, er
return out.Commits, out.Found, err
}
+func (c *Client) ShowCommit(name, hash string) (gitexec.CommitDetail, bool, error) {
+ var out showCommitResult
+ err := c.call(methodShowCommit, nameHashArgs{Name: name, Hash: hash}, &out)
+ return out.Detail, out.Found, err
+}
+
+func (c *Client) CommitDiff(name, hash string) (string, bool, error) {
+ var out commitDiffResult
+ err := c.call(methodCommitDiff, nameHashArgs{Name: name, Hash: hash}, &out)
+ return out.Diff, out.Truncated, err
+}
+
func (c *Client) PinRepo(principal, domain, repo, label string) error {
return c.call(methodPinRepo, pinArgs{Principal: principal, Domain: domain, Repo: repo, Label: label}, nil)
}
internal/adminrpc/protocol.go
@@ -46,6 +46,8 @@ const (
methodDeleteSession = "DeleteSession"
methodCheckAccess = "CheckAccess"
methodListCommits = "ListCommits"
+ methodShowCommit = "ShowCommit"
+ methodCommitDiff = "CommitDiff"
methodPinRepo = "PinRepo"
methodUnpinRepo = "UnpinRepo"
@@ -243,3 +245,18 @@ type listCommitsResult struct {
Commits []gitexec.Commit `json:"commits"`
Found bool `json:"found"`
}
+
+type nameHashArgs struct {
+ Name string `json:"name"`
+ Hash string `json:"hash"`
+}
+
+type showCommitResult struct {
+ Detail gitexec.CommitDetail `json:"detail"`
+ Found bool `json:"found"`
+}
+
+type commitDiffResult struct {
+ Diff string `json:"diff"`
+ Truncated bool `json:"truncated"`
+}
internal/adminrpc/server.go
@@ -294,6 +294,22 @@ func (s *Server) dispatch(req wireRequest) (any, error) {
commits, found, err := s.ops.ListCommits(a.Name, a.Limit)
return listCommitsResult{Commits: commits, Found: found}, err
+ case methodShowCommit:
+ var a nameHashArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ detail, found, err := s.ops.ShowCommit(a.Name, a.Hash)
+ return showCommitResult{Detail: detail, Found: found}, err
+
+ case methodCommitDiff:
+ var a nameHashArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ diff, truncated, err := s.ops.CommitDiff(a.Name, a.Hash)
+ return commitDiffResult{Diff: diff, Truncated: truncated}, err
+
case methodPinRepo:
var a pinArgs
if err := json.Unmarshal(req.Args, &a); err != nil {
internal/gitexec/gitexec.go
@@ -193,6 +193,7 @@ type TreeEntry struct {
func notFoundGitError(msg string) bool {
return strings.Contains(msg, "does not exist") ||
strings.Contains(msg, "bad revision") ||
+ strings.Contains(msg, "bad object") ||
strings.Contains(msg, "not in the tree") ||
strings.Contains(msg, "invalid object name") ||
strings.Contains(msg, "valid object name") ||
@@ -317,3 +318,170 @@ func ListCommits(repoPath string, limit int) (commits []Commit, found bool, err
}
return commits, true, nil
}
+
+// DiffFile summarizes one file changed by a commit.
+type DiffFile struct {
+ Path string
+ Status string // "added", "modified", "deleted", "renamed", "copied"
+ Insertions int
+ Deletions int
+ Binary bool
+}
+
+// CommitDetail is a single commit's full metadata plus the files it changed.
+type CommitDetail struct {
+ Commit
+ Body string // commit message with the subject line removed, trimmed
+ Parents []string // full parent hashes, empty for a root commit
+ Files []DiffFile
+}
+
+// commitDiffMaxLines caps how many lines of patch text CommitDiff returns,
+// so one huge commit can't make gitfed-web buffer an unbounded amount of
+// memory rendering it.
+const commitDiffMaxLines = 4000
+
+// ShowCommit returns a commit's full metadata and changed-file summary.
+// found is false (nil error) if hash doesn't resolve to a commit in
+// repoPath.
+func ShowCommit(repoPath, hash string) (detail CommitDetail, found bool, err error) {
+ format := strings.Join([]string{"%H", "%h", "%an", "%ae", "%aI", "%P"}, commitFieldSep) + commitFieldSep + "%B"
+ cmd := exec.Command("git", "--git-dir="+repoPath, "show", "--no-patch", "--format="+format, hash)
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ if notFoundGitError(stderr.String()) {
+ return CommitDetail{}, false, nil
+ }
+ return CommitDetail{}, false, fmt.Errorf("gitexec: show %s: %w: %s", hash, err, stderr.String())
+ }
+
+ out := strings.TrimRight(stdout.String(), "\n")
+ f := strings.SplitN(out, commitFieldSep, 7)
+ if len(f) != 7 {
+ return CommitDetail{}, false, fmt.Errorf("gitexec: show %s: unexpected output", hash)
+ }
+ date, _ := time.Parse(time.RFC3339, f[4])
+ var parents []string
+ if p := strings.TrimSpace(f[5]); p != "" {
+ parents = strings.Fields(p)
+ }
+ subject, body := splitCommitMessage(f[6])
+
+ files, err := commitFiles(repoPath, hash)
+ if err != nil {
+ return CommitDetail{}, false, err
+ }
+
+ return CommitDetail{
+ Commit: Commit{Hash: f[0], ShortHash: f[1], Author: f[2], Email: f[3], Date: date, Subject: subject},
+ Body: body,
+ Parents: parents,
+ Files: files,
+ }, true, nil
+}
+
+func splitCommitMessage(raw string) (subject, body string) {
+ raw = strings.TrimRight(raw, "\n")
+ parts := strings.SplitN(raw, "\n", 2)
+ subject = parts[0]
+ if len(parts) == 2 {
+ body = strings.TrimSpace(parts[1])
+ }
+ return subject, body
+}
+
+func statusName(code byte) string {
+ switch code {
+ case 'A':
+ return "added"
+ case 'D':
+ return "deleted"
+ case 'R':
+ return "renamed"
+ case 'C':
+ return "copied"
+ default:
+ return "modified"
+ }
+}
+
+// commitFiles lists the files a commit touched, combining --name-status
+// (for the change type) and --numstat (for line counts) by position: both
+// invocations walk the same diff in the same deterministic order, so the
+// Nth line of each always describes the same file.
+func commitFiles(repoPath, hash string) ([]DiffFile, error) {
+ statusLines, err := runGitLines(repoPath, "show", "--format=", "--name-status", hash)
+ if err != nil {
+ return nil, err
+ }
+ numLines, err := runGitLines(repoPath, "show", "--format=", "--numstat", hash)
+ if err != nil {
+ return nil, err
+ }
+
+ files := make([]DiffFile, 0, len(statusLines))
+ for i, line := range statusLines {
+ f := strings.Split(line, "\t")
+ if len(f) < 2 {
+ continue
+ }
+ code, path := f[0], f[len(f)-1]
+ if (code[0] == 'R' || code[0] == 'C') && len(f) >= 3 {
+ path = f[1] + " → " + f[2]
+ }
+
+ var add, del int
+ var binary bool
+ if i < len(numLines) {
+ nf := strings.Split(numLines[i], "\t")
+ if len(nf) >= 2 {
+ if nf[0] == "-" && nf[1] == "-" {
+ binary = true
+ } else {
+ add, _ = strconv.Atoi(nf[0])
+ del, _ = strconv.Atoi(nf[1])
+ }
+ }
+ }
+ files = append(files, DiffFile{Path: path, Status: statusName(code[0]), Insertions: add, Deletions: del, Binary: binary})
+ }
+ return files, nil
+}
+
+func runGitLines(repoPath string, args ...string) ([]string, error) {
+ cmd := exec.Command("git", append([]string{"--git-dir=" + repoPath}, args...)...)
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ return nil, fmt.Errorf("gitexec: %s: %w: %s", strings.Join(args, " "), err, stderr.String())
+ }
+ var lines []string
+ for _, l := range strings.Split(stdout.String(), "\n") {
+ if l != "" {
+ lines = append(lines, l)
+ }
+ }
+ return lines, nil
+}
+
+// CommitDiff returns the unified diff patch text for a commit (all files),
+// capped at commitDiffMaxLines lines. truncated reports whether the cap
+// was hit.
+func CommitDiff(repoPath, hash string) (diff string, truncated bool, err error) {
+ cmd := exec.Command("git", "--git-dir="+repoPath, "show", "--format=", "--no-color", hash)
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ return "", false, fmt.Errorf("gitexec: diff %s: %w: %s", hash, err, stderr.String())
+ }
+ lines := strings.Split(strings.TrimRight(stdout.String(), "\n"), "\n")
+ truncated = len(lines) > commitDiffMaxLines
+ if truncated {
+ lines = lines[:commitDiffMaxLines]
+ }
+ return strings.Join(lines, "\n"), truncated, nil
+}
internal/i18n/strings_en.go
@@ -256,6 +256,10 @@ var en = map[string]string{
"repo.tags": "Tags",
"repo.commits_title": "Commits",
"repo.commits_empty": "No commits yet.",
+ "repo.commit_label": "commit",
+ "repo.parent_label": "parent",
+ "repo.files_changed": "files changed",
+ "repo.diff_truncated": "This commit's diff is too large to show in full — the list above still shows every changed file.",
"repo.license": "License",
"repo.view_file": "View file",
"repo.binary_file": "Binary file — not shown.",
internal/i18n/strings_fr.go
@@ -256,6 +256,10 @@ var fr = map[string]string{
"repo.tags": "Tags",
"repo.commits_title": "Commits",
"repo.commits_empty": "Aucun commit pour le moment.",
+ "repo.commit_label": "commit",
+ "repo.parent_label": "parent",
+ "repo.files_changed": "fichiers modifiés",
+ "repo.diff_truncated": "Le diff de ce commit est trop volumineux pour être affiché en entier — la liste ci-dessus montre tout de même chaque fichier modifié.",
"repo.license": "Licence",
"repo.view_file": "Voir le fichier",
"repo.binary_file": "Fichier binaire — non affiché.",