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
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ commit-sprout water # buy a grace day before wilting (weekends / PTO)
commit-sprout watch # live full-screen view; the plant gently sways
commit-sprout garden # a row of plants, one per repo (a windowsill)
commit-sprout --species cactus # pick your plant's species (remembered as default)
commit-sprout --season winter # force a seasonal palette (default: derived from date)
```

`status` prints a plain, script-friendly summary:
Expand Down Expand Up @@ -201,6 +202,39 @@ commit-sprout --species sunflower # a tall stalk that ends in a big bloom
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.

### Seasons & themes

The plant dresses for the time of year. `commit-sprout` derives a **season**
purely from today's date and layers a light palette + ASCII decoration on top of
the plant — falling leaves in autumn, snowflakes in winter, blossoms in spring,
bright sun in summer:

```
$ commit-sprout --season winter --no-color
. * . * .
\ | /
\|/
--+--
|
_|_
( )
'-'

stage: leafy (healthy)
...
```

- **Date-driven by default:** Dec–Feb = winter, Mar–May = spring, Jun–Aug =
summer, Sep–Nov = autumn. No configuration needed.
- **Force or preview:** `--season <winter|spring|summer|autumn>` (alias `fall`)
pins a season for testing or if you just like a different vibe.
- **Disable it:** `--no-season` turns off all dressing for the neutral look.
- **Holiday skins (opt-in):** `--holiday` layers a gentle extra sparkle/decoration
on the base season. It's off by default — no surprise decorations.
- **Cosmetic only:** seasons *never* touch stage, health, or streak. It's pure
ambience layered on the same real, commit-driven plant, and it degrades to
plain ASCII under `--no-color` / `NO_COLOR` / piped output.

## Install

### Prebuilt binary (recommended)
Expand Down Expand Up @@ -328,6 +362,9 @@ See [PLAN.md](./PLAN.md) for the full plan, milestones (M1–M6), and the v0.2+
each with its own art set; the cactus survives droughts longer as a gameplay twist.
- **Animated watch mode** (`commit-sprout watch`) — a live full-screen view where the
plant gently sways and re-reads git activity on an interval.
- **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.

## License

Expand Down
42 changes: 42 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"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/season"
"github.com/rwrife/commit-sprout/internal/species"
"github.com/rwrife/commit-sprout/internal/store"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -52,6 +53,20 @@ var promptMode bool
// default for future flag-less runs.
var speciesFlag string

// seasonFlag backs the --season flag: force a specific seasonal dressing
// (winter / spring / summer / autumn) instead of deriving it from the current
// date. Empty means "derive from now". It is cosmetic only and never persisted.
var seasonFlag string

// noSeason backs the --no-season flag, which disables seasonal dressing
// entirely for a neutral look.
var noSeason bool

// holidaySkin backs the --holiday flag, an opt-in that layers gentle holiday
// decorations on top of the base season. Off by default so no surprise
// decorations appear beyond the base season.
var holidaySkin bool

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

// runPipeline performs the shared read half of every command: read git
Expand Down Expand Up @@ -145,6 +161,22 @@ func runPipeline() (pipelineResult, error) {

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

// 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.
sea := season.FromTime(now)
if seasonFlag != "" {
chosen, ok := season.Parse(seasonFlag)
if !ok {
return pipelineResult{}, fmt.Errorf("unknown season %q (choose one of: %s)",
seasonFlag, strings.Join(season.Names(), ", "))
}
sea = chosen
}
if noSeason {
sea = season.None
}

return pipelineResult{
activity: act,
persisted: persisted,
Expand All @@ -153,6 +185,7 @@ func runPipeline() (pipelineResult, error) {
plant: ps,
now: now,
species: sp,
season: sea,
}, nil
}

Expand Down Expand Up @@ -206,6 +239,8 @@ func renderPlant(cmd *cobra.Command) error {
Now: r.now,
LastCommit: r.activity.LastCommit,
Species: r.species,
Season: r.season,
Holiday: holidaySkin,
})
if _, perr := fmt.Fprintln(out, frame); perr != nil {
return perr
Expand Down Expand Up @@ -299,6 +334,13 @@ func init() {
rootCmd.PersistentFlags().StringVar(&speciesFlag, "species", "",
"plant species: "+strings.Join(species.Names(), " / ")+" (remembered as default)")

// --season forces a cosmetic season; --no-season disables seasonal dressing;
// --holiday opts into gentle holiday skins. All are ambient only.
rootCmd.PersistentFlags().StringVar(&seasonFlag, "season", "",
"force seasonal dressing: "+strings.Join(season.Names(), " / ")+" (default: derived from date)")
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")

// 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
16 changes: 14 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/season"
"github.com/rwrife/commit-sprout/internal/species"
)

Expand Down Expand Up @@ -55,6 +56,17 @@ type Options struct {
// 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

// Season selects the cosmetic seasonal dressing (palette + decoration).
// The zero value (season.None) is a clean no-op that renders the neutral
// look, so existing callers are unaffected. Seasonal dressing never
// changes stage/health/streak — it is purely ambient.
Season season.Season

// Holiday enables gentle holiday skins layered on top of the base season.
// It is opt-in and off by default: no surprise decorations appear beyond
// the base season unless a caller explicitly sets this.
Holiday bool
}

// Seedling returns the hard-coded ASCII seedling frame (the healthy Seed art).
Expand All @@ -78,11 +90,11 @@ func Frame(ps plant.PlantState, opt Options) string {

var b strings.Builder
if opt.Color {
b.WriteString(colorizeArt(art, ps))
b.WriteString(dressArt(colorizeArt(art, ps), opt.Season, true, opt.Holiday))
b.WriteString("\n\n")
b.WriteString(colorizeCaption(caption, ps))
} else {
b.WriteString(art)
b.WriteString(dressArt(art, opt.Season, false, opt.Holiday))
b.WriteString("\n\n")
b.WriteString(caption)
}
Expand Down
94 changes: 94 additions & 0 deletions internal/render/season_render.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Package render seasonal dressing.
//
// Seasons are cosmetic only: they layer a palette and a light ASCII decoration
// (falling leaves, snowflakes, blossoms) on top of the existing frame without
// ever touching the plant's stage, health, or streak. Everything here degrades
// gracefully — under the plain path (--no-color / NO_COLOR / non-TTY) the
// decoration is drawn as plain ASCII with no styling, and season.None is a
// clean no-op that returns the neutral frame untouched.
package render

import (
"strings"

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

// dressArt applies a season's decoration and palette to already-rendered art.
//
// For season.None it returns art unchanged (the neutral look). Otherwise it
// prepends a thin decoration line (e.g. drifting snow or falling leaves) and,
// when color is enabled, tints that decoration with a season-appropriate color.
// The base plant art keeps its own health-based coloring done by the caller;
// dressing only adds the ambient top line so the plant's own coloring is never
// overwritten.
func dressArt(art string, s season.Season, color bool, holiday bool) string {
deco := seasonDecoration(s, holiday)
if deco == "" {
return art
}
if color {
deco = seasonStyle(s).Render(deco)
}
return deco + "\n" + art
}

// seasonDecoration returns the ambient decoration line for a season, or "" when
// there is nothing to add (season.None). When holiday is true and the season
// has a gentle holiday variant, that is used instead of the base line.
//
// Decorations are intentionally a single short ASCII line so they never disturb
// the plant's own art block width in a disruptive way and remain readable in a
// plain terminal.
func seasonDecoration(s season.Season, holiday bool) string {
switch s {
case season.Winter:
if holiday {
return " * . * . *" // a touch more sparkle for the holidays
}
return " . * . * ." // drifting snow
case season.Spring:
if holiday {
return " @ . @ . @" // blossoms in bloom
}
return " ' , ' , '" // fresh buds
case season.Summer:
return " \\ | / | \\" // bright sun rays
case season.Autumn:
if holiday {
return " % . % . %" // extra falling leaves
}
return " , . ` . ," // falling leaves
default:
return ""
}
}

// seasonStyle picks a lipgloss color for a season's decoration. It is only
// consulted on the color path; the plain path ignores it entirely.
func seasonStyle(s season.Season) lipgloss.Style {
switch s {
case season.Winter:
return lipgloss.NewStyle().Foreground(lipgloss.Color("6")) // cyan snow
case season.Spring:
return lipgloss.NewStyle().Foreground(lipgloss.Color("13")) // pink blossoms
case season.Summer:
return lipgloss.NewStyle().Foreground(lipgloss.Color("11")) // bright yellow sun
case season.Autumn:
return lipgloss.NewStyle().Foreground(lipgloss.Color("208")) // orange leaves
default:
return lipgloss.NewStyle()
}
}

// trimSeasonDeco is a small helper used by tests to strip a leading decoration
// line from a dressed frame, leaving the underlying neutral art for comparison.
// It removes exactly one leading line when it matches a known decoration.
func trimSeasonDeco(dressed string) string {
idx := strings.IndexByte(dressed, '\n')
if idx < 0 {
return dressed
}
return dressed[idx+1:]
}
102 changes: 102 additions & 0 deletions internal/render/season_render_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package render

import (
"strings"
"testing"
"time"

"github.com/rwrife/commit-sprout/internal/plant"
"github.com/rwrife/commit-sprout/internal/season"
)

// samplePlant returns a representative plant state for dressing tests.
func samplePlant() plant.PlantState {
return plant.PlantState{
Stage: plant.Leafy,
Health: plant.Healthy,
Streak: 5,
DaysSinceCommit: 0,
}
}

// TestSeasonNoneIsNeutral confirms season.None renders identically to a frame
// with no seasonal dressing — the neutral look is a true no-op.
func TestSeasonNoneIsNeutral(t *testing.T) {
ps := samplePlant()
now := time.Date(2026, time.July, 13, 12, 0, 0, 0, time.UTC)

neutral := Frame(ps, Options{Now: now, LastCommit: now})
explicitNone := Frame(ps, Options{Now: now, LastCommit: now, Season: season.None})

if neutral != explicitNone {
t.Fatalf("season.None changed output:\nneutral:\n%s\nnone:\n%s", neutral, explicitNone)
}
}

// TestSeasonPreservesPlantState asserts the cosmetic-only invariant: for every
// season the underlying plant art and full caption block are byte-identical to
// the neutral render once the decoration line is stripped. Seasonal dressing
// must never alter stage/health/streak or the caption that reports them.
func TestSeasonPreservesPlantState(t *testing.T) {
ps := samplePlant()
now := time.Date(2026, time.July, 13, 12, 0, 0, 0, time.UTC)
neutral := Frame(ps, Options{Now: now, LastCommit: now})

for _, s := range []season.Season{season.Winter, season.Spring, season.Summer, season.Autumn} {
dressed := Frame(ps, Options{Now: now, LastCommit: now, Season: s})
// A season prepends exactly one decoration line to the art; strip it
// and the rest must equal the neutral frame.
stripped := trimSeasonDeco(dressed)
if stripped != neutral {
t.Errorf("season %v altered plant body:\nneutral:\n%q\nstripped:\n%q", s, neutral, stripped)
}
}
}

// TestSeasonPlainNoAnsi ensures the plain (no-color) path emits no ANSI escape
// codes in the seasonal decoration, so it degrades to clean ASCII.
func TestSeasonPlainNoAnsi(t *testing.T) {
ps := samplePlant()
now := time.Date(2026, time.December, 25, 12, 0, 0, 0, time.UTC)

for _, s := range []season.Season{season.Winter, season.Spring, season.Summer, season.Autumn} {
out := Frame(ps, Options{Now: now, LastCommit: now, Season: s, Color: false})
if strings.Contains(out, "\x1b[") {
t.Errorf("season %v plain render contains ANSI escape: %q", s, out)
}
}
}

// TestSeasonAddsDecoration confirms a non-None season actually adds a visible
// decoration line (i.e. the feature does something).
func TestSeasonAddsDecoration(t *testing.T) {
ps := samplePlant()
now := time.Date(2026, time.July, 13, 12, 0, 0, 0, time.UTC)

neutral := Frame(ps, Options{Now: now, LastCommit: now})
dressed := Frame(ps, Options{Now: now, LastCommit: now, Season: season.Winter})

if !strings.HasPrefix(dressed, seasonDecoration(season.Winter, false)) {
t.Errorf("winter frame missing decoration prefix: %q", dressed)
}
if len(dressed) <= len(neutral) {
t.Errorf("dressed frame not longer than neutral (%d vs %d)", len(dressed), len(neutral))
}
}

// TestHolidayIsOptIn verifies holiday skins only change output when explicitly
// enabled: with Holiday=false the base season decoration is used.
func TestHolidayIsOptIn(t *testing.T) {
ps := samplePlant()
now := time.Date(2026, time.December, 25, 12, 0, 0, 0, time.UTC)

base := Frame(ps, Options{Now: now, LastCommit: now, Season: season.Winter, Holiday: false})
holiday := Frame(ps, Options{Now: now, LastCommit: now, Season: season.Winter, Holiday: true})

if base == holiday {
t.Errorf("holiday skin did not change winter output when opted in")
}
if !strings.HasPrefix(base, seasonDecoration(season.Winter, false)) {
t.Errorf("base winter should use non-holiday decoration")
}
}
Loading
Loading