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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ 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 # buy a grace day before wilting (weekends / PTO)
commit-sprout garden # a row of plants, one per repo (a windowsill)
commit-sprout --species cactus # pick your plant's species (remembered as default)
```

`status` prints a plain, script-friendly summary:
Expand Down Expand Up @@ -132,6 +133,26 @@ Details:
(honoring `$COLUMNS`), and color / `--no-color` / `NO_COLOR` behave exactly
like the single-plant view.

### Species

Pick what kind of plant you're growing. Each species has its own full art set
(across every growth stage and wilt state), and one has a gameplay twist.

```bash
commit-sprout --species fern # the default: upright and leafy
commit-sprout --species cactus # blocky and spiky — and drought-hardy
commit-sprout --species bonsai # a gnarled, sculptural little tree
commit-sprout --species sunflower # a tall stalk that ends in a big bloom
```

- **Remembered:** the species you choose is saved as your default (in the same
state file as everything else, under `species`), so later flag-less runs keep
rendering it. Pass `--species` again anytime to switch.
- **Cactus twist:** a cactus tolerates much longer commit gaps before it wilts
(roughly a week and a half of silence vs. a few days for the leafier species).
It only bends the *health* thresholds — growth and streak still come from real
commits, so a cactus can't fake progress, just survive a drought.

## Install

### Prebuilt binary (recommended)
Expand Down Expand Up @@ -255,6 +276,8 @@ See [PLAN.md](./PLAN.md) for the full plan, milestones (M1–M6), and the v0.2+
- **Watering can** (`commit-sprout water`) — buy bounded grace days for planned breaks.
- **Multi-repo garden** (`commit-sprout garden`) — a windowsill of plants, one per repo,
with a saveable repo set and per-repo author identity.
- **Species selection** (`commit-sprout --species`) — fern / cactus / bonsai / sunflower,
each with its own art set; the cactus survives droughts longer as a gameplay twist.

## License

Expand Down
48 changes: 47 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ import (
"errors"
"fmt"
"os"
"strings"
"time"

"github.com/mattn/go-isatty"
"github.com/rwrife/commit-sprout/internal/gitstat"
"github.com/rwrife/commit-sprout/internal/plant"
"github.com/rwrife/commit-sprout/internal/render"
"github.com/rwrife/commit-sprout/internal/species"
"github.com/rwrife/commit-sprout/internal/store"
"github.com/spf13/cobra"
)
Expand All @@ -43,6 +45,13 @@ var noSave bool
// for the `prompt` subcommand so it drops cleanly into a shell prompt string.
var promptMode bool

// speciesFlag backs the --species flag: the chosen plant species (fern /
// cactus / bonsai / sunflower). Empty means "unset", in which case the saved
// default from state (or the built-in default species) is used. When set to a
// valid name it both renders as that species and is remembered as the new
// default for future flag-less runs.
var speciesFlag string

// rootCmd is the base command invoked as `commit-sprout` with no subcommand.
var rootCmd = &cobra.Command{
Use: "commit-sprout",
Expand Down Expand Up @@ -79,6 +88,7 @@ type pipelineResult struct {
pathErr error
plant plant.PlantState
now time.Time
species species.Kind
}

// runPipeline performs the shared read half of every command: read git
Expand All @@ -104,7 +114,36 @@ func runPipeline() (pipelineResult, error) {
}

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

// Resolve the species: an explicit --species wins, otherwise fall back to
// the saved default, otherwise the built-in default. An unrecognized flag
// value is reported so a typo is not silently ignored.
sp := species.Default
if saved, ok := species.Parse(persisted.Species); ok {
sp = saved
}
if speciesFlag != "" {
chosen, ok := species.Parse(speciesFlag)
if !ok {
return pipelineResult{}, fmt.Errorf("unknown species %q (choose one of: %s)",
speciesFlag, strings.Join(species.Names(), ", "))
}
sp = chosen
// Remember the newly chosen species as the default for later runs.
persisted.Species = sp.String()
}

// Species can widen how long the plant tolerates a dry spell (the cactus
// twist). Growth is unaffected; only wilt thresholds move.
tune := plant.DefaultHealthTuning()
if st, ok := species.TuningFor(sp); ok {
tune = plant.HealthTuning{
ThirstyAfterDays: st.ThirstyAfterDays,
WiltingAfterDays: st.WiltingAfterDays,
}
}

ps := plant.ComputeWith(act, persisted.Plant(now, act.LastCommit), now, tune)

return pipelineResult{
activity: act,
Expand All @@ -113,6 +152,7 @@ func runPipeline() (pipelineResult, error) {
pathErr: pathErr,
plant: ps,
now: now,
species: sp,
}, nil
}

Expand Down Expand Up @@ -165,6 +205,7 @@ func renderPlant(cmd *cobra.Command) error {
Color: useColor(cmd),
Now: r.now,
LastCommit: r.activity.LastCommit,
Species: r.species,
})
if _, perr := fmt.Fprintln(out, frame); perr != nil {
return perr
Expand Down Expand Up @@ -253,6 +294,11 @@ func init() {
// drops straight into a shell prompt string.
rootCmd.Flags().BoolVar(&promptMode, "prompt", false, "emit a one-line glyph for shell prompt / tmux / starship")

// --species picks the plant's art set (and, for the cactus, its drought
// tolerance). A chosen species is remembered as the default for later runs.
rootCmd.PersistentFlags().StringVar(&speciesFlag, "species", "",
"plant species: "+strings.Join(species.Names(), " / ")+" (remembered as default)")

// 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
62 changes: 49 additions & 13 deletions internal/plant/plant.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,9 +212,41 @@ type PlantState struct {
// 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 {
return ComputeWith(act, st, now, DefaultHealthTuning())
}

// HealthTuning carries the idle-day thresholds that decide when a plant turns
// thirsty and then wilts. It exists so a species can bend the wilt rules (e.g.
// a drought-hardy cactus) without the pure plant package importing the species
// package, which would create an import cycle. Callers that have no species in
// hand should pass DefaultHealthTuning().
type HealthTuning struct {
// ThirstyAfterDays is the last idle day count still considered Healthy.
ThirstyAfterDays int
// WiltingAfterDays is the last idle day count still considered Thirsty;
// beyond it the plant Wilts.
WiltingAfterDays int
}

// DefaultHealthTuning returns the shared, historical wilt thresholds used when
// a species offers no override. These match the package's original constants so
// a default plant behaves exactly as before.
func DefaultHealthTuning() HealthTuning {
return HealthTuning{
ThirstyAfterDays: thirstyAfterDays,
WiltingAfterDays: wiltingAfterDays,
}
}

// ComputeWith is Compute with an explicit HealthTuning so a species can widen
// (or tighten) how long the plant tolerates a dry spell before wilting. Growth
// (stage/streak) is unaffected -- only the health thresholds move -- so a
// species twist can never fake progress, only resilience. It remains pure and
// deterministic.
func ComputeWith(act gitstat.Activity, st State, now time.Time, tune HealthTuning) PlantState {
days := daysSinceCommit(act, now)
grace := clampGrace(st.GraceDays)
health := healthFor(days, act.HasCommits, grace)
health := healthForTuned(days, act.HasCommits, grace, tune)
live := liveStage(act)

// Remember the tallest we've ever been (peak of memory and live growth).
Expand Down Expand Up @@ -306,33 +338,37 @@ func dayStart(t time.Time) time.Time {
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).
//
// 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 {
// healthForTuned is healthFor with explicit thresholds supplied by the caller
// (typically from a species). See healthFor for the shared-default behavior.
func healthForTuned(days int, hasCommits bool, grace int, tune HealthTuning) 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 effective <= thirstyAfterDays:
case effective <= tune.ThirstyAfterDays:
return Healthy
case effective <= wiltingAfterDays:
case effective <= tune.WiltingAfterDays:
return Thirsty
default:
return Wilting
}
}

// 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).
//
// 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 {
return healthForTuned(days, hasCommits, grace, DefaultHealthTuning())
}

// 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.
Expand Down
52 changes: 52 additions & 0 deletions internal/plant/species_tuning_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package plant

import (
"testing"
"time"

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

// TestComputeWithDefaultMatchesCompute ensures ComputeWith with the default
// tuning is identical to Compute, so the species hook is a pure superset that
// changes nothing for the default plant.
func TestComputeWithDefaultMatchesCompute(t *testing.T) {
now := time.Date(2026, 7, 9, 12, 0, 0, 0, time.UTC)
act := gitstat.Activity{
HasCommits: true,
Streak: 5,
TotalInWindow: 5,
LastCommit: now.AddDate(0, 0, -4), // 4 idle days
}
st := State{HighestStage: Tall}

want := Compute(act, st, now)
got := ComputeWith(act, st, now, DefaultHealthTuning())
if got != want {
t.Errorf("ComputeWith(default) = %+v, want %+v", got, want)
}
}

// TestComputeWithDroughtTuningSurvivesLonger verifies a more forgiving tuning
// (the cactus twist) keeps a plant healthier for the same idle gap: where the
// default plant would wilt, the drought-hardy tuning is merely thirsty or fine.
func TestComputeWithDroughtTuningSurvivesLonger(t *testing.T) {
now := time.Date(2026, 7, 9, 12, 0, 0, 0, time.UTC)
// 6 idle days: well past the default wilting threshold (day 4+).
act := gitstat.Activity{
HasCommits: true,
Streak: 0,
LastCommit: now.AddDate(0, 0, -6),
}
st := State{HighestStage: Leafy}

def := Compute(act, st, now)
if def.Health != Wilting {
t.Fatalf("precondition: default plant should be Wilting at 6 idle days, got %v", def.Health)
}

hardy := ComputeWith(act, st, now, HealthTuning{ThirstyAfterDays: 4, WiltingAfterDays: 9})
if hardy.Health == Wilting {
t.Errorf("drought-hardy plant should not be Wilting at 6 idle days, got %v", hardy.Health)
}
}
13 changes: 11 additions & 2 deletions internal/render/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (

"github.com/charmbracelet/lipgloss"
"github.com/rwrife/commit-sprout/internal/plant"
"github.com/rwrife/commit-sprout/internal/species"
)

// seedling is the original M1 hard-coded plant: a tiny sprout in a pot. It is
Expand Down Expand Up @@ -50,6 +51,10 @@ type Options struct {
// ignores it. Zero or negative means "unknown", and Garden falls back to
// DefaultGardenWidth.
Width int

// Species selects which art set to render. The zero value is the default
// species (fern), so callers that never set it get the historical art.
Species species.Kind
}

// Seedling returns the hard-coded ASCII seedling frame (the healthy Seed art).
Expand All @@ -68,7 +73,7 @@ func Seedling() string {
// Rendering is deterministic given ps and opt. When opt.Color is false the
// output is plain ASCII suitable for pipes, files, and CI snapshots.
func Frame(ps plant.PlantState, opt Options) string {
art := artFor(ps.Stage, ps.Health)
art := species.ArtFor(opt.Species, ps.Stage, ps.Health)
caption := caption(ps, opt)

var b strings.Builder
Expand Down Expand Up @@ -386,6 +391,10 @@ type GardenPlant struct {
// LastCommit is the repo's most recent commit time, used for this
// plant's compact caption. The zero value renders as "no commits yet".
LastCommit time.Time

// Species selects this plant's art set. The zero value is the default
// species (fern), so existing callers are unaffected.
Species species.Kind
}

// Garden renders plants as one or more rows of side-by-side cells, wrapping to
Expand Down Expand Up @@ -423,7 +432,7 @@ func Garden(plants []GardenPlant, opt Options) string {
// rectangle (all lines padded to equal width) so cells align cleanly when set
// beside one another.
func gardenCell(p GardenPlant, opt Options) string {
art := artFor(p.State.Stage, p.State.Health)
art := species.ArtFor(p.Species, p.State.Stage, p.State.Health)
if opt.Color {
art = colorizeArt(art, p.State)
}
Expand Down
Loading
Loading