diff --git a/README.md b/README.md
index f390ed3..6220ff0 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,7 @@ 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 # buy a grace day before wilting (weekends / PTO)
+commit-sprout garden # a row of plants, one per repo (a windowsill)
```
`status` prints a plain, script-friendly summary:
@@ -81,6 +82,56 @@ skips the state write unless you pass `--save`) and it **fails silently outside
a git repo** — it prints nothing and exits `0`, so it can never break your
shell. Drop it in and forget it.
+### Multi-repo garden (windowsill)
+
+Most people ship across more than one repo. `commit-sprout garden` renders a
+row of small plants — one per repository — so you can eyeball your cadence
+everywhere at once:
+
+```bash
+commit-sprout garden ~/code/foo ~/code/bar # explicit repos, side by side
+commit-sprout garden --scan ~/code # scan a parent dir one level deep
+commit-sprout garden # render your saved garden
+```
+
+Example:
+
+```text
+foo bar
+ \|/ ,
+ | /|
+ _|_ ' |
+ ( ) _|_
+ '-' ( )
+seed/healthy '-'
+no commits yet leafy/healthy
+ today (2026-07-06)
+```
+
+Save a set of repos so plain `garden` just works, and manage it over time:
+
+```bash
+commit-sprout garden --add ~/code/foo ~/code/bar # remember these repos
+commit-sprout garden --add # remember the current repo
+commit-sprout garden --remove ~/code/bar # forget one
+commit-sprout garden --list # show the saved set
+```
+
+Details:
+
+- **Repo resolution order:** explicit path args → `--scan
` → your saved
+ set. Whichever is present first wins.
+- **Per-repo identity:** each repo is scored against *its own*
+ `git config user.email` by default, so a garden of repos you committed under
+ different addresses still counts correctly. Pass `--author ` to score
+ every repo against one address instead.
+- **Read-only:** the garden is a glance, not a writer — it never changes any
+ repo's remembered plant state. The saved *repo list* lives in the same state
+ file as everything else (`garden_repos`).
+- **Layout:** plants wrap onto multiple rows to fit your terminal width
+ (honoring `$COLUMNS`), and color / `--no-color` / `NO_COLOR` behave exactly
+ like the single-plant view.
+
## Install
### Prebuilt binary (recommended)
@@ -197,7 +248,13 @@ Go + cobra + lipgloss. Boring and fast, because this might run on every shell pr
## Roadmap
See [PLAN.md](./PLAN.md) for the full plan, milestones (M1–M6), and the v0.2+ backlog
-(multi-repo gardens, species, pests, brag export, and more).
+(species, pests, brag export, and more).
+
+**Shipped since v0.1 (v0.2 backlog):**
+
+- **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.
## License
diff --git a/cmd/garden.go b/cmd/garden.go
new file mode 100644
index 0000000..ced3277
--- /dev/null
+++ b/cmd/garden.go
@@ -0,0 +1,352 @@
+// Garden subcommand: a multi-repo "windowsill" that renders one small plant per
+// repository, side by side, so you can glance at cadence across several
+// projects at once. It is deliberately a read-only view -- it never persists
+// per-repo plant memory -- because the single-repo commands already own that
+// state and a garden is a dashboard, not another writer.
+//
+// Repos are resolved in priority order:
+//
+// 1. explicit path arguments (`commit-sprout garden ~/a ~/b`), else
+// 2. a one-level scan of a parent dir (`commit-sprout garden --scan ~/code`), else
+// 3. the saved garden set persisted in the state file.
+//
+// The saved set is managed with `--add`, `--remove`, and `--list`, which map
+// onto small helpers in internal/store. Per-repo author filtering is preserved:
+// each repo defaults to its own `git config user.email` unless --author pins a
+// single address across the whole garden.
+package cmd
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "time"
+
+ "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/store"
+ "github.com/spf13/cobra"
+)
+
+// Garden flag backing vars.
+var (
+ // gardenScan scans a parent directory one level deep for git repos.
+ gardenScan string
+ // gardenAdd adds the given paths (or the current dir when none) to the
+ // saved garden set and exits.
+ gardenAdd bool
+ // gardenRemove removes the given paths from the saved garden set and exits.
+ gardenRemove bool
+ // gardenList prints the saved garden set and exits.
+ gardenList bool
+ // gardenAuthor pins a single author email across every repo, overriding
+ // each repo's configured user.email. Empty means per-repo default.
+ gardenAuthor string
+ // gardenWindow overrides the activity look-back window (days).
+ gardenWindow int
+)
+
+// gardenCmd implements `commit-sprout garden`.
+var gardenCmd = &cobra.Command{
+ Use: "garden [path...]",
+ Short: "Render a row of plants, one per repo (a multi-repo windowsill)",
+ Long: `Show several repositories as a row of small plants, so you can see your
+commit cadence across projects at a glance.
+
+Which repos are shown is resolved in this order:
+ 1. any paths you pass as arguments, otherwise
+ 2. the directory given to --scan (scanned one level deep for git repos), otherwise
+ 3. your saved garden set.
+
+Manage the saved set:
+ commit-sprout garden --add ~/code/foo ~/code/bar # remember these repos
+ commit-sprout garden --add # remember the current repo
+ commit-sprout garden --remove ~/code/bar # forget one
+ commit-sprout garden --list # show the saved set
+
+Each repo keeps its own author identity by default (its git config user.email);
+pass --author to score every repo against one address instead.`,
+ SilenceUsage: true,
+ SilenceErrors: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return runGarden(cmd, args)
+ },
+}
+
+// runGarden dispatches the management flags first (--add/--remove/--list, which
+// mutate or print the saved set and return), then renders the resolved garden.
+func runGarden(cmd *cobra.Command, args []string) error {
+ switch {
+ case gardenList:
+ return runGardenList(cmd)
+ case gardenAdd:
+ return runGardenAdd(cmd, args)
+ case gardenRemove:
+ return runGardenRemove(cmd, args)
+ }
+ return renderGarden(cmd, args)
+}
+
+// loadGardenState loads the persisted state (best-effort, like the main
+// pipeline) so the saved garden set and the --add/--remove/--list flows share
+// one loader. A path resolution failure is fatal for the management flows since
+// they must write, so it is surfaced to the caller.
+func loadGardenState() (store.State, string, error) {
+ path, err := store.DefaultPath()
+ if err != nil {
+ return store.DefaultState(), "", err
+ }
+ st, lerr := store.Load(path)
+ if lerr != nil {
+ fmt.Fprintln(os.Stderr, "commit-sprout: warning:", lerr)
+ }
+ return st, path, nil
+}
+
+// runGardenList prints the saved garden set, one path per line, or a friendly
+// hint when it is empty.
+func runGardenList(cmd *cobra.Command) error {
+ st, _, err := loadGardenState()
+ if err != nil {
+ return err
+ }
+ out := cmd.OutOrStdout()
+ if len(st.GardenRepos) == 0 {
+ fmt.Fprintln(out, "No repos saved in the garden yet.")
+ fmt.Fprintln(out, "Add some with: commit-sprout garden --add [...]")
+ return nil
+ }
+ fmt.Fprintf(out, "Saved garden (%d repo%s):\n", len(st.GardenRepos), plural(len(st.GardenRepos)))
+ for _, p := range st.GardenRepos {
+ fmt.Fprintf(out, " %s\n", p)
+ }
+ return nil
+}
+
+// runGardenAdd adds paths (defaulting to the current directory when none are
+// given) to the saved garden set. Non-repo paths are rejected up front so the
+// saved set stays meaningful. With --no-save the change is computed and
+// reported but not written.
+func runGardenAdd(cmd *cobra.Command, args []string) error {
+ st, path, err := loadGardenState()
+ if err != nil {
+ return err
+ }
+ out := cmd.OutOrStdout()
+
+ paths := args
+ if len(paths) == 0 {
+ paths = []string{"."}
+ }
+
+ // Validate every path is a git repo before saving; a typo shouldn't
+ // silently pollute the saved set.
+ var valid []string
+ for _, p := range paths {
+ if !isRepo(p) {
+ fmt.Fprintf(out, "skip: %s is not a git repository\n", p)
+ continue
+ }
+ valid = append(valid, p)
+ }
+ if len(valid) == 0 {
+ return fmt.Errorf("garden: no valid repositories to add")
+ }
+
+ updated, added := st.AddGardenRepos(valid...)
+ if added == 0 {
+ fmt.Fprintln(out, "Nothing new to add \u2014 those repos are already in the garden.")
+ return nil
+ }
+
+ if noSave {
+ fmt.Fprintf(out, "Would add %d repo%s (not saved: --no-save).\n", added, plural(added))
+ return nil
+ }
+ if serr := store.Save(path, updated); serr != nil {
+ return fmt.Errorf("garden: saving state: %w", serr)
+ }
+ fmt.Fprintf(out, "Added %d repo%s to the garden (now %d total).\n",
+ added, plural(added), len(updated.GardenRepos))
+ return nil
+}
+
+// runGardenRemove drops the given paths from the saved garden set.
+func runGardenRemove(cmd *cobra.Command, args []string) error {
+ st, path, err := loadGardenState()
+ if err != nil {
+ return err
+ }
+ out := cmd.OutOrStdout()
+ if len(args) == 0 {
+ return fmt.Errorf("garden: --remove needs at least one path")
+ }
+
+ updated, removed := st.RemoveGardenRepos(args...)
+ if removed == 0 {
+ fmt.Fprintln(out, "Nothing removed \u2014 none of those paths were in the garden.")
+ return nil
+ }
+ if noSave {
+ fmt.Fprintf(out, "Would remove %d repo%s (not saved: --no-save).\n", removed, plural(removed))
+ return nil
+ }
+ if serr := store.Save(path, updated); serr != nil {
+ return fmt.Errorf("garden: saving state: %w", serr)
+ }
+ fmt.Fprintf(out, "Removed %d repo%s from the garden (%d remaining).\n",
+ removed, plural(removed), len(updated.GardenRepos))
+ return nil
+}
+
+// renderGarden resolves the repo list, reads each repo's activity, computes its
+// plant, and prints the side-by-side windowsill. It never persists plant state
+// (a garden is a read-only glance). Repos that cannot be read are reported on
+// stderr and skipped so one bad path doesn't sink the whole view.
+func renderGarden(cmd *cobra.Command, args []string) error {
+ out := cmd.OutOrStdout()
+
+ repos, err := resolveGardenRepos(args)
+ if err != nil {
+ return err
+ }
+ if len(repos) == 0 {
+ fmt.Fprintln(out, "No repos to show. Pass paths, use --scan , or save some with --add.")
+ return nil
+ }
+
+ window := gardenWindow
+ if window <= 0 {
+ window = gitstat.DefaultWindowDays
+ }
+
+ now := time.Now()
+ plants := make([]render.GardenPlant, 0, len(repos))
+ skipped := 0
+ for _, dir := range repos {
+ act, rerr := gitstat.ReadAt(dir, gardenAuthor, window)
+ if rerr != nil {
+ fmt.Fprintf(os.Stderr, "commit-sprout: skip %s: %v\n", dir, rerr)
+ skipped++
+ continue
+ }
+ // Read-only: start from a fresh (unremembered) plant so the garden
+ // reflects live activity without needing per-repo saved state.
+ ps := plant.Compute(act, store.DefaultState().Plant(now, act.LastCommit), now)
+ plants = append(plants, render.GardenPlant{
+ Label: gitstat.RepoName(dir),
+ State: ps,
+ LastCommit: act.LastCommit,
+ })
+ }
+
+ if len(plants) == 0 {
+ fmt.Fprintln(out, "No readable git repositories in the garden.")
+ return nil
+ }
+
+ frame := render.Garden(plants, render.Options{
+ Color: useColor(cmd),
+ Now: now,
+ Width: terminalWidth(cmd),
+ })
+ if _, perr := fmt.Fprintln(out, frame); perr != nil {
+ return perr
+ }
+ if skipped > 0 {
+ fmt.Fprintf(out, "\n(%d repo%s skipped \u2014 see messages above.)\n", skipped, plural(skipped))
+ }
+ return nil
+}
+
+// resolveGardenRepos returns the ordered list of repo directories to render,
+// following the documented precedence: explicit args, then --scan, then the
+// saved set. Explicit args and --scan are mutually informative but args win.
+func resolveGardenRepos(args []string) ([]string, error) {
+ if len(args) > 0 {
+ return args, nil
+ }
+ if gardenScan != "" {
+ return scanForRepos(gardenScan)
+ }
+ st, _, err := loadGardenState()
+ if err != nil {
+ return nil, err
+ }
+ return st.GardenRepos, nil
+}
+
+// scanForRepos lists immediate subdirectories of root that are git repositories
+// (contain a .git entry). It scans exactly one level deep -- enough to point it
+// at a parent "code" directory -- and returns the matches sorted by name for a
+// stable windowsill order.
+func scanForRepos(root string) ([]string, error) {
+ entries, err := os.ReadDir(root)
+ if err != nil {
+ return nil, fmt.Errorf("garden: scanning %s: %w", root, err)
+ }
+ var repos []string
+ for _, e := range entries {
+ if !e.IsDir() {
+ continue
+ }
+ dir := filepath.Join(root, e.Name())
+ if isRepo(dir) {
+ repos = append(repos, dir)
+ }
+ }
+ sort.Strings(repos)
+ return repos, nil
+}
+
+// isRepo reports whether dir looks like a git working tree by checking for a
+// .git entry (directory or file, the latter covering worktrees/submodules). It
+// is a cheap filesystem check that avoids spawning git just to filter a scan.
+func isRepo(dir string) bool {
+ if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
+ return true
+ }
+ return false
+}
+
+// terminalWidth determines the column budget for the garden layout. It prefers
+// an explicit COLUMNS environment variable (respected by many shells and easy
+// to pin in tests/CI), and otherwise returns 0 so render.Garden falls back to
+// its own DefaultGardenWidth. Keeping width detection dependency-free avoids
+// pulling in a terminal-sizing library for a cosmetic layout hint.
+func terminalWidth(_ *cobra.Command) int {
+ if cols := os.Getenv("COLUMNS"); cols != "" {
+ if n, err := strconv.Atoi(cols); err == nil && n > 0 {
+ return n
+ }
+ }
+ return 0
+}
+
+// plural returns "s" for counts other than 1, for simple message pluralization.
+func plural(n int) string {
+ if n == 1 {
+ return ""
+ }
+ return "s"
+}
+
+func init() {
+ gardenCmd.Flags().StringVar(&gardenScan, "scan", "",
+ "scan a parent directory one level deep for git repos")
+ gardenCmd.Flags().BoolVar(&gardenAdd, "add", false,
+ "add path arguments (or the current repo) to the saved garden set")
+ gardenCmd.Flags().BoolVar(&gardenRemove, "remove", false,
+ "remove path arguments from the saved garden set")
+ gardenCmd.Flags().BoolVar(&gardenList, "list", false,
+ "list the saved garden set and exit")
+ gardenCmd.Flags().StringVar(&gardenAuthor, "author", "",
+ "score every repo against this author email (default: each repo's own)")
+ gardenCmd.Flags().IntVar(&gardenWindow, "window", 0,
+ "activity look-back window in days (default: 7)")
+
+ rootCmd.AddCommand(gardenCmd)
+}
diff --git a/cmd/garden_test.go b/cmd/garden_test.go
new file mode 100644
index 0000000..18f077f
--- /dev/null
+++ b/cmd/garden_test.go
@@ -0,0 +1,154 @@
+package cmd
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestPlural(t *testing.T) {
+ cases := []struct {
+ n int
+ want string
+ }{
+ {0, "s"},
+ {1, ""},
+ {2, "s"},
+ {100, "s"},
+ }
+ for _, c := range cases {
+ if got := plural(c.n); got != c.want {
+ t.Errorf("plural(%d) = %q; want %q", c.n, got, c.want)
+ }
+ }
+}
+
+// mkRepo creates a directory with a .git marker so isRepo/scanForRepos treat it
+// as a repository without needing a real git init.
+func mkRepo(t *testing.T, root, name string) string {
+ t.Helper()
+ dir := filepath.Join(root, name)
+ if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
+ t.Fatalf("mkRepo %s: %v", name, err)
+ }
+ return dir
+}
+
+func TestIsRepo(t *testing.T) {
+ root := t.TempDir()
+ repo := mkRepo(t, root, "withgit")
+ plain := filepath.Join(root, "plain")
+ if err := os.MkdirAll(plain, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ if !isRepo(repo) {
+ t.Errorf("isRepo(%q) = false; want true", repo)
+ }
+ if isRepo(plain) {
+ t.Errorf("isRepo(%q) = true; want false", plain)
+ }
+ if isRepo(filepath.Join(root, "does-not-exist")) {
+ t.Errorf("isRepo(missing) = true; want false")
+ }
+}
+
+// TestIsRepoGitFile covers the worktree/submodule case where .git is a file
+// rather than a directory.
+func TestIsRepoGitFile(t *testing.T) {
+ root := t.TempDir()
+ dir := filepath.Join(root, "wt")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: ../real\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if !isRepo(dir) {
+ t.Errorf("isRepo with .git file = false; want true")
+ }
+}
+
+func TestScanForReposFindsOnlyRepos(t *testing.T) {
+ root := t.TempDir()
+ mkRepo(t, root, "alpha")
+ mkRepo(t, root, "beta")
+ // A non-repo dir and a stray file that must be ignored.
+ if err := os.MkdirAll(filepath.Join(root, "notrepo"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "afile"), []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := scanForRepos(root)
+ if err != nil {
+ t.Fatalf("scanForRepos: %v", err)
+ }
+ want := []string{filepath.Join(root, "alpha"), filepath.Join(root, "beta")}
+ if len(got) != len(want) {
+ t.Fatalf("scanForRepos = %v; want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("scanForRepos[%d] = %q; want %q (should be sorted)", i, got[i], want[i])
+ }
+ }
+}
+
+func TestScanForReposMissingDirErrors(t *testing.T) {
+ _, err := scanForRepos(filepath.Join(t.TempDir(), "nope"))
+ if err == nil {
+ t.Error("scanForRepos on a missing dir: want error, got nil")
+ }
+}
+
+// TestResolveGardenReposArgsWin verifies explicit path args take precedence over
+// both --scan and any saved set.
+func TestResolveGardenReposArgsWin(t *testing.T) {
+ // Set --scan to something that would otherwise be used; args should win.
+ prevScan := gardenScan
+ gardenScan = t.TempDir()
+ defer func() { gardenScan = prevScan }()
+
+ args := []string{"/explicit/a", "/explicit/b"}
+ got, err := resolveGardenRepos(args)
+ if err != nil {
+ t.Fatalf("resolveGardenRepos: %v", err)
+ }
+ if len(got) != 2 || got[0] != args[0] || got[1] != args[1] {
+ t.Errorf("resolveGardenRepos(args) = %v; want %v", got, args)
+ }
+}
+
+// TestResolveGardenReposScanBranch verifies that with no args, --scan drives the
+// result.
+func TestResolveGardenReposScanBranch(t *testing.T) {
+ root := t.TempDir()
+ mkRepo(t, root, "one")
+ mkRepo(t, root, "two")
+
+ prevScan := gardenScan
+ gardenScan = root
+ defer func() { gardenScan = prevScan }()
+
+ got, err := resolveGardenRepos(nil)
+ if err != nil {
+ t.Fatalf("resolveGardenRepos: %v", err)
+ }
+ if len(got) != 2 {
+ t.Errorf("resolveGardenRepos(--scan) = %v; want 2 repos", got)
+ }
+}
+
+func TestTerminalWidthFromColumns(t *testing.T) {
+ t.Setenv("COLUMNS", "123")
+ if w := terminalWidth(nil); w != 123 {
+ t.Errorf("terminalWidth() = %d; want 123 from COLUMNS", w)
+ }
+ // Non-numeric COLUMNS falls back to 0 (renderer default).
+ t.Setenv("COLUMNS", "wide")
+ if w := terminalWidth(nil); w != 0 {
+ t.Errorf("terminalWidth() with bad COLUMNS = %d; want 0", w)
+ }
+}
diff --git a/internal/gitstat/gitstat.go b/internal/gitstat/gitstat.go
index 2921ee1..85a8322 100644
--- a/internal/gitstat/gitstat.go
+++ b/internal/gitstat/gitstat.go
@@ -16,7 +16,9 @@ package gitstat
import (
"errors"
"fmt"
+ "os"
"os/exec"
+ "path/filepath"
"sort"
"strings"
"time"
@@ -105,25 +107,41 @@ type commit struct {
}
// Read returns the Activity for the git repository in the current working
-// directory. When author is empty it defaults to the configured
-// `git config user.email`. windowDays <= 0 falls back to DefaultWindowDays.
+// directory. It is a thin convenience wrapper over ReadAt(".", ...); see that
+// function for the parameter semantics.
//
// Read shells out to git; the parsing it relies on is exercised directly by
// tests via parseLog, so this thin wrapper needs no live-repo test.
func Read(author string, windowDays int) (Activity, error) {
+ return ReadAt(".", author, windowDays)
+}
+
+// ReadAt returns the Activity for the git repository rooted at dir. An empty
+// dir is treated as the current working directory. When author is empty it
+// defaults to that repository's configured `git config user.email`, so
+// per-repo author identities are honored (each repo in a garden may have been
+// committed under a different address). windowDays <= 0 falls back to
+// DefaultWindowDays.
+//
+// Like Read, ReadAt only orchestrates git and delegates the interesting work
+// to the pure parseLog, so it needs no live-repo test of its own.
+func ReadAt(dir, author string, windowDays int) (Activity, error) {
if windowDays <= 0 {
windowDays = DefaultWindowDays
}
+ if dir == "" {
+ dir = "."
+ }
- if err := ensureRepo(); err != nil {
+ if err := ensureRepo(dir); err != nil {
return Activity{}, err
}
if author == "" {
- author = configuredEmail()
+ author = configuredEmail(dir)
}
- raw, err := runLog(author, windowDays)
+ raw, err := runLog(dir, author, windowDays)
if err != nil {
return Activity{}, err
}
@@ -131,30 +149,34 @@ func Read(author string, windowDays int) (Activity, error) {
return parseLog(raw, author, time.Now(), windowDays), nil
}
-// ensureRepo verifies the current directory is inside a git working tree,
-// translating git's failure into the friendly ErrNotARepo sentinel.
-func ensureRepo() error {
+// ensureRepo verifies dir is inside a git working tree, translating git's
+// failure into the friendly ErrNotARepo sentinel.
+func ensureRepo(dir string) error {
cmd := exec.Command("git", "rev-parse", "--is-inside-work-tree")
+ cmd.Dir = dir
if err := cmd.Run(); err != nil {
return ErrNotARepo
}
return nil
}
-// configuredEmail returns `git config user.email`, or "" if it is unset. An
-// empty author simply means "do not filter by author" downstream.
-func configuredEmail() string {
- out, err := exec.Command("git", "config", "user.email").Output()
+// configuredEmail returns dir's `git config user.email`, or "" if it is unset.
+// An empty author simply means "do not filter by author" downstream.
+func configuredEmail(dir string) string {
+ cmd := exec.Command("git", "config", "user.email")
+ cmd.Dir = dir
+ out, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
-// runLog runs `git log` and returns its raw stdout. It fetches enough history
-// to compute a long streak accurately (streakLookbackDays), even when the
-// aggregate window is small; parseLog then bounds the aggregates to the window.
-func runLog(author string, windowDays int) (string, error) {
+// runLog runs `git log` in dir and returns its raw stdout. It fetches enough
+// history to compute a long streak accurately (streakLookbackDays), even when
+// the aggregate window is small; parseLog then bounds the aggregates to the
+// window.
+func runLog(dir, author string, windowDays int) (string, error) {
lookback := windowDays
if streakLookbackDays > lookback {
lookback = streakLookbackDays
@@ -173,13 +195,34 @@ func runLog(author string, windowDays int) (string, error) {
args = append(args, "--author="+author)
}
- out, err := exec.Command("git", args...).Output()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("gitstat: git log failed: %w", err)
}
return string(out), nil
}
+// RepoName returns a short, human-friendly label for a repo directory, used as
+// the caption above each plant in a garden. It resolves dir to an absolute path
+// and takes its base name, falling back to the raw input when resolution fails
+// (e.g. dir does not exist) so a label is always produced.
+func RepoName(dir string) string {
+ if dir == "" || dir == "." {
+ if wd, err := os.Getwd(); err == nil {
+ return filepath.Base(wd)
+ }
+ }
+ if abs, err := filepath.Abs(dir); err == nil {
+ base := filepath.Base(abs)
+ if base != "." && base != string(filepath.Separator) {
+ return base
+ }
+ }
+ return dir
+}
+
// parseLog turns raw `git log` output into an Activity. It is pure: all clock
// and timezone decisions come from now, so identical input always yields
// identical output. windowDays is assumed already normalized (> 0).
diff --git a/internal/gitstat/gitstat_test.go b/internal/gitstat/gitstat_test.go
index 7babd67..b927629 100644
--- a/internal/gitstat/gitstat_test.go
+++ b/internal/gitstat/gitstat_test.go
@@ -284,3 +284,22 @@ func TestParseLogLargeWindowStreak(t *testing.T) {
t.Errorf("TotalInWindow = %d; want 3", act.TotalInWindow)
}
}
+
+func TestRepoNameBaseName(t *testing.T) {
+ if got := RepoName("/home/user/code/myrepo"); got != "myrepo" {
+ t.Errorf("RepoName(/home/user/code/myrepo) = %q; want myrepo", got)
+ }
+ if got := RepoName("/home/user/code/myrepo/"); got != "myrepo" {
+ t.Errorf("RepoName with trailing slash = %q; want myrepo", got)
+ }
+}
+
+func TestRepoNameEmptyFallsBackToCwdBase(t *testing.T) {
+ // "." and "" resolve to the current working directory's base name; we
+ // don't assert the exact value (it varies) but it must be non-empty.
+ for _, in := range []string{"", "."} {
+ if got := RepoName(in); got == "" {
+ t.Errorf("RepoName(%q) returned empty; want the cwd base name", in)
+ }
+ }
+}
diff --git a/internal/render/garden_test.go b/internal/render/garden_test.go
new file mode 100644
index 0000000..c68d4a3
--- /dev/null
+++ b/internal/render/garden_test.go
@@ -0,0 +1,188 @@
+package render
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/rwrife/commit-sprout/internal/plant"
+)
+
+// gp is a small helper to build a GardenPlant for tests.
+func gp(label string, stage plant.Stage, health plant.Health, last time.Time) GardenPlant {
+ return GardenPlant{
+ Label: label,
+ State: plant.PlantState{Stage: stage, Health: health, DaysSinceCommit: 0},
+ LastCommit: last,
+ }
+}
+
+func TestGardenEmptyIsFriendly(t *testing.T) {
+ out := Garden(nil, Options{})
+ if strings.TrimSpace(out) == "" {
+ t.Fatal("Garden(nil) returned empty; expected a friendly hint")
+ }
+ if !strings.Contains(out, "garden") {
+ t.Errorf("empty-garden message should mention the garden; got %q", out)
+ }
+}
+
+func TestGardenSingleContainsLabelAndCaption(t *testing.T) {
+ last := time.Date(2026, 7, 6, 10, 0, 0, 0, time.UTC)
+ now := last
+ out := Garden([]GardenPlant{
+ gp("myrepo", plant.Sprout, plant.Healthy, last),
+ }, Options{Now: now, Width: 80})
+
+ for _, want := range []string{"myrepo", "sprout/healthy", "today"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("single-plant garden missing %q; got:\n%s", want, out)
+ }
+ }
+}
+
+// TestGardenPacksMultipleOnWideRow verifies that several narrow cells share one
+// row when the width allows, i.e. the label line has both repo names on it.
+func TestGardenPacksMultipleOnWideRow(t *testing.T) {
+ last := time.Date(2026, 7, 6, 10, 0, 0, 0, time.UTC)
+ out := Garden([]GardenPlant{
+ gp("aaa", plant.Sprout, plant.Healthy, last),
+ gp("bbb", plant.Leafy, plant.Healthy, last),
+ }, Options{Now: last, Width: 120})
+
+ firstLine := strings.SplitN(out, "\n", 2)[0]
+ if !strings.Contains(firstLine, "aaa") || !strings.Contains(firstLine, "bbb") {
+ t.Errorf("expected both labels on the first row at width 120; got first line %q", firstLine)
+ }
+}
+
+// TestGardenWrapsWhenNarrow verifies that a tight width forces cells onto
+// separate rows (the second label appears on a later line, not the first).
+func TestGardenWrapsWhenNarrow(t *testing.T) {
+ last := time.Date(2026, 7, 6, 10, 0, 0, 0, time.UTC)
+ out := Garden([]GardenPlant{
+ gp("aaa", plant.Sprout, plant.Healthy, last),
+ gp("bbb", plant.Leafy, plant.Healthy, last),
+ }, Options{Now: last, Width: 12})
+
+ firstLine := strings.SplitN(out, "\n", 2)[0]
+ if strings.Contains(firstLine, "bbb") {
+ t.Errorf("did not expect second label on the first row at width 12; got %q", firstLine)
+ }
+ if !strings.Contains(out, "bbb") {
+ t.Errorf("second plant missing entirely from narrow garden:\n%s", out)
+ }
+}
+
+// TestGardenRowsAreRectangular checks that every line in a packed row has the
+// same visible width, which is what keeps columns aligned.
+func TestGardenRowsAreRectangular(t *testing.T) {
+ last := time.Date(2026, 7, 6, 10, 0, 0, 0, time.UTC)
+ out := Garden([]GardenPlant{
+ gp("aaa", plant.Blooming, plant.Healthy, last),
+ gp("bb", plant.Seed, plant.Wilting, last),
+ }, Options{Now: last, Width: 120})
+
+ // Consider only the first row block (up to a blank separator line).
+ rowBlock := out
+ if idx := strings.Index(out, "\n\n"); idx >= 0 {
+ rowBlock = out[:idx]
+ }
+ lines := strings.Split(rowBlock, "\n")
+ width := visibleWidth(lines[0])
+ for i, ln := range lines {
+ if w := visibleWidth(ln); w != width {
+ t.Errorf("row line %d width = %d; want %d (line %q)", i, w, width, ln)
+ }
+ }
+}
+
+// TestGardenNoWidthUsesDefault ensures Width<=0 falls back rather than piling
+// everything onto one infinitely wide row: with a default of 80, three ~18-wide
+// cells fit on one row, but the call must not panic and must include all three.
+func TestGardenNoWidthUsesDefault(t *testing.T) {
+ last := time.Date(2026, 7, 6, 10, 0, 0, 0, time.UTC)
+ out := Garden([]GardenPlant{
+ gp("one", plant.Sprout, plant.Healthy, last),
+ gp("two", plant.Sprout, plant.Healthy, last),
+ gp("three", plant.Sprout, plant.Healthy, last),
+ }, Options{Now: last}) // Width omitted -> DefaultGardenWidth
+
+ for _, want := range []string{"one", "two", "three"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("garden missing %q at default width:\n%s", want, out)
+ }
+ }
+}
+
+// TestGardenColorPadsToSameWidthAsPlain guards the ANSI-aware width logic: a
+// colored row must have the same *visible* geometry as the plain one, so color
+// codes never break column alignment.
+func TestGardenColorPadsToSameWidthAsPlain(t *testing.T) {
+ last := time.Date(2026, 7, 6, 10, 0, 0, 0, time.UTC)
+ plants := []GardenPlant{
+ gp("aaa", plant.Tall, plant.Healthy, last),
+ gp("bbb", plant.Sprout, plant.Thirsty, last),
+ }
+ plain := Garden(plants, Options{Now: last, Width: 120, Color: false})
+ colored := Garden(plants, Options{Now: last, Width: 120, Color: true})
+
+ plainLines := strings.Split(plain, "\n")
+ colorLines := strings.Split(colored, "\n")
+ if len(plainLines) != len(colorLines) {
+ t.Fatalf("line count differs: plain=%d colored=%d", len(plainLines), len(colorLines))
+ }
+ for i := range plainLines {
+ if pv, cv := visibleWidth(plainLines[i]), visibleWidth(colorLines[i]); pv != cv {
+ t.Errorf("line %d visible width: plain=%d colored=%d", i, pv, cv)
+ }
+ }
+}
+
+func TestVisibleWidthIgnoresANSI(t *testing.T) {
+ // "\x1b[2m" ... "\x1b[0m" wrap "hi" -> visible width 2.
+ s := "\x1b[2mhi\x1b[0m"
+ if w := visibleWidth(s); w != 2 {
+ t.Errorf("visibleWidth(%q) = %d; want 2", s, w)
+ }
+ if w := visibleWidth("plain"); w != 5 {
+ t.Errorf("visibleWidth(plain) = %d; want 5", w)
+ }
+}
+
+func TestGardenLabelTruncatesLongNames(t *testing.T) {
+ long := gardenLabel("a-really-long-repository-name-here")
+ if r := []rune(long); len(r) > 16 {
+ t.Errorf("gardenLabel too long: %d runes (%q)", len(r), long)
+ }
+ if !strings.HasSuffix(long, "\u2026") {
+ t.Errorf("expected ellipsis on truncated label; got %q", long)
+ }
+ if got := gardenLabel(""); got != "(repo)" {
+ t.Errorf("empty label = %q; want (repo)", got)
+ }
+}
+
+func TestPackRowsRespectsWidth(t *testing.T) {
+ // Three cells each 10 wide, gutter 3: at width 25 only two fit per row.
+ cells := []string{
+ strings.Repeat("x", 10),
+ strings.Repeat("y", 10),
+ strings.Repeat("z", 10),
+ }
+ rows := packRows(cells, 25)
+ if len(rows) != 2 {
+ t.Fatalf("packRows rows = %d; want 2", len(rows))
+ }
+ if len(rows[0]) != 2 || len(rows[1]) != 1 {
+ t.Errorf("packRows layout = %v; want [2,1] cells", []int{len(rows[0]), len(rows[1])})
+ }
+}
+
+func TestPackRowsOversizeCellGetsOwnRow(t *testing.T) {
+ cells := []string{strings.Repeat("x", 100), "y"}
+ rows := packRows(cells, 20)
+ if len(rows) != 2 {
+ t.Fatalf("oversize cell should still occupy its own row; rows=%d", len(rows))
+ }
+}
diff --git a/internal/render/render.go b/internal/render/render.go
index 6359174..effab2f 100644
--- a/internal/render/render.go
+++ b/internal/render/render.go
@@ -44,6 +44,12 @@ type Options struct {
// caption. The zero value means "no commits yet" and is rendered as a
// friendly seed-planting nudge.
LastCommit time.Time
+
+ // Width is the terminal width (in columns) available for multi-plant
+ // garden layout. It is only consulted by Garden; single-plant Frame
+ // ignores it. Zero or negative means "unknown", and Garden falls back to
+ // DefaultGardenWidth.
+ Width int
}
// Seedling returns the hard-coded ASCII seedling frame (the healthy Seed art).
@@ -352,3 +358,261 @@ func colorizeArt(art string, ps plant.PlantState) string {
func colorizeCaption(caption string, _ plant.PlantState) string {
return captionStyle.Render(caption)
}
+
+// --- garden (multi-repo windowsill) -----------------------------------------
+//
+// Garden renders several plants side by side as a row (a "windowsill"), each
+// under its repo label with a compact one-plant caption. It reuses the same
+// per-stage/health art as the single-plant Frame so a garden plant looks
+// identical to its solo view, just laid out in a grid.
+
+// gardenGutter is the number of spaces placed between adjacent plants in a row.
+const gardenGutter = 3
+
+// DefaultGardenWidth is the terminal width assumed when a caller does not know
+// the real one (Options.Width <= 0). 80 columns is the classic safe default and
+// keeps the row from sprawling when output is piped or width detection fails.
+const DefaultGardenWidth = 80
+
+// GardenPlant is one entry in a garden: a repo's resolved plant state plus the
+// label (typically the repo directory name) to caption it with.
+type GardenPlant struct {
+ // Label is the short name shown above the plant (e.g. the repo dir name).
+ Label string
+
+ // State is the computed plant to render for this repo.
+ State plant.PlantState
+
+ // 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
+}
+
+// Garden renders plants as one or more rows of side-by-side cells, wrapping to
+// the next row when the next cell would exceed opt.Width (falling back to
+// DefaultGardenWidth when opt.Width <= 0). It is deterministic given its inputs.
+//
+// An empty plants slice yields a friendly "empty windowsill" line rather than a
+// blank string, so callers can print the result unconditionally.
+func Garden(plants []GardenPlant, opt Options) string {
+ if len(plants) == 0 {
+ return "No repos in the garden yet \u2014 add some with `commit-sprout garden --add `\n" +
+ "or pass repo paths: `commit-sprout garden `."
+ }
+
+ width := opt.Width
+ if width <= 0 {
+ width = DefaultGardenWidth
+ }
+
+ cells := make([]string, len(plants))
+ for i, p := range plants {
+ cells[i] = gardenCell(p, opt)
+ }
+
+ rows := packRows(cells, width)
+ blocks := make([]string, len(rows))
+ for i, row := range rows {
+ blocks[i] = joinSideBySide(row, gardenGutter)
+ }
+ return strings.Join(blocks, "\n\n")
+}
+
+// gardenCell builds the full text block for a single garden plant: a centered
+// label, the stage/health art, and a compact two-line caption. The block is a
+// 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)
+ if opt.Color {
+ art = colorizeArt(art, p.State)
+ }
+
+ caption := gardenCaption(p, opt)
+ if opt.Color {
+ caption = captionStyle.Render(caption)
+ }
+
+ label := gardenLabel(p.Label)
+ if opt.Color {
+ label = captionStyle.Render(label)
+ }
+
+ block := label + "\n" + art + "\n" + caption
+ return padBlock(block)
+}
+
+// gardenLabel formats the repo name shown above a garden plant, truncating
+// overly long names so a single cell cannot dominate the row width.
+func gardenLabel(name string) string {
+ const max = 16
+ name = strings.TrimSpace(name)
+ if name == "" {
+ name = "(repo)"
+ }
+ r := []rune(name)
+ if len(r) > max {
+ name = string(r[:max-1]) + "\u2026"
+ }
+ return name
+}
+
+// gardenCaption is the compact per-plant caption used in a garden: stage/health
+// on one line and a short last-commit phrase on the next. It intentionally
+// omits the mood and multi-line hints that the full single-plant Frame shows,
+// keeping each cell narrow.
+func gardenCaption(p GardenPlant, opt Options) string {
+ opt.LastCommit = p.LastCommit
+ last := lastCommitPhrase(p.State, opt)
+ if len(last) > 22 {
+ // The parenthetical date is the least essential part in a tight
+ // cell; drop it when the phrase runs long.
+ if idx := strings.Index(last, " ("); idx > 0 {
+ last = last[:idx]
+ }
+ }
+ return fmt.Sprintf("%s/%s\n%s", p.State.Stage, p.State.Health, last)
+}
+
+// packRows greedily groups cells into rows so that each row's total rendered
+// width (cells plus gutters) stays within width. A single cell wider than width
+// still occupies its own row rather than being dropped. Order is preserved.
+func packRows(cells []string, width int) [][]string {
+ var rows [][]string
+ var cur []string
+ curWidth := 0
+
+ for _, c := range cells {
+ cw := blockWidth(c)
+ add := cw
+ if len(cur) > 0 {
+ add += gardenGutter
+ }
+ if len(cur) > 0 && curWidth+add > width {
+ rows = append(rows, cur)
+ cur = nil
+ curWidth = 0
+ add = cw
+ }
+ cur = append(cur, c)
+ curWidth += add
+ }
+ if len(cur) > 0 {
+ rows = append(rows, cur)
+ }
+ return rows
+}
+
+// joinSideBySide places rectangular text blocks next to each other separated by
+// a gutter of spaces, top-aligned. Blocks of unequal height are bottom-padded
+// with blank lines so every block contributes the same number of rows.
+func joinSideBySide(blocks []string, gutter int) string {
+ if len(blocks) == 0 {
+ return ""
+ }
+ if len(blocks) == 1 {
+ return blocks[0]
+ }
+
+ split := make([][]string, len(blocks))
+ height := 0
+ for i, b := range blocks {
+ lines := strings.Split(b, "\n")
+ split[i] = lines
+ if len(lines) > height {
+ height = len(lines)
+ }
+ }
+
+ sep := strings.Repeat(" ", gutter)
+ var out strings.Builder
+ for row := 0; row < height; row++ {
+ for i, lines := range split {
+ if i > 0 {
+ out.WriteString(sep)
+ }
+ if row < len(lines) {
+ out.WriteString(lines[row])
+ } else {
+ // Pad missing lines to this block's width so later
+ // columns still line up.
+ out.WriteString(strings.Repeat(" ", blockWidth(blocks[i])))
+ }
+ }
+ if row < height-1 {
+ out.WriteByte('\n')
+ }
+ }
+ return out.String()
+}
+
+// padBlock right-pads every line of a block to the block's maximum line width,
+// turning ragged text into a clean rectangle so it tiles correctly beside other
+// blocks. Width is measured in printable columns (see visibleWidth) so ANSI
+// color codes and wide runes do not skew the padding.
+func padBlock(block string) string {
+ lines := strings.Split(block, "\n")
+ w := 0
+ for _, ln := range lines {
+ if vw := visibleWidth(ln); vw > w {
+ w = vw
+ }
+ }
+ for i, ln := range lines {
+ if pad := w - visibleWidth(ln); pad > 0 {
+ lines[i] = ln + strings.Repeat(" ", pad)
+ }
+ }
+ return strings.Join(lines, "\n")
+}
+
+// blockWidth returns the maximum printable width across a block's lines.
+func blockWidth(block string) int {
+ w := 0
+ for _, ln := range strings.Split(block, "\n") {
+ if vw := visibleWidth(ln); vw > w {
+ w = vw
+ }
+ }
+ return w
+}
+
+// visibleWidth returns the number of printable columns in s, ignoring ANSI SGR
+// escape sequences (e.g. lipgloss color codes) so colored and plain cells pad
+// to the same width. It counts runes rather than bytes; the plant art is ASCII
+// plus a handful of BMP symbols, all single-width, so a rune count is accurate
+// here without pulling in a full east-asian-width dependency.
+//
+// It recognizes CSI sequences of the form ESC '[' ... final, where the final
+// byte is in the range 0x40..0x7E. The '[' introducer immediately after ESC is
+// consumed rather than treated as a terminator, so short codes like "\x1b[2m"
+// are skipped in full.
+func visibleWidth(s string) int {
+ n := 0
+ // esc state: 0 = normal, 1 = just saw ESC, 2 = inside CSI params.
+ esc := 0
+ for _, r := range s {
+ switch esc {
+ case 1:
+ if r == '[' {
+ esc = 2 // CSI; consume until a final byte
+ } else {
+ esc = 0 // a non-CSI escape; stop skipping
+ }
+ continue
+ case 2:
+ // Parameter/intermediate bytes are 0x20..0x3F; the sequence
+ // ends at the first final byte in 0x40..0x7E.
+ if r >= '@' && r <= '~' {
+ esc = 0
+ }
+ continue
+ }
+ if r == '\x1b' {
+ esc = 1
+ continue
+ }
+ n++
+ }
+ return n
+}
diff --git a/internal/store/garden_test.go b/internal/store/garden_test.go
new file mode 100644
index 0000000..42ef326
--- /dev/null
+++ b/internal/store/garden_test.go
@@ -0,0 +1,131 @@
+package store
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// abs is a tiny helper turning a relative path into the absolute form the store
+// normalizes to, so tests can assert against exactly what gets persisted.
+func abs(t *testing.T, p string) string {
+ t.Helper()
+ a, err := filepath.Abs(p)
+ if err != nil {
+ t.Fatalf("filepath.Abs(%q): %v", p, err)
+ }
+ return a
+}
+
+func TestAddGardenReposAbsolutizesAndDedups(t *testing.T) {
+ s := DefaultState()
+ s, added := s.AddGardenRepos("./a", "./b", "./a") // duplicate ./a
+ if added != 2 {
+ t.Fatalf("added = %d; want 2", added)
+ }
+ want := []string{abs(t, "./a"), abs(t, "./b")}
+ if len(s.GardenRepos) != len(want) {
+ t.Fatalf("GardenRepos = %v; want %v", s.GardenRepos, want)
+ }
+ for i := range want {
+ if s.GardenRepos[i] != want[i] {
+ t.Errorf("GardenRepos[%d] = %q; want %q", i, s.GardenRepos[i], want[i])
+ }
+ }
+}
+
+func TestAddGardenReposReportsOnlyNew(t *testing.T) {
+ s := DefaultState()
+ s, _ = s.AddGardenRepos("./a", "./b")
+ s2, added := s.AddGardenRepos("./b", "./c") // b already present
+ if added != 1 {
+ t.Errorf("added = %d; want 1 (only ./c is new)", added)
+ }
+ if len(s2.GardenRepos) != 3 {
+ t.Errorf("GardenRepos len = %d; want 3", len(s2.GardenRepos))
+ }
+}
+
+func TestAddGardenReposSkipsBlanks(t *testing.T) {
+ s := DefaultState()
+ s, added := s.AddGardenRepos(" ", "", "./real")
+ if added != 1 {
+ t.Fatalf("added = %d; want 1", added)
+ }
+ if len(s.GardenRepos) != 1 || s.GardenRepos[0] != abs(t, "./real") {
+ t.Errorf("GardenRepos = %v; want [%s]", s.GardenRepos, abs(t, "./real"))
+ }
+}
+
+func TestRemoveGardenReposByAnyForm(t *testing.T) {
+ s := DefaultState()
+ s, _ = s.AddGardenRepos("./a", "./b", "./c")
+ // Remove using a relative path; it should match the stored absolute form.
+ s2, removed := s.RemoveGardenRepos("./b")
+ if removed != 1 {
+ t.Fatalf("removed = %d; want 1", removed)
+ }
+ for _, p := range s2.GardenRepos {
+ if p == abs(t, "./b") {
+ t.Errorf("./b still present after removal: %v", s2.GardenRepos)
+ }
+ }
+ if len(s2.GardenRepos) != 2 {
+ t.Errorf("GardenRepos len = %d; want 2", len(s2.GardenRepos))
+ }
+}
+
+func TestRemoveGardenReposNoMatchIsZero(t *testing.T) {
+ s := DefaultState()
+ s, _ = s.AddGardenRepos("./a")
+ _, removed := s.RemoveGardenRepos("./nope")
+ if removed != 0 {
+ t.Errorf("removed = %d; want 0 for a path not in the set", removed)
+ }
+}
+
+// TestGardenReposSurviveRoundTrip ensures the saved set persists through
+// Save/Load unchanged (absolute, ordered, de-duplicated).
+func TestGardenReposSurviveRoundTrip(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "state.json")
+
+ s := DefaultState()
+ s, _ = s.AddGardenRepos("./x", "./y")
+ if err := Save(path, s); err != nil {
+ t.Fatalf("Save: %v", err)
+ }
+
+ loaded, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if len(loaded.GardenRepos) != 2 {
+ t.Fatalf("loaded GardenRepos = %v; want 2 entries", loaded.GardenRepos)
+ }
+ if loaded.GardenRepos[0] != abs(t, "./x") || loaded.GardenRepos[1] != abs(t, "./y") {
+ t.Errorf("round-trip GardenRepos = %v; want [%s %s]",
+ loaded.GardenRepos, abs(t, "./x"), abs(t, "./y"))
+ }
+}
+
+// TestGardenReposAdditiveToExistingState checks backward compatibility: a state
+// file with no garden_repos key still loads fine and starts with an empty set.
+func TestGardenReposAdditiveToExistingState(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "state.json")
+ // Minimal legacy-ish document with no garden_repos field.
+ if err := os.WriteFile(path, []byte(`{"version":1,"highest_stage":"leafy","best_streak":4}`), 0o600); err != nil {
+ t.Fatalf("write legacy: %v", err)
+ }
+ loaded, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if loaded.GardenRepos != nil {
+ t.Errorf("expected nil GardenRepos for legacy file; got %v", loaded.GardenRepos)
+ }
+ if loaded.HighestStage != "leafy" {
+ t.Errorf("legacy highest_stage lost: %q", loaded.HighestStage)
+ }
+}
diff --git a/internal/store/store.go b/internal/store/store.go
index db189e2..cb81d73 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -26,6 +26,7 @@ import (
"os"
"path/filepath"
"sort"
+ "strings"
"time"
"github.com/rwrife/commit-sprout/internal/plant"
@@ -92,6 +93,15 @@ type State struct {
// the most recent MaxWateredDatesKept entries are retained so the file
// cannot grow without bound.
WateredDates []string `json:"watered_dates,omitempty"`
+
+ // GardenRepos is the user's saved set of repository paths for the
+ // multi-repo garden view (`commit-sprout garden` with no path arguments).
+ // Paths are stored absolute and de-duplicated by normalize so the list is
+ // stable regardless of the directory the command was run from. It is
+ // purely a convenience list of *where* to look; no per-repo plant memory
+ // is persisted here (the garden is a read-only glance), which keeps this
+ // additive and backward-compatible with existing state files.
+ GardenRepos []string `json:"garden_repos,omitempty"`
}
// MaxWateredDatesKept bounds how many watering dates are retained on disk. Only
@@ -439,9 +449,81 @@ func normalize(s State) State {
s.BestStreak = 0
}
s.WateredDates = normalizeWaterings(s.WateredDates)
+ s.GardenRepos = normalizeRepos(s.GardenRepos)
return s
}
+// normalizeRepos cleans, absolutizes, and de-duplicates the saved garden repo
+// paths, preserving first-seen order so the windowsill layout is stable across
+// runs. Blank entries are dropped; paths that cannot be absolutized are kept as
+// given (cleaned) so a still-useful relative path is never silently discarded.
+// A nil/empty input stays nil so the field is omitted from JSON.
+func normalizeRepos(in []string) []string {
+ if len(in) == 0 {
+ return nil
+ }
+ seen := make(map[string]struct{}, len(in))
+ out := make([]string, 0, len(in))
+ for _, p := range in {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ continue
+ }
+ if abs, err := filepath.Abs(p); err == nil {
+ p = abs
+ } else {
+ p = filepath.Clean(p)
+ }
+ if _, dup := seen[p]; dup {
+ continue
+ }
+ seen[p] = struct{}{}
+ out = append(out, p)
+ }
+ if len(out) == 0 {
+ return nil
+ }
+ return out
+}
+
+// AddGardenRepos returns a copy of the state with the given paths added to the
+// saved garden set (absolutized and de-duplicated by normalize). It reports how
+// many paths were newly added so the CLI can give accurate feedback. The
+// receiver is not mutated.
+func (s State) AddGardenRepos(paths ...string) (State, int) {
+ before := len(normalizeRepos(s.GardenRepos))
+ out := s
+ out.GardenRepos = append(append([]string(nil), s.GardenRepos...), paths...)
+ out = normalize(out)
+ return out, len(out.GardenRepos) - before
+}
+
+// RemoveGardenRepos returns a copy of the state with the given paths removed
+// from the saved garden set, matching on the same absolutized form used for
+// storage so a relative path removes its absolute equivalent. It reports how
+// many entries were actually removed. The receiver is not mutated.
+func (s State) RemoveGardenRepos(paths ...string) (State, int) {
+ if len(s.GardenRepos) == 0 || len(paths) == 0 {
+ return normalize(s), 0
+ }
+ drop := make(map[string]struct{}, len(paths))
+ for _, p := range normalizeRepos(paths) {
+ drop[p] = struct{}{}
+ }
+ cur := normalizeRepos(s.GardenRepos)
+ kept := make([]string, 0, len(cur))
+ for _, p := range cur {
+ if _, gone := drop[p]; gone {
+ continue
+ }
+ kept = append(kept, p)
+ }
+ out := s
+ out.GardenRepos = kept
+ out = normalize(out)
+ return out, len(cur) - len(kept)
+}
+
// normalizeWaterings de-duplicates and sorts watering dates ascending, drops
// obviously invalid entries (anything not parseable as YYYY-MM-DD), and keeps
// only the most recent MaxWateredDatesKept so a long-lived state file cannot