Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand Down
52 changes: 44 additions & 8 deletions internal/gitstat/gitstat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<original subject>"`. 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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -267,13 +288,24 @@ 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++
}
}
}

act.Streak = computeStreak(allDays, now, loc)
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 {
Expand All @@ -284,17 +316,21 @@ 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]))
if err != nil {
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
Expand Down
57 changes: 55 additions & 2 deletions internal/gitstat/gitstat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
}
}
47 changes: 47 additions & 0 deletions internal/plant/plant.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
53 changes: 53 additions & 0 deletions internal/plant/plant_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading
Loading