diff --git a/.changes/21.enh.md b/.changes/21.enh.md new file mode 100644 index 0000000..b049a49 --- /dev/null +++ b/.changes/21.enh.md @@ -0,0 +1 @@ +`wt uninstall` prints the exact steps that remove wt, with your real paths: the eval lines in your `.zshrc` by line number, `brew uninstall` or `rm` for the binary depending on how it was installed, and the config and state directories that actually exist. It removes nothing itself. (#21) diff --git a/PLAN.md b/PLAN.md index 6057d11..9f128a9 100644 --- a/PLAN.md +++ b/PLAN.md @@ -779,7 +779,10 @@ stays short): (`NO_COLOR` and width-degradation cases); help-text audit — every `--help` fits one screen and links its `docs/` page. -- [ ] `wt uninstall`; end-to-end `brew uninstall` re-verification. +- [x] `wt uninstall`: prints the removal plan with real paths + (Homebrew cask detected through the PATH symlink; + the rc file's actual eval lines quoted by line number). +- [ ] End-to-end `brew uninstall` re-verification (needs the `v0.1.0` cask). - [ ] Failure-message audit: every error names the fix (`error: branch 'x' already checked out in ../acme.trees/x — wt go x?`). - [ ] Cut `v0.1.0` — the first real release: diff --git a/README.md b/README.md index 57e8873..a86ffb1 100644 --- a/README.md +++ b/README.md @@ -63,10 +63,9 @@ Details in [docs/shell.md](docs/shell.md). | `wt path [name]` | Print a tree's absolute path (plumbing). | | `wt config [--edit]` | Show active config paths and merged values; `--edit` opens `$VISUAL`/`$EDITOR`. | | `wt shell-init zsh [--prompt]` | Emit the shim, completions, and optional prompt hook for eval in `.zshrc`. | +| `wt uninstall` | Print the exact removal steps with your real paths; removes nothing itself. | | `wt --version` | Version, commit, build date. | -The remaining surface (`wt uninstall`, …) -lands phase by phase; see [PLAN.md](PLAN.md). Monorepo pool mode: [docs/pool-mode.md](docs/pool-mode.md). Configuration reference: [docs/configuration.md](docs/configuration.md). Scripting and agent contract: [docs/agents.md](docs/agents.md). diff --git a/docs/agents.md b/docs/agents.md index 5fc0831..f31839b 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -52,6 +52,7 @@ then retry", never "wt is broken". | `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. | | `wt doctor` | The check report; `--json` for the machine shape below. Exit 0 healthy, 3 when a `fail`-status check needs fixing; `warn`/`info` are advisory and never exit 3. Works outside a repository (repo checks simply absent). | The claim/release loop for agents diff --git a/docs/shell.md b/docs/shell.md index ece00a6..61d61c7 100644 --- a/docs/shell.md +++ b/docs/shell.md @@ -8,7 +8,7 @@ eval "$(wt shell-init zsh)" ``` Add `--prompt` to also get the [prompt indicator](#prompt-indicator). -`wt` never edits `.zshrc` for you, and `wt uninstall` (Phase 7) +`wt` never edits `.zshrc` for you, and `wt uninstall` tells you the exact line to delete. ## The cd protocol diff --git a/internal/cli/root.go b/internal/cli/root.go index 3ffe000..711c699 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -97,6 +97,7 @@ func newRootCmd(info BuildInfo) *cobra.Command { newPathCmd(), newConfigCmd(), newShellInitCmd(), + newUninstallCmd(), ) // Argument validators are wrapped centrally so bad arguments // exit 2 (D13) on every command, present and future: diff --git a/internal/cli/testdata/script/uninstall.txtar b/internal/cli/testdata/script/uninstall.txtar new file mode 100644 index 0000000..80f30dd --- /dev/null +++ b/internal/cli/testdata/script/uninstall.txtar @@ -0,0 +1,54 @@ +# `wt uninstall` prints the removal plan with real paths and +# deletes nothing. Outside a repo, on a fresh machine, it +# still answers: generic eval line, no data dirs to remove. + +[!exec:git] skip 'git not on PATH' + +env HOME=$WORK/home +env XDG_CONFIG_HOME= +env ZDOTDIR= +mkdir home + +exec wt uninstall +stdout '^# 1\. Finish your trees' +stdout 'keeps a \.git/wt\.toml; delete it there\.$' +stdout '^# 2\. Delete the eval line from .*home[/\\]\.zshrc, if you added it:$' +stdout '^# eval "\$\(wt shell-init zsh\)"$' +stdout '^# 3\. Remove the binary:$' +stdout '^rm ' +! stdout 'rm -rf' +! stderr . + +# inside an initialized repo, with an rc file and wt's data dirs: +# the actual rc lines are quoted, and every path is the real one +exec git init -q -b main acme +cd acme +exec git commit -q --allow-empty -m 'initial' +exec wt init --yes +mkdir $WORK/home/.config/wt $WORK/.state/wt +cp $WORK/zshrc $WORK/home/.zshrc + +exec wt uninstall +stdout 'this one included:$' +stdout '^rm .*acme[/\\]\.git[/\\]wt\.toml$' +stdout '^# 2\. Delete the shell integration from .*home[/\\]\.zshrc:$' +stdout '^# line 3: eval "\$\(wt shell-init zsh --prompt\)"$' +stdout '^# 4\. Delete wt''s global config and state' +stdout '^rm -rf .*home[/\\]\.config[/\\]wt .*\.state[/\\]wt$' +! stderr . + +# ZDOTDIR moves the rc file, and the plan follows it +env ZDOTDIR=$WORK/zdot +exec wt uninstall +stdout 'Delete the eval line from .*zdot[/\\]\.zshrc' + +# nothing was removed +exists .git/wt.toml +exists $WORK/home/.zshrc +exists $WORK/home/.config/wt +exists $WORK/.state/wt + +-- zshrc -- +export EDITOR=vim + +eval "$(wt shell-init zsh --prompt)" diff --git a/internal/cli/uninstall.go b/internal/cli/uninstall.go new file mode 100644 index 0000000..9f5827e --- /dev/null +++ b/internal/cli/uninstall.go @@ -0,0 +1,215 @@ +package cli + +import ( + "bufio" + "cmp" + "context" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/spf13/cobra" + + "github.com/loganthomas/wt/internal/config" + "github.com/loganthomas/wt/internal/repo" +) + +// caskPathMarker identifies a binary the Homebrew cask installed: +// Homebrew must remove it, or its install records go stale. +const caskPathMarker = "/Caskroom/wt/" + +var ( + shellInitLine = regexp.MustCompile(`\bwt shell-init\b`) + shellSafe = regexp.MustCompile(`^[A-Za-z0-9_./+-]+$`) +) + +func newUninstallCmd() *cobra.Command { + return &cobra.Command{ + Use: "uninstall", + Short: "Print the exact steps that remove wt (removes nothing itself)", + Args: cobra.NoArgs, + RunE: runUninstall, + } +} + +// uninstallStep is one numbered step of the removal plan: +// comment lines explaining it, then the commands that carry it out. +type uninstallStep struct { + notes []string + commands []string +} + +// runUninstall prints the removal plan and changes nothing: +// deleting config or editing an rc file stays the user's call, +// made with the real paths in front of them. +// The plan is a shell document, explanation as comments and +// commands bare, so any line on stdout can be copied as-is. +// Trees come first because finishing them safely needs wt itself. +func runUninstall(cmd *cobra.Command, _ []string) error { + steps := []uninstallStep{ + treesStep(cmd.Context()), + rcStep(zshrcPath()), + binaryStep(executablePath()), + } + if s, ok := dataStep(); ok { + steps = append(steps, s) + } + return writeSteps(cmd.OutOrStdout(), steps) +} + +func writeSteps(w io.Writer, steps []uninstallStep) error { + var b strings.Builder + for i, s := range steps { + if i > 0 { + b.WriteString("\n") + } + for j, note := range s.notes { + prefix := "# " + if j == 0 { + prefix = fmt.Sprintf("# %d. ", i+1) + } + b.WriteString(prefix + note + "\n") + } + for _, c := range s.commands { + b.WriteString(c + "\n") + } + } + _, err := io.WriteString(w, b.String()) + return err +} + +// treesStep is best-effort about the current repo: uninstall must +// work from anywhere, so a failed lookup only drops the specifics. +func treesStep(ctx context.Context) uninstallStep { + s := uninstallStep{notes: []string{ + "Finish your trees while wt can still check them for unpushed work:", + "`wt ls` lists them and `wt done ` removes each one.", + "Pool slots and leftovers are plain worktrees: `git worktree remove `.", + }} + r, err := repo.Find(ctx, "") + if err != nil || !exists(r.ConfigPath()) { + s.notes = append(s.notes, "Each repo you ran `wt init` in keeps a .git/wt.toml; delete it there.") + return s + } + s.notes = append(s.notes, + "Each repo you ran `wt init` in keeps a .git/wt.toml, this one included:") + s.commands = []string{"rm " + shellQuote(r.ConfigPath())} + return s +} + +// rcStep quotes the rc file's actual integration lines, so a +// customized eval (--prompt, a guard around it) is found as written. +func rcStep(rc string) uninstallStep { + found := matchingLines(rc, shellInitLine) + if len(found) == 0 { + return uninstallStep{notes: []string{ + fmt.Sprintf("Delete the eval line from %s, if you added it:", rc), + `eval "$(wt shell-init zsh)"`, + }} + } + return uninstallStep{notes: append( + []string{fmt.Sprintf("Delete the shell integration from %s:", rc)}, found...)} +} + +func zshrcPath() string { + home, err := os.UserHomeDir() + if err != nil { + home = "~" + } + return filepath.Join(cmp.Or(os.Getenv("ZDOTDIR"), home), ".zshrc") +} + +// matchingLines returns "line N: " for each match; +// an unreadable file reads as having none. +func matchingLines(path string, pattern *regexp.Regexp) []string { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() //nolint:errcheck // read-only file + var found []string + sc := bufio.NewScanner(f) + for n := 1; sc.Scan(); n++ { + if pattern.MatchString(sc.Text()) { + found = append(found, fmt.Sprintf("line %d: %s", n, strings.TrimSpace(sc.Text()))) + } + } + return found +} + +// executablePath returns "" when the running binary can't be located. +func executablePath() string { + exe, err := os.Executable() + if err != nil { + return "" + } + return exe +} + +func binaryStep(exe string) uninstallStep { + if exe == "" { + return uninstallStep{ + notes: []string{"Remove the binary:"}, + commands: []string{`rm "$(command -v wt)"`}, + } + } + resolved := exe + if r, err := filepath.EvalSymlinks(exe); err == nil { + resolved = r + } + if strings.Contains(resolved, caskPathMarker) { + return uninstallStep{ + notes: []string{ + "Remove the binary through Homebrew, which installed it", + "(skip the untap if you use other formulae from the tap):", + }, + commands: []string{"brew uninstall wt", "brew untap loganthomas/tap"}, + } + } + return uninstallStep{ + notes: []string{"Remove the binary:"}, + commands: []string{"rm " + shellQuote(exe)}, + } +} + +// dataStep lists only directories that exist, so a fresh install +// isn't told to delete paths wt never created. +func dataStep() (uninstallStep, bool) { + var dirs []string + if p, err := config.GlobalPath(); err == nil { + dirs = append(dirs, filepath.Dir(p)) + } + if p, err := repo.StateRoot(); err == nil { + dirs = append(dirs, p) + } + dirs = slices.DeleteFunc(dirs, func(d string) bool { return !exists(d) }) + if len(dirs) == 0 { + return uninstallStep{}, false + } + quoted := make([]string, len(dirs)) + for i, d := range dirs { + quoted[i] = shellQuote(d) + } + return uninstallStep{ + notes: []string{"Delete wt's global config and state (leases, fetch stamps, caches):"}, + commands: []string{"rm -rf " + strings.Join(quoted, " ")}, + }, true +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// shellQuote single-quotes a path unless every character is inert +// to the shell; `~` is excluded since it would expand. +func shellQuote(s string) string { + if shellSafe.MatchString(s) { + return s + } + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} diff --git a/internal/cli/uninstall_test.go b/internal/cli/uninstall_test.go new file mode 100644 index 0000000..0a6b6df --- /dev/null +++ b/internal/cli/uninstall_test.go @@ -0,0 +1,58 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestBinaryStep(t *testing.T) { + // A cask install reaches PATH through a symlink into the + // Caskroom; the step must see through it to hand off to brew. + dir := t.TempDir() + caskBin := filepath.Join(dir, "Caskroom", "wt", "0.1.0", "wt") + if err := os.MkdirAll(filepath.Dir(caskBin), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(caskBin, nil, 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "wt") + if err := os.Symlink(caskBin, link); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + exe string + want []string + }{ + {"cask via symlink", link, []string{"brew uninstall wt", "brew untap loganthomas/tap"}}, + {"cask direct", caskBin, []string{"brew uninstall wt", "brew untap loganthomas/tap"}}, + {"go install", "/Users/me/go/bin/wt", []string{"rm /Users/me/go/bin/wt"}}, + {"unknown location", "", []string{`rm "$(command -v wt)"`}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if diff := cmp.Diff(tt.want, binaryStep(tt.exe).commands); diff != "" { + t.Errorf("commands (-want +got):\n%s", diff) + } + }) + } +} + +func TestShellQuote(t *testing.T) { + tests := []struct{ in, want string }{ + {"/Users/me/.config/wt", "/Users/me/.config/wt"}, + {"/Users/me/Application Support/wt", "'/Users/me/Application Support/wt'"}, + {"/tmp/it's", `'/tmp/it'\''s'`}, + {"~/wt", "'~/wt'"}, + } + for _, tt := range tests { + if got := shellQuote(tt.in); got != tt.want { + t.Errorf("shellQuote(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} diff --git a/internal/repo/repo.go b/internal/repo/repo.go index 6c6e9da..237864a 100644 --- a/internal/repo/repo.go +++ b/internal/repo/repo.go @@ -80,9 +80,18 @@ func (r *Repo) ConfigPath() string { } // StateDir is where wt keeps this repo's leases, hashes, and -// timestamps, honoring $XDG_STATE_HOME with the ~/.local/state -// fallback. The directory is not created here. +// timestamps. The directory is not created here. func (r *Repo) StateDir() (string, error) { + root, err := StateRoot() + if err != nil { + return "", err + } + return filepath.Join(root, "repos", r.Slug()), nil +} + +// StateRoot is the parent of every repo's state directory, +// honoring $XDG_STATE_HOME with the ~/.local/state fallback. +func StateRoot() (string, error) { base := os.Getenv("XDG_STATE_HOME") if base == "" { home, err := os.UserHomeDir() @@ -91,7 +100,7 @@ func (r *Repo) StateDir() (string, error) { } base = filepath.Join(home, ".local", "state") } - return filepath.Join(base, "wt", "repos", r.Slug()), nil + return filepath.Join(base, "wt"), nil } // DefaultTreesDir is the built-in trees container in its relative