Skip to content

Add git blame gutter toggle (B key) - #38

Merged
umputun merged 5 commits into
umputun:masterfrom
melonamin:feature/blame-gutter
Apr 6, 2026
Merged

umputun merged 5 commits into
umputun:masterfrom
melonamin:feature/blame-gutter

Conversation

@melonamin

@melonamin melonamin commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds a git blame gutter toggled with B key, showing author name (truncated to 8 chars) and relative commit age (e.g. 3d, 2w, 1y) per line
  • Blame data loads asynchronously via git blame and is keyed by new-side line numbers; removed lines and dividers show blank gutters
  • New Blamer interface (ui.Blamer) with diff.Git implementation; gracefully nil when git is unavailable
  • Extracted shared gutter helpers (lineGutters, gutterExtra, gutterBlanks, applyHorizontalScroll) to reduce duplication across expanded, collapsed, and wrapped render paths
  • Status bar @ icon indicates blame gutter is active
  • Updated README, docs site, CLAUDE.md, and plugin reference docs

New files

  • diff/blame.goFileBlame() parser, RelativeAge() formatter, BlameLine struct
  • diff/blame_test.go — tests for blame parsing, age formatting, hex validation

Screenshots

image

@melonamin
melonamin requested a review from umputun as a code owner April 6, 2026 17:16
@melonamin
melonamin marked this pull request as draft April 6, 2026 17:18
@melonamin
melonamin marked this pull request as ready for review April 6, 2026 17:23

@umputun umputun left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice feature, the gutter helper refactoring is a good cleanup. couple things to fix:

linter issues:

  1. diff/blame.go:35 - os.Remove return unchecked in defer, wrap with _ = os.Remove(tmpName)
  2. diff/blame.go:22 - param types can be combined: ref, file string
  3. diff/blame.go:37 - tmp.Close() error unhandled in error path

code:
4. Blamer interface in ui/model.go is missing //go:generate moq directive, project convention requires it for all consumer-side interfaces (see Renderer and SyntaxHighlighter above it)
5. ui/annotate.go:290-296 duplicates gutter width calculation that's already in m.gutterExtra(), pls reuse it

question:
6. @ icon for blame status conflicts with @ key for annotation list popup. different things, same symbol. maybe use smth else like b or similar?

@umputun

umputun commented Apr 6, 2026

Copy link
Copy Markdown
Owner

one more thing, pls also add --blame CLI flag + REVDIFF_BLAME env var + config file option, same as --collapsed, --wrap, and --line-numbers do. all toggleable modes should be persistable via config.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an optional git blame gutter to the diff UI, toggled via B, to show per-line authorship and relative commit age while keeping blame loading asynchronous and non-blocking.

Changes:

  • Introduces ui.Blamer and a diff.Git blame implementation with parsing + relative age formatting.
  • Updates diff rendering paths (normal/wrapped/collapsed) to support an additional gutter and shared gutter helpers.
  • Extends keymap, status bar iconography, tests, and documentation to cover the new blame toggle.

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
ui/model.go Adds blamer plumbing, async blame loading, toggle handling
ui/model_test.go Updates/extends model tests for blame + toggle signature change
ui/diffview.go Implements blame gutter rendering + shared gutter helpers
ui/collapsed.go Adds blame-aware gutters and placeholder wrap-height logic
ui/annotate.go Reuses placeholder visual-height helper (wrap + gutters)
ui/mdtoc.go Preallocs TOC entries slice
ui/filetree.go Preallocs tree entries slice
keymap/keymap.go Adds ActionToggleBlame and default B binding + help text
keymap/keymap_test.go Updates expected default bindings to include B
diff/blame.go New: FileBlame, porcelain parser, hex validation, RelativeAge
diff/blame_test.go New: tests for blame parsing, age formatting, and git behavior
diff/diff.go Adjusts git command invocation (runGit)
diff/diff_test.go Adjusts test helper git command invocation
diff/fallback.go Returns nil for empty file sets + prealloc result slice
diff/directory.go Preallocs files slice for git ls-files -z parsing
cmd/revdiff/main.go Wires optional blamer into UI when git is available
README.md Documents blame gutter and B toggle
site/index.html Mentions blame gutter in feature copy
site/docs.html Adds B to view toggles table
theme/theme.go Preallocs theme names slice
CLAUDE.md Documents blame gutter behavior and new status icon
.claude-plugin/skills/revdiff/references/usage.md Adds B binding to plugin usage docs

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread diff/diff.go
@@ -92,7 +92,7 @@ func (g *Git) diffArgs(ref string, staged bool) []string {

// runGit executes a git command in the working directory and returns its output.
func (g *Git) runGit(args ...string) (string, error) {

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

runGit now uses exec.CommandContext without the previous //nolint:gosec suppression. Since gosec is enabled in .golangci.yml, this will typically trigger G204 (subprocess launched with variable) and fail CI. Either restore a //nolint:gosec with a brief justification that args are internally constructed (and not user-controlled), or restructure to satisfy gosec (e.g., avoid passing arbitrary slices without validation).

Suggested change
func (g *Git) runGit(args ...string) (string, error) {
func (g *Git) runGit(args ...string) (string, error) {
//nolint:gosec // G204: "git" is fixed and args are passed as argv entries, constructed internally without shell interpretation.

Copilot uses AI. Check for mistakes.
Comment thread diff/diff_test.go Outdated
func gitCmd(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...) //nolint:gosec // test helper
cmd := exec.Command("git", args...)

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test helper gitCmd calls exec.Command("git", args...) without //nolint:gosec. With gosec enabled, this is likely to be flagged (G204) and fail lint. Add back a targeted //nolint:gosec with an explanation (test-only helper; args are controlled by the test code) or adjust the helper to avoid gosec complaints.

Suggested change
cmd := exec.Command("git", args...)
cmd := exec.Command("git", args...) //nolint:gosec // test-only helper; command is fixed and args are controlled by the test code

Copilot uses AI. Check for mistakes.
Comment thread ui/diffview.go
Comment on lines +86 to +90
// hasBlameGutter returns true when the blame gutter should be rendered.
func (m Model) hasBlameGutter() bool {
return m.showBlame && len(m.blameData) > 0
}

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasBlameGutter is gated on len(m.blameData) > 0, which means toggling blame on shows the @ status icon but renders no gutter until blame data arrives (and renders nothing at all on blame load errors/untracked files). This makes the toggle appear broken/inconsistent. Consider rendering the gutter whenever showBlame is true (using blanks while loading/unavailable) and ensure blameAuthorLen has a non-zero default width while loading.

Copilot uses AI. Check for mistakes.
Comment thread diff/blame.go Outdated
Comment on lines +64 to +68
parts := strings.Split(ref, "..")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return ""
}
return parts[1]

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blameTargetRef splits on ".." and will mis-handle the valid git range syntax A...B (triple-dot), returning a ref like ".B". This can cause git blame to fail or blame the wrong revision. Consider explicitly handling both .. and ... forms (and/or use strings.Cut/SplitN with a check for ...).

Suggested change
parts := strings.Split(ref, "..")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return ""
}
return parts[1]
if left, right, ok := strings.Cut(ref, "..."); ok {
if left == "" || right == "" {
return ""
}
return right
}
if left, right, ok := strings.Cut(ref, ".."); ok {
if left == "" || right == "" {
return ""
}
return right
}
return ""

Copilot uses AI. Check for mistakes.
Comment thread diff/blame.go
Comment on lines +84 to +95
// header line: <40-hex-hash> <orig_line> <final_line> [<group_lines>]
if len(line) >= 40 && isHexString(line[:40]) {
parts := strings.Fields(line)
if len(parts) >= 3 {
if n, err := strconv.Atoi(parts[2]); err == nil {
lineNum = n
}
}
author = ""
authorTime = time.Time{}
continue
}

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In parseBlame, when a header line is detected but the final line number fails to parse (or fields are missing), lineNum is left unchanged from the previous entry. This can incorrectly attribute the next content line to the prior line number. Reset lineNum to 0 at the start of each header (and only set it when parsing succeeds) to avoid stale state.

Copilot uses AI. Check for mistakes.
Comment thread diff/blame.go
Comment on lines +53 to +59
if _, err := tmp.WriteString(indexContent); err != nil {
tmp.Close() //nolint:gosec // best-effort close in error path
return "", fmt.Errorf("write temp blame file for %s: %w", file, err)
}
if err := tmp.Close(); err != nil {
return "", fmt.Errorf("close temp blame file for %s: %w", file, err)
}

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

writeStagedBlameFile can leak temp files on error paths: if WriteString or the initial Close() fails, the temp file is left on disk. Also tmp.Close() in the write-error path is suppressed with //nolint:gosec, but the relevant linter for ignoring close errors is errcheck (or just handle the error). Consider removing the temp file on any failure after creation and use the correct nolint (or check the close error).

Suggested change
if _, err := tmp.WriteString(indexContent); err != nil {
tmp.Close() //nolint:gosec // best-effort close in error path
return "", fmt.Errorf("write temp blame file for %s: %w", file, err)
}
if err := tmp.Close(); err != nil {
return "", fmt.Errorf("close temp blame file for %s: %w", file, err)
}
success := false
defer func() {
if success {
return
}
_ = tmp.Close()
_ = os.Remove(tmp.Name())
}()
if _, err := tmp.WriteString(indexContent); err != nil {
return "", fmt.Errorf("write temp blame file for %s: %w", file, err)
}
if err := tmp.Close(); err != nil {
return "", fmt.Errorf("close temp blame file for %s: %w", file, err)
}
success = true

Copilot uses AI. Check for mistakes.
@melonamin
melonamin marked this pull request as draft April 6, 2026 18:38
Show author name and relative commit age per line in a gutter column,
toggled with the B key. Blame data loads asynchronously via git blame
and is keyed by new-side line numbers (blank for removed lines/dividers).

- Add diff/blame.go with FileBlame parser and RelativeAge formatter
- Add Blamer interface in ui/, wired via ModelConfig from cmd/main.go
- Add blameGutter rendering in diffview.go alongside lineNumGutter
- Extract shared gutter helpers (lineGutters, gutterExtra, gutterBlanks,
  applyHorizontalScroll) to reduce duplication across render paths
- Add ActionToggleBlame keymap action bound to B
- Update status bar mode icons to show @ when blame is active
- Update README, docs, site, CLAUDE.md, and plugin references
- Extract writeStagedBlameFile() to reduce nestif complexity in blame.go
- Combine param types in FileBlame signature (gocritic)
- Handle errcheck/gosec on temp file cleanup paths
- Extract deletePlaceholderVisualHeight() to reduce nestif in annotate.go
- Remove stale //nolint:gosec directives from diff.go and diff_test.go
- Pre-allocate slices in directory.go, fallback.go, theme.go, filetree.go,
  mdtoc.go, and model_test.go; preserve nil-vs-empty semantics in fallback.go
@melonamin
melonamin force-pushed the feature/blame-gutter branch from 35e4cc5 to e80adaf Compare April 6, 2026 18:54
strings.Split("A...B", "..") produced ".B" which caused git blame
to fail or blame the wrong revision. Use strings.Cut and check for
"..." before ".." to correctly extract the target ref.
@melonamin
melonamin marked this pull request as ready for review April 6, 2026 19:02
@melonamin

Copy link
Copy Markdown
Contributor Author

@umputun addressed all yours and one reasonable from Copilot

@melonamin
melonamin requested a review from umputun April 6, 2026 19:08
@umputun

umputun commented Apr 6, 2026

Copy link
Copy Markdown
Owner

looks good, tested locally. one last thing, the "Fix all golangci-lint warnings" commit removed existing //nolint:gosec comments from diff/diff.go:95 and diff/diff_test.go:328. those were intentional suppressions (git args are constructed internally, not user input). pls restore them, otherwise CI will fail.

CI golangci-lint flags G204 on exec.Command with variable args.
These are safe — git args are constructed internally, not from user input.
@melonamin

Copy link
Copy Markdown
Contributor Author

Restored

@umputun umputun left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thx for addressing everything

@umputun
umputun merged commit 21723ce into umputun:master Apr 6, 2026
sanchesfree pushed a commit to sanchesfree/revdiff that referenced this pull request Apr 8, 2026
* Add git blame gutter toggle (B key)

Show author name and relative commit age per line in a gutter column,
toggled with the B key. Blame data loads asynchronously via git blame
and is keyed by new-side line numbers (blank for removed lines/dividers).

- Add diff/blame.go with FileBlame parser and RelativeAge formatter
- Add Blamer interface in ui/, wired via ModelConfig from cmd/main.go
- Add blameGutter rendering in diffview.go alongside lineNumGutter
- Extract shared gutter helpers (lineGutters, gutterExtra, gutterBlanks,
  applyHorizontalScroll) to reduce duplication across render paths
- Add ActionToggleBlame keymap action bound to B
- Update status bar mode icons to show @ when blame is active
- Update README, docs, site, CLAUDE.md, and plugin references

* Fix all golangci-lint warnings

- Extract writeStagedBlameFile() to reduce nestif complexity in blame.go
- Combine param types in FileBlame signature (gocritic)
- Handle errcheck/gosec on temp file cleanup paths
- Extract deletePlaceholderVisualHeight() to reduce nestif in annotate.go
- Remove stale //nolint:gosec directives from diff.go and diff_test.go
- Pre-allocate slices in directory.go, fallback.go, theme.go, filetree.go,
  mdtoc.go, and model_test.go; preserve nil-vs-empty semantics in fallback.go

* Fix blame review follow-ups and persist view modes

* Fix blameTargetRef to handle triple-dot ref syntax

strings.Split("A...B", "..") produced ".B" which caused git blame
to fail or blame the wrong revision. Use strings.Cut and check for
"..." before ".." to correctly extract the target ref.

* Restore //nolint:gosec for internal exec.Command calls

CI golangci-lint flags G204 on exec.Command with variable args.
These are safe — git args are constructed internally, not from user input.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants