Files
gico/CODE_REVIEW.md
Tai Groot 30dea2c239 test(types,common): add unit tests, fix goimports formatting
- Add tests for types package: NewDataSet, NewCommit, Commit.String,
  Freq.Merge, DataSet operations
- Add tests for graph/common package: MinMax, ColorForFrequency,
  CreateGraph, SetColorScheme
- Fix goimports alignment in common.go and settings.go
2026-03-11 06:01:45 +00:00

12 KiB

Code Review: gico

Reviewer: Claude (automated)
Date: 2026-02-17
Commit: b165dc2


Executive Summary

gico is a well-structured Go project with a clean separation between core library (commits/, types/, graph/) and executables (cmd/). The architecture is sound — channel-based parallel repo parsing, memoization cache, pluggable rendering (SVG, terminal). However, there are several bugs (including a crash-on-panic HTTP server, incorrect leap year logic, and race conditions in the cache), zero test coverage, stale dead code, and some naming/idiom issues.

Severity breakdown: 🔴 3 critical | 🟡 8 moderate | 🟢 6 minor


🔴 Critical Issues

1. HTTP handlers panic instead of returning errors (cmd/gico-server/main.go:33-34, 53-54, 79-80)

Every handler calls panic(err) on failure. This crashes the entire server on any bad request or transient git error.

// BAD: crashes server
if err != nil {
    panic(err)
}

// GOOD: return HTTP error
if err != nil {
    http.Error(w, err.Error(), http.StatusInternalServerError)
    return
}

Also, line 59 ignores the error from json.Marshal:

b, _ := json.Marshal(freq)  // if this fails, writes empty body with 200

2. Incorrect leap year calculation (multiple files)

The leap year check year%4 == 0 is wrong. Years divisible by 100 but not 400 are NOT leap years (e.g., 1900, 2100).

Affected locations:

  • commits/chancommits.go:58, 113-114, 156-157, 209-210
  • commits/commits.go:40-41, 119-120
  • ui/ui.go:70-73
  • ui/graph.go:22-23

Fix: Extract a helper and use the correct algorithm:

func isLeapYear(year int) bool {
    return year%4 == 0 && (year%100 != 0 || year%400 == 0)
}

func yearLength(year int) int {
    if isLeapYear(year) {
        return 366
    }
    return 365
}

This is duplicated 8+ times — a single yearLength() function in types or commits would DRY it up.

3. Cache goroutine leak / race condition (commits/cache.go:84-89, 98-102, 154-160, 175-180)

Every CacheRepo/CacheGraph/etc. spawns a goroutine that sleeps for 1 hour then deletes the entry. Problems:

  1. Goroutine leak: If the same key is cached repeatedly (e.g., TUI refreshing), goroutines accumulate — each sleeping for an hour. In a server context with frequent requests, this is unbounded.
  2. Stale deletion: The goroutine captures the key at spawn time. If the entry is refreshed (re-cached), the old goroutine still fires and deletes the new entry. The comment on line 158 ("optimization, check if the creation time has changed") acknowledges this but doesn't implement the fix.

Fix: Use a time.Time comparison before deleting, or better, use a single background reaper:

func CacheRepo(path string, commits []types.Commit) {
    mapTex.Lock()
    defer mapTex.Unlock()
    repoCache[path] = types.ExpRepo{Commits: commits, Created: time.Now()}
}

// Single reaper goroutine started in init()
func startReaper() {
    go func() {
        ticker := time.NewTicker(5 * time.Minute)
        for range ticker.C {
            mapTex.Lock()
            now := time.Now()
            for k, v := range repoCache {
                if now.Sub(v.Created) > time.Hour {
                    delete(repoCache, k)
                }
            }
            mapTex.Unlock()
        }
    }()
}

🟡 Moderate Issues

4. hashSlice appends a newline (commits/cache.go:42)

return fmt.Sprintf("%x\n", b)  // trailing \n in cache key

This works consistently (always there) but is surprising and would break if any key comparison strips whitespace. Remove the \n.

5. OpenRepo dereferences potentially nil pointer (commits/common.go:36)

r, err := git.PlainOpenWithOptions(directory, &(git.PlainOpenOptions{DetectDotGit: true}))
return Repo{Repo: *r, Path: directory}, err

If err != nil, r may be nil, causing a nil pointer dereference before the caller can check err. Fix:

r, err := git.PlainOpenWithOptions(directory, &git.PlainOpenOptions{DetectDotGit: true})
if err != nil {
    return Repo{}, err
}
return Repo{Repo: *r, Path: directory}, nil

6. SetColorScheme appends instead of replacing (graph/common/common.go:30-34)

func SetColorScheme(c []color.Color) {
    for _, c := range c {
        colorScheme = append(colorScheme, sc.FromRGBA(c.RGBA()))
    }
}

This appends to the existing scheme. Callers likely expect it to replace. Also, the parameter c shadows the function parameter — use a different name:

func SetColorScheme(colors []color.Color) {
    colorScheme = colorScheme[:0]
    for _, clr := range colors {
        colorScheme = append(colorScheme, sc.FromRGBA(clr.RGBA()))
    }
}

7. colorsLoaded sync.Once is unused everywhere

graph/common/common.go:12, graph/svg/svg.go:22-23, graph/term/term.go:15-16 all declare colorsLoaded sync.Once and colorScheme variables that are never used. staticcheck flags these. The color init is done in init() instead. Remove the dead declarations.

8. svgToPng is dead code with hardcoded filenames (graph/svg/svg.go:92-116)

This function reads in.svg and writes out.png — clearly leftover development code. It's unused (staticcheck U1000). Remove it or move it to a cmd/ tool if needed.

9. Author filter uses regex without escaping (commits/chancommits.go:239, commits/commits.go:132)

Author names/emails are passed directly to regexp.Compile. An email like user+tag@example.com contains + which is a regex quantifier. A name with ( would cause a compile error.

// Fix: escape the input or use strings.Contains
r, err := regexp.Compile(regexp.QuoteMeta(a))

Or if regex matching is intentional, document it clearly in the API.

10. GetWeekFreq doesn't handle year boundaries correctly (commits/commits.go:22-33)

When today < 6 (first week of year), it fetches last year's freq and prepends it. But today is adjusted by adding 365 (or 366), then it slices freq[today-6 : today+1]. The logic assumes freq is a simple concatenation, which works, but:

  • If curYear is a leap year, the combined slice has 366 + N entries, but indexing by today + 365/366 could go wrong at boundaries.
  • No test coverage makes this fragile.

11. Frequency (sync version) silently continues on error (commits/commits.go:52-53)

commits, err := repo.GetCommitSet()
if err != nil {
    log.Printf("skipping repo %s\n", repo.Path)
}
// continues to use `commits` even when err != nil

Should continue after the log:

if err != nil {
    log.Printf("skipping repo %s\n", repo.Path)
    continue
}

🟢 Minor Issues

12. Naming conventions

Per Go conventions and Tai's standards:

  • mapTexmapMu (it's a mutex, not a texture) — commits/cache.go:15
  • ystyearStrcmd/gico-server/main.go:44, 65
  • yparsedYearcmd/gico-server/main.go:46, 68
  • gfreqglobalFreq or mergedFreqcommits/commits.go:44
  • aName/aEmailauthorName/authorEmailcmd/mgfetch/main.go:17-18
  • cscommitSetcommits/commits.go:75
  • cccommitChancommits/chancommits.go:19, 70
  • a (in cache functions) → authorHashcommits/cache.go:46
  • r (in cache functions) → repoHashcommits/cache.go:47
  • sbbufgraph/svg/svg.go:37
  • Single-letter s, c, x used as non-loop variables throughout graph/ — expand these

13. delegateKeyMap and newDelegateKeyMap unused (ui/listdelegate.go:52-62)

Dead code. Remove or wire it up.

14. highlightedEntry field unused (ui/settings.go:25)

highlightedEntry int  // never read or written

15. colorsLoaded sync.Once pattern abandoned

The init() approach in graph/common/common.go:21-28 overwrites colors immediately (line 23 assigns, line 24 reassigns). The first palette is dead code:

colors := []string{"#767960", "#a7297f", "#e8ca89", "#f5efd6", "#158266"}
colors = []string{"#0e4429", "#006d32", "#26a641", "#39d353"}  // overwrites above

16. Content-Type for SVG endpoints should be image/svg+xml

cmd/gico-server/main.go:30, 84 set Content-Type: text/html for SVG responses. Should be image/svg+xml.

17. Gorilla mux/handlers are archived

github.com/gorilla/mux and github.com/gorilla/handlers are archived (Dec 2023). Consider migrating to net/http (Go 1.22+ has pattern matching) or chi.


Test Coverage

There are zero test files in the entire project. Priority areas for testing:

  1. types/helpers.goFreq.Merge(): Easy to unit test, handles edge cases (different lengths)
  2. commits/cache.go: Cache hit/miss/expiry logic
  3. graph/common/common.goColorForFrequency, MinMax: Pure functions, easy to test
  4. commits/chancommits.goFilterCChanByYear, FilterCChanByAuthor: Channel pipeline correctness
  5. Leap year helper (once extracted): Table-driven tests for 1900, 2000, 2004, 2100

Dependency Hygiene

  • go 1.19: Very old. The project uses generics-era dependencies (bubbletea v0.25). Consider bumping to at least go 1.21.
  • Gorilla packages archived: gorilla/mux v1.8.0 and gorilla/handlers v1.5.1 — see item 17.
  • srwiley/oksvg and srwiley/rasterx: Only used in dead svgToPng() function. Removing that function drops two dependencies.
  • golang.org/x/image: Also only pulled in by the dead SVG-to-PNG code.
  • imdario/mergo + dario.cat/mergo: Both in go.sum — transitive dep duplication from go-git. Not actionable but worth noting.

Run go mod tidy after removing dead code to clean up.


Security Issues

  1. Regex injection via author filter (item 9): User-supplied author strings from query params are compiled as regex. A malicious ?author=(.*) matches everything; pathological patterns could cause ReDoS.
  2. No input validation on HTTP endpoints: Year parameter is safely handled (defaults on parse failure), but author is passed through unchecked.
  3. Server binds to all interfaces (cmd/gico-server/main.go:88): ":8822" listens on 0.0.0.0. For a local tool, consider "127.0.0.1:8822" or make it configurable.
  4. MD5 used for cache keys (commits/cache.go:39): Not a security issue (not used cryptographically), but hash/fnv would be faster and more idiomatic for non-crypto hashing.

Architecture Notes

Good patterns:

  • Clean separation of concerns: types/ for data, commits/ for git ops, graph/ for rendering, ui/ for TUI
  • Channel-based parallel repo parsing is well-structured
  • Cache layer is a reasonable optimization for the TUI use case
  • BubbleTea integration is solid with separate models per view

Suggestions:

  • The Repo type wrapping git.Repository by value (commits/common.go:16-19) copies the entire struct. Use a pointer: Repo *git.Repository
  • RepoSet as []string is fine but consider a dedicated type with methods for validation
  • The Freq type ([]int) having String() and StringSelected() methods that depend on graph/term creates a circular concern — the data type knows about its terminal rendering. Consider making these standalone functions in graph/term instead.

README Accuracy

  • "svg-server" is mentioned in the gico-server section but the binary is gico-server (renamed in commit b3e3047). Update the description.
  • Example GIF placeholder exists but no image is linked.
  • The backtick formatting for mgfetch has a stray backtick at the end of "terminal.`"
  • No mention of the --author or any CLI flags (there aren't any — consider adding flag support).

Priority Action
P0 Fix panics in HTTP handlers → return proper HTTP errors
P0 Fix nil pointer deref in OpenRepo
P0 Fix leap year calculation (extract yearLength helper)
P1 Replace goroutine-per-cache-entry with single reaper
P1 Escape regex in author filter (or use strings.Contains)
P1 Add tests for core functions
P1 Remove dead code (svgToPng, unused vars, delegateKeyMap)
P2 Fix Content-Type for SVG endpoints
P2 Fix silent error continuation in Frequency()
P2 Rename variables per Go conventions
P2 Bump Go version, consider replacing archived gorilla deps
P3 Bind server to localhost by default
P3 Fix README inaccuracies
P3 Remove hashSlice newline, switch MD5 → FNV