Gitfed
bastien-mrq/gitfed/ Commits/ 75662f6

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.

bastien-mrq 2026-07-28 21:39 commit 75662f65bf73be46185c7d3625df9ea91c9deadb parent 0ca8a179199068ec90f77548e7def244cbc859cd
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
diff --git a/CHANGELOG.md b/CHANGELOG.md index aaa81e4..6afdef4 100644 --- a/CHANGELOG.md +++ b/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
diff --git a/cmd/gitfed-web/handlers_repo_commits.go b/cmd/gitfed-web/handlers_repo_commits.go index cb14a4d..4ba63de 100644 --- a/cmd/gitfed-web/handlers_repo_commits.go +++ b/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
diff --git a/cmd/gitfed-web/render.go b/cmd/gitfed-web/render.go index 66586e8..094b74f 100644 --- a/cmd/gitfed-web/render.go +++ b/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
diff --git a/cmd/gitfed-web/routes.go b/cmd/gitfed-web/routes.go index 75afabb..c29d9d5 100644 --- a/cmd/gitfed-web/routes.go +++ b/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
diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 679c3c5..3f2acb9 100644 --- a/internal/admin/admin.go +++ b/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
diff --git a/internal/adminrpc/client.go b/internal/adminrpc/client.go index 25c5182..6d0d5e8 100644 --- a/internal/adminrpc/client.go +++ b/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
diff --git a/internal/adminrpc/protocol.go b/internal/adminrpc/protocol.go index 604aae1..bf1fdbb 100644 --- a/internal/adminrpc/protocol.go +++ b/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
diff --git a/internal/adminrpc/server.go b/internal/adminrpc/server.go index 1ded3ca..f33035f 100644 --- a/internal/adminrpc/server.go +++ b/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
diff --git a/internal/gitexec/gitexec.go b/internal/gitexec/gitexec.go index 7f2b538..f1d0922 100644 --- a/internal/gitexec/gitexec.go +++ b/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
diff --git a/internal/i18n/strings_en.go b/internal/i18n/strings_en.go index c0213fd..9e305ce 100644 --- a/internal/i18n/strings_en.go +++ b/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
diff --git a/internal/i18n/strings_fr.go b/internal/i18n/strings_fr.go index 87c1758..4ecfd4e 100644 --- a/internal/i18n/strings_fr.go +++ b/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é.",