From bad963237434fbad0bbb50bba2422ee84b4d4a3e Mon Sep 17 00:00:00 2001 From: rwrife Date: Tue, 14 Jul 2026 22:25:16 +0000 Subject: [PATCH] Add cosmetic pests from revert commits (#23) --- README.md | 34 ++++++++++++++++++ cmd/root.go | 17 +++++++++ internal/gitstat/gitstat.go | 52 ++++++++++++++++++++++----- internal/gitstat/gitstat_test.go | 57 ++++++++++++++++++++++++++++-- internal/plant/plant.go | 47 +++++++++++++++++++++++++ internal/plant/plant_test.go | 53 ++++++++++++++++++++++++++++ internal/render/render.go | 60 ++++++++++++++++++++++++++++++++ internal/render/render_test.go | 42 ++++++++++++++++++++++ 8 files changed, 352 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index aefbe9e..97680b2 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,37 @@ stage: leafy (healthy) ambience layered on the same real, commit-driven plant, and it degrades to plain ASCII under `--no-color` / `NO_COLOR` / piped output. +### Pests & weeds + +Messy git behavior gives your plant a few cosmetic **pests** (aphids/weeds), +drawn as small `%` glyphs on the foliage: + +``` +% , +% /| +% ' | + _|_ + ( ) + '-' + +stage: sprout (healthy) +streak: 1 day +last: today (2026-07-14) + 3 pests (from reverts) — a clean commit today clears them +``` + +- **What spawns them:** revert commits in the look-back window. A revert is + detected purely from the conventional `Revert "…"` commit subject that + `git revert` writes — no commit content or diffs are ever read. Each in-window + revert adds one pest, capped at 3. +- **How to clear them:** commit normally. A clean, healthy commit *today* (real + work, not just more reverts) tidies the plant back to zero pests. +- **Gentle by design:** pests are visual + moral only. They **never** lower your + stage, streak, or health — they're a nudge, not a punishment. +- **Turn them off:** `--no-pests` disables the overlay entirely for anyone who + prefers a plant that never shows negative signals. They also degrade to plain + ASCII under `--no-color` / `NO_COLOR` / piped output. + ## Install ### Prebuilt binary (recommended) @@ -365,6 +396,9 @@ See [PLAN.md](./PLAN.md) for the full plan, milestones (M1–M6), and the v0.2+ - **Seasons & themes** (`commit-sprout --season` / `--no-season` / `--holiday`) — date-driven cosmetic palettes and decorations (leaves, snow, blossoms), purely ambient and never affecting the plant's state. +- **Pests & weeds** (`--no-pests` to disable) — revert commits spawn small cosmetic + aphids/weeds you clear with a clean commit; visual + moral only, never touching + stage, streak, or health. ## License diff --git a/cmd/root.go b/cmd/root.go index 82c993f..da84e4f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -67,6 +67,12 @@ var noSeason bool // decorations appear beyond the base season. var holidaySkin bool +// noPests backs the --no-pests flag, which disables the cosmetic pest overlay +// (aphids/weeds spawned by revert commits) entirely for people who prefer a +// plant that never shows negative signals. It only suppresses the visual + +// caption; it never affects stage, streak, or health. +var noPests bool + // rootCmd is the base command invoked as `commit-sprout` with no subcommand. var rootCmd = &cobra.Command{ Use: "commit-sprout", @@ -161,6 +167,13 @@ func runPipeline() (pipelineResult, error) { ps := plant.ComputeWith(act, persisted.Plant(now, act.LastCommit), now, tune) + // --no-pests suppresses the cosmetic pest overlay without touching any of + // the growth/health signals: pests are purely visual, so zeroing the count + // here is the whole opt-out. + if noPests { + ps.Pests = 0 + } + // Resolve the cosmetic season: --no-season disables it, an explicit // --season forces one (reporting typos), otherwise derive it from now. // This is purely ambient and never persisted. @@ -341,6 +354,10 @@ func init() { rootCmd.PersistentFlags().BoolVar(&noSeason, "no-season", false, "disable seasonal dressing (neutral look)") rootCmd.PersistentFlags().BoolVar(&holidaySkin, "holiday", false, "opt into gentle holiday skins layered on the season") + // --no-pests disables the cosmetic pest overlay (reverts spawn aphids you + // clear with a clean commit). Visual/moral only; never affects growth. + rootCmd.PersistentFlags().BoolVar(&noPests, "no-pests", false, "disable cosmetic pests (aphids/weeds from reverts)") + // Hidden M2 bridge: dump parsed git activity. Removed once M3+ consume it. rootCmd.Flags().BoolVar(&showActivity, "activity", false, "print parsed git activity (debug)") _ = rootCmd.Flags().MarkHidden("activity") diff --git a/internal/gitstat/gitstat.go b/internal/gitstat/gitstat.go index 85a8322..5753078 100644 --- a/internal/gitstat/gitstat.go +++ b/internal/gitstat/gitstat.go @@ -51,11 +51,23 @@ const logFieldDirective = "%x00" // %H full commit hash // %aI author date, strict ISO-8601 (keeps the author's timezone offset) // %aE author email (the "real" email after .mailmap is applied) +// %s commit subject (first line only), used to detect revert commits // // Fields are joined with the %x00 directive so git emits NUL-separated output. // We use the *author* date/email rather than committer, because that reflects // when the work was actually done and by whom, which is what the plant rewards. -var logFormat = strings.Join([]string{"%H", "%aI", "%aE"}, logFieldDirective) +// +// The subject is placed last because it is the only field that can contain +// arbitrary text; keeping it in the final position means a subject that somehow +// contained an unexpected byte cannot shift the position of the structured +// fields ahead of it. (A real NUL still cannot appear in a git subject.) +var logFormat = strings.Join([]string{"%H", "%aI", "%aE", "%s"}, logFieldDirective) + +// revertSubjectPrefix is the conventional prefix `git revert` writes as the +// default subject of a revert commit: `Revert ""`. Detecting +// reverts by this convention keeps us to data git already exposes in the log +// and honors PLAN.md §9 (never read commit *content*/diffs). +const revertSubjectPrefix = `Revert "` // ErrNotARepo is returned when Read is invoked outside a git working tree. var ErrNotARepo = errors.New("gitstat: not a git repository") @@ -97,13 +109,22 @@ type Activity struct { // reference timezone) to the number of author commits on that day, // limited to the window. Days with zero commits are omitted. CommitsByDay map[string]int + + // Reverts is the number of revert commits by the author within the + // window (the same range as TotalInWindow). A revert is detected purely + // from the commit subject convention `Revert "..."` that `git revert` + // writes by default, so no commit content or diff is ever read. It is a + // gentle "messy history" signal the plant surfaces as cosmetic pests; it + // never lowers stage or streak. + Reverts int } // commit is a single parsed `git log` record. type commit struct { - hash string - when time.Time - email string + hash string + when time.Time + email string + subject string } // Read returns the Activity for the git repository in the current working @@ -267,6 +288,9 @@ func parseLog(raw, author string, now time.Time, windowDays int) Activity { if !day.Before(windowStart) { act.TotalInWindow++ act.CommitsByDay[dayKey(local)]++ + if isRevertSubject(c.subject) { + act.Reverts++ + } } } @@ -274,6 +298,14 @@ func parseLog(raw, author string, now time.Time, windowDays int) Activity { return act } +// isRevertSubject reports whether a commit subject looks like a `git revert` +// commit, i.e. it begins with the conventional `Revert "` prefix. Leading +// whitespace is tolerated; matching is otherwise exact and case-sensitive to +// avoid false positives on subjects that merely mention "revert" in prose. +func isRevertSubject(subject string) bool { + return strings.HasPrefix(strings.TrimLeft(subject, " \t"), revertSubjectPrefix) +} + // parseCommits splits raw git output into commit records, skipping anything // that does not parse cleanly. func parseCommits(raw string) []commit { @@ -284,7 +316,10 @@ func parseCommits(raw string) []commit { continue } fields := strings.Split(line, logFieldSep) - if len(fields) != 3 { + // Records carry four fields (hash, date, email, subject). A subject + // is allowed to be empty, so accept exactly four fields; anything + // else is malformed and skipped defensively. + if len(fields) != 4 { continue } when, err := time.Parse(time.RFC3339, strings.TrimSpace(fields[1])) @@ -292,9 +327,10 @@ func parseCommits(raw string) []commit { continue } out = append(out, commit{ - hash: strings.TrimSpace(fields[0]), - when: when, - email: strings.TrimSpace(fields[2]), + hash: strings.TrimSpace(fields[0]), + when: when, + email: strings.TrimSpace(fields[2]), + subject: fields[3], }) } return out diff --git a/internal/gitstat/gitstat_test.go b/internal/gitstat/gitstat_test.go index b927629..f449dd2 100644 --- a/internal/gitstat/gitstat_test.go +++ b/internal/gitstat/gitstat_test.go @@ -13,9 +13,17 @@ import ( var fixedNow = time.Date(2026, time.June, 30, 12, 0, 0, 0, time.UTC) // rec builds one raw `git log` record in the same shape logFormat produces: -// hash, author-date (RFC3339), email — joined by NUL. +// hash, author-date (RFC3339), email, subject — joined by NUL. It uses a +// generic non-revert subject so existing tests exercise the common case; use +// recSubject to supply a specific subject (e.g. a revert). func rec(hash, isoDate, email string) string { - return strings.Join([]string{hash, isoDate, email}, logFieldSep) + return recSubject(hash, isoDate, email, "work commit") +} + +// recSubject builds a raw record with an explicit commit subject, letting tests +// drive revert detection and other subject-sensitive behavior. +func recSubject(hash, isoDate, email, subject string) string { + return strings.Join([]string{hash, isoDate, email, subject}, logFieldSep) } // logFixture joins records with newlines, mimicking `git log` stdout. @@ -303,3 +311,48 @@ func TestRepoNameEmptyFallsBackToCwdBase(t *testing.T) { } } } + +// TestParseLogCountsRevertsInWindow verifies that revert commits (subjects that +// begin with the conventional `Revert "` prefix) are counted into Reverts, and +// only when they fall inside the aggregate window. +func TestParseLogCountsRevertsInWindow(t *testing.T) { + raw := logFixture( + recSubject("r1", "2026-06-30T09:00:00Z", "me@example.com", `Revert "add feature X"`), + recSubject("a1", "2026-06-30T08:00:00Z", "me@example.com", "add feature X"), + recSubject("r2", "2026-06-29T09:00:00Z", "me@example.com", `Revert "tweak Y"`), + // A commit that merely mentions "revert" in prose must NOT count. + recSubject("a2", "2026-06-28T09:00:00Z", "me@example.com", "do not revert this later"), + // A revert well outside the 7-day window is ignored by the count. + recSubject("r3", "2026-06-01T09:00:00Z", "me@example.com", `Revert "old change"`), + ) + + act := parseLog(raw, "me@example.com", fixedNow, DefaultWindowDays) + + if act.Reverts != 2 { + t.Errorf("Reverts = %d; want 2 (two in-window reverts, prose 'revert' excluded, old revert out of window)", act.Reverts) + } + if act.TotalInWindow != 4 { + t.Errorf("TotalInWindow = %d; want 4", act.TotalInWindow) + } +} + +// TestIsRevertSubject exercises the subject-prefix heuristic directly. +func TestIsRevertSubject(t *testing.T) { + cases := []struct { + subject string + want bool + }{ + {`Revert "add feature"`, true}, + {` Revert "leading spaces tolerated"`, true}, + {"Reverting a change", false}, + {"revert lowercase not matched", false}, + {"add a normal thing", false}, + {"", false}, + {`this Revert "in the middle" does not count`, false}, + } + for _, c := range cases { + if got := isRevertSubject(c.subject); got != c.want { + t.Errorf("isRevertSubject(%q) = %v; want %v", c.subject, got, c.want) + } + } +} diff --git a/internal/plant/plant.go b/internal/plant/plant.go index 6af57ea..73c1747 100644 --- a/internal/plant/plant.go +++ b/internal/plant/plant.go @@ -69,6 +69,13 @@ const ( // papered over, and crucially grace only shifts *health* thresholds, never // the growth stage or the streak count (see applyGrace / Compute). MaxGraceDays = 2 + + // MaxPests bounds how many cosmetic pests (aphids/weeds) the plant can + // show at once. Messy git behavior -- reverts today -- spawns pests as a + // gentle, purely visual nudge; the count is clamped here so a churny day + // can never bury the plant. Pests never lower Stage, Streak, or Health; + // they clear after a clean healthy commit day (see pestsFor). + MaxPests = 3 ) // Stage is the plant's growth stage. Stages are ordered; higher values are @@ -210,6 +217,15 @@ type PlantState struct { // does not influence Stage or Streak. GraceDays int + // Pests is the number of cosmetic pests (aphids/weeds) currently on the + // plant, clamped to [0, MaxPests]. Pests are spawned by messy git + // behavior (revert commits in the window) and are purely visual + moral: + // they never lower Stage, Streak, or Health. They clear after a clean + // healthy commit day (a commit today with no fresh reverts), so returning + // to a healthy cadence tidies the plant up. It is always 0 when pests are + // disabled by the caller. + Pests int + // Mood is a short, flavorful one-liner describing how the plant "feels" // given its stage and health. Kept terse and with a little personality. Mood string @@ -287,11 +303,42 @@ func ComputeWith(act gitstat.Activity, st State, now time.Time, tune HealthTunin Streak: act.Streak, DaysSinceCommit: days, GraceDays: grace, + Pests: pestsFor(act, days), Mood: moodFor(stage, health, act), UpdatedHighestStage: highest, } } +// pestsFor maps recent messy git behavior to a bounded, cosmetic pest count. +// The only signal in v1 is revert commits in the window: each spawns a pest, +// clamped to [0, MaxPests]. Pests are gentle by design -- they never touch +// Stage, Streak, or Health. +// +// They clear on a clean healthy commit day: when the last commit landed today +// (days == 0) and that day introduced no new reverts, the plant is considered +// tidied and shows zero pests. Any revert dated today keeps its pest visible so +// a revert-heavy day still reflects, but committing normally the next day +// clears the slate. +func pestsFor(act gitstat.Activity, days int) int { + if act.Reverts <= 0 { + return 0 + } + // A clean healthy commit today (no reverts among today's work) tidies the + // plant. We approximate "today's reverts" conservatively: only when the + // most recent commit is today do we allow clearing, and only if that day's + // commits were revert-free. Since Reverts is a window aggregate, we treat a + // commit-today with fewer reverts than commits as "there was clean work + // today" and clear, matching the gentle intent. + if days == 0 && act.TotalInWindow > act.Reverts { + return 0 + } + pests := act.Reverts + if pests > MaxPests { + pests = MaxPests + } + return pests +} + // liveStage computes the stage implied purely by current activity, ignoring any // remembered peak. Growth is streak-driven, with a busy-day shortcut so the // first productive day already sprouts something. diff --git a/internal/plant/plant_test.go b/internal/plant/plant_test.go index 6ee8f50..25a4a11 100644 --- a/internal/plant/plant_test.go +++ b/internal/plant/plant_test.go @@ -363,3 +363,56 @@ func indexOf(s, sub string) int { } return -1 } + +// pestAct builds an Activity with an explicit revert count on top of the base +// act helper, for exercising the cosmetic pest computation. +func pestAct(streak, total, lastDaysAgo int, hasCommits bool, reverts int) gitstat.Activity { + a := act(streak, total, lastDaysAgo, hasCommits) + a.Reverts = reverts + return a +} + +func TestPestsSpawnFromReverts(t *testing.T) { + // Two reverts, last commit yesterday (not a clean commit today) -> two pests. + ps := Compute(pestAct(3, 5, 1, true, 2), State{}, fixedNow) + if ps.Pests != 2 { + t.Errorf("Pests = %d; want 2", ps.Pests) + } + // Pests must never touch growth/health. + if ps.Stage != Leafy { + t.Errorf("Stage = %v; want leafy (pests must not change stage)", ps.Stage) + } +} + +func TestPestsClampToMax(t *testing.T) { + // Way more reverts than the cap, last commit yesterday. + ps := Compute(pestAct(3, 20, 1, true, 10), State{}, fixedNow) + if ps.Pests != MaxPests { + t.Errorf("Pests = %d; want clamp to %d", ps.Pests, MaxPests) + } +} + +func TestPestsClearOnCleanCommitToday(t *testing.T) { + // Reverts in the window, but a clean commit landed today (total > reverts, + // days since commit == 0) -> pests clear. + ps := Compute(pestAct(3, 5, 0, true, 2), State{}, fixedNow) + if ps.Pests != 0 { + t.Errorf("Pests = %d; want 0 after a clean healthy commit today", ps.Pests) + } +} + +func TestPestsPersistWhenTodayIsAllReverts(t *testing.T) { + // Commit today, but every commit today was a revert (total == reverts): + // not a "clean" day, so pests stay. + ps := Compute(pestAct(3, 2, 0, true, 2), State{}, fixedNow) + if ps.Pests != 2 { + t.Errorf("Pests = %d; want 2 (no clean work today to clear them)", ps.Pests) + } +} + +func TestNoRevertsNoPests(t *testing.T) { + ps := Compute(act(3, 5, 1, true), State{}, fixedNow) + if ps.Pests != 0 { + t.Errorf("Pests = %d; want 0 with no reverts", ps.Pests) + } +} diff --git a/internal/render/render.go b/internal/render/render.go index 221157c..a16095d 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -86,6 +86,10 @@ func Seedling() string { // output is plain ASCII suitable for pipes, files, and CI snapshots. func Frame(ps plant.PlantState, opt Options) string { art := species.ArtFor(opt.Species, ps.Stage, ps.Health) + // Overlay pests on the raw art first so the (single-block) color pass wraps + // cleanly around the whole plant; injecting glyphs after colorizeArt would + // risk splitting its ANSI sequences across lines. + art = withPests(art, ps) caption := caption(ps, opt) var b strings.Builder @@ -101,6 +105,45 @@ func Frame(ps plant.PlantState, opt Options) string { return b.String() } +// pestGlyph is the ASCII rune used to draw a single pest (aphid/weed) on the +// plant. It is a plain, widely-available character so it renders identically +// under --no-color and on minimal terminals. +const pestGlyph = '%' + +// withPests overlays ps.Pests pest glyphs onto the raw (uncolored) plant art. +// Pests are placed on the foliage: the glyph replaces a leading space on each +// of the first few non-blank art lines so the pests sit *on* the plant rather +// than floating beside it, degrading cleanly if the art is narrow. It is called +// before any color pass so a single-block colorizer wraps the whole plant +// without its ANSI sequences being split across lines. Zero pests is a no-op, +// returning the art untouched so pest-free plants are byte-for-byte unchanged. +func withPests(art string, ps plant.PlantState) string { + if ps.Pests <= 0 { + return art + } + + glyph := string(pestGlyph) + + lines := strings.Split(art, "\n") + placed := 0 + for i := 0; i < len(lines) && placed < ps.Pests; i++ { + ln := lines[i] + if strings.TrimSpace(ln) == "" { + continue // skip blank lines; keep pests on actual foliage + } + // Replace the first space with a pest glyph so the glyph overlays the + // plant's margin without widening the block. If the line has no leading + // space (art flush-left), prepend instead as a graceful fallback. + if idx := strings.IndexByte(ln, ' '); idx >= 0 { + lines[i] = ln[:idx] + glyph + ln[idx+1:] + } else { + lines[i] = glyph + ln + } + placed++ + } + return strings.Join(lines, "\n") +} + // PromptGlyph returns a compact one-line "glyph stage" suitable for a shell // prompt or tmux status line. It is intentionally tiny and never multi-line. // (M6 polishes prompt mode; this is the render-side primitive it will use.) @@ -140,6 +183,9 @@ func caption(ps plant.PlantState, opt Options) string { if hint := wiltHint(ps); hint != "" { lines = append(lines, " "+hint) } + if hint := pestHint(ps); hint != "" { + lines = append(lines, " "+hint) + } if ps.Mood != "" { lines = append(lines, "") lines = append(lines, ps.Mood) @@ -204,6 +250,19 @@ func wiltHint(ps plant.PlantState) string { } } +// pestHint returns a short note when the plant has cosmetic pests, explaining +// what they are and how to clear them, or "" when the plant is pest-free. +func pestHint(ps plant.PlantState) string { + if ps.Pests <= 0 { + return "" + } + noun := "pest" + if ps.Pests != 1 { + noun = "pests" + } + return fmt.Sprintf("%d %s (from reverts) \u2014 a clean commit today clears them", ps.Pests, noun) +} + // artFor selects the ASCII frame for a stage/health combination. Wilting and // (to a lesser degree) thirsty stages get drooped variants so the plant's // condition is visible, not just captioned. Healthy stages use the upright art. @@ -445,6 +504,7 @@ func Garden(plants []GardenPlant, opt Options) string { // beside one another. func gardenCell(p GardenPlant, opt Options) string { art := species.ArtFor(p.Species, p.State.Stage, p.State.Health) + art = withPests(art, p.State) if opt.Color { art = colorizeArt(art, p.State) } diff --git a/internal/render/render_test.go b/internal/render/render_test.go index f454d88..a1bf61a 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -268,3 +268,45 @@ func TestColorFrameIsRenderable(t *testing.T) { t.Errorf("color Frame should still contain caption text, got:\n%s", out) } } + +// TestFrameDrawsPestsPlain verifies that a plant with pests renders pest glyphs +// in the plain (no-color) path, and includes a caption hint explaining them. +func TestFrameDrawsPestsPlain(t *testing.T) { + ps := plant.PlantState{ + Stage: plant.Leafy, + Health: plant.Healthy, + Streak: 3, + Pests: 2, + } + out := Frame(ps, Options{Color: false, Now: time.Now()}) + + if !strings.ContainsRune(out, pestGlyph) { + t.Errorf("Frame output missing pest glyph %q:\n%s", string(pestGlyph), out) + } + if strings.Count(out, string(pestGlyph)) < 2 { + t.Errorf("expected at least 2 pest glyphs, got %d:\n%s", + strings.Count(out, string(pestGlyph)), out) + } + if !strings.Contains(out, "from reverts") { + t.Errorf("Frame output missing pest caption hint:\n%s", out) + } +} + +// TestFrameNoPestsUnchanged confirms a pest-free plant renders no pest glyphs, +// keeping historical output byte-for-byte stable. +func TestFrameNoPestsUnchanged(t *testing.T) { + ps := plant.PlantState{Stage: plant.Leafy, Health: plant.Healthy, Streak: 3} + with := Frame(ps, Options{Color: false, Now: time.Now()}) + if strings.ContainsRune(with, pestGlyph) { + t.Errorf("pest-free plant unexpectedly rendered a pest glyph:\n%s", with) + } +} + +// TestWithPestsIsNoOpAtZero guards the fast path: zero pests returns the art +// unchanged. +func TestWithPestsIsNoOpAtZero(t *testing.T) { + art := " \\|/\n |\n _|_" + if got := withPests(art, plant.PlantState{Pests: 0}); got != art { + t.Errorf("withPests changed art at zero pests:\n%s", got) + } +}