check-safety/changedlines.go
Alex Dunmow c4d01de82c feat: concise output + diff-scoped any check
Reporter is now a collector: checks declare a verdict (OK/Skip/Warn/Fail/
Fatal) plus findings, and a single central render() prints output. Default
output is silent on pass/skip — only FAIL/WARN/ERR checks print, followed by
one tally line, so a clean run is two lines. --verbose restores full per-check
output. All ~30 checks were converted to this API; orphaned guidance/label
helpers (printPerTargetOKLines, per-check *Help blocks, colors_format.go) were
removed.

The any-usage check (2e) now defaults to only the unstaged working-tree diff
(changed lines), via a new per-repo git-diff index in changedlines.go; --all-any
restores the full scan. Not-a-git-repo / no-diff warns on nothing.

Golden fixtures regenerated; integration tests updated to the new format; added
unit tests for the diff index.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 01:16:56 +08:00

136 lines
3.8 KiB
Go

package main
import (
"bufio"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
// diffIndex answers "is this file:line part of the unstaged working-tree diff?"
// It resolves each file to its enclosing git repo and, once per repo, parses
// `git diff --unified=0` (added/modified lines) plus untracked files (which count
// as fully changed). Everything is cached; files with no enclosing repo, or no
// matching change, are treated as unchanged.
type diffIndex struct {
repoOf map[string]string // dir -> git repo root ("" = none)
repos map[string]*repoDiff // repo root -> parsed diff
}
type repoDiff struct {
changed map[string]map[int]bool // repo-relative slash path -> changed line set
untracked map[string]bool // repo-relative slash path (fully changed)
}
func newDiffIndex() *diffIndex {
return &diffIndex{repoOf: map[string]string{}, repos: map[string]*repoDiff{}}
}
// isChanged reports whether absPath:line lies in the unstaged working-tree diff.
func (d *diffIndex) isChanged(absPath string, line int) bool {
repo := d.repoRoot(filepath.Dir(absPath))
if repo == "" {
return false
}
rd := d.repoDiff(repo)
rel, err := filepath.Rel(repo, absPath)
if err != nil {
return false
}
rel = filepath.ToSlash(rel)
if rd.untracked[rel] {
return true
}
if lines, ok := rd.changed[rel]; ok {
return lines[line]
}
return false
}
// repoRoot walks up from dir to find the enclosing git repo root, cached.
func (d *diffIndex) repoRoot(dir string) string {
if root, ok := d.repoOf[dir]; ok {
return root
}
out, err := exec.Command("git", "-C", dir, "rev-parse", "--show-toplevel").Output()
root := ""
if err == nil {
root = strings.TrimSpace(string(out))
}
d.repoOf[dir] = root
return root
}
// repoDiff parses (and caches) the unstaged diff + untracked files for a repo.
func (d *diffIndex) repoDiff(repo string) *repoDiff {
if rd, ok := d.repos[repo]; ok {
return rd
}
rd := &repoDiff{changed: map[string]map[int]bool{}, untracked: map[string]bool{}}
if out, err := exec.Command("git", "-C", repo, "diff", "--unified=0", "--no-color").Output(); err == nil {
parseUnifiedDiff(string(out), rd)
}
if out, err := exec.Command("git", "-C", repo, "ls-files", "--others", "--exclude-standard").Output(); err == nil {
for _, f := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if f != "" {
rd.untracked[filepath.ToSlash(f)] = true
}
}
}
d.repos[repo] = rd
return rd
}
// parseUnifiedDiff records added/modified line numbers per file from a
// `git diff --unified=0` payload. Only the new-file side (`+c,d`) matters.
func parseUnifiedDiff(diff string, rd *repoDiff) {
var cur string
sc := bufio.NewScanner(strings.NewReader(diff))
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "+++ b/"):
cur = strings.TrimPrefix(line, "+++ b/")
if _, ok := rd.changed[cur]; !ok {
rd.changed[cur] = map[int]bool{}
}
case strings.HasPrefix(line, "@@"):
start, count := parseHunkNewRange(line)
if start <= 0 || cur == "" {
continue
}
if rd.changed[cur] == nil {
rd.changed[cur] = map[int]bool{}
}
for i := 0; i < count; i++ {
rd.changed[cur][start+i] = true
}
}
}
}
// parseHunkNewRange extracts (start, count) from the `+c,d` part of a hunk
// header like `@@ -a,b +c,d @@`. A missing `,d` means count 1.
func parseHunkNewRange(header string) (int, int) {
plus := strings.IndexByte(header, '+')
if plus < 0 {
return 0, 0
}
rest := header[plus+1:]
if sp := strings.IndexByte(rest, ' '); sp >= 0 {
rest = rest[:sp]
}
start, count := 0, 1
if comma := strings.IndexByte(rest, ','); comma >= 0 {
start, _ = strconv.Atoi(rest[:comma])
count, _ = strconv.Atoi(rest[comma+1:])
} else {
start, _ = strconv.Atoi(rest)
}
return start, count
}