Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changes/23.enh.md
Original file line number Diff line number Diff line change
@@ -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)
11 changes: 6 additions & 5 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,15 +357,14 @@ 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 <branch> [--base <ref>]` | 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. |
| `wt clean [-n]` | Reap merged/stale trees and dead leases, `git worktree prune`, drop orphaned state entries. |
| `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 <n>` | 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. |
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch> [--base <ref>]` | 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. |
Expand All @@ -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 <branch>` | 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 <n>` | 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`. |
Expand Down
22 changes: 17 additions & 5 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <q>` | 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`). |
Expand All @@ -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. |
Expand Down Expand Up @@ -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"
}
]
```
Expand All @@ -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:

Expand All @@ -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…" }
]
}
}
Expand Down
13 changes: 13 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <slot>` is the documented escape hatch
for a lease record that cannot be read.
4 changes: 2 additions & 2 deletions docs/pool-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/claim.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
88 changes: 80 additions & 8 deletions internal/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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 <absolute path>`, " +
"or keep the hooks directory tracked"
}
Expand Down Expand Up @@ -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
}
2 changes: 1 addition & 1 deletion internal/cli/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
Loading
Loading