diff --git a/.changes/23.enh.md b/.changes/23.enh.md new file mode 100644 index 0000000..89ef1dc --- /dev/null +++ b/.changes/23.enh.md @@ -0,0 +1 @@ +Human output is colored by status on a terminal: `wt doctor`'s ok/warn/fail, tree and pool-slot states, and errors. `ui.color` (`auto`, `always`, `never`) controls it, and `NO_COLOR` is honored. Narrow terminals shorten long paths in the middle and wrap `wt doctor`'s text. `wt ls` and `wt status` show a header row, clean/dirty state, HEAD, age, and (with a `refresh_if_changed` gate) whether the refresh hook is fresh, with the path last and home shortened to `~`. In pool mode `wt ls` lists every slot with its lease and claim age, replacing `wt pool ls`. `--porcelain` is unchanged. (#23) diff --git a/PLAN.md b/PLAN.md index fd354f1..a9c55c0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -357,7 +357,7 @@ merged-branch slots, and `wt clean -n` previews every action. | `wt` | Interactive fuzzy picker over trees → cd. Non-TTY: porcelain list. | | `wt init` | Interactive setup: base branch, trees dir, pool y/n + size, prompt indicator, copy list; writes `.git/wt.toml`. Answers pre-filled from a repo-root scan. | | `wt new [--base ]` | Default: create worktree + branch off base. Pool: claim a slot, reset, branch there. Prints tree path on stdout. | -| `wt ls [--porcelain] [--json]` | List trees: branch, path, age, ahead/behind base, dirty, slot/lease state. | +| `wt ls [--porcelain] [--json]` | List trees: branch, clean/dirty, HEAD, age, path. Pool mode: every slot with its lease (replaces `wt pool ls`). | | `wt go [query]` | Fuzzy-jump: best match cd (with query) or picker (without). | | `wt done [name] [--keep-branch]` | Finish a tree: safety checks, then remove (default) or release+reset slot (pool). Alias: `wt rm`. | | `wt sync [--all]` | Fetch base, fast-forward it, re-park idle slots, report behind-counts. Never touches branches with user commits. | @@ -365,7 +365,6 @@ merged-branch slots, and `wt clean -n` previews every action. | `wt status [--json]` | Repo overview: mode, base + fetch age, slot occupancy, per-tree disk usage. | | `wt doctor [--json] [--offline]` | Actionable diagnostics + update check. Exit 0 healthy / 3 issues found. | | `wt pool resize ` | Grow (provision + setup hook) or shrink (free slots only) the pool. | -| `wt pool ls` | Slot-centric view: free/claimed/by-whom/warm-since. | | `wt config [--edit]` | Print the active config paths and merged values; `--edit` opens the repo config in `$VISUAL`/`$EDITOR`. | | `wt path [name]` | Plumbing: print a tree's path (or the current tree's root). | | `wt claim` / `wt release` | Pool plumbing for scripts/agents: claim prints slot path on stdout. | @@ -775,9 +774,11 @@ stays short): - [ ] README rewrite to the two-tier structure; verify the 60-second walkthrough by literally following it on a clean machine, timing it. -- [ ] lipgloss pass over all human output, golden-file tested - (`NO_COLOR` and width-degradation cases); - help-text audit — every `--help` fits one screen and links its +- [x] lipgloss pass over all human output, golden-file tested + (`NO_COLOR` and width-degradation cases). + Color marks status only; narrow terminals middle-truncate paths + and wrap doctor text; non-terminal output is never touched. +- [ ] Help-text audit — every `--help` fits one screen and links its `docs/` page. - [x] `wt uninstall`: prints the removal plan with real paths (Homebrew cask detected through the PATH symlink; diff --git a/README.md b/README.md index a86ffb1..6c58943 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Details in [docs/shell.md](docs/shell.md). | `wt` | Interactive fuzzy picker over trees → cd. Without a TTY: porcelain list. | | `wt init` | Set up wt for a repo (prompts, or `--yes` + flags); writes `.git/wt.toml`. Pre-fills its answers from a [scan of the repo root](docs/configuration.md#detected-defaults). | | `wt new [--base ]` | Create a worktree + branch off the base, and cd there under the shim. In [pool mode](docs/pool-mode.md): claim a pre-warmed slot instead. | -| `wt ls [--porcelain] [--json]` | List worktrees: branch, path, state. | +| `wt ls [--porcelain] [--json]` | List worktrees: branch, clean/dirty, HEAD, age, path. In pool mode: every slot too, with its lease. | | `wt go [query]` | Fuzzy-jump: best match cds (with a query) or picker (without). | | `wt done [name] [--keep-branch]` | Finish a tree: safety checks, remove it, delete its branch. Alias: `wt rm`. | | `wt sync [--all]` | Fetch the base, fast-forward it (ff-only), report tree staleness; `--all` re-parks idle [pool](docs/pool-mode.md#staying-fresh) slots onto the new tip. | @@ -58,7 +58,6 @@ Details in [docs/shell.md](docs/shell.md). | `wt doctor [--json] [--offline]` | Diagnostics: each symptom with cause and exact fix, plus an update check. Exit 0 healthy, 3 issues found. | | `wt claim ` | Pool mode: claim a slot for a branch; slot path on stdout (plumbing). | | `wt release [name]` | Pool mode: park a slot back on the base, keeping its branch (plumbing). | -| `wt pool ls` | Pool mode: slot-centric view — free, claimed, by whom. | | `wt pool resize ` | Pool mode: grow (provision + warm) or shrink (free slots only). | | `wt path [name]` | Print a tree's absolute path (plumbing). | | `wt config [--edit]` | Show active config paths and merged values; `--edit` opens `$VISUAL`/`$EDITOR`. | diff --git a/docs/agents.md b/docs/agents.md index f31839b..2dd9e44 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -12,6 +12,12 @@ suite. - **stderr** carries everything meant for humans: progress notes, hook output, hints, errors. +Output that isn't a terminal is never colored, shortened, or wrapped, +so captured output is byte-identical to what a script expects. +The one opt-out is `ui.color = "always"` in the global config, +which colors human tables even when piped; +`--json` and `--porcelain` are never colored. + So this is always safe: ```sh @@ -39,7 +45,7 @@ then retry", never "wt is broken". | `wt new` | The new tree's absolute path, one line. | | `wt path` | The resolved tree's absolute path, one line. | | `wt go ` | The matched tree's absolute path, one line. Ambiguous: contenders on stderr, exit 3. No match: exit 1. | -| `wt ls` | One aligned row per tree. | +| `wt ls` | A header, then one aligned row per tree: `BRANCH STATE [TOOLS] HEAD AGE PATH`, or in pool mode `TREE LEASE BRANCH STATE [TOOLS] HEAD AGE NOTE PATH` with every slot (LEASE `free`, `claimed`, `stale`, `unprovisioned`; AGE is claim age). STATE is `clean`/`dirty` plus `locked`/`prunable`; AGE is the HEAD commit's age; TOOLS (`fresh`/`stale`) appears only with a `refresh_if_changed` gate. For scripts, use `--porcelain` or `--json`: this table may gain columns. | | `wt ls --porcelain` | One tree per line, three tab-separated fields: branch label, absolute path, comma-joined states (`-` when none). The field count never varies. | | bare `wt`, bare `wt go` | Without a TTY on stdin and stderr: exactly the `--porcelain` listing, so agents never hang on the interactive picker. | | `wt shell-init zsh` | The zsh integration script itself (it is the machine output — meant for `eval`). | @@ -49,7 +55,6 @@ then retry", never "wt is broken". | `wt sync` | Nothing (fetch, fast-forward, and per-tree behind report all ride stderr). | | `wt claim` | The claimed slot's absolute path, one line. No free slot: exit 3. | | `wt release` | Nothing (chatter on stderr). Not a slot / not claimed: exit 3. | -| `wt pool ls` | One aligned row per slot: slot, state (`free`, `claimed`, `stale`, `unprovisioned`), branch, detail. | | `wt clean` | Nothing (every action, and `-n`'s previews, ride stderr). | | `wt status` | The overview table; `--json` for the machine shape below. | | `wt uninstall` | The removal plan as a shell document: explanation as `#` comments, commands bare. Removes nothing; always exit 0. | @@ -83,7 +88,10 @@ and value is stable. "path": "/Users/you/acme.trees/feature-login", "head": "0b7e…", "locked": true, - "locked_reason": "keep me" + "locked_reason": "keep me", + "dirty": true, + "committed_at": "2026-07-25T08:00:00-05:00", + "tools": "stale" } ] ``` @@ -92,6 +100,9 @@ Every field except `path` is omitted when false/empty — test presence, not value. That includes `branch` (absent on detached and bare trees) and `head` (absent on bare trees), not just the boolean flags and reason strings. +`dirty` is present only for a tree with changes; +`tools` only when a `refresh_if_changed` gate is configured; +`slot` and `lease` only on pool slots (an unprovisioned slot has no tree, so no entry). `wt status --json` — the repo overview: @@ -106,8 +117,9 @@ boolean flags and reason strings. "size": 2, "slots": [ { "slot": "slot-1", "state": "claimed", "branch": "PROJ-123", - "pid": 4242, "claimed_at": "2026-07-25T08:00:00Z", "note": "…" }, - { "slot": "slot-2", "state": "free" } + "pid": 4242, "claimed_at": "2026-07-25T08:00:00Z", "note": "…", + "path": "/Users/you/acme.trees/slot-1", "head": "0b7e…", "dirty": true }, + { "slot": "slot-2", "state": "free", "path": "/Users/you/acme.trees/slot-2", "head": "4a53…" } ] } } diff --git a/docs/configuration.md b/docs/configuration.md index 41ca33f..6cadf88 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -138,6 +138,19 @@ copy = [".env"] color = "auto" # auto | always | never ``` +`ui.color` decides when human output is colored. +Color only marks status (doctor's ok/warn/fail, tree and slot states, errors), +never decoration: + +- `auto` colors only on a terminal, + and respects [`NO_COLOR`](https://no-color.org/) and `CLICOLOR_FORCE`. +- `always` colors even through pipes; `never` turns it off everywhere. + +On a terminal too narrow for a table, +wt shortens long paths in the middle (`/Users/me/sr…/feature-login`) +and wraps `wt doctor`'s text. +Piped output is never colored (outside `always`), shortened, or wrapped. + `[pool]` is deliberately not accepted under `[defaults]`: pool mode is a per-repo decision (PLAN.md D3), and a global pool would silently flip every repo into it. diff --git a/docs/faq.md b/docs/faq.md index 4bf0e4c..c1051d9 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -107,7 +107,7 @@ wt doctor exits 0 when healthy and 3 when something needs fixing, with the fix command printed next to each finding. -For pool repos, `wt pool ls` shows who holds every slot, +For pool repos, `wt ls` shows who holds every slot, `wt clean` releases provably dead leases, and `wt release ` is the documented escape hatch for a lease record that cannot be read. diff --git a/docs/pool-mode.md b/docs/pool-mode.md index f5aace8..350d762 100644 --- a/docs/pool-mode.md +++ b/docs/pool-mode.md @@ -116,7 +116,7 @@ where `wt done` deletes it (unless `--keep-branch`). ## Sizing ```sh -wt pool ls # slot, state, branch, holder +wt ls # every slot: lease, branch, clean/dirty, HEAD, claim age wt pool resize 6 # grow: provision + warm the new slots wt pool resize 2 # shrink: refuses while a doomed slot is claimed ``` @@ -138,7 +138,7 @@ Deadness is only provable on the host that claimed: a lease from another machine — or from before a hostname change — reads as unverifiable and is never reaped; `wt release slot-N` clears it. -`wt pool ls` shows stale leases as `stale`; +`wt ls` shows stale leases as `stale`; a wedged slot can always be freed by hand with `wt release slot-N`. A slot the guards refuse to reset — stranded commits, say — is skipped with a notice and the claim moves on to the next one. diff --git a/go.mod b/go.mod index baafd29..9d26a09 100644 --- a/go.mod +++ b/go.mod @@ -4,24 +4,25 @@ go 1.25.8 require ( charm.land/huh/v2 v2.0.3 + charm.land/lipgloss/v2 v2.0.1 + github.com/charmbracelet/colorprofile v0.4.2 + github.com/charmbracelet/x/ansi v0.11.6 github.com/google/go-cmp v0.7.0 github.com/ktr0731/go-fuzzyfinder v0.9.0 github.com/pelletier/go-toml/v2 v2.4.3 github.com/rogpeppe/go-internal v1.15.0 github.com/sahilm/fuzzy v0.1.3 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.9 golang.org/x/term v0.45.0 ) require ( charm.land/bubbles/v2 v2.0.0 // indirect charm.land/bubbletea/v2 v2.0.2 // indirect - charm.land/lipgloss/v2 v2.0.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/catppuccin/go v0.2.0 // indirect - github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect @@ -41,7 +42,6 @@ require ( github.com/nsf/termbox-go v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.47.0 // indirect diff --git a/internal/cli/claim.go b/internal/cli/claim.go index a3c7190..d8ef529 100644 --- a/internal/cli/claim.go +++ b/internal/cli/claim.go @@ -89,7 +89,7 @@ func runRelease(cmd *cobra.Command, name string) error { // A slot named directly can be leased yet have no worktree: a // claim or provision killed before worktree-add, or a tree // removed out of band. resolveTree cannot name such a slot, and - // pool ls sends users here to clear exactly those states. + // wt ls sends users here to clear exactly those states. if pool.IsSlotName(name) { if _, registered := findTree(trees, filepath.Join(p.treesDir(), name)); !registered { return p.releaseVacantSlot(name, cmd.ErrOrStderr()) diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 7796e3b..3dbcc8e 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -23,6 +23,8 @@ import ( "syscall" "time" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" "github.com/spf13/cobra" "github.com/loganthomas/wt/internal/gitx" @@ -103,13 +105,13 @@ func runDoctor(cmd *cobra.Command, info BuildInfo, jsonOut, offline bool) error if err := render.JSON(out, view); err != nil { return err } - } else if _, err := fmt.Fprint(out, formatDoctor(view)); err != nil { + } else if _, err := fmt.Fprint(out, formatDoctor(view, lookFor(out))); err != nil { return err } // No locational claim in the message: under --json the fixes are // in the payload, not "above". if view.Issues > 0 { - return preconditionf("%d %s found — each fix is listed above", + return preconditionf("%d %s found — the report gives each one's fix", view.Issues, plural(view.Issues, "issue")) } return nil @@ -307,7 +309,7 @@ func checkSubmodules(root string) checkResult { } c.Status = statusWarn c.Symptom = "submodules present" - c.Cause = "git supports worktrees with submodules, but wt adds no smoothing (R5)" + c.Cause = "git supports worktrees with submodules, but wt adds no smoothing" c.Fix = "run `git submodule update --init` inside new trees; see docs/faq.md" return c } @@ -326,7 +328,7 @@ func checkHooksPath(path string, err error) checkResult { c.Status = statusWarn c.Symptom = fmt.Sprintf("core.hooksPath = %s (relative)", path) c.Cause = "a relative hooks path resolves inside each tree; " + - "hooks vanish in trees where it is untracked (R7)" + "hooks vanish in trees where it is untracked" c.Fix = "`git config core.hooksPath `, " + "or keep the hooks directory tracked" } @@ -532,16 +534,86 @@ func newerRelease(latest, current release) bool { // per check, causes and fixes indented under their symptom. The // continuation rows ride through render.Align as rows with empty // leading cells, so the indent always lands at the symptom column. -func formatDoctor(view doctorView) string { +// On a terminal, text wraps to its width with a hanging indent. +func formatDoctor(view doctorView, l look) string { + budget := doctorTextBudget(view.Checks, l.width) var rows [][]string for _, c := range view.Checks { - rows = append(rows, []string{c.Status, c.Name, c.Symptom}) + status := l.paint(doctorStatusStyle(c.Status), c.Status) + rows = append(rows, doctorText{text: c.Symptom}.rows(status, c.Name, budget, l)...) if c.Cause != "" { - rows = append(rows, []string{"", "", "cause: " + c.Cause}) + cause := doctorText{"cause: ", c.Cause, styleDim, styleDim} + rows = append(rows, cause.rows("", "", budget, l)...) } if c.Fix != "" { - rows = append(rows, []string{"", "", "fix: " + c.Fix}) + fix := doctorText{label: "fix: ", text: c.Fix, labelStyle: styleBold} + rows = append(rows, fix.rows("", "", budget, l)...) } } return render.Align(rows) } + +// hangIndent sets wrapped continuation lines in from their first, +// so a long cause still reads as one item. +const hangIndent = " " + +// minDoctorText is the narrowest text column worth wrapping into; +// any tighter and the terminal's own wrapping reads better. +const minDoctorText = 30 + +// doctorTextBudget is the width left for the text column once the +// status and name columns take theirs; 0 means don't wrap. +func doctorTextBudget(checks []checkResult, width int) int { + if width <= 0 { + return 0 + } + used := 0 + for _, c := range checks { + used = max(used, len(c.Status)+2+len(c.Name)+2) + } + if budget := width - used; budget >= minDoctorText { + return budget + } + return 0 +} + +// doctorText is one text cell of the report: an optional label +// ("cause: ", "fix: ") and its text, each with its own style. +type doctorText struct { + label, text string + labelStyle, textStyle lipgloss.Style +} + +// rows renders the cell wrapped to budget (0: unwrapped), the +// first row carrying status and name, continuation rows leaving +// them empty so the text stays in its column. +func (d doctorText) rows(status, name string, budget int, l look) [][]string { + lines := []string{d.label + d.text} + if budget > 0 { + lines = strings.Split(ansi.Wrap(d.label+d.text, budget-len(hangIndent), ""), "\n") + } + rows := make([][]string, len(lines)) + for i, line := range lines { + if i == 0 { + rest := strings.TrimPrefix(line, d.label) + line = l.paint(d.labelStyle, d.label) + l.paint(d.textStyle, rest) + } else { + line = hangIndent + l.paint(d.textStyle, line) + } + rows[i] = []string{"", "", line} + } + rows[0][0], rows[0][1] = status, name + return rows +} + +func doctorStatusStyle(status string) lipgloss.Style { + switch status { + case statusOK: + return styleGood + case statusWarn: + return styleWarn + case statusFail: + return styleBad + } + return styleDim +} diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go index 00d058b..a38935c 100644 --- a/internal/cli/doctor_test.go +++ b/internal/cli/doctor_test.go @@ -205,7 +205,7 @@ func TestFormatDoctorIndentsCauseAndFix(t *testing.T) { }, Issues: 1, } - got := formatDoctor(view) + got := formatDoctor(view, plainLook) lines := strings.Split(strings.TrimSuffix(got, "\n"), "\n") if !strings.HasPrefix(lines[0], "ok") || !strings.Contains(lines[0], "2.50.1") { t.Errorf("first row = %q, want the ok git row", lines[0]) diff --git a/internal/cli/look.go b/internal/cli/look.go new file mode 100644 index 0000000..72f8bda --- /dev/null +++ b/internal/cli/look.go @@ -0,0 +1,147 @@ +package cli + +import ( + "cmp" + "io" + "os" + "path/filepath" + "slices" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "golang.org/x/term" + + "github.com/loganthomas/wt/internal/config" +) + +// look is how human output should appear where it is going: +// whether to color it, how many columns the terminal has +// (0 means unlimited: never truncate or wrap), and the home +// directory to abbreviate as ~ ("" keeps paths whole). +type look struct { + color bool + width int + home string +} + +// plainLook is the barest human output: no color, full width, +// full paths. +var plainLook = look{} + +// Color carries meaning only (status, never decoration), in the +// 16 basic ANSI colors, so it follows the user's terminal theme. +var ( + styleGood = lipgloss.NewStyle().Foreground(lipgloss.Green) + styleWarn = lipgloss.NewStyle().Foreground(lipgloss.Yellow) + styleBad = lipgloss.NewStyle().Foreground(lipgloss.Red) + styleDim = lipgloss.NewStyle().Faint(true) + styleBold = lipgloss.NewStyle().Bold(true) +) + +// paint styles text when this look has color; empty text stays +// empty so Align can still drop trailing blank cells. +func (l look) paint(s lipgloss.Style, text string) string { + if !l.color || text == "" { + return text + } + return s.Render(text) +} + +// header renders a table's column titles: uppercase and dim, +// so they frame the data without competing with it. +func (l look) header(titles ...string) []string { + row := make([]string, len(titles)) + for i, t := range titles { + row[i] = l.paint(styleDim, strings.ToUpper(t)) + } + return row +} + +// path abbreviates the home directory as ~ for human tables; +// machine output always carries the absolute path. +func (l look) path(p string) string { + if l.home == "" { + return p + } + if p == l.home { + return "~" + } + if rest, ok := strings.CutPrefix(p, l.home+string(filepath.Separator)); ok { + return "~" + string(filepath.Separator) + rest + } + return p +} + +// dash fills an unknown or empty cell, so a column never reads +// as missing. +func dash(s string) string { + return cmp.Or(s, "-") +} + +// shortHead is the abbreviated commit for a HEAD column. +func shortHead(sha string) string { + const n = 9 + if len(sha) <= n { + return dash(sha) + } + return sha[:n] +} + +// lookFor resolves the look for one output stream. Only a real +// terminal gets a width: piped output is never truncated. +func lookFor(w io.Writer) look { + f, ok := w.(*os.File) + if !ok { + return plainLook + } + l := look{color: useColor(f, colorSetting(), os.Environ())} + if home, err := os.UserHomeDir(); err == nil { + l.home = home + } + if isTerminal(f) { + if width, _, err := term.GetSize(int(f.Fd())); err == nil { + l.width = width + } + } + return l +} + +// useColor applies ui.color: "always" and "never" are absolute, +// and "auto" defers to colorprofile for CLICOLOR_FORCE, TERM=dumb, +// and whether w is a terminal. NO_COLOR is checked here, to the +// no-color.org letter: colorprofile skips it on non-terminals, so +// CLICOLOR_FORCE would win, and only counts boolean values. +func useColor(w io.Writer, setting string, env []string) bool { + switch setting { + case "always": + return true + case "never": + return false + } + if slices.ContainsFunc(env, isNoColorSet) { + return false + } + return colorprofile.Detect(w, env) >= colorprofile.ANSI +} + +func isNoColorSet(kv string) bool { + value, ok := strings.CutPrefix(kv, "NO_COLOR=") + return ok && value != "" +} + +// colorSetting reads ui.color from the global config, the only +// file it may live in. Any trouble reads as "auto": a broken +// config is for the commands that load it to report, and color +// must never be the reason a command fails. +func colorSetting() string { + path, err := config.GlobalPath() + if err != nil { + return "auto" + } + ui, err := config.LoadUI(path) + if err != nil || ui.Color == "" { + return "auto" + } + return ui.Color +} diff --git a/internal/cli/look_test.go b/internal/cli/look_test.go new file mode 100644 index 0000000..48020ca --- /dev/null +++ b/internal/cli/look_test.go @@ -0,0 +1,190 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + "github.com/google/go-cmp/cmp" + + "github.com/loganthomas/wt/internal/gitx" +) + +// goldenLooks covers what a human can be shown: a wide color +// terminal, the same output with color off (NO_COLOR, pipes, +// ui.color = "never"), and a narrow terminal that forces paths +// to truncate and doctor text to wrap. +var goldenLooks = []struct { + name string + look look +}{ + {"color-120", look{color: true, width: 120, home: "/Users/me"}}, + {"plain-120", look{width: 120, home: "/Users/me"}}, + {"plain-60", look{width: 60, home: "/Users/me"}}, +} + +func TestHumanOutputMatchesGolden(t *testing.T) { + kb := int64(54 * 1024) + clean, dirty := false, true + trees := []gitx.Worktree{ + {Branch: "main", Path: "/Users/me/src/acme", Head: "82506bc30aa1"}, + { + Branch: "feature/login", Path: "/Users/me/src/acme.trees/feature-login", + Head: "a08fe1242bb2", Locked: true, + }, + { + Branch: "fix/a-rather-long-branch-name", Head: "422825f71cc3", + Path: "/Users/me/src/acme.trees/fix-a-rather-long-branch-name-for-a-long-path", + }, + {Branch: "old", Path: "/Users/me/src/acme.trees/old", Head: "4a53648b9dd4", Prunable: true}, + } + facts := map[string]treeFacts{ + "/Users/me/src/acme": {Dirty: &clean, Age: "4d"}, + "/Users/me/src/acme.trees/feature-login": {Dirty: &dirty, Age: "2h", Tools: "stale"}, + "/Users/me/src/acme.trees/fix-a-rather-long-branch-name-for-a-long-path": { + Dirty: &clean, Age: "now", Tools: "fresh", + }, + } + status := statusView{ + Mode: "pool", + Base: baseView{Name: "main", Stale: true}, + Trees: []treeStatus{ + {Branch: "main", Path: "/Users/me/src/acme", DiskKB: &kb}, + {Detached: true, Path: "/Users/me/src/acme.trees/slot-1"}, + }, + Pool: &poolStatus{Size: 4, Slots: []slotView{ + slotView{ + Slot: "slot-1", State: "free", Path: "/Users/me/src/acme.trees/slot-1", + Head: "878e4ca68ee5", Tools: "fresh", + }.withWork(gitx.Worktree{}, treeFacts{Dirty: &clean}), + slotView{ + Slot: "slot-2", State: "claimed", Branch: "feature/pay", Note: "pid 4242", + Age: "4d", Path: "/Users/me/src/acme.trees/slot-2", + Head: "a08fe1242bb2", Dirty: true, Tools: "stale", + }.withWork(gitx.Worktree{}, treeFacts{Dirty: &dirty}), + slotView{ + Slot: "slot-3", State: "stale", Branch: "spike", + Note: "dead pid 7", Path: "/Users/me/src/acme.trees/slot-3", + Head: "422825f71cc3", Tools: "fresh", + }.withWork(gitx.Worktree{Locked: true}, treeFacts{Dirty: &clean}), + { + Slot: "slot-4", State: "unprovisioned", Note: "provisions on first claim", + Path: "/Users/me/src/acme.trees/slot-4", + }, + }}, + } + // wt ls in pool mode: the main checkout, unleased, then the slots. + poolLs := append([]slotView{ + slotView{ + Slot: "acme", Branch: "main", Path: "/Users/me/src/acme", Head: "82506bc30aa1", + }.withWork(gitx.Worktree{}, treeFacts{Dirty: &clean}), + }, status.Pool.Slots...) + doctor := doctorView{Issues: 1, Checks: []checkResult{ + {Name: "git", Status: statusOK, Symptom: "2.50.1"}, + { + Name: "hooks-path", Status: statusWarn, + Symptom: "core.hooksPath = .githooks (relative)", + Cause: "a relative hooks path resolves inside each tree; " + + "hooks vanish in trees where it is untracked", + Fix: "`git config core.hooksPath `, or keep the hooks directory tracked", + }, + { + Name: "config", Status: statusFail, + Symptom: "wt.toml:3:1: unknown key \"colour\"", + Fix: "wt config --edit", + }, + {Name: "update", Status: statusInfo, Symptom: "check skipped (--offline)"}, + }} + + for _, gl := range goldenLooks { + for name, got := range map[string]string{ + "ls": formatRows(trees, facts, gl.look), + "ls-pool": formatSlots(poolLs, gl.look), + "status": formatStatus(status, gl.look), + "doctor": formatDoctor(doctor, gl.look), + } { + t.Run(name+"-"+gl.name, func(t *testing.T) { + checkGolden(t, filepath.Join("look", name+"-"+gl.name+".txt"), got) + checkFitsWidth(t, got, gl.look.width) + }) + } + } +} + +func checkGolden(t *testing.T, name, got string) { + t.Helper() + golden := filepath.Join("testdata", "golden", name) + if *update { + if err := os.MkdirAll(filepath.Dir(golden), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(golden, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + } + want, err := os.ReadFile(golden) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(string(want), got); diff != "" { + t.Errorf("%s mismatch (-want +got):\n%s", name, diff) + } +} + +// checkFitsWidth holds the truncation bargain: a line that lost +// characters to "…" must fit the terminal, since cutting a path and +// still overflowing would lose information for nothing. Lines too +// wide even at the minimum path width are left whole and overflow. +func checkFitsWidth(t *testing.T, got string, width int) { + t.Helper() + for line := range strings.Lines(got) { + line = strings.TrimSuffix(line, "\n") + if strings.HasSuffix(line, " ") { + t.Errorf("trailing whitespace: %q", line) + } + truncated := strings.Contains(line, "…") + if w := ansi.StringWidth(line); truncated && width > 0 && w > width { + t.Errorf("truncated line is %d cells wide, terminal is %d: %q", w, width, line) + } + } +} + +func TestPlainLookEmitsNoEscapes(t *testing.T) { + got := formatDoctor(doctorView{Checks: []checkResult{ + {Name: "git", Status: statusFail, Symptom: "too old", Cause: "c", Fix: "f"}, + }}, look{width: 40}) + if strings.Contains(got, "\x1b") { + t.Errorf("plain look emitted an escape sequence: %q", got) + } +} + +func TestUseColor(t *testing.T) { + tests := []struct { + name string + setting string + env []string + want bool + }{ + {"always beats a pipe", "always", nil, true}, + {"never beats a forced terminal", "never", []string{"CLICOLOR_FORCE=1"}, false}, + {"auto on a pipe is plain", "auto", []string{"TERM=xterm-256color"}, false}, + {"auto honors CLICOLOR_FORCE", "auto", []string{"TERM=xterm-256color", "CLICOLOR_FORCE=1"}, true}, + { + "NO_COLOR beats CLICOLOR_FORCE", "auto", + []string{"TERM=xterm-256color", "CLICOLOR_FORCE=1", "NO_COLOR=1"}, + false, + }, + {"NO_COLOR counts any value", "auto", []string{"CLICOLOR_FORCE=1", "NO_COLOR=yes"}, false}, + {"empty NO_COLOR is unset", "auto", []string{"CLICOLOR_FORCE=1", "NO_COLOR="}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := useColor(&bytes.Buffer{}, tt.setting, tt.env); got != tt.want { + t.Errorf("useColor(%q, %q) = %v, want %v", tt.setting, tt.env, got, tt.want) + } + }) + } +} diff --git a/internal/cli/ls.go b/internal/cli/ls.go index e43a349..51b72be 100644 --- a/internal/cli/ls.go +++ b/internal/cli/ls.go @@ -3,12 +3,17 @@ package cli import ( "cmp" "fmt" + "slices" "strings" + "time" + "charm.land/lipgloss/v2" "github.com/spf13/cobra" "github.com/loganthomas/wt/internal/gitx" "github.com/loganthomas/wt/internal/render" + "github.com/loganthomas/wt/internal/repo" + "github.com/loganthomas/wt/internal/state" ) func newLsCmd() *cobra.Command { @@ -32,48 +37,102 @@ func runLs(cmd *cobra.Command, porcelain, jsonOut bool) error { return usageError{fmt.Errorf("--porcelain and --json are two spellings " + "of the machine listing — choose one")} } - r, trees, err := repoTrees(cmd.Context()) + ctx := cmd.Context() + r, trees, err := repoTrees(ctx) if err != nil { return err } + if porcelain { + _, err := fmt.Fprint(cmd.OutOrStdout(), formatPorcelain(trees)) + return err + } + w, st := lsRepo(r) + var tools toolsFunc + if w != nil { + tools = w.refreshTools(st) + } + facts := gatherTreeFacts(ctx, gitx.New(r.Root), trees, tools, time.Now()) + pooled := w != nil && w.cfg.Pool != nil if jsonOut { - return render.JSON(cmd.OutOrStdout(), treeViews(trees)) + views := treeViews(trees, facts) + if pooled { + markSlots(views, slotViews(w, st, trees, facts)) + } + return render.JSON(cmd.OutOrStdout(), views) } - format := formatRows - if porcelain { - format = formatPorcelain + l := lookFor(cmd.OutOrStdout()) + out := formatRows(trees, facts, l) + if pooled { + out = formatSlots(poolRows(w, st, trees, facts), l) } - if _, err := fmt.Fprint(cmd.OutOrStdout(), format(trees)); err != nil { + if _, err := fmt.Fprint(cmd.OutOrStdout(), out); err != nil { return err } // A human staleness note on stderr, so stdout stays the machine - // contract (D13). Porcelain callers are scripts: no chatter for - // them. Best-effort, and silent until wt has a fetch on record, - // so it never touches the network or disturbs the listing. - if !porcelain { - noteFetchStaleness(r, cmd.ErrOrStderr()) - } + // contract. Best-effort, and silent until wt has a fetch on + // record, so it never touches the network or disturbs the listing. + noteFetchStaleness(r, cmd.ErrOrStderr()) return nil } +// lsRepo loads what ls reads beyond git: the config, for pool mode +// and the refresh gate, and the state dir, for leases. ls must keep +// listing in a repo whose wt.toml is broken, so any failure returns +// nil and ls falls back to the plain tree table. +func lsRepo(r *repo.Repo) (*wtRepo, state.Dir) { + cfg, err := loadMerged(r) + if err != nil { + return nil, "" + } + w := &wtRepo{repo: r, cfg: cfg} + st, err := w.stateDir() + if err != nil { + return nil, "" + } + return w, st +} + +// markSlots tags each slot tree in ls --json with its slot name and +// lease state. Unprovisioned slots have no tree, so no entry. +func markSlots(views []treeView, slots []slotView) { + byPath := make(map[string]slotView, len(slots)) + for _, s := range slots { + byPath[s.Path] = s + } + for i, v := range views { + if s, ok := byPath[v.Path]; ok { + views[i].Slot, views[i].Lease = s.Slot, s.State + } + } +} + // treeView is one worktree in ls --json: git's facts, spelled // stably for machine consumers (D13). type treeView struct { - Branch string `json:"branch,omitempty"` - Path string `json:"path"` - Head string `json:"head,omitempty"` - Bare bool `json:"bare,omitempty"` - Detached bool `json:"detached,omitempty"` - Locked bool `json:"locked,omitempty"` - LockedReason string `json:"locked_reason,omitempty"` - Prunable bool `json:"prunable,omitempty"` - PrunableReason string `json:"prunable_reason,omitempty"` + Branch string `json:"branch,omitempty"` + Path string `json:"path"` + Head string `json:"head,omitempty"` + Bare bool `json:"bare,omitempty"` + Detached bool `json:"detached,omitempty"` + Locked bool `json:"locked,omitempty"` + LockedReason string `json:"locked_reason,omitempty"` + Prunable bool `json:"prunable,omitempty"` + PrunableReason string `json:"prunable_reason,omitempty"` + Dirty bool `json:"dirty,omitempty"` + CommittedAt *time.Time `json:"committed_at,omitempty"` // HEAD's committer date + Tools string `json:"tools,omitempty"` // fresh | stale + Slot string `json:"slot,omitempty"` // pool mode only + Lease string `json:"lease,omitempty"` // free | claimed | stale } -func treeViews(trees []gitx.Worktree) []treeView { +func treeViews(trees []gitx.Worktree, facts map[string]treeFacts) []treeView { views := make([]treeView, 0, len(trees)) for _, t := range trees { + f := facts[t.Path] views = append(views, treeView{ + Dirty: f.dirty(), + CommittedAt: f.Committed, + Tools: f.Tools, Branch: t.Branch, Path: t.Path, Head: t.Head, @@ -101,13 +160,66 @@ func formatPorcelain(trees []gitx.Worktree) string { return out.String() } -// formatRows renders one aligned row per worktree. -func formatRows(trees []gitx.Worktree) string { - rows := make([][]string, 0, len(trees)) +// formatRows renders the human tree table: a header, then one +// row per worktree with the path last, so a long path runs off +// the edge instead of pushing columns around. TOOLS appears only +// when some tree has a refresh gate to report on. +func formatRows(trees []gitx.Worktree, facts map[string]treeFacts, l look) string { + tools := slices.ContainsFunc(trees, func(t gitx.Worktree) bool { + return facts[t.Path].Tools != "" + }) + titles := []string{"branch", "state", "head", "age", "path"} + if tools { + titles = slices.Insert(titles, 2, "tools") + } + rows := [][]string{l.header(titles...)} for _, t := range trees { - rows = append(rows, []string{branchLabel(t), t.Path, stateLabel(t)}) + f := facts[t.Path] + state := treeState(t, f) + row := []string{branchLabel(t), l.paint(treeStateStyle(t, f), state)} + if tools { + row = append(row, l.paint(toolsStyle(f.Tools), dash(f.Tools))) + } + rows = append(rows, append(row, shortHead(t.Head), dash(f.Age), l.path(t.Path))) + } + return render.Align(render.FitColumn(rows, len(titles)-1, l.width)) +} + +// treeState joins what's worth knowing about a working tree: +// clean or dirty first, then git's own locked/prunable marks. +// A prunable tree is gone from disk, so it has no clean or dirty. +func treeState(t gitx.Worktree, f treeFacts) string { + var parts []string + switch { + case f.Dirty == nil: + case *f.Dirty: + parts = append(parts, "dirty") + default: + parts = append(parts, "clean") + } + if label := stateLabel(t); label != "" { + parts = append(parts, label) + } + return dash(strings.Join(parts, ",")) +} + +// treeStateStyle flags a vanished tree as an error and anything +// blocking a clean `wt done` (edits, a lock) as a warning. +func treeStateStyle(t gitx.Worktree, f treeFacts) lipgloss.Style { + switch { + case t.Prunable: + return styleBad + case t.Locked || (f.Dirty != nil && *f.Dirty): + return styleWarn + } + return lipgloss.NewStyle() +} + +func toolsStyle(tools string) lipgloss.Style { + if tools == "stale" { + return styleWarn } - return render.Align(rows) + return lipgloss.NewStyle() } func branchLabel(t gitx.Worktree) string { diff --git a/internal/cli/ls_test.go b/internal/cli/ls_test.go index 22086ac..62d076b 100644 --- a/internal/cli/ls_test.go +++ b/internal/cli/ls_test.go @@ -12,12 +12,12 @@ func TestFormatRowsKeepsStateAlignedAcrossStatelessRows(t *testing.T) { {Branch: "main", Path: "/short", Locked: true}, {Branch: "feature/login", Path: "/a/plain/tree"}, {Branch: "fix", Path: "/a/much/longer/path", Locked: true}, - }) + }, nil, plainLook) lines := strings.Split(strings.TrimSuffix(rows, "\n"), "\n") - if len(lines) != 3 { - t.Fatalf("formatRows() = %d lines, want 3:\n%s", len(lines), rows) + if len(lines) != 4 { + t.Fatalf("formatRows() = %d lines, want a header and 3 rows:\n%s", len(lines), rows) } - first, last := strings.Index(lines[0], "locked"), strings.Index(lines[2], "locked") + first, last := strings.Index(lines[1], "locked"), strings.Index(lines[3], "locked") if first == -1 || first != last { t.Errorf("state columns misaligned (offsets %d and %d):\n%s", first, last, rows) } @@ -31,8 +31,10 @@ func TestFormatRowsKeepsStateAlignedAcrossStatelessRows(t *testing.T) { func TestFormatRowsPreservesPathTrailingSpace(t *testing.T) { rows := formatRows([]gitx.Worktree{ {Branch: "main", Path: "/ends/with/space "}, - }) - if want := "main /ends/with/space \n"; rows != want { + }, nil, plainLook) + want := "BRANCH STATE HEAD AGE PATH\n" + + "main - - - /ends/with/space \n" + if rows != want { t.Errorf("formatRows() = %q, want %q", rows, want) } } @@ -54,7 +56,7 @@ func TestFormatPorcelainEmitsFixedTabSeparatedFields(t *testing.T) { func TestFormatRowsJoinsMultipleStates(t *testing.T) { rows := formatRows([]gitx.Worktree{ {Branch: "old", Path: "/gone", Locked: true, Prunable: true}, - }) + }, nil, plainLook) if !strings.Contains(rows, "locked,prunable") { t.Errorf("formatRows() = %q, want a locked,prunable state cell", rows) } diff --git a/internal/cli/pool.go b/internal/cli/pool.go index adddad9..0f6ac29 100644 --- a/internal/cli/pool.go +++ b/internal/cli/pool.go @@ -1,19 +1,19 @@ package cli import ( - "cmp" "context" "errors" "fmt" "io" "path/filepath" + "slices" "strconv" "time" + "charm.land/lipgloss/v2" "github.com/spf13/cobra" "github.com/loganthomas/wt/internal/config" - "github.com/loganthomas/wt/internal/freshness" "github.com/loganthomas/wt/internal/gitx" "github.com/loganthomas/wt/internal/lease" "github.com/loganthomas/wt/internal/pool" @@ -24,51 +24,109 @@ import ( func newPoolCmd() *cobra.Command { cmd := &cobra.Command{ Use: "pool", - Short: "Inspect and size the slot pool", + Short: "Size the slot pool (wt ls lists it)", } - cmd.AddCommand(newPoolLsCmd(), newPoolResizeCmd()) + cmd.AddCommand(newPoolResizeCmd()) return cmd } -func newPoolLsCmd() *cobra.Command { - return &cobra.Command{ - Use: "ls", - Short: "List slots: free, claimed, and by whom", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - return runPoolLs(cmd) - }, +// formatSlots is the pool-mode tree table, shared by wt ls and +// wt status so the two can never disagree on a slot's spelling. +// LEASE, who holds a slot, leads because it is a slot's defining +// fact; STATE is the working tree, spelled exactly as in default +// mode. AGE is claim age: in a pool, how long a slot has been +// held is what tells a forgotten claim from active work. +func formatSlots(views []slotView, l look) string { + tools := slices.ContainsFunc(views, func(v slotView) bool { return v.Tools != "" }) + titles := []string{"tree", "lease", "branch", "state", "head", "age", "note", "path"} + if tools { + titles = slices.Insert(titles, 4, "tools") } + rows := [][]string{l.header(titles...)} + for _, v := range views { + row := []string{ + v.Slot, + l.paint(slotStateStyle(v.State), dash(v.State)), + dash(v.Branch), + l.paint(v.workStyle, dash(v.work)), + } + if tools { + row = append(row, l.paint(toolsStyle(v.Tools), dash(v.Tools))) + } + row = append(row, shortHead(v.Head), dash(v.Age), dash(v.Note), l.path(v.Path)) + rows = append(rows, row) + } + return render.Align(render.FitColumn(rows, len(titles)-1, l.width)) } -func runPoolLs(cmd *cobra.Command) error { - ctx := cmd.Context() - p, err := openPool(ctx) - if err != nil { - return err - } - trees, err := p.g.Worktrees(ctx) - if err != nil { - return err +// slotStateStyle colors what needs attention: a stale lease is +// a warning, a free slot is good news, an unprovisioned one is +// merely latent. +func slotStateStyle(state string) lipgloss.Style { + switch state { + case "free": + return styleGood + case "stale": + return styleWarn + case "unprovisioned": + return styleDim } - rows := make([][]string, 0, p.cfg.Pool.Size) - for _, view := range slotViews(p.wtRepo, p.state, trees) { - rows = append(rows, slotRow(view)) + return lipgloss.NewStyle() +} + +// poolRows is every row of the pool-mode table: the trees outside +// the pool first (the main checkout, hand-made trees), named by +// their directory and leased by no one, then every slot. +func poolRows( + w *wtRepo, st state.Dir, trees []gitx.Worktree, facts map[string]treeFacts, +) []slotView { + var rows []slotView + for _, t := range trees { + if w.isSlot(t.Path) { + continue + } + f := facts[t.Path] + v := slotView{ + Slot: filepath.Base(t.Path), Branch: branchLabel(t), Path: t.Path, + Head: t.Head, Dirty: f.dirty(), Tools: f.Tools, + } + rows = append(rows, v.withWork(t, f)) } - _, err = fmt.Fprint(cmd.OutOrStdout(), render.Align(rows)) - return err + return append(rows, slotViews(w, st, trees, facts)...) +} + +// slotTrees is the subset of trees that are pool slots. +func (w *wtRepo) slotTrees(trees []gitx.Worktree) []gitx.Worktree { + return slices.DeleteFunc(slices.Clone(trees), func(t gitx.Worktree) bool { + return !w.isSlot(t.Path) + }) +} + +func (w *wtRepo) isSlot(path string) bool { + _, ok := pool.SlotPath(w.treesDir(), path) + return ok } // slotViews builds every configured slot's occupancy view, in -// slot order. It takes the bare repo seams rather than a -// poolRepo, so wt status can feed it without re-deriving what it -// already holds. -func slotViews(w *wtRepo, st state.Dir, trees []gitx.Worktree) []slotView { +// slot order, with each provisioned slot's working-tree facts. +// It takes the bare repo seams rather than a poolRepo, so wt +// status can feed it without re-deriving what it already holds. +func slotViews( + w *wtRepo, st state.Dir, trees []gitx.Worktree, facts map[string]treeFacts, +) []slotView { views := make([]slotView, 0, w.cfg.Pool.Size) for _, slot := range pool.Names(w.cfg.Pool.Size) { - _, registered := findTree(trees, filepath.Join(w.treesDir(), slot)) + path := filepath.Join(w.treesDir(), slot) + t, registered := findTree(trees, path) held, err := lease.Get(st.LeasesDir(), slot) - views = append(views, newSlotView(slot, registered, held, err)) + v := newSlotView(slot, registered, held, err) + v.Path = path + if registered { + f := facts[t.Path] + v.Head, v.Dirty, v.Tools = t.Head, f.dirty(), f.Tools + v = v.withWork(t, f) + } + views = append(views, v) } return views } @@ -84,6 +142,21 @@ type slotView struct { PID int `json:"pid,omitempty"` ClaimedAt *time.Time `json:"claimed_at,omitempty"` Note string `json:"note,omitempty"` + Path string `json:"path"` + Head string `json:"head,omitempty"` + Dirty bool `json:"dirty,omitempty"` + Tools string `json:"tools,omitempty"` // fresh | stale + Age string `json:"-"` // claim age, for the human table + + // work is the STATE cell (clean/dirty, locked, prunable) and its + // color, spelled by the same helpers as the default-mode table. + work string + workStyle lipgloss.Style +} + +func (v slotView) withWork(t gitx.Worktree, f treeFacts) slotView { + v.work, v.workStyle = treeState(t, f), treeStateStyle(t, f) + return v } // newSlotView classifies one slot's occupancy. An unreadable @@ -100,27 +173,20 @@ func newSlotView(slot string, registered bool, held *lease.Info, err error) slot case held.Stale(): return slotView{ Slot: slot, State: "stale", Branch: held.Branch, PID: held.PID, - Note: fmt.Sprintf("dead pid %d — reclaimed on next claim", held.PID), + Note: fmt.Sprintf("dead pid %d", held.PID), } default: at := held.ClaimedAt return slotView{ Slot: slot, State: "claimed", Branch: held.Branch, PID: held.PID, ClaimedAt: &at, - Note: fmt.Sprintf("pid %d, claimed %s", - held.PID, freshness.Age(time.Since(held.ClaimedAt))), + Note: fmt.Sprintf("pid %d", held.PID), Age: compactAge(time.Since(at)), } } } -// slotRow renders one slot view as a table row: -// slot, state, branch, detail. -func slotRow(v slotView) []string { - return []string{v.Slot, v.State, cmp.Or(v.Branch, "-"), v.Note} -} - // unreadableLeaseAdvice is the one spelling of the escape hatch // for a lease record wt cannot read and so never clears on a -// guess (D15): shared by pool ls and wt clean. +// guess (D15): shared by wt ls and wt clean. func unreadableLeaseAdvice(slot string) string { return fmt.Sprintf("lease record unreadable — `wt release %s` clears it", slot) } diff --git a/internal/cli/root.go b/internal/cli/root.go index f1f896d..4092bf7 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -55,7 +55,7 @@ func (b BuildInfo) String() string { func Main(info BuildInfo) int { root := newRootCmd(info) if err := root.Execute(); err != nil { - fmt.Fprintf(os.Stderr, "wt: %v\n", err) + fmt.Fprintf(os.Stderr, "%s %v\n", lookFor(os.Stderr).paint(styleBad, "wt:"), err) return exitCodeFor(err) } return 0 diff --git a/internal/cli/slots.go b/internal/cli/slots.go index 339bf1f..07f0044 100644 --- a/internal/cli/slots.go +++ b/internal/cli/slots.go @@ -185,7 +185,7 @@ func (p *poolRepo) prepareSlot( // An unregistered slot may have just been removed by a // shrink working from a newer config than this claim // loaded; re-read the size before materializing a tree the - // pool no longer owns; it would be invisible to pool ls. + // pool no longer owns; it would be invisible to wt ls. if err := p.checkStillInPool(slot); err != nil { return "", err } @@ -284,7 +284,7 @@ func (p *poolRepo) acquire( "resolve a blocked slot, or `wt pool resize %d`", size, len(skip), size+1) } return "", nil, nil, preconditionf( - "no free slot in the pool of %d — `wt pool ls` shows the holders; "+ + "no free slot in the pool of %d — `wt ls` shows the holders; "+ "`wt done` a finished one, or `wt pool resize %d`", size, size+1) } @@ -449,7 +449,7 @@ func (p *poolRepo) pinForRelease( held, err := lease.Get(leases, slot) if err == nil && held == nil && claimRequired { return nil, preconditionf( - "%s is not claimed — nothing to release; `wt pool ls` shows the claimed slots", slot) + "%s is not claimed — nothing to release; `wt ls` shows the claimed slots", slot) } pinned, err := lease.Repin(leases, slot, branch, held) if err != nil { diff --git a/internal/cli/status.go b/internal/cli/status.go index 4fcc385..8a87cff 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -77,7 +77,7 @@ func runStatus(cmd *cobra.Command, jsonOut bool) error { if jsonOut { return render.JSON(cmd.OutOrStdout(), view) } - _, err = fmt.Fprint(cmd.OutOrStdout(), formatStatus(view)) + _, err = fmt.Fprint(cmd.OutOrStdout(), formatStatus(view, lookFor(cmd.OutOrStdout()))) return err } @@ -99,8 +99,10 @@ func buildStatusView( view.Trees = append(view.Trees, ts) } if w.cfg.Pool != nil { + slots := w.slotTrees(trees) + facts := gatherTreeFacts(ctx, gitx.New(w.repo.Root), slots, w.refreshTools(st), now) view.Mode = "pool" - view.Pool = &poolStatus{Size: w.cfg.Pool.Size, Slots: slotViews(w, st, trees)} + view.Pool = &poolStatus{Size: w.cfg.Pool.Size, Slots: slotViews(w, st, trees, facts)} } return view } @@ -165,37 +167,37 @@ func writeCachedSize(w *wtRepo, st state.Dir, path string, u state.DiskUsage) { // formatStatus lays the view out for humans: a mode/base header, // one sized row per tree, and, in pool mode, one row per slot in -// exactly the wt pool ls spelling. -func formatStatus(view statusView) string { +// exactly the pool-mode wt ls spelling. +func formatStatus(view statusView, l look) string { mode := view.Mode if view.Pool != nil { mode = fmt.Sprintf("pool (%d %s)", view.Pool.Size, plural(view.Pool.Size, "slot")) } + base := baseLine(view.Base) + if view.Base.Stale { + base = l.paint(styleWarn, base) + } out := render.Align([][]string{ {"mode", mode}, - {"base", baseLine(view.Base)}, + {"base", base}, }) - rows := make([][]string, 0, len(view.Trees)) - for _, t := range view.Trees { - size := "?" - if t.DiskKB != nil { - size = humanKB(*t.DiskKB) + if len(view.Trees) > 0 { + rows := [][]string{l.header("branch", "size", "path")} + for _, t := range view.Trees { + size := "?" + if t.DiskKB != nil { + size = humanKB(*t.DiskKB) + } + rows = append(rows, []string{worktreeLabel(t.Bare, t.Detached, t.Branch), size, l.path(t.Path)}) } - rows = append(rows, []string{worktreeLabel(t.Bare, t.Detached, t.Branch), t.Path, size}) - } - if len(rows) > 0 { - out += "\n" + render.Align(rows) + out += "\n" + render.Align(render.FitColumn(rows, 2, l.width)) } if view.Pool == nil { return out } - slotRows := make([][]string, 0, len(view.Pool.Slots)) - for _, s := range view.Pool.Slots { - slotRows = append(slotRows, slotRow(s)) - } - return out + "\n" + render.Align(slotRows) + return out + "\n" + formatSlots(view.Pool.Slots, l) } // baseLine phrases the base's fetch age through lastFetchPhrase, diff --git a/internal/cli/status_test.go b/internal/cli/status_test.go index f6700ab..4ee3420 100644 --- a/internal/cli/status_test.go +++ b/internal/cli/status_test.go @@ -40,7 +40,7 @@ func TestFormatStatusDefaultMode(t *testing.T) { {Branch: "feature/login", Path: "/repo.trees/feature-login"}, }, } - got := formatStatus(view) + got := formatStatus(view, plainLook) for _, want := range []string{ "mode default", "base main — not yet fetched", @@ -70,7 +70,7 @@ func TestFormatStatusPoolModeListsSlots(t *testing.T) { }, }, } - got := formatStatus(view) + got := formatStatus(view, plainLook) for _, want := range []string{ "mode pool (2 slots)", "last fetched 2h ago", diff --git a/internal/cli/testdata/golden/look/doctor-color-120.txt b/internal/cli/testdata/golden/look/doctor-color-120.txt new file mode 100644 index 0000000..b1f5fc6 --- /dev/null +++ b/internal/cli/testdata/golden/look/doctor-color-120.txt @@ -0,0 +1,7 @@ +ok git 2.50.1 +warn hooks-path core.hooksPath = .githooks (relative) + cause: a relative hooks path resolves inside each tree; hooks vanish in trees where it is untracked + fix: `git config core.hooksPath `, or keep the hooks directory tracked +fail config wt.toml:3:1: unknown key "colour" + fix: wt config --edit +info update check skipped (--offline) diff --git a/internal/cli/testdata/golden/look/doctor-plain-120.txt b/internal/cli/testdata/golden/look/doctor-plain-120.txt new file mode 100644 index 0000000..3a6b4a5 --- /dev/null +++ b/internal/cli/testdata/golden/look/doctor-plain-120.txt @@ -0,0 +1,7 @@ +ok git 2.50.1 +warn hooks-path core.hooksPath = .githooks (relative) + cause: a relative hooks path resolves inside each tree; hooks vanish in trees where it is untracked + fix: `git config core.hooksPath `, or keep the hooks directory tracked +fail config wt.toml:3:1: unknown key "colour" + fix: wt config --edit +info update check skipped (--offline) diff --git a/internal/cli/testdata/golden/look/doctor-plain-60.txt b/internal/cli/testdata/golden/look/doctor-plain-60.txt new file mode 100644 index 0000000..4019ad1 --- /dev/null +++ b/internal/cli/testdata/golden/look/doctor-plain-60.txt @@ -0,0 +1,11 @@ +ok git 2.50.1 +warn hooks-path core.hooksPath = .githooks (relative) + cause: a relative hooks path resolves + inside each tree; hooks vanish in trees + where it is untracked + fix: `git config core.hooksPath + `, or keep the hooks + directory tracked +fail config wt.toml:3:1: unknown key "colour" + fix: wt config --edit +info update check skipped (--offline) diff --git a/internal/cli/testdata/golden/look/ls-color-120.txt b/internal/cli/testdata/golden/look/ls-color-120.txt new file mode 100644 index 0000000..290d050 --- /dev/null +++ b/internal/cli/testdata/golden/look/ls-color-120.txt @@ -0,0 +1,5 @@ +BRANCH STATE TOOLS HEAD AGE PATH +main clean - 82506bc30 4d ~/src/acme +feature/login dirty,locked stale a08fe1242 2h ~/src/acme.trees/feature-login +fix/a-rather-long-branch-name clean fresh 422825f71 now ~/src/acme.t…rather-long-branch-name-for-a-long-path +old prunable - 4a53648b9 - ~/src/acme.trees/old diff --git a/internal/cli/testdata/golden/look/ls-plain-120.txt b/internal/cli/testdata/golden/look/ls-plain-120.txt new file mode 100644 index 0000000..80e6344 --- /dev/null +++ b/internal/cli/testdata/golden/look/ls-plain-120.txt @@ -0,0 +1,5 @@ +BRANCH STATE TOOLS HEAD AGE PATH +main clean - 82506bc30 4d ~/src/acme +feature/login dirty,locked stale a08fe1242 2h ~/src/acme.trees/feature-login +fix/a-rather-long-branch-name clean fresh 422825f71 now ~/src/acme.t…rather-long-branch-name-for-a-long-path +old prunable - 4a53648b9 - ~/src/acme.trees/old diff --git a/internal/cli/testdata/golden/look/ls-plain-60.txt b/internal/cli/testdata/golden/look/ls-plain-60.txt new file mode 100644 index 0000000..f4e3435 --- /dev/null +++ b/internal/cli/testdata/golden/look/ls-plain-60.txt @@ -0,0 +1,5 @@ +BRANCH STATE TOOLS HEAD AGE PATH +main clean - 82506bc30 4d ~/src/acme +feature/login dirty,locked stale a08fe1242 2h ~/src/acme.trees/feature-login +fix/a-rather-long-branch-name clean fresh 422825f71 now ~/src/acme.trees/fix-a-rather-long-branch-name-for-a-long-path +old prunable - 4a53648b9 - ~/src/acme.trees/old diff --git a/internal/cli/testdata/golden/look/ls-pool-color-120.txt b/internal/cli/testdata/golden/look/ls-pool-color-120.txt new file mode 100644 index 0000000..4fe07f8 --- /dev/null +++ b/internal/cli/testdata/golden/look/ls-pool-color-120.txt @@ -0,0 +1,6 @@ +TREE LEASE BRANCH STATE TOOLS HEAD AGE NOTE PATH +acme - main clean - 82506bc30 - - ~/src/acme +slot-1 free - clean fresh 878e4ca68 - - ~/src/acme.trees/slot-1 +slot-2 claimed feature/pay dirty stale a08fe1242 4d pid 4242 ~/src/acme.trees/slot-2 +slot-3 stale spike clean,locked fresh 422825f71 - dead pid 7 ~/src/acme.trees/slot-3 +slot-4 unprovisioned - - - - - provisions on first claim ~/src/acme.trees/slot-4 diff --git a/internal/cli/testdata/golden/look/ls-pool-plain-120.txt b/internal/cli/testdata/golden/look/ls-pool-plain-120.txt new file mode 100644 index 0000000..069f57f --- /dev/null +++ b/internal/cli/testdata/golden/look/ls-pool-plain-120.txt @@ -0,0 +1,6 @@ +TREE LEASE BRANCH STATE TOOLS HEAD AGE NOTE PATH +acme - main clean - 82506bc30 - - ~/src/acme +slot-1 free - clean fresh 878e4ca68 - - ~/src/acme.trees/slot-1 +slot-2 claimed feature/pay dirty stale a08fe1242 4d pid 4242 ~/src/acme.trees/slot-2 +slot-3 stale spike clean,locked fresh 422825f71 - dead pid 7 ~/src/acme.trees/slot-3 +slot-4 unprovisioned - - - - - provisions on first claim ~/src/acme.trees/slot-4 diff --git a/internal/cli/testdata/golden/look/ls-pool-plain-60.txt b/internal/cli/testdata/golden/look/ls-pool-plain-60.txt new file mode 100644 index 0000000..069f57f --- /dev/null +++ b/internal/cli/testdata/golden/look/ls-pool-plain-60.txt @@ -0,0 +1,6 @@ +TREE LEASE BRANCH STATE TOOLS HEAD AGE NOTE PATH +acme - main clean - 82506bc30 - - ~/src/acme +slot-1 free - clean fresh 878e4ca68 - - ~/src/acme.trees/slot-1 +slot-2 claimed feature/pay dirty stale a08fe1242 4d pid 4242 ~/src/acme.trees/slot-2 +slot-3 stale spike clean,locked fresh 422825f71 - dead pid 7 ~/src/acme.trees/slot-3 +slot-4 unprovisioned - - - - - provisions on first claim ~/src/acme.trees/slot-4 diff --git a/internal/cli/testdata/golden/look/status-color-120.txt b/internal/cli/testdata/golden/look/status-color-120.txt new file mode 100644 index 0000000..4313a31 --- /dev/null +++ b/internal/cli/testdata/golden/look/status-color-120.txt @@ -0,0 +1,12 @@ +mode pool (4 slots) +base main — not yet fetched + +BRANCH SIZE PATH +main 54.0M ~/src/acme +(detached) ? ~/src/acme.trees/slot-1 + +TREE LEASE BRANCH STATE TOOLS HEAD AGE NOTE PATH +slot-1 free - clean fresh 878e4ca68 - - ~/src/acme.trees/slot-1 +slot-2 claimed feature/pay dirty stale a08fe1242 4d pid 4242 ~/src/acme.trees/slot-2 +slot-3 stale spike clean,locked fresh 422825f71 - dead pid 7 ~/src/acme.trees/slot-3 +slot-4 unprovisioned - - - - - provisions on first claim ~/src/acme.trees/slot-4 diff --git a/internal/cli/testdata/golden/look/status-plain-120.txt b/internal/cli/testdata/golden/look/status-plain-120.txt new file mode 100644 index 0000000..52b422b --- /dev/null +++ b/internal/cli/testdata/golden/look/status-plain-120.txt @@ -0,0 +1,12 @@ +mode pool (4 slots) +base main — not yet fetched + +BRANCH SIZE PATH +main 54.0M ~/src/acme +(detached) ? ~/src/acme.trees/slot-1 + +TREE LEASE BRANCH STATE TOOLS HEAD AGE NOTE PATH +slot-1 free - clean fresh 878e4ca68 - - ~/src/acme.trees/slot-1 +slot-2 claimed feature/pay dirty stale a08fe1242 4d pid 4242 ~/src/acme.trees/slot-2 +slot-3 stale spike clean,locked fresh 422825f71 - dead pid 7 ~/src/acme.trees/slot-3 +slot-4 unprovisioned - - - - - provisions on first claim ~/src/acme.trees/slot-4 diff --git a/internal/cli/testdata/golden/look/status-plain-60.txt b/internal/cli/testdata/golden/look/status-plain-60.txt new file mode 100644 index 0000000..52b422b --- /dev/null +++ b/internal/cli/testdata/golden/look/status-plain-60.txt @@ -0,0 +1,12 @@ +mode pool (4 slots) +base main — not yet fetched + +BRANCH SIZE PATH +main 54.0M ~/src/acme +(detached) ? ~/src/acme.trees/slot-1 + +TREE LEASE BRANCH STATE TOOLS HEAD AGE NOTE PATH +slot-1 free - clean fresh 878e4ca68 - - ~/src/acme.trees/slot-1 +slot-2 claimed feature/pay dirty stale a08fe1242 4d pid 4242 ~/src/acme.trees/slot-2 +slot-3 stale spike clean,locked fresh 422825f71 - dead pid 7 ~/src/acme.trees/slot-3 +slot-4 unprovisioned - - - - - provisions on first claim ~/src/acme.trees/slot-4 diff --git a/internal/cli/testdata/script/pool-clean.txtar b/internal/cli/testdata/script/pool-clean.txtar index 2582176..c2da43c 100644 --- a/internal/cli/testdata/script/pool-clean.txtar +++ b/internal/cli/testdata/script/pool-clean.txtar @@ -18,20 +18,20 @@ stdout '[/\\]slot-1$' exec wt claim feat-dead stdout '[/\\]slot-2$' exec sh -c 'd="$(echo "$XDG_STATE_HOME"/wt/repos/*)" && sed "s/HOSTNAME/$(uname -n)/" "$WORK/dead-lease.toml" > "$d/leases/slot-2/lease.toml"' -exec wt pool ls +exec wt ls stdout 'slot-2\s+stale\s+feat-dead' # the dry run names the reap without performing it exec wt clean -n stderr 'would release slot-2' -exec wt pool ls +exec wt ls stdout 'slot-2\s+stale' # the real run frees the dead slot and leaves the live claim exec wt clean stderr 'released slot-2 \(dead pid 99999999, was feat-dead\)' ! stderr 'slot-1' -exec wt pool ls +exec wt ls stdout 'slot-1\s+claimed\s+feat-live' stdout 'slot-2\s+free' @@ -43,7 +43,7 @@ exec sh -c 'd="$(echo "$XDG_STATE_HOME"/wt/repos/*)" && mkdir -p "$d/leases/slot exec wt clean stderr 'slot-2 lease record unreadable' ! stderr 'nothing to clean' -exec wt pool ls +exec wt ls stdout 'slot-2\s+claimed\s+\?' -- dead-lease.toml -- diff --git a/internal/cli/testdata/script/pool-recovery.txtar b/internal/cli/testdata/script/pool-recovery.txtar index c5a5d74..53a855a 100644 --- a/internal/cli/testdata/script/pool-recovery.txtar +++ b/internal/cli/testdata/script/pool-recovery.txtar @@ -23,8 +23,8 @@ exec sh -c 'd="$(echo "$XDG_STATE_HOME"/wt/repos/*)" && mkdir -p "$d/leases/slot # the pool is wedged: claim must not steal what it cannot prove dead exitcode 3 wt claim feat-b stderr 'no free slot' -exec wt pool ls -stdout 'slot-1\s+claimed\s+\?\s+lease record unreadable' +exec wt ls +stdout 'slot-1\s+claimed\s+\?\s+.*lease record unreadable' # the escape hatch: release frees the slot anyway exec wt release slot-1 diff --git a/internal/cli/testdata/script/pool-resize.txtar b/internal/cli/testdata/script/pool-resize.txtar index 395deec..3bb1ffd 100644 --- a/internal/cli/testdata/script/pool-resize.txtar +++ b/internal/cli/testdata/script/pool-resize.txtar @@ -14,7 +14,7 @@ exec wt pool resize 3 stderr 'provisioned slot-2' stderr 'provisioned slot-3' grep 'size = 3' .git/wt.toml -exec wt pool ls +exec wt ls stdout 'slot-2\s+free' stdout 'slot-3\s+free' @@ -35,7 +35,7 @@ stderr 'removed slot-3' ! exists ../acme.trees/slot-2 ! exists ../acme.trees/slot-3 grep 'size = 1' .git/wt.toml -exec wt pool ls +exec wt ls stdout 'slot-1\s+claimed\s+feat-a' ! stdout 'slot-2' diff --git a/internal/cli/testdata/script/pool-wedged.txtar b/internal/cli/testdata/script/pool-wedged.txtar index a52d544..8a3e379 100644 --- a/internal/cli/testdata/script/pool-wedged.txtar +++ b/internal/cli/testdata/script/pool-wedged.txtar @@ -24,8 +24,8 @@ exec wt release feat-a # of band and the lease record is torn exec git worktree remove --force ../acme.trees/slot-1 exec sh -c 'd="$(echo "$XDG_STATE_HOME"/wt/repos/*)" && mkdir -p "$d/leases/slot-1" && printf "not = [toml" > "$d/leases/slot-1/lease.toml"' -exec wt pool ls -stdout 'slot-1\s+claimed\s+\?\s+lease record unreadable' +exec wt ls +stdout 'slot-1\s+claimed\s+\?\s+.*lease record unreadable' # no claim can prove that record dead, so the pool is full exitcode 3 wt claim feat-b @@ -79,7 +79,7 @@ exec wt release feat-f exec sh -c 'echo x > fail-now' exitcode 1 wt claim feat-g stderr 'refresh hook failed' -exec wt pool ls +exec wt ls stdout 'slot-1\s+free' exec git -C ../acme.trees/slot-1 status stdout 'HEAD detached' diff --git a/internal/cli/testdata/script/pool.txtar b/internal/cli/testdata/script/pool.txtar index 809dd2c..c1b91d4 100644 --- a/internal/cli/testdata/script/pool.txtar +++ b/internal/cli/testdata/script/pool.txtar @@ -32,7 +32,7 @@ stderr 'no free slot' ! stdout . # slot-centric view -exec wt pool ls +exec wt ls stdout 'slot-1\s+claimed\s+feat-a' stdout 'slot-2\s+claimed\s+feat-b' @@ -50,7 +50,7 @@ exec git -C ../acme.trees/slot-1 branch --show-current ! stdout . exec git -C ../acme.trees/slot-1 status --porcelain ! stdout . -exec wt pool ls +exec wt ls stdout 'slot-1\s+free' # a released slot is claimable again; claim prints the slot path @@ -84,7 +84,7 @@ stderr 'already checked out in .*[/\\]slot-2' cp $WORK/junk.txt ../acme.trees/slot-1/junk.txt exitcode 3 wt done feat-c stderr 'uncommitted changes' -exec wt pool ls +exec wt ls stdout 'slot-1\s+claimed\s+feat-c' rm ../acme.trees/slot-1/junk.txt exec wt done feat-c diff --git a/internal/cli/testdata/script/status.txtar b/internal/cli/testdata/script/status.txtar index 0ee4b6a..fa85103 100644 --- a/internal/cli/testdata/script/status.txtar +++ b/internal/cli/testdata/script/status.txtar @@ -15,8 +15,8 @@ exec wt new feat-a exec wt status stdout 'mode default' stdout 'base main — not yet fetched' -stdout 'main\s+.*[/\\]acme\s+\d+(\.\d+)?[KMG]' -stdout 'feat-a\s+.*[/\\]feat-a\s+\d+(\.\d+)?[KMG]' +stdout 'main\s+\d+(\.\d+)?[KMG]\s+.*[/\\]acme$' +stdout 'feat-a\s+\d+(\.\d+)?[KMG]\s+.*[/\\]feat-a$' ! stderr . # the JSON view carries the same facts, machine-shaped diff --git a/internal/cli/treefacts.go b/internal/cli/treefacts.go new file mode 100644 index 0000000..9b9e040 --- /dev/null +++ b/internal/cli/treefacts.go @@ -0,0 +1,115 @@ +package cli + +import ( + "context" + "strings" + "sync" + "time" + + "github.com/loganthomas/wt/internal/freshness" + "github.com/loganthomas/wt/internal/gitx" + "github.com/loganthomas/wt/internal/pool" + "github.com/loganthomas/wt/internal/state" +) + +// treeFacts are the per-tree table columns that cost git calls, +// gathered once per listing so every table reads the same answer. +// Zero values mean "unknown" and render as "-". +type treeFacts struct { + Dirty *bool + Committed *time.Time + Age string // HEAD commit age, compact ("4d") + Tools string // "fresh" | "stale"; empty when no refresh gate applies +} + +// dirty is the machine-contract reading: true only when known +// dirty, so JSON can omit the field otherwise. +func (f treeFacts) dirty() bool { + return f.Dirty != nil && *f.Dirty +} + +// toolsFunc reports a tree's refresh-gate state, or "" when none +// applies; nil when the config could not be loaded. +type toolsFunc func(path string) string + +// gatherTreeFacts runs one git status per tree, all concurrent +// (the trees are independent and each status is I/O bound), plus +// a single git show for every HEAD's commit time. Bare and +// prunable trees have no working tree to ask about. +func gatherTreeFacts( + ctx context.Context, g *gitx.Git, trees []gitx.Worktree, tools toolsFunc, now time.Time, +) map[string]treeFacts { + var live []gitx.Worktree + var heads []string + for _, t := range trees { + if t.Bare || t.Prunable { + continue + } + live = append(live, t) + if t.Head != "" { + heads = append(heads, t.Head) + } + } + committed, err := g.CommitTimes(ctx, heads) + if err != nil { + committed = nil + } + + var mu sync.Mutex + var wg sync.WaitGroup + facts := make(map[string]treeFacts, len(live)) + for _, t := range live { + wg.Go(func() { + f := treeFacts{} + if dirty, err := gitx.New(t.Path).IsDirty(ctx); err == nil { + f.Dirty = &dirty + } + if at, ok := committed[t.Head]; ok { + f.Committed = &at + f.Age = compactAge(now.Sub(at)) + } + if tools != nil { + f.Tools = tools(t.Path) + } + mu.Lock() + defer mu.Unlock() + facts[t.Path] = f + }) + } + wg.Wait() + return facts +} + +// compactAge is freshness.Age for table cells: "4d", not "4d ago", +// and "now" for anything under a minute. +func compactAge(d time.Duration) string { + if d < time.Minute { + return "now" + } + return strings.TrimSuffix(freshness.Age(d), " ago") +} + +// refreshTools compares a managed tree's recorded refresh hash with +// its lockfiles now: "stale" means the next claim or new reruns +// the refresh hook. Only a configured gate gives the column +// meaning; without one, every tree reads "". +func (w *wtRepo) refreshTools(st state.Dir) toolsFunc { + files := w.cfg.Hooks.RefreshIfChanged + if w.cfg.Hooks.Refresh == "" || len(files) == 0 { + return nil + } + return func(path string) string { + name, ok := w.treeStateName(path) + if !ok { + return "" + } + current, err := pool.Hash(path, files) + if err != nil { + return "" + } + if current == st.RefreshHash(name) { + return "fresh" + } + return "stale" + } +} diff --git a/internal/config/load.go b/internal/config/load.go index 030adb7..fcff0d0 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -68,3 +68,14 @@ func positionedError(path string, err error) error { } return fmt.Errorf("%s: %w", path, err) } + +// LoadUI reads only the presentation settings from the global +// config at path, for commands that otherwise never load config. +// A missing file yields the zero UI. +func LoadUI(path string) (UI, error) { + var global globalFile + if err := decodeFile(path, &global); err != nil { + return UI{}, err + } + return global.UI, nil +} diff --git a/internal/gitx/gitx.go b/internal/gitx/gitx.go index 57398a7..8b3ff02 100644 --- a/internal/gitx/gitx.go +++ b/internal/gitx/gitx.go @@ -14,6 +14,7 @@ import ( "os/exec" "strconv" "strings" + "time" ) // Git runs git commands rooted at a fixed working directory. @@ -149,6 +150,39 @@ func (g *Git) ShortStatus(ctx context.Context) (string, error) { return strings.TrimRight(string(out), "\n"), nil } +// IsDirty reports whether the tree has any change git status +// shows: staged, unstaged, or untracked (ignored files never count). +func (g *Git) IsDirty(ctx context.Context) (bool, error) { + out, err := g.run(ctx, "status", "--porcelain") + if err != nil { + return false, err + } + return len(out) > 0, nil +} + +// CommitTimes maps each commit to its committer date, in one git +// call however many commits are asked for. Any unknown commit +// fails the whole call: callers treat the map as best-effort. +func (g *Git) CommitTimes(ctx context.Context, shas []string) (map[string]time.Time, error) { + times := make(map[string]time.Time, len(shas)) + if len(shas) == 0 { + return times, nil + } + out, err := g.run(ctx, append([]string{"show", "-s", "--format=%H %ct"}, shas...)...) + if err != nil { + return nil, err + } + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + sha, stamp, ok := strings.Cut(line, " ") + secs, perr := strconv.ParseInt(stamp, 10, 64) + if !ok || perr != nil { + return nil, fmt.Errorf("unexpected git show output: %q", line) + } + times[sha] = time.Unix(secs, 0) + } + return times, nil +} + // LastCommit returns a one-line summary of the tree's HEAD. func (g *Git) LastCommit(ctx context.Context) (string, error) { return g.runLine(ctx, "log", "-1", "--format=%h %s (%cr)") diff --git a/internal/render/fit_test.go b/internal/render/fit_test.go new file mode 100644 index 0000000..732225a --- /dev/null +++ b/internal/render/fit_test.go @@ -0,0 +1,63 @@ +package render + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +func TestAlignMeasuresDisplayWidthNotEscapes(t *testing.T) { + red := "\x1b[31mlocked\x1b[m" + got := Align([][]string{ + {red, "/a"}, + {"locked", "/b"}, + }) + lines := strings.Split(strings.TrimSuffix(got, "\n"), "\n") + if ansi.StringWidth(lines[0]) != ansi.StringWidth(lines[1]) { + t.Errorf("colored and plain rows misalign:\n%q\n%q", lines[0], lines[1]) + } +} + +func TestFitColumnUnlimitedIsUntouched(t *testing.T) { + rows := [][]string{{"main", strings.Repeat("x", 200)}} + if got := FitColumn(rows, 1, 0); got[0][1] != rows[0][1] { + t.Errorf("FitColumn(width 0) changed the cell: %q", got[0][1]) + } +} + +func TestFitColumnTruncatesMiddleToFit(t *testing.T) { + path := "/Users/me/src/acme.trees/fix-a-rather-long-branch-name" + rows := [][]string{ + {"fix", path, "prunable"}, + {"main", "/Users/me/src/acme", ""}, + } + got := FitColumn(rows, 1, 50) + cell := got[0][1] + if !strings.Contains(cell, "…") { + t.Fatalf("long path not truncated: %q", cell) + } + if !strings.HasPrefix(cell, "/Users") || !strings.HasSuffix(cell, "branch-name") { + t.Errorf("truncation lost the path's ends: %q", cell) + } + if got[1][1] != "/Users/me/src/acme" { + t.Errorf("a path that fits was changed: %q", got[1][1]) + } + for _, line := range strings.Split(strings.TrimSuffix(Align(got), "\n"), "\n") { + if w := ansi.StringWidth(line); w > 50 { + t.Errorf("line is %d cells, want ≤ 50: %q", w, line) + } + } + if rows[0][1] != path { + t.Errorf("FitColumn mutated its input: %q", rows[0][1]) + } +} + +func TestFitColumnLeavesHopelessTablesAlone(t *testing.T) { + // The other columns alone overflow 20 cells: truncating the + // path can't make the row fit, so it must not lose characters. + rows := [][]string{{"a-very-long-branch-name", "/some/path/here/x", "prunable"}} + if got := FitColumn(rows, 1, 20); got[0][1] != rows[0][1] { + t.Errorf("FitColumn truncated a table that cannot fit: %q", got[0][1]) + } +} diff --git a/internal/render/render.go b/internal/render/render.go index 0881243..bacdd6c 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -10,7 +10,8 @@ import ( "fmt" "io" "strings" - "unicode/utf8" + + "github.com/charmbracelet/x/ansi" ) // Align renders rows in aligned columns, shared by every tabular @@ -20,17 +21,9 @@ import ( // trailing spaces, and stdout must stay exact for machine // consumers (D13). Trailing empty cells drop their padding too, // so no line ever ends in spaces. +// Widths are display cells, so colored cells align with plain ones. func Align(rows [][]string) string { - var width []int - for _, row := range rows { - for i, cell := range row { - if i == len(width) { - width = append(width, 0) - } - width[i] = max(width[i], utf8.RuneCountInString(cell)) - } - } - const gap = 2 + width := columnWidths(rows) var out strings.Builder for _, row := range rows { last := len(row) - 1 @@ -38,14 +31,79 @@ func Align(rows [][]string) string { last-- } for i := range last { - // fmt pads %s to a minimum rune count, matching the width math above. - fmt.Fprintf(&out, "%-*s", width[i]+gap, row[i]) + out.WriteString(row[i]) + out.WriteString(strings.Repeat(" ", width[i]+gap-ansi.StringWidth(row[i]))) } fmt.Fprintln(&out, row[last]) } return out.String() } +const gap = 2 + +// minFit keeps a squeezed column legible: below it, a truncated +// path says too little to be worth the lost characters, and a +// slightly long row reads better than a mangled short path. +const minFit = 24 + +// FitColumn middle-truncates column col so the aligned table fits +// within maxWidth display cells; maxWidth 0 means unlimited. +// The middle goes because a path's ends carry its meaning: the +// root it hangs from and the tree it names. +// When even a minFit-wide col can't make the table fit, nothing +// is truncated: the rows overflow either way, and losing +// characters would then buy nothing. +func FitColumn(rows [][]string, col, maxWidth int) [][]string { + if maxWidth <= 0 { + return rows + } + width := columnWidths(rows) + if col >= len(width) { + return rows + } + others := gap * (len(width) - 1) + for i, w := range width { + if i != col { + others += w + } + } + budget := maxWidth - others + if budget < minFit { + return rows + } + fitted := make([][]string, len(rows)) + for i, row := range rows { + fitted[i] = row + if col < len(row) && ansi.StringWidth(row[col]) > budget { + fitted[i] = append([]string(nil), row...) + fitted[i][col] = truncateMiddle(row[col], budget) + } + } + return fitted +} + +// truncateMiddle shortens s to width cells around an ellipsis, +// keeping a short head for orientation and the rest for the tail, +// where a path names its tree. +func truncateMiddle(s string, width int) string { + head := (width - 1) / 4 + tail := width - 1 - head + return ansi.Truncate(s, head, "") + "…" + ansi.TruncateLeft(s, ansi.StringWidth(s)-tail, "") +} + +func columnWidths(rows [][]string) []int { + var width []int + for _, row := range rows { + for i, cell := range row { + if i == len(width) { + width = append(width, 0) + } + width[i] = max(width[i], ansi.StringWidth(cell)) + } + } + return width +} + // JSON writes v as two-space-indented JSON with a final newline. // HTML escaping is off: this output goes to terminals and scripts, // never into web pages, and `` in a fix command must stay