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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ tagged release. Watch the repo.
window. You can peek at it today with the hidden `commit-sprout --activity` debug flag.)_
2. Map that activity → a growth **stage** (seed → sprout → leafy → tall → blooming) plus a
**health** modifier (wilting when you've gone quiet).
_(Implemented — M3. The state machine lives in `internal/plant`: `plant.Compute(activity,
state, now)` is a pure, deterministic function that resolves the stage (streak-driven, with a
busy-day shortcut), a health modifier (healthy → thirsty → wilting by commit recency), and a
short mood line. Remembered peak growth floors the stage so one quiet day dents health before
it shrinks the plant. Thresholds all live in one tunable block.)_
3. Persist a little state (`~/.config/commit-sprout/state.json`) so the plant remembers its best
days and your streak.
4. Render ASCII. No cloud. No telemetry. We never read your code — just commit counts and times.
Expand Down
321 changes: 318 additions & 3 deletions internal/plant/plant.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,323 @@
// the most heavily unit-tested module in the project.
//
// Stages progress seed -> sprout -> leafy -> tall -> blooming, with a health
// modifier that wilts the plant when commits have gone quiet.
// modifier (healthy / thirsty / wilting) that degrades the plant when commits
// have gone quiet. The whole package is pure: Compute takes an Activity, a
// remembered State, and a reference clock, and returns a deterministic
// PlantState. Same inputs always yield the same output, so it can be exercised
// entirely with table-driven tests and no live repository.
//
// Implementation lands in M3 (Plant state machine). This stub exists so the
// package directory and intent are established during M1 scaffolding.
// All tunable numbers live together in the "Tuning thresholds" block below so
// the plant's personality can be adjusted in exactly one place.
package plant

import (
"time"

"github.com/rwrife/commit-sprout/internal/gitstat"
)

// ---------------------------------------------------------------------------
// Tuning thresholds
//
// Everything that decides how the plant grows and wilts is gathered here so the
// behavior is easy to reason about and tune. Nothing else in the package should
// hard-code a magic number.
// ---------------------------------------------------------------------------

const (
// Growth is driven primarily by the current streak: consistent daily
// commits are what make the plant climb through its stages. These are
// the minimum streak lengths (in consecutive days) required to *reach*
// each stage.
//
// streak >= 1 -> Sprout (you showed up today/yesterday)
// streak >= 3 -> Leafy
// streak >= 7 -> Tall
// streak >= 14 -> Blooming
//
// A streak of 0 with no commits at all is Seed.
sproutStreak = 1
leafyStreak = 3
tallStreak = 7
bloomingStreak = 14

// busyDayCommits is a "productive burst" shortcut: a lot of commits in a
// single window, even without a long streak, is enough to nudge a bare
// seed up to a sprout. It keeps the plant from feeling dead on the very
// first productive day before any streak has accumulated.
busyDayCommits = 3

// Health is driven by how long it has been since the last commit,
// measured in whole calendar days in the reference timezone.
//
// <= thirstyAfterDays -> Healthy
// <= wiltingAfterDays -> Thirsty
// > wiltingAfterDays -> Wilting
//
// "Today" is 0 days idle, "yesterday" is 1, and so on.
thirstyAfterDays = 1 // still healthy through yesterday
wiltingAfterDays = 3 // thirsty on days 2-3, wilting from day 4+
)

// Stage is the plant's growth stage. Stages are ordered; higher values are
// more grown. The zero value is Seed.
type Stage int

const (
// Seed is a plant with no activity yet -- nothing has sprouted.
Seed Stage = iota
// Sprout is the first green: a recent commit or a fresh streak.
Sprout
// Leafy is a plant with a few consecutive days of commits.
Leafy
// Tall is a well-established streak.
Tall
// Blooming is the reward stage for a long, consistent streak.
Blooming
)

// String returns a lowercase, stable name for the stage. These names are part
// of the CLI/prompt contract (they can surface in output), so keep them stable.
func (s Stage) String() string {
switch s {
case Seed:
return "seed"
case Sprout:
return "sprout"
case Leafy:
return "leafy"
case Tall:
return "tall"
case Blooming:
return "blooming"
default:
return "unknown"
}
}

// Health is the plant's condition modifier, driven by commit recency. The zero
// value is Healthy.
type Health int

const (
// Healthy: committed recently (today or yesterday).
Healthy Health = iota
// Thirsty: a short dry spell -- a gentle nudge, not yet wilting.
Thirsty
// Wilting: no commits for several days; the plant is visibly drooping.
Wilting
)

// String returns a lowercase, stable name for the health state.
func (h Health) String() string {
switch h {
case Healthy:
return "healthy"
case Thirsty:
return "thirsty"
case Wilting:
return "wilting"
default:
return "unknown"
}
}

// State is the remembered, persisted memory of the plant between runs. It is
// deliberately small and lives here (rather than in the store package) so that
// plant.Compute has no dependency on I/O and can be tested in isolation. The
// store package (M5) is responsible for loading/saving a value that maps onto
// this shape.
//
// The zero value is a brand-new plant that has never grown: HighestStage Seed,
// no best streak.
type State struct {
// HighestStage is the tallest stage the plant has ever reached. It acts
// as a soft floor so a single missed day doesn't collapse a mature plant
// straight back to a seed; see Compute for exactly how it is applied.
HighestStage Stage

// BestStreak is the longest streak ever achieved. It is carried for
// display/brag purposes and does not currently affect the computed
// stage, but lives here so persistence is forward-compatible.
BestStreak int
}

// PlantState is the fully-resolved state of the plant for one render: its
// current growth Stage, its Health modifier, and a short Mood line with
// personality. It is a pure function of (Activity, State, now) and carries no
// behavior of its own.
type PlantState struct {
// Stage is the growth stage to render.
Stage Stage

// Health is the condition modifier to render.
Health Health

// Streak is the current streak, surfaced for convenience (mirrors
// Activity.Streak) so renderers/status output don't need the raw
// Activity.
Streak int

// DaysSinceCommit is the number of whole calendar days since the last
// commit, in the reference timezone. It is 0 when a commit landed today
// and -1 when there are no commits at all (nothing to measure from).
DaysSinceCommit 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

// UpdatedHighestStage is the highest stage the plant has now reached,
// i.e. max(State.HighestStage, computed live stage). Callers that
// persist state (M5) should store this back so growth is remembered.
UpdatedHighestStage Stage
}

// Compute maps activity plus remembered state into a PlantState, as of the
// reference time now. It is pure and deterministic: identical inputs always
// produce an identical PlantState.
//
// Growth comes from the live streak (with a small "busy day" shortcut), then is
// floored by the highest stage ever reached so a mature plant degrades in
// *health* first rather than instantly regressing to a seed on one quiet day.
// A prolonged silence (Wilting) does allow the visible stage to slip one step
// below its remembered peak, so neglect eventually shows in growth too -- but
// never below Sprout once anything has ever grown.
func Compute(act gitstat.Activity, st State, now time.Time) PlantState {
days := daysSinceCommit(act, now)
health := healthFor(days, act.HasCommits)
live := liveStage(act)

// Remember the tallest we've ever been (peak of memory and live growth).
highest := st.HighestStage
if live > highest {
highest = live
}

// Resolve the stage to render. Start from the live stage, then apply the
// remembered floor so we don't yo-yo on a single missed day.
stage := live
if highest > stage {
stage = flooredStage(highest, health)
}

return PlantState{
Stage: stage,
Health: health,
Streak: act.Streak,
DaysSinceCommit: days,
Mood: moodFor(stage, health, act),
UpdatedHighestStage: highest,
}
}

// 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.
func liveStage(act gitstat.Activity) Stage {
switch {
case act.Streak >= bloomingStreak:
return Blooming
case act.Streak >= tallStreak:
return Tall
case act.Streak >= leafyStreak:
return Leafy
case act.Streak >= sproutStreak:
return Sprout
case act.HasCommits && act.TotalInWindow >= busyDayCommits:
// A burst of commits with no established streak still counts as a
// sprout -- you clearly did something.
return Sprout
default:
return Seed
}
}

// flooredStage decides how much of a remembered peak stage the plant keeps when
// its live growth has fallen behind. While Healthy or Thirsty the plant holds
// its remembered peak (a couple of quiet days shouldn't visibly shrink a mature
// plant). Once Wilting, it slips exactly one stage below its peak to make
// prolonged neglect visible -- but never below Sprout, so a plant that has ever
// grown never fully reverts to a bare seed.
func flooredStage(highest Stage, health Health) Stage {
if health != Wilting {
return highest
}
slipped := highest - 1
if slipped < Sprout {
slipped = Sprout
}
return slipped
}

// daysSinceCommit returns whole calendar days between the last commit and now,
// in now's timezone. It returns -1 when there are no commits (nothing to
// measure from). A commit earlier today yields 0, yesterday 1, and so on.
func daysSinceCommit(act gitstat.Activity, now time.Time) int {
if !act.HasCommits || act.LastCommit.IsZero() {
return -1
}
loc := now.Location()
last := dayStart(act.LastCommit.In(loc))
today := dayStart(now)
d := int(today.Sub(last).Hours() / 24)
if d < 0 {
// A commit timestamped slightly in the future (clock skew) is
// treated as "today" rather than a negative age.
d = 0
}
return d
}

// dayStart truncates a time to midnight in its own location, giving a stable
// calendar-day anchor for day-difference math.
func dayStart(t time.Time) time.Time {
y, m, d := t.Date()
return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
}

// healthFor maps days-since-last-commit to a Health. With no commits at all the
// plant is a fresh Seed and reported Healthy (there is nothing to wilt yet).
func healthFor(days int, hasCommits bool) Health {
if !hasCommits || days < 0 {
return Healthy
}
switch {
case days <= thirstyAfterDays:
return Healthy
case days <= wiltingAfterDays:
return Thirsty
default:
return Wilting
}
}

// moodFor returns a short flavor line for the given stage/health, with a little
// personality. Health takes priority for the "problem" states (thirsty/wilting)
// so the message nudges you to commit; otherwise the message celebrates the
// current growth stage.
func moodFor(stage Stage, health Health, act gitstat.Activity) string {
switch health {
case Wilting:
return "Parched and drooping. A commit today would really help."
case Thirsty:
return "Getting a little dry -- a commit soon keeps it perky."
}

// Healthy: celebrate the stage.
switch stage {
case Seed:
return "Just a seed in the soil. Commit something to make it sprout."
case Sprout:
return "A fresh little sprout. Keep the streak going!"
case Leafy:
return "Leafing out nicely -- a few solid days in a row."
case Tall:
return "Standing tall on a healthy streak. Impressive cadence."
case Blooming:
return "In full bloom! That is a serious commit streak. 🌸"
default:
return "Growing along."
}
}
Loading
Loading