From 19bab57dc60c8abcd4da42543569232e4ca47fb1 Mon Sep 17 00:00:00 2001 From: rwrife Date: Mon, 6 Jul 2026 22:28:53 +0000 Subject: [PATCH] Add multi-repo garden windowsill view (#8) Render a row of small plants, one per repository, so cadence across several projects is visible at a glance. - gitstat.ReadAt(dir, author, window) reads a specific repo (Read now delegates to it); each repo defaults to its own git user.email so per-repo author identity is preserved. Adds RepoName() for labels. - render.Garden([]GardenPlant, Options) lays out cells side by side, wrapping rows to terminal width, with ANSI-aware column padding so colored and plain output align identically. - store persists a saveable garden repo set (garden_repos, additive and backward-compatible) with Add/RemoveGardenRepos helpers. - cmd/garden: 'commit-sprout garden [paths...]', --scan , saved-set resolution, --add/--remove/--list management, --author/--window, and a read-only render (never mutates per-repo plant state). - Tests across gitstat/render/store/cmd; README documents the feature. --- README.md | 59 +++++- cmd/garden.go | 352 +++++++++++++++++++++++++++++++ cmd/garden_test.go | 154 ++++++++++++++ internal/gitstat/gitstat.go | 77 +++++-- internal/gitstat/gitstat_test.go | 19 ++ internal/render/garden_test.go | 188 +++++++++++++++++ internal/render/render.go | 264 +++++++++++++++++++++++ internal/store/garden_test.go | 131 ++++++++++++ internal/store/store.go | 82 +++++++ 9 files changed, 1308 insertions(+), 18 deletions(-) create mode 100644 cmd/garden.go create mode 100644 cmd/garden_test.go create mode 100644 internal/render/garden_test.go create mode 100644 internal/store/garden_test.go 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