diff --git a/.changes/22.enh.md b/.changes/22.enh.md new file mode 100644 index 0000000..ac7ae60 --- /dev/null +++ b/.changes/22.enh.md @@ -0,0 +1 @@ +Every error now ends with how to fix it: an already checked-out branch points at `wt go`, config errors name both files the bad value could live in, unknown config keys and failing setup/refresh hooks say what to change, and a failed `wt sync` fast-forward lists the local commits in the way. (#22) diff --git a/PLAN.md b/PLAN.md index 9f128a9..fd354f1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -783,7 +783,7 @@ stays short): (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 +- [x] 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: the cask publishes to the tap; diff --git a/internal/cli/claim.go b/internal/cli/claim.go index a3f3736..a3c7190 100644 --- a/internal/cli/claim.go +++ b/internal/cli/claim.go @@ -47,7 +47,8 @@ func runClaim(cmd *cobra.Command, branch, baseFlag string, noFetch bool) error { return err } if t, ok := treeHoldingBranch(trees, branch); ok { - return preconditionf("branch %q is already checked out in %s", branch, t.Path) + return preconditionf( + "branch %q is already checked out in %s — `wt go %s` jumps there", branch, t.Path, branch) } if err := checkBase(ctx, p.g, base); err != nil { return err diff --git a/internal/cli/config.go b/internal/cli/config.go index 0088095..c196cac 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -88,7 +88,7 @@ func runConfigEdit(cmd *cobra.Command) error { edit.Stdout = os.Stdout edit.Stderr = os.Stderr if err := edit.Run(); err != nil { - return fmt.Errorf("editor %q: %w", editor, err) + return fmt.Errorf("editor %q: %w — set $VISUAL or $EDITOR to a working editor", editor, err) } // Validate right away: a typo surfaces now, at the terminal diff --git a/internal/cli/copies.go b/internal/cli/copies.go index 59a1003..ce34cc2 100644 --- a/internal/cli/copies.go +++ b/internal/cli/copies.go @@ -47,7 +47,7 @@ func copyFiles( continue } if err != nil { - return fmt.Errorf("copy %s: %w", name, err) + return copyError(name, err) } fmt.Fprintf(chatter, "copy: %s\n", name) } @@ -147,7 +147,11 @@ func readCopy(root, name string) (data []byte, ok bool, err error) { return nil, false, nil } if err != nil { - return nil, false, fmt.Errorf("copy %s: %w", name, err) + return nil, false, copyError(name, err) } return data, true, nil } + +func copyError(name string, err error) error { + return fmt.Errorf("copy %s: %w — fix the file, or drop it from copy in wt.toml", name, err) +} diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 1b9ed9f..7796e3b 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -109,7 +109,8 @@ func runDoctor(cmd *cobra.Command, info BuildInfo, jsonOut, offline bool) error // 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", view.Issues, plural(view.Issues, "issue")) + return preconditionf("%d %s found — each fix is listed above", + view.Issues, plural(view.Issues, "issue")) } return nil } diff --git a/internal/cli/init.go b/internal/cli/init.go index d63acee..da8d574 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -167,8 +167,7 @@ func provisionInitialPool(ctx context.Context, r *repo.Repo, chatter io.Writer) // exists: a resize to the size already configured returns // early, while every claim provisions what it finds missing. return fmt.Errorf( - "%w — the config is saved; fix the cause (`wt config --edit` for hooks), "+ - "and the missing slots provision on the next claim", err) + "%w; the config is saved, and the missing slots provision on the next claim", err) } return nil } diff --git a/internal/cli/new.go b/internal/cli/new.go index 5976c69..e1c728b 100644 --- a/internal/cli/new.go +++ b/internal/cli/new.go @@ -52,7 +52,8 @@ func runNew(cmd *cobra.Command, branch, baseFlag string, noFetch bool) error { // R4: when the branch lives in some tree already, // the error must point straight at it. if t, ok := treeHoldingBranch(trees, branch); ok { - return preconditionf("branch %q is already checked out in %s", branch, t.Path) + return preconditionf("branch %q is already checked out in %s — `wt go %s` jumps there", + branch, t.Path, branch) } // In pool mode the natural resume for an existing branch, // say one left by a claim that failed after its branch diff --git a/internal/cli/root.go b/internal/cli/root.go index 711c699..f1f896d 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -128,7 +128,7 @@ func runRoot(cmd *cobra.Command, _ []string) error { err := runJump(cmd, "") var notRepo *repo.NotARepoError if errors.As(err, ¬Repo) { - return fmt.Errorf("%w — `wt --help` shows usage", err) + return fmt.Errorf("%w; `wt --help` shows usage", err) } return err } diff --git a/internal/cli/shellinit.go b/internal/cli/shellinit.go index 91b23e2..b1e9eec 100644 --- a/internal/cli/shellinit.go +++ b/internal/cli/shellinit.go @@ -27,7 +27,7 @@ func newShellInitCmd() *cobra.Command { Short: "Emit the shell integration for eval in ~/.zshrc", Args: cobra.MatchAll(cobra.ExactArgs(1), func(_ *cobra.Command, args []string) error { if args[0] != "zsh" { - return fmt.Errorf("unsupported shell %q — v1 supports zsh only", args[0]) + return fmt.Errorf("unsupported shell %q — wt supports zsh only: `wt shell-init zsh`", args[0]) } return nil }), diff --git a/internal/cli/slots.go b/internal/cli/slots.go index 6c2cc8e..339bf1f 100644 --- a/internal/cli/slots.go +++ b/internal/cli/slots.go @@ -448,7 +448,8 @@ func (p *poolRepo) pinForRelease( // lease reaches Repin as "expect nothing" without further ado. held, err := lease.Get(leases, slot) if err == nil && held == nil && claimRequired { - return nil, preconditionf("%s is not claimed — nothing to release", slot) + return nil, preconditionf( + "%s is not claimed — nothing to release; `wt pool ls` shows the claimed slots", slot) } pinned, err := lease.Repin(leases, slot, branch, held) if err != nil { diff --git a/internal/cli/sync.go b/internal/cli/sync.go index ac0542d..9860eca 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -57,7 +57,8 @@ func runSync(cmd *cobra.Command, all bool) error { } if err := g.Fetch(ctx, remote); err != nil { - return fmt.Errorf("fetching %s: %w", remote, err) + return fmt.Errorf("fetching %s: %w — check `git remote -v` and your network, then rerun", + remote, err) } fmt.Fprintf(chatter, "fetched %s\n", remote) // Best-effort: the fetch already succeeded, so a state-write @@ -144,7 +145,8 @@ func fastForwardBase( } if ffErr != nil { return preconditionf( - "%s could not fast-forward to %s (%v) — resolve it by hand", base, up, ffErr) + "%s could not fast-forward to %s (%v) — `git log %s..%s` lists the local commits "+ + "in the way; move them to a branch, then rerun", base, up, ffErr, up, base) } fmt.Fprintf(chatter, "%s fast-forwarded %s to %s\n", base, commits(behind), up) return nil diff --git a/internal/cli/testdata/script/errors-name-fix.txtar b/internal/cli/testdata/script/errors-name-fix.txtar new file mode 100644 index 0000000..3030c85 --- /dev/null +++ b/internal/cli/testdata/script/errors-name-fix.txtar @@ -0,0 +1,47 @@ +# Every error names its fix: the user should never have to guess +# the next command. One case per family of failure. + +[!exec:git] skip 'git not on PATH' + +# outside a repository +exitcode 4 wt ls +stderr 'not inside a git repository — cd into one first' + +# unsupported shell +exitcode 2 wt shell-init bash +stderr 'wt supports zsh only: `wt shell-init zsh`' + +exec git init -q -b main acme +cd acme +exec git commit -q --allow-empty -m 'initial' +exec wt init --yes + +# a branch already checked out points at the tree that holds it +exec wt new feature/login +exitcode 3 wt new feature/login +stderr 'already checked out in .*feature-login — `wt go feature/login` jumps there' + +# an invalid config value names both files it could live in +cp $WORK/bad-staleness.toml .git/wt.toml +exitcode 1 wt config +stderr 'staleness_hours must not be negative, got -1 — fix it in .*\.git[/\\]wt\.toml or ' + +# an unknown key says what to do about it +cp $WORK/unknown-key.toml .git/wt.toml +exitcode 1 wt config +stderr 'unknown key "colour" .* — remove it or fix its spelling' + +# a failing setup hook names the key to fix +cp $WORK/bad-hook.toml .git/wt.toml +! exec wt new feature/pay +stderr 'setup hook failed: .* — fix hooks.setup with `wt config --edit`' + +-- bad-staleness.toml -- +staleness_hours = -1 + +-- unknown-key.toml -- +colour = "auto" + +-- bad-hook.toml -- +[hooks] +setup = "exit 7" diff --git a/internal/cli/tree.go b/internal/cli/tree.go index a777394..e8518ed 100644 --- a/internal/cli/tree.go +++ b/internal/cli/tree.go @@ -22,7 +22,8 @@ func resolveTree(ctx context.Context, trees []gitx.Worktree, name string) (gitx. if t, ok := findTree(trees, top); ok { return t, nil } - return gitx.Worktree{}, fmt.Errorf("git does not list the current tree %s", top) + return gitx.Worktree{}, fmt.Errorf( + "git does not list the current tree %s — `git worktree repair` re-registers it", top) } // Every tree is a candidate here, bare entries included: // exact-name commands may need to name states a jump never diff --git a/internal/cli/warm.go b/internal/cli/warm.go index 32cfcab..a53d2b7 100644 --- a/internal/cli/warm.go +++ b/internal/cli/warm.go @@ -38,7 +38,7 @@ func finishFresh( } fmt.Fprintf(chatter, "running setup hook: %s\n", setup) if err := runHook(ctx, dest, setup, chatter); err != nil { - return fmt.Errorf("setup hook failed: %w", err) + return fmt.Errorf("setup hook failed: %w — fix hooks.setup with `wt config --edit`", err) } if files := cfg.Hooks.RefreshIfChanged; len(files) > 0 { hash, err := pool.Hash(dest, files) @@ -76,7 +76,7 @@ func refreshTree( } fmt.Fprintf(chatter, "running refresh hook: %s\n", refresh) if err := runHook(ctx, dest, refresh, chatter); err != nil { - return fmt.Errorf("refresh hook failed: %w", err) + return fmt.Errorf("refresh hook failed: %w — fix hooks.refresh with `wt config --edit`", err) } if len(files) > 0 { return st.WriteRefreshHash(name, current) diff --git a/internal/config/config.go b/internal/config/config.go index 4ca2dc8..4451277 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -119,7 +119,7 @@ func Load(globalPath, repoPath string) (Config, error) { normalize(&cfg) if err := validate(cfg); err != nil { - return Config{}, err + return Config{}, fmt.Errorf("%w — fix it in %s or %s", err, repoPath, globalPath) } return cfg, nil } diff --git a/internal/config/load.go b/internal/config/load.go index 50eff56..030adb7 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -57,7 +57,8 @@ func positionedError(path string, err error) error { // beats a wall of positions. e := strict.Errors[0] row, col := e.Position() - return fmt.Errorf("%s:%d:%d: unknown key %q (not part of wt's config)", + return fmt.Errorf( + "%s:%d:%d: unknown key %q (not part of wt's config) — remove it or fix its spelling", path, row, col, strings.Join(e.Key(), ".")) } var decode *toml.DecodeError diff --git a/internal/repo/repo.go b/internal/repo/repo.go index 237864a..fc41365 100644 --- a/internal/repo/repo.go +++ b/internal/repo/repo.go @@ -43,7 +43,8 @@ func Find(ctx context.Context, dir string) (*Repo, error) { common, bare := lines[0], lines[1] == "true" if bare { return nil, fmt.Errorf( - "%s is a bare repository; wt needs a main checkout to anchor config and trees", common) + "%s is a bare repository; wt needs a main checkout to anchor config and trees — "+ + "run wt from a clone with a working tree", common) } if filepath.Base(common) == ".git" { return &Repo{CommonDir: common, Root: filepath.Dir(common)}, nil @@ -143,7 +144,7 @@ type NotARepoError struct { } func (e *NotARepoError) Error() string { - return fmt.Sprintf("%s is not inside a git repository", e.Dir) + return fmt.Sprintf("%s is not inside a git repository — cd into one first", e.Dir) } // WtExitCode maps to D13's "not a wt repo" contract code;