Skip to content
Merged
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/21.enh.md
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 4 additions & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
54 changes: 54 additions & 0 deletions internal/cli/testdata/script/uninstall.txtar
Original file line number Diff line number Diff line change
@@ -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)"
215 changes: 215 additions & 0 deletions internal/cli/uninstall.go
Original file line number Diff line number Diff line change
@@ -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 <name>` removes each one.",
"Pool slots and leftovers are plain worktrees: `git worktree remove <path>`.",
}}
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: <text>" 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, "'", `'\''`) + "'"
}
58 changes: 58 additions & 0 deletions internal/cli/uninstall_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading