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
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ commit-sprout # render the current plant for the repo you're in
commit-sprout status # streak, last commit, days until it wilts
commit-sprout prompt # compact glyph for your shell prompt / tmux status
commit-sprout --prompt # same thing, as a flag (handy inside a prompt string)
commit-sprout water # (planned) buy a grace day before wilting
commit-sprout water # buy a grace day before wilting (weekends / PTO)
```

`status` prints a plain, script-friendly summary:
Expand All @@ -44,6 +44,37 @@ last: today (2026-07-04)
health: healthy — 2 days of cushion before it gets thirsty
```

After watering, `status` also reports the grace in effect and the projected
wilt date:

```text
stage: leafy (healthy)
streak: 6 days (best: 11 days)
last: 2 days ago (2026-07-03)
grace: 1 day (watered)
health: healthy — commit today to stay ahead of thirst
wilts: 2026-07-08
```

### Watering can (grace days)

Heading into a weekend or PTO and don't want your plant to droop? Water it:

```bash
commit-sprout water
```

Each watering buys **one extra idle day** before the plant turns thirsty and,
later, wilts. It is deliberately hard to abuse, so it nudges *health* without
ever faking *progress*:

- **One per day** — watering twice on the same day is a no-op.
- **Capped** — at most two grace days can be in effect at once.
- **Self-resetting** — a fresh commit clears any grace, so you can't bank
waterings across commits into a permanent green streak.
- **Health only** — grace never changes your growth stage or streak count; it
just keeps a watered plant perky through a planned break.

`prompt` prints a single glyph + stage (e.g. `ل leafy`). It is built for the
hot path: **read-only by default** (a prompt renders on every command, so it
skips the state write unless you pass `--save`) and it **fails silently outside
Expand Down
12 changes: 11 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func runPipeline() (pipelineResult, error) {
}

now := time.Now()
ps := plant.Compute(act, persisted.Plant(), now)
ps := plant.Compute(act, persisted.Plant(now, act.LastCommit), now)

return pipelineResult{
activity: act,
Expand All @@ -116,6 +116,15 @@ func runPipeline() (pipelineResult, error) {
}, nil
}

// recomputeWith re-runs the pure plant computation for an already-read pipeline
// result but with a different persisted State (e.g. after watering). It reuses
// the same activity and reference clock so only the state-derived inputs (like
// grace days) change, giving callers an updated PlantState without touching git
// again.
func recomputeWith(r pipelineResult, updated store.State) plant.PlantState {
return plant.Compute(r.activity, updated.Plant(r.now, r.activity.LastCommit), r.now)
}

// persist folds the computed plant state back into the remembered file, unless
// --no-save was set or the state path could not be resolved. It is best-effort:
// a write failure is surfaced on stderr but never fails the command, so a
Expand Down Expand Up @@ -251,4 +260,5 @@ func init() {
// Subcommands (M6): status + prompt ride the shared pipeline.
rootCmd.AddCommand(statusCmd)
rootCmd.AddCommand(promptCmd)
rootCmd.AddCommand(waterCmd)
}
68 changes: 64 additions & 4 deletions cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,13 @@ func renderStatus(cmd *cobra.Command) error {
fmt.Fprintf(out, "stage: %s (%s)\n", ps.Stage, ps.Health)
fmt.Fprintf(out, "streak: %s\n", statusStreak(ps.Streak, r.persisted.BestStreak))
fmt.Fprintf(out, "last: %s\n", statusLastCommit(ps, r.activity.LastCommit, r.now))
if line := statusGrace(ps); line != "" {
fmt.Fprintf(out, "grace: %s\n", line)
}
fmt.Fprintf(out, "health: %s\n", statusWilt(ps))
if when := nextWiltDate(ps, r.activity.LastCommit, r.now); when != "" {
fmt.Fprintf(out, "wilts: %s\n", when)
}

persist(r)
return nil
Expand Down Expand Up @@ -100,16 +106,44 @@ func statusLastCommit(ps plant.PlantState, last time.Time, now time.Time) string
}
}

// statusGrace reports how many watering-can grace days are currently propping
// the plant up, or "" when none are in effect (so the line is omitted). It
// makes the escape-hatch visible without cluttering the common no-grace case.
func statusGrace(ps plant.PlantState) string {
switch {
case ps.GraceDays <= 0:
return ""
case ps.GraceDays == 1:
return "1 day (watered)"
default:
return fmt.Sprintf("%d days (watered)", ps.GraceDays)
}
}

// effectiveIdle is the idle-day count the health thresholds actually see: raw
// days since the last commit, rewound by any watering-can grace, floored at 0.
// It is the single source of truth shared by statusWilt and nextWiltDate so the
// text and the date can never disagree.
func effectiveIdle(ps plant.PlantState) int {
d := ps.DaysSinceCommit - ps.GraceDays
if d < 0 {
d = 0
}
return d
}

// statusWilt turns the health state into a one-line, days-until-wilt nudge that
// mirrors the render caption's spirit. Healthy plants report how many idle days
// they can still absorb before turning thirsty.
// they can still absorb before turning thirsty. Watering-can grace is folded in
// via effectiveIdle, so a watered plant honestly reports the extra runway it
// bought rather than the raw calendar gap.
func statusWilt(ps plant.PlantState) string {
switch ps.Health {
case plant.Wilting:
return "wilting \u2014 commit to revive it"
case plant.Thirsty:
// Thirsty spans days 2-3 idle; wilting begins at day 4.
remaining := 4 - ps.DaysSinceCommit
// Thirsty spans effective days 2-3 idle; wilting begins at day 4.
remaining := 4 - effectiveIdle(ps)
if remaining < 1 {
remaining = 1
}
Expand All @@ -122,7 +156,7 @@ func statusWilt(ps plant.PlantState) string {
if ps.DaysSinceCommit < 0 {
return "healthy"
}
cushion := 2 - ps.DaysSinceCommit
cushion := 2 - effectiveIdle(ps)
if cushion < 1 {
cushion = 1
}
Expand All @@ -132,3 +166,29 @@ func statusWilt(ps plant.PlantState) string {
return fmt.Sprintf("healthy \u2014 %d days of cushion before it gets thirsty", cushion)
}
}

// nextWiltDate estimates the calendar date on which the plant will begin to
// wilt if no further commits (or waterings) land, accounting for any grace
// already in effect. It returns "" when there is nothing to project (no commits
// yet) or when the plant is already wilting. Wilting begins once effective idle
// days exceed wiltingAfterDays (3), i.e. on the 4th effective idle day.
func nextWiltDate(ps plant.PlantState, last time.Time, now time.Time) string {
if ps.DaysSinceCommit < 0 || last.IsZero() {
return ""
}
if ps.Health == plant.Wilting {
return ""
}
if now.IsZero() {
now = time.Now()
}
// Days from now until the first wilting day: the plant wilts when
// effectiveIdle reaches 4, so it has (4 - effectiveIdle) safe days left,
// and wilting lands the day after the last safe day.
daysLeft := 4 - effectiveIdle(ps)
if daysLeft < 1 {
daysLeft = 1
}
wiltDay := now.AddDate(0, 0, daysLeft)
return wiltDay.Format("2006-01-02")
}
101 changes: 101 additions & 0 deletions cmd/water.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Water subcommand: buy the plant a grace day so a deliberate gap (a weekend,
// PTO, a conference) doesn't wilt it. Watering is the honest escape hatch to
// the wilt mechanic -- it shifts the health thresholds back by a bounded number
// of idle days without ever touching the growth stage or the streak count, so
// it can nudge the plant's *health* but can never fake *progress*.
//
// Guardrails (all enforced in internal/store.Water) keep it from being gamed:
//
// - Idempotent per day: watering twice on the same date is a no-op.
// - Capped: at most plant.MaxGraceDays of grace can be in effect at once.
// - Self-resetting: a fresh commit prunes prior waterings, so grace only ever
// covers the current dry spell and cannot be banked across commits.
package cmd

import (
"errors"
"fmt"

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

// waterCmd implements `commit-sprout water`.
var waterCmd = &cobra.Command{
Use: "water",
Short: "Buy the plant one grace day before it wilts (for weekends / PTO)",
Long: `Record a "watering" for today, buying the plant one extra idle day before
it turns thirsty and, later, wilts. Use it for deliberate gaps -- a weekend, a
day off, a conference -- when you know you won't commit but don't want the plant
to droop.

Watering is intentionally hard to abuse: it is limited to one grace day per
calendar day, capped at a small maximum at any one time, and a fresh commit
clears it. It only affects the plant's health, never its growth stage or streak,
so it can keep a plant perky through a planned break without faking that you
shipped anything.`,
Args: cobra.NoArgs,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runWater(cmd)
},
}

// runWater loads state, applies a watering scoped to the current dry spell, and
// persists the result (unless --no-save). It prints what happened and the plant
// state after watering.
func runWater(cmd *cobra.Command) error {
r, err := runPipeline()
if err != nil {
if errors.Is(err, gitstat.ErrNotARepo) {
return notARepoMessage(cmd)
}
return err
}

out := cmd.OutOrStdout()

updated, res := r.persisted.Water(r.now, r.activity.LastCommit)

if !res.Applied {
// Nothing to persist (state is unchanged), so just report why.
fmt.Fprintf(out, "watering can: %s.\n", res.Reason)
fmt.Fprintf(out, "grace: %s\n", graceSummary(res.Grace))
fmt.Fprintf(out, "health: %s\n", statusWilt(r.plant))
return nil
}

// Recompute the plant with the new grace in effect so the post-water
// summary reflects reality.
wateredPlant := recomputeWith(r, updated)

fmt.Fprintln(out, "💧 Watered. The plant can coast one more day.")
fmt.Fprintf(out, "grace: %s\n", graceSummary(res.Grace))
fmt.Fprintf(out, "health: %s\n", statusWilt(wateredPlant))

if noSave {
fmt.Fprintln(out,
"note: --no-save is set, so this watering was not persisted "+
"(it won't count on the next run).")
return nil
}

if serr := store.Save(r.statePath, updated); serr != nil {
return fmt.Errorf("water: saving state: %w", serr)
}
return nil
}

// graceSummary phrases the effective grace-day count for the status block.
func graceSummary(grace int) string {
switch {
case grace <= 0:
return "none"
case grace == 1:
return "1 grace day in effect"
default:
return fmt.Sprintf("%d grace days in effect", grace)
}
}
59 changes: 55 additions & 4 deletions internal/plant/plant.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ const (
// "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+

// MaxGraceDays caps how many watering-can grace days can ever be in
// effect at once. Watering (`commit-sprout water`) buys the plant extra
// idle days before it turns thirsty/wilts -- a deliberate "I'm on PTO /
// weekend" escape hatch. The cap is what keeps watering from being spammed
// into a permanent green streak: at most MaxGraceDays of neglect can be
// papered over, and crucially grace only shifts *health* thresholds, never
// the growth stage or the streak count (see applyGrace / Compute).
MaxGraceDays = 2
)

// Stage is the plant's growth stage. Stages are ordered; higher values are
Expand Down Expand Up @@ -143,6 +152,16 @@ type State struct {
// display/brag purposes and does not currently affect the computed
// stage, but lives here so persistence is forward-compatible.
BestStreak int

// GraceDays is the number of watering-can grace days currently in effect
// for the ongoing dry spell. It pushes the thirsty/wilting thresholds
// back by that many idle days so a deliberately watered plant survives a
// weekend or PTO without a commit. It never affects the growth Stage or
// the Streak -- watering buys health, not fake progress -- and is clamped
// to [0, MaxGraceDays] by Compute so a hand-edited file cannot grant an
// unbounded reprieve. It resets to 0 once a fresh commit lands (the store
// prunes stale waterings), so grace only ever covers the current gap.
GraceDays int
}

// PlantState is the fully-resolved state of the plant for one render: its
Expand All @@ -166,6 +185,12 @@ type PlantState struct {
// and -1 when there are no commits at all (nothing to measure from).
DaysSinceCommit int

// GraceDays is the number of watering-can grace days actually in effect
// for this render (clamped to [0, MaxGraceDays]). It is surfaced so status
// output can report remaining grace and a corrected next-wilt date; it
// does not influence Stage or Streak.
GraceDays 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 All @@ -188,7 +213,8 @@ type PlantState struct {
// 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)
grace := clampGrace(st.GraceDays)
health := healthFor(days, act.HasCommits, grace)
live := liveStage(act)

// Remember the tallest we've ever been (peak of memory and live growth).
Expand All @@ -209,6 +235,7 @@ func Compute(act gitstat.Activity, st State, now time.Time) PlantState {
Health: health,
Streak: act.Streak,
DaysSinceCommit: days,
GraceDays: grace,
Mood: moodFor(stage, health, act),
UpdatedHighestStage: highest,
}
Expand Down Expand Up @@ -281,20 +308,44 @@ func dayStart(t time.Time) time.Time {

// 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 {
//
// grace is the number of watering-can days in effect (already clamped): it
// shifts the idle count back so a watered plant tolerates that many extra quiet
// days before drying out. Grace only moves the health thresholds; the caller
// keeps Stage and Streak untouched, so watering can never fake growth.
func healthFor(days int, hasCommits bool, grace int) Health {
if !hasCommits || days < 0 {
return Healthy
}
// Grace effectively rewinds the dry spell: two watered days make a
// four-day gap feel like two. Never let it drop below "committed today".
effective := days - grace
if effective < 0 {
effective = 0
}
switch {
case days <= thirstyAfterDays:
case effective <= thirstyAfterDays:
return Healthy
case days <= wiltingAfterDays:
case effective <= wiltingAfterDays:
return Thirsty
default:
return Wilting
}
}

// clampGrace bounds a raw grace-day count into the supported [0, MaxGraceDays]
// range so neither a negative nor an over-large (hand-edited) value can distort
// the health calculation.
func clampGrace(g int) int {
if g < 0 {
return 0
}
if g > MaxGraceDays {
return MaxGraceDays
}
return g
}

// 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
Expand Down
Loading
Loading