diff --git a/CHANGELOG.md b/CHANGELOG.md index e0b9a9df6e..ca6f390c3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,47 +1,89 @@ # Changelog -## Unreleased +## 0.78.0 ### Improved -- **`wt config show` gives a single `wt config shell install` hint**: an outdated wrapper, a fish wrapper at the deprecated `conf.d` path, and missing fish completions each printed their own hint. The zsh `compinit` snippet is now syntax-highlighted. ([#4059](https://github.com/max-sixty/worktrunk/pull/4059)) +- **Hook scripts read renamed context keys**: the JSON piped to every hook drops `worktree`, `repo_root`, `main_worktree`, and `main_worktree_path` in favour of `worktree_path`, `repo_path`, `repo`, and `primary_worktree_path`. Config templates migrate on load, so hooks written with the old names keep working; scripts reading the JSON, `--execute` templates, and `--var` overrides need updating. (Breaking.) ([#4080](https://github.com/max-sixty/worktrunk/pull/4080)) + +- **`wt config plugins pi` installs for Pi; oh-my-pi moves to `wt config plugins omp`**: `pi install` wrote an oh-my-pi hook, which Pi never loads; Pi users re-run it to get a Pi extension. Existing oh-my-pi hooks keep working, listed as outdated until `wt config plugins omp install`. (Breaking: scripts that run `wt config plugins pi` for oh-my-pi need `omp`.) [Docs](https://worktrunk.dev/claude-code/#oh-my-pi) ([#4135](https://github.com/max-sixty/worktrunk/pull/4135), thanks @ashebanow) - **Interactive prompts no longer open with a blank line**: `wt config shell install`, `wt config plugins claude install`, and the commit-message setup offer in `wt merge` began with one. ([#4059](https://github.com/max-sixty/worktrunk/pull/4059)) +- **`wt config show` gives a single `wt config shell install` hint**: an outdated wrapper, a fish wrapper at the deprecated `conf.d` path, and missing fish completions each printed their own hint. The zsh `compinit` snippet is now syntax-highlighted. ([#4059](https://github.com/max-sixty/worktrunk/pull/4059)) + - **`wt config update --output ` confirms the write**: it now prints `✓ Wrote user config migration @ ~/migrated.toml` where it printed nothing. `--output=-` stays silent. ([#4053](https://github.com/max-sixty/worktrunk/pull/4053)) ### Fixed -- **`wt merge` skips `post-commit` when it removes that hook's worktree**: before, the hook ran in the primary worktree when the removed one sat inside the repository, and otherwise didn't run. The merge now prints `▲ Skipped post-commit`; move work that must finish there to `pre-remove`. ([#4049](https://github.com/max-sixty/worktrunk/pull/4049)) +- **Removal no longer discards untracked files hidden by `status.showUntrackedFiles = no`**: under that setting, a worktree holding only untracked files looked clean, so `wt remove`, `wt merge`, and `wt step prune` removed it without `--force`. Safety checks now always count untracked files, and auto-staging warnings list every file they stage. ([#4111](https://github.com/max-sixty/worktrunk/pull/4111), thanks @Duang777) -- **`wt step prune` no longer deletes a branch created minutes ago from an older commit**: `--min-age` aged a branch without a worktree by its commit's date, so a new branch off a day-old commit was pruned on the next run. Age now comes from the branch's oldest reflog entry. ([#4077](https://github.com/max-sixty/worktrunk/pull/4077)) +- **`wt remove` and `wt merge` check the right worktree under an inherited `GIT_DIR`**: run from a `!wt` git alias or a git hook, which pass `GIT_DIR` down, `wt remove` could delete a dirty worktree without `--force`, and `wt merge` could refuse a clean merge. Both now check the worktree they act on. ([#4082](https://github.com/max-sixty/worktrunk/pull/4082), thanks @Duang777) - **`wt merge` no longer deletes a worktree locked with `git worktree lock`**: the merge completes and prints `Worktree preserved (locked)`. Every Worktrunk removal refuses a locked worktree, even with `--force`; run `git worktree unlock ` first. ([#4073](https://github.com/max-sixty/worktrunk/pull/4073), thanks @Duang777) -- **Removal no longer discards untracked files hidden by `status.showUntrackedFiles = no`**: under that setting, a worktree holding only untracked files looked clean, so `wt remove`, `wt merge`, and `wt step prune` removed it without `--force`. Safety checks now always count untracked files, and auto-staging warnings list every file they stage. ([#4111](https://github.com/max-sixty/worktrunk/pull/4111), thanks @Duang777) +- **`wt config update` no longer overwrites a config edited while its prompt is open**: it wrote the pre-prompt snapshot over the newer file and reported success. It now fails with the path and leaves the newer file; re-running previews the current contents. ([#4127](https://github.com/max-sixty/worktrunk/pull/4127), thanks @zach-hammad-vs for reporting) -- **`wt remove` and `wt merge` check the right worktree under an inherited `GIT_DIR`**: run from a `!wt` git alias or a git hook, which pass `GIT_DIR` down, `wt remove` could delete a dirty worktree without `--force`, and `wt merge` could refuse a clean merge. Both now check the worktree they act on. ([#4082](https://github.com/max-sixty/worktrunk/pull/4082), thanks @Duang777) +- **`wt step prune` no longer deletes a branch created minutes ago from an older commit**: `--min-age` aged a branch without a worktree by its commit's date, so a new branch off a day-old commit was pruned on the next run. Age now comes from the branch's oldest reflog entry. ([#4077](https://github.com/max-sixty/worktrunk/pull/4077)) -- **`wt config shell install` no longer tells an already-wrapped shell to restart**: reinstalling from a shell with the wrapper loaded, such as after a version bump, printed `↳ Restart shell to activate shell integration`. ([#4059](https://github.com/max-sixty/worktrunk/pull/4059)) +- **A comment or blank line above an inline config section no longer breaks the config**: a save or migration that rewrote `commit = { … }` as `[commit]` put that line inside the brackets. `wt switch`, `wt merge`, `wt remove`, and several `wt step` commands then failed with `Failed to load config`; `wt list` warned and ran without the config. Saves also dropped unrecognized keys from inline sections. ([#4120](https://github.com/max-sixty/worktrunk/pull/4120), thanks @zach-hammad-vs for reporting) -- **Plugin status and uninstall checks ask the agent CLIs**: `wt config show` could list an installed Claude Code plugin or Gemini CLI extension as not installed, and `wt config plugins claude|codex uninstall` could report success over a marketplace still configured. A repeat Claude uninstall now runs both removals instead of stopping at `Plugin not installed`. ([#4048](https://github.com/max-sixty/worktrunk/pull/4048), [#4054](https://github.com/max-sixty/worktrunk/pull/4054)) +- **Config saves keep the trailing comment on a value they rewrite**: a save such as answering the commit-generation offer dropped the comment after each value it rewrote, including one after an inline section's closing brace. ([#4138](https://github.com/max-sixty/worktrunk/pull/4138)) + +- **Ignored project-config keys warn from every worktree**: an unknown key in `.config/wt.toml`, or one that belongs in user config such as `[merge]`, warned only in the main worktree, so `wt merge` from a linked worktree ignored it silently. ([#4145](https://github.com/max-sixty/worktrunk/pull/4145), thanks @ee-prog for reporting) + +- **`wt merge` skips `post-commit` when it removes that hook's worktree**: before, the hook ran in the primary worktree when the removed one sat inside the repository, and otherwise didn't run. The merge now prints `▲ Skipped post-commit`; move work that must finish there to `pre-remove`. ([#4049](https://github.com/max-sixty/worktrunk/pull/4049)) -- **`wt switch` recovers from a removed worktree in a bare repository**: `wt switch` and the picker showed git's "cannot resolve CWD" error instead, and the removed-directory message lacked `wt switch ^`. ([#4067](https://github.com/max-sixty/worktrunk/pull/4067)) +- **`wt switch` recovers from a removed worktree in a bare repository**: `wt switch` and the picker surfaced git's raw `fatal: Unable to read current working directory` instead, and the removed-directory message lacked `wt switch ^`. ([#4067](https://github.com/max-sixty/worktrunk/pull/4067)) - **`wt config shell install fish` puts the wrapper where fish reads it under a custom `$XDG_CONFIG_HOME`**: the wrapper always went to `~/.config/fish/functions/`, so install reported success while `wt switch` changed no directory. On Windows, the fish completion moves from `%APPDATA%\fish\completions` to the wrapper's directory. ([#4107](https://github.com/max-sixty/worktrunk/pull/4107)) +- **`wt config shell install zsh` honours `$ZDOTDIR` only when it is absolute**: an empty or relative value resolved against the current directory, so install appended to a `.zshrc` under the current directory and reported success while the file zsh reads went untouched. A non-absolute value now falls back to `$HOME`. ([#4085](https://github.com/max-sixty/worktrunk/pull/4085)) + +- **`wt config shell install` for Nushell no longer deletes a `wt.nu` under the current directory**: an empty or relative `$XDG_CONFIG_HOME` resolved against it, so install removed a `nushell/vendor/autoload/wt.nu` beneath it unread, as a stale wrapper. It now falls back to `~/.config`, which also fixes unread `tea` logins that let `wt switch pr:` treat a Gitea host as GitHub. ([#4104](https://github.com/max-sixty/worktrunk/pull/4104)) + +- **`wt switch --create` names the branch a failed worktree add leaves behind**: `git worktree add -b` writes the branch ref before populating the worktree, so a failure in between left the branch with no worktree, and the next attempt reported a name collision. The error now says how to delete or reuse it. ([#4109](https://github.com/max-sixty/worktrunk/pull/4109), thanks @technicalpickles for reporting) + +- **`wt config shell install` no longer tells an already-wrapped shell to restart**: reinstalling from a shell with the wrapper loaded, such as after a version bump, printed `↳ Restart shell to activate shell integration`. ([#4059](https://github.com/max-sixty/worktrunk/pull/4059)) + - **`wt config shell install` migrates a deprecated `conf.d` fish wrapper when `~/.config/fish/functions` is missing**: before, only `wt config shell install fish` migrated it. `wt config show` now reports that wrapper even when fish isn't on `PATH`. ([#4059](https://github.com/max-sixty/worktrunk/pull/4059)) -- **`wt config update` no longer overwrites a config edited while its prompt is open**: it wrote the pre-prompt snapshot over the newer file and reported success. It now fails with the path and leaves the newer file; re-running previews the current contents. ([#4127](https://github.com/max-sixty/worktrunk/pull/4127)) +- **Plugin status and uninstall checks ask the agent CLIs**: `wt config show` could list an installed Claude Code plugin or Gemini CLI extension as not installed, and `wt config plugins claude|codex uninstall` could report success over a marketplace still configured. A repeat Claude uninstall now runs both removals instead of stopping at `Plugin not installed`. ([#4048](https://github.com/max-sixty/worktrunk/pull/4048), [#4054](https://github.com/max-sixty/worktrunk/pull/4054)) + +- **`wt config plugins opencode install` no longer writes the plugin into the current directory**: with `OPENCODE_CONFIG_DIR` set but empty, it wrote `plugins/worktrunk.ts` there, typically inside a repository, reporting success though OpenCode never saw it. An empty value now counts as unset. ([#4084](https://github.com/max-sixty/worktrunk/pull/4084)) + +- **The OpenCode plugin clears its activity marker when OpenCode 1 disposes it**: only explicit session deletion cleared it, so a finished session kept its `💬` and `wt list` showed it as active until `wt config state marker clear` was run by hand. ([#4129](https://github.com/max-sixty/worktrunk/pull/4129), thanks @drewdas for reporting) - **`wt config update` migrates `[ci] platform` into a `[forge]` section that only sets `hostname`**: that config, common with GitHub Enterprise and self-hosted GitLab, kept the deprecated key with no deprecation warning. ([#4061](https://github.com/max-sixty/worktrunk/pull/4061)) -- **A deprecated config section the migration can't rewrite now warns**: `select = "not a table"`, or a `[select]` whose `[switch.picker]` destination is already set, was ignored without a message. It now reports `unknown field select (will be ignored)`. ([#4121](https://github.com/max-sixty/worktrunk/pull/4121)) +- **Deprecated keys in an inline `[projects]` entry now migrate**: `"example.com/org/repo" = { merge = { no-ff = true } }` skipped the migration the section form got, so the per-project setting was ignored. ([#4122](https://github.com/max-sixty/worktrunk/pull/4122), thanks @zach-hammad-vs for reporting) -- **`wt config plugins opencode install` no longer writes the plugin into the current directory**: with `OPENCODE_CONFIG_DIR` set but empty, it wrote `plugins/worktrunk.ts` there, typically inside a repository, reporting success though OpenCode never saw it. An empty value now counts as unset. ([#4084](https://github.com/max-sixty/worktrunk/pull/4084)) +- **A deprecated config section the migration can't rewrite now warns**: `select = "not a table"`, or a `[select]` whose `[switch.picker]` destination is already set, was ignored without a message. It now reports `unknown field select (will be ignored)`. ([#4121](https://github.com/max-sixty/worktrunk/pull/4121), thanks @zach-hammad-vs for reporting) + +- **`wt config update` no longer changes what a template renders**: it could rename `{{ repo_root }}` inside a quoted string, and after `{% set %}` rebound it. A template binding a retired name or its replacement stays unmigrated and unwarned: a hook reading that name fails, and a squash template can silently drop it. ([#4124](https://github.com/max-sixty/worktrunk/pull/4124), thanks @zach-hammad-vs for reporting) + +- **Config migration previews ignore `diff.external` and report a failed diff**: `wt config show` and the `wt config update` prompt showed an external diff program's output in place of the patch, and nothing when the diff failed. A failure now prints `Could not render the proposed diff` with git's error. ([#4126](https://github.com/max-sixty/worktrunk/pull/4126), thanks @zach-hammad-vs for reporting) - **An alias that binds `dry_run` can take `--dry-run `**: that spelling failed with the retired-flag error even when the template referenced `{{ dry_run }}`, while `--dry-run=` worked. A bare `--dry-run` still errors; pass `--dry-run=1`. ([#4058](https://github.com/max-sixty/worktrunk/pull/4058)) +- **`wt ` outside a repository names the alias**: it fell through to clap's `unrecognized subcommand`, which suggested the name just typed. It now reports that aliases only run inside a git repository and quotes git's own error. ([#3982](https://github.com/max-sixty/worktrunk/pull/3982), thanks @yzx9) + +### Documentation + +- **Shell integration has its own page**: [Shell integration](https://worktrunk.dev/shell-integration/) was previously skill-only. The site gains a footer, the `wt config` and `wt step` pages stop repeating the global options under every subcommand, the generated `--help` blocks drop their copy button, and three recipes are corrected — including the OpenCode `-x` form, wrong since 0.76.0. ([#4000](https://github.com/max-sixty/worktrunk/pull/4000)) + +- **`wt hook --help` describes `post-merge` correctly**: it said the hook runs in the destination worktree "with removal", a condition that never existed — it always runs there. ([#4071](https://github.com/max-sixty/worktrunk/pull/4071), thanks @kyle641320 for reporting) + +- **Docs heading anchors are scoped to their subcommand**: `/step/#min-age-guard` is now `/step/#wt-step-prune--min-age-guard`, so headings repeated across subcommands no longer collide. Links to the old anchors land at the top of the page. ([#4079](https://github.com/max-sixty/worktrunk/pull/4079)) + +- **Docs copy buttons sit with their code**: the button sat 40px below a block's top — on line two, or hanging below a one-line block — and a file-name tab sat inset. A block of several commands now gives each its own button, not one copying all, and trailing shell comments stay out of what's copied. ([#4090](https://github.com/max-sixty/worktrunk/pull/4090), [#4091](https://github.com/max-sixty/worktrunk/pull/4091), [#4078](https://github.com/max-sixty/worktrunk/pull/4078)) + +- **Demos re-recorded with the site's palette**: dark-theme demos showed a light Zellij bar and a light Claude Code theme. ([#4069](https://github.com/max-sixty/worktrunk/pull/4069)) + +### Internal + +- **The Nix flake uses `stdenv.hostPlatform.isDarwin`**, which stops the deprecation warning nixpkgs prints on every eval. ([#4132](https://github.com/max-sixty/worktrunk/pull/4132), thanks @ashebanow) + ## 0.77.0 ### Improved diff --git a/Cargo.lock b/Cargo.lock index 38dc3ee6e2..fef0422e52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4657,7 +4657,7 @@ dependencies = [ [[package]] name = "worktrunk" -version = "0.77.0" +version = "0.78.0" dependencies = [ "ansi-str", "ansi-to-tui", diff --git a/Cargo.toml b/Cargo.toml index 076e88e42b..bf7badfe20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,7 +56,7 @@ filterset = "test(/aur_pkgbuild/)" [package] name = "worktrunk" -version = "0.77.0" +version = "0.78.0" edition = "2024" rust-version = "1.97" build = "build.rs" diff --git a/docs/src/content/docs/faq.md b/docs/src/content/docs/faq.md index f45bb972c7..e1f1acf90f 100644 --- a/docs/src/content/docs/faq.md +++ b/docs/src/content/docs/faq.md @@ -214,7 +214,7 @@ A branch checked out in a second worktree is retained regardless, `-D` included. - `wt merge` / `wt step push` — the target branch's checked-out worktree is updated to the merged commits, so a file those commits delete disappears from it, and an ignored file at a path they track is overwritten — the same result a `git merge` run in that worktree would produce. Uncommitted changes at paths the merge doesn't touch stay in place, staged or not; one at a path it does touch refuses the merge upfront, naming the file - `wt remove` — besides the worktree being removed, two cleanup mechanisms run. The removed worktree's own `git fsmonitor--daemon` (git's per-worktree filesystem watcher under `core.fsmonitor=true`, which would leak once its worktree is gone) is sent `git fsmonitor--daemon stop`, then force-terminated (`SIGTERM`, then `SIGKILL`) via the PID resolved from its IPC socket if it didn't exit. A background sweep then deletes `.git/wt/trash/` entries older than 24 hours (directories orphaned when a previous background removal was interrupted) and terminates fsmonitor daemons whose worktree no longer exists (orphans from `git worktree remove`, `rm -rf`, or a crashed `wt`) - `wt config state clear` — removes all worktrunk data from `.git/` (config keys, caches, markers, hints, variables, logs, stale trash) -- `wt config shell install` — when migrating an integration to a new location, removes the file left at the old one: fish `conf.d/wt.fish` (now `functions/wt.fish`) and nushell wrappers stranded under `/vendor/autoload` (now `/vendor/autoload`). The old path is where worktrunk's own wrapper lived and is named after the command being installed, so it's taken back whole without reading it — a `conf.d/wt.fish` left in place would be sourced at startup and shadow the new wrapper anyway. Only that exact filename is touched, and each removal is printed +- `wt config shell install` — when migrating an integration to a new location, removes the file left at the old one: fish `conf.d/wt.fish` (now `functions/wt.fish`) and nushell wrappers stranded under `/vendor/autoload` (now `/vendor/autoload`). The old path is where worktrunk's own wrapper lived and is named after the command being installed, so it's taken back whole without reading it — a `conf.d/wt.fish` left in place would be sourced at startup and shadow the new wrapper anyway. Only that exact filename is touched, and each removal is printed. The same rule covers the path being installed to, so an existing `functions/wt.fish`, `completions/wt.fish`, or `wt.nu` is replaced whole; [Files created](/shell-integration/#files-created) names the file each shell gets - `wt config shell uninstall` — removes integration lines from bash/zsh/PowerShell rc files, and deletes worktrunk's wrapper and completion files (fish `functions/`, `conf.d/`, and `completions/`; nushell `vendor/autoload`). Uninstall takes no command name, so it lists those directories and recognizes files by worktrunk's own content markers, whatever binary name they were installed under; files without the markers are left alone. An rc file belongs to the user, so a line qualifies only where it runs the init command: one that merely mentions it, inside a comment, an `echo`, or an alias body, stays. Every line uninstall does take is printed, before removal and again after - `wt config plugins opencode uninstall` / `wt config plugins pi uninstall` / `wt config plugins omp uninstall` — deletes that agent's `worktrunk.ts` plugin file. Only worktrunk's own file is touched; the rest of the agent's plugin directory is left alone diff --git a/plugins/worktrunk/skills/worktrunk/reference/faq.md b/plugins/worktrunk/skills/worktrunk/reference/faq.md index cd54b6fc87..cf50adfa26 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/faq.md +++ b/plugins/worktrunk/skills/worktrunk/reference/faq.md @@ -210,7 +210,7 @@ A branch checked out in a second worktree is retained regardless, `-D` included. - `wt merge` / `wt step push` — the target branch's checked-out worktree is updated to the merged commits, so a file those commits delete disappears from it, and an ignored file at a path they track is overwritten — the same result a `git merge` run in that worktree would produce. Uncommitted changes at paths the merge doesn't touch stay in place, staged or not; one at a path it does touch refuses the merge upfront, naming the file - `wt remove` — besides the worktree being removed, two cleanup mechanisms run. The removed worktree's own `git fsmonitor--daemon` (git's per-worktree filesystem watcher under `core.fsmonitor=true`, which would leak once its worktree is gone) is sent `git fsmonitor--daemon stop`, then force-terminated (`SIGTERM`, then `SIGKILL`) via the PID resolved from its IPC socket if it didn't exit. A background sweep then deletes `.git/wt/trash/` entries older than 24 hours (directories orphaned when a previous background removal was interrupted) and terminates fsmonitor daemons whose worktree no longer exists (orphans from `git worktree remove`, `rm -rf`, or a crashed `wt`) - `wt config state clear` — removes all worktrunk data from `.git/` (config keys, caches, markers, hints, variables, logs, stale trash) -- `wt config shell install` — when migrating an integration to a new location, removes the file left at the old one: fish `conf.d/wt.fish` (now `functions/wt.fish`) and nushell wrappers stranded under `/vendor/autoload` (now `/vendor/autoload`). The old path is where worktrunk's own wrapper lived and is named after the command being installed, so it's taken back whole without reading it — a `conf.d/wt.fish` left in place would be sourced at startup and shadow the new wrapper anyway. Only that exact filename is touched, and each removal is printed +- `wt config shell install` — when migrating an integration to a new location, removes the file left at the old one: fish `conf.d/wt.fish` (now `functions/wt.fish`) and nushell wrappers stranded under `/vendor/autoload` (now `/vendor/autoload`). The old path is where worktrunk's own wrapper lived and is named after the command being installed, so it's taken back whole without reading it — a `conf.d/wt.fish` left in place would be sourced at startup and shadow the new wrapper anyway. Only that exact filename is touched, and each removal is printed. The same rule covers the path being installed to, so an existing `functions/wt.fish`, `completions/wt.fish`, or `wt.nu` is replaced whole; [Files created](https://worktrunk.dev/shell-integration/#files-created) names the file each shell gets - `wt config shell uninstall` — removes integration lines from bash/zsh/PowerShell rc files, and deletes worktrunk's wrapper and completion files (fish `functions/`, `conf.d/`, and `completions/`; nushell `vendor/autoload`). Uninstall takes no command name, so it lists those directories and recognizes files by worktrunk's own content markers, whatever binary name they were installed under; files without the markers are left alone. An rc file belongs to the user, so a line qualifies only where it runs the init command: one that merely mentions it, inside a comment, an `echo`, or an alias body, stays. Every line uninstall does take is printed, before removal and again after - `wt config plugins opencode uninstall` / `wt config plugins pi uninstall` / `wt config plugins omp uninstall` — deletes that agent's `worktrunk.ts` plugin file. Only worktrunk's own file is touched; the rest of the agent's plugin directory is left alone diff --git a/skills/worktrunk/reference/faq.md b/skills/worktrunk/reference/faq.md index cd54b6fc87..cf50adfa26 100644 --- a/skills/worktrunk/reference/faq.md +++ b/skills/worktrunk/reference/faq.md @@ -210,7 +210,7 @@ A branch checked out in a second worktree is retained regardless, `-D` included. - `wt merge` / `wt step push` — the target branch's checked-out worktree is updated to the merged commits, so a file those commits delete disappears from it, and an ignored file at a path they track is overwritten — the same result a `git merge` run in that worktree would produce. Uncommitted changes at paths the merge doesn't touch stay in place, staged or not; one at a path it does touch refuses the merge upfront, naming the file - `wt remove` — besides the worktree being removed, two cleanup mechanisms run. The removed worktree's own `git fsmonitor--daemon` (git's per-worktree filesystem watcher under `core.fsmonitor=true`, which would leak once its worktree is gone) is sent `git fsmonitor--daemon stop`, then force-terminated (`SIGTERM`, then `SIGKILL`) via the PID resolved from its IPC socket if it didn't exit. A background sweep then deletes `.git/wt/trash/` entries older than 24 hours (directories orphaned when a previous background removal was interrupted) and terminates fsmonitor daemons whose worktree no longer exists (orphans from `git worktree remove`, `rm -rf`, or a crashed `wt`) - `wt config state clear` — removes all worktrunk data from `.git/` (config keys, caches, markers, hints, variables, logs, stale trash) -- `wt config shell install` — when migrating an integration to a new location, removes the file left at the old one: fish `conf.d/wt.fish` (now `functions/wt.fish`) and nushell wrappers stranded under `/vendor/autoload` (now `/vendor/autoload`). The old path is where worktrunk's own wrapper lived and is named after the command being installed, so it's taken back whole without reading it — a `conf.d/wt.fish` left in place would be sourced at startup and shadow the new wrapper anyway. Only that exact filename is touched, and each removal is printed +- `wt config shell install` — when migrating an integration to a new location, removes the file left at the old one: fish `conf.d/wt.fish` (now `functions/wt.fish`) and nushell wrappers stranded under `/vendor/autoload` (now `/vendor/autoload`). The old path is where worktrunk's own wrapper lived and is named after the command being installed, so it's taken back whole without reading it — a `conf.d/wt.fish` left in place would be sourced at startup and shadow the new wrapper anyway. Only that exact filename is touched, and each removal is printed. The same rule covers the path being installed to, so an existing `functions/wt.fish`, `completions/wt.fish`, or `wt.nu` is replaced whole; [Files created](https://worktrunk.dev/shell-integration/#files-created) names the file each shell gets - `wt config shell uninstall` — removes integration lines from bash/zsh/PowerShell rc files, and deletes worktrunk's wrapper and completion files (fish `functions/`, `conf.d/`, and `completions/`; nushell `vendor/autoload`). Uninstall takes no command name, so it lists those directories and recognizes files by worktrunk's own content markers, whatever binary name they were installed under; files without the markers are left alone. An rc file belongs to the user, so a line qualifies only where it runs the init command: one that merely mentions it, inside a comment, an `echo`, or an alias body, stays. Every line uninstall does take is printed, before removal and again after - `wt config plugins opencode uninstall` / `wt config plugins pi uninstall` / `wt config plugins omp uninstall` — deletes that agent's `worktrunk.ts` plugin file. Only worktrunk's own file is touched; the rest of the agent's plugin directory is left alone diff --git a/src/cli/config.rs b/src/cli/config.rs index 0f98bc7c6e..6a0d84b30b 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -186,8 +186,10 @@ $ wt config plugins opencode install --yes ## Plugin location The plugin is written to `~/.config/opencode/plugins/worktrunk.ts`, -mirroring OpenCode's own global-config precedence: -`$OPENCODE_CONFIG_DIR` > `$XDG_CONFIG_HOME/opencode` > `~/.config/opencode`."# +following OpenCode's global-config precedence: +`$OPENCODE_CONFIG_DIR` > `$XDG_CONFIG_HOME/opencode` > `~/.config/opencode`. +An empty `$OPENCODE_CONFIG_DIR` and an empty or relative `$XDG_CONFIG_HOME` +are ignored."# )] Install, @@ -465,9 +467,10 @@ $ wt config plugins opencode uninstall ## Plugin location -Written to `~/.config/opencode/plugins/worktrunk.ts`. Honors OpenCode's +Written to `~/.config/opencode/plugins/worktrunk.ts`. Follows OpenCode's config precedence: `$OPENCODE_CONFIG_DIR` > `$XDG_CONFIG_HOME/opencode` > -`~/.config/opencode`."# +`~/.config/opencode`. An empty `$OPENCODE_CONFIG_DIR` and an empty or relative +`$XDG_CONFIG_HOME` are ignored."# )] Opencode { #[command(subcommand)] diff --git a/src/commands/step/prune.rs b/src/commands/step/prune.rs index f688129e04..751a6e8e3e 100644 --- a/src/commands/step/prune.rs +++ b/src/commands/step/prune.rs @@ -698,10 +698,13 @@ fn worktree_age( /// forbids `@{` inside a ref name, so the last `@{` opens the timestamp. /// /// Returns `None` if the reflog is missing or unparsable — callers treat -/// "unknown age" as "old enough". A bare repository defaults -/// `core.logAllRefUpdates` to false, so a branch created or updated only from -/// the bare directory itself has no reflog and no min-age guard; one touched -/// from inside a linked worktree has a reflog. +/// "unknown age" as "old enough". `core.logAllRefUpdates` defaults to whether +/// the repository is bare as seen from where the command runs, so a ref +/// written from a bare directory has no reflog and no min-age guard: +/// `git clone --bare` writes none for the branches it brings down, and +/// neither does a later `git fetch` into `refs/heads/*`. A linked worktree is +/// not bare, so a branch created or updated from one — every branch `wt` +/// itself creates — is aged normally. fn orphan_branch_age(repo: &Repository, branch: &str, now_secs: u64) -> Option { let ref_name = format!("refs/heads/{branch}"); let stdout = repo diff --git a/src/config/mod.rs b/src/config/mod.rs index cfdbc09257..08788e7bf4 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -155,8 +155,8 @@ pub fn is_user_project_override_key(key: &str) -> bool { .any(|k| k == key) } -/// Replace a key's inline-table value with a standard table, carrying the key's -/// leading decor onto the table header. +/// Replace a key's inline-table value with a standard table, carrying the line's +/// comments onto the table header. /// /// The key was parsed from `merge = { … }`, so its leaf decor holds whatever /// preceded the line — comments, blank lines — plus the space before `=`. A @@ -165,12 +165,16 @@ pub fn is_user_project_override_key(key: &str) -> bool { /// and the user's own comment is what breaks it. Move the prefix to the header /// and drop the rest. /// +/// A trailing comment after the closing brace sits in the inline value's own +/// decor, which `InlineTable::into_table` discards, so it is read from +/// `existing` before the replacement and lands after the header's `]`. It is +/// carried only when it holds a comment; bare whitespace there would just trail +/// the header. +/// /// Both places that rewrite a table the user wrote inline go through here — the /// save-path merge in `user::persistence`, and `ensure_standard_table_parent` /// in `deprecation`, which has no choice but to convert because TOML forbids -/// extending an inline table with a later subtable. The value's own decor (a -/// trailing comment after the closing brace) is still dropped by -/// `InlineTable::into_table`. +/// extending an inline table with a later subtable. pub(crate) fn replace_inline_with_table( existing: &mut toml_edit::Table, key: &str, @@ -184,6 +188,15 @@ pub(crate) fn replace_inline_with_table( if let Some(prefix) = prefix { table.decor_mut().set_prefix(prefix); } + let suffix = existing + .get(key) + .and_then(|item| item.as_inline_table()) + .and_then(|inline| inline.decor().suffix()) + .filter(|suffix| suffix.as_str().is_some_and(|s| s.contains('#'))) + .cloned(); + if let Some(suffix) = suffix { + table.decor_mut().set_suffix(suffix); + } if let Some(mut key_mut) = existing.key_mut(key) { key_mut.leaf_decor_mut().clear(); } diff --git a/src/config/user/persistence.rs b/src/config/user/persistence.rs index 3239a3cec7..2aba08823a 100644 --- a/src/config/user/persistence.rs +++ b/src/config/user/persistence.rs @@ -102,7 +102,7 @@ impl UserConfig { } Some(existing_item) => { if !Self::items_equal(existing_item, desired_item) { - *existing_item = desired_item.clone(); + Self::replace_keeping_decor(existing_item, desired_item); } } None => { @@ -112,6 +112,28 @@ impl UserConfig { } } + /// Overwrite an item, keeping the value's own decor — the spacing after `=` + /// and the trailing comment after the value. + /// + /// Comments and blank lines *above* the line sit on the key's leaf decor, + /// which a value replacement never touches. The trailing comment sits on the + /// value, so replacing the item wholesale drops it: whenever a save changes + /// a value, and — since template-variable migration became `Structural` — + /// on a line the command never touched, because every load rewrites retired + /// names and the next unrelated mutation (declining the commit-generation + /// offer, say) finds that line changed. An inline table turning into a + /// standard one takes the other path, `replace_inline_with_table`, which + /// moves decor onto the header. + fn replace_keeping_decor(existing_item: &mut toml_edit::Item, desired_item: &toml_edit::Item) { + let decor = existing_item.as_value().map(|v| v.decor().clone()); + *existing_item = desired_item.clone(); + if let Some(decor) = decor + && let Some(value) = existing_item.as_value_mut() + { + *value.decor_mut() = decor; + } + } + /// Compare two Items for value equality, ignoring formatting and comments. fn items_equal(a: &toml_edit::Item, b: &toml_edit::Item) -> bool { match (a, b) { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 0732a842ae..f7a7ce6314 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1339,7 +1339,17 @@ pub fn add_pty_filters(settings: &mut insta::Settings) { // stdout (`exec 2>&1`) so that leaked job-control *notifications* are // visible to tests — those stay visible, since `assert_no_job_control_messages` // matches the `[1] 12345` / `[1]+ Done` shape this filter does not touch. - settings.add_filter(r"(?m)^\w+: child setpgid \(\d+ to \d+\): [^\n]*\n?", ""); + // + // The shell writes the line whenever the race resolves, so it need not + // start a line: it has also landed straight after the capture's trailing + // `\x1b[0m`, with no newline between (macOS CI, actions/runs/35132033122). + // The pattern therefore starts at a line start or right after an SGR + // escape, and keeps the escape. A bare unanchored `\w+` would instead eat + // the tail of whatever word preceded the shell's name. + settings.add_filter( + r"(?m)(^|\x1b\[[0-9;]*m)\w+: child setpgid \(\d+ to \d+\): [^\n]*\n?", + "$1", + ); } /// Add filters for binary paths (target/debug/wt) in PTY output. @@ -1368,13 +1378,15 @@ pub fn add_pty_binary_path_filters(settings: &mut insta::Settings) { // ============================================================================= /// PTY capture carrying a shell's failed-`setpgid` diagnostic alongside the -/// job-control notifications the wrapper tests assert on. +/// job-control notifications the wrapper tests assert on — once at a line +/// start, and once appended to the capture's trailing SGR reset. #[cfg(test)] -const SETPGID_NOISE_SAMPLE: &str = r#"bash: child setpgid (42242 to 42242): Operation not permitted +const SETPGID_NOISE_SAMPLE: &str = "bash: child setpgid (42242 to 42242): Operation not permitted [1] 42243 Switched to worktree for feature-api [1]+ Done wt hook post-start -"#; +\x1b[0mbash: child setpgid (64019 to 64019): Operation not permitted +"; #[cfg(test)] mod tests { @@ -1389,11 +1401,14 @@ mod tests { fn pty_filters_drop_the_setpgid_diagnostic_but_keep_job_control_notices() { let mut settings = insta::Settings::clone_current(); add_pty_filters(&mut settings); + // Runs after the PTY filters, so it shows the reset they must keep. + settings.add_filter(r"\x1b\[0m", "[RESET]"); settings.bind(|| { assert_snapshot!(SETPGID_NOISE_SAMPLE, @r" [1] 42243 Switched to worktree for feature-api [1]+ Done wt hook post-start + [RESET] "); }); } diff --git a/tests/integration_tests/approval_save.rs b/tests/integration_tests/approval_save.rs index ef9535f3f7..39bb5eb657 100644 --- a/tests/integration_tests/approval_save.rs +++ b/tests/integration_tests/approval_save.rs @@ -243,15 +243,18 @@ fn test_saving_config_mutation_preserves_toml_comments() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); - // Create a config file with comments + // `main_worktree` is retired, so loading migrates this line to `{{ repo }}` + // and the save below rewrites it — the case where decor is easiest to drop. + // The `command` line carries one too, since that is the line the mutation + // itself changes. let initial_content = r#"# User preferences for worktrunk # These comments should be preserved after saving -worktree-path = "../{{ repo }}.{{ branch }}" # inline comment should also be preserved +worktree-path = "../{{ main_worktree }}.{{ branch }}" # inline comment should also be preserved # LLM commit generation settings [commit.generation] -command = "llm -m claude-haiku-4.5" +command = "llm -m claude-haiku-4.5" # comment on the mutated line # Per-project settings below "#; @@ -282,12 +285,22 @@ command = "llm -m claude-haiku-4.5" saved_content.contains("# inline comment should also be preserved"), "Inline comment was lost. Saved content:\n{saved_content}" ); + assert!( + saved_content.contains("# comment on the mutated line"), + "Inline comment on the mutated line was lost. Saved content:\n{saved_content}" + ); // Verify the command was updated assert!( saved_content.contains("llm -m claude-sonnet-4"), "Command was not updated. Saved content:\n{saved_content}" ); + + // The retired name still migrates — keeping the comment must not cost the rewrite + assert!( + saved_content.contains(r#"worktree-path = "../{{ repo }}.{{ branch }}""#), + "Retired variable was not migrated. Saved content:\n{saved_content}" + ); } /// @@ -862,3 +875,43 @@ approved-commands = [ "Should not have standalone [commit] header when only generation is set:\n{saved}" ); } + +/// The same line-decor guarantee for a `[projects]` entry written inline. +/// +/// This entry is an inline table, which `toml_edit` holds as a value, and the +/// save replaces it with a standard table — a different shape from the scalar +/// case above, and the one where the comment is easiest to drop. The retired +/// `main_worktree` inside it is what makes the entry differ from the file at +/// all, since template migration runs on every load. +#[test] +fn test_saving_config_mutation_preserves_an_inline_entry_comment() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + + let initial_content = r#"[projects] +"example.com/org/repo" = { worktree-path = "../{{ main_worktree }}.{{ branch }}" } # entry note + +[commit.generation] +command = "llm -m claude-haiku-4.5" +"#; + fs::write(&config_path, initial_content).unwrap(); + + let toml_str = fs::read_to_string(&config_path).unwrap(); + let mut config: UserConfig = toml::from_str(&toml_str).unwrap(); + config + .set_commit_generation_command("llm -m claude-sonnet-4".to_string(), &config_path) + .unwrap(); + + // The comment lands after the header's `]`, and the rewrite still parses. + let saved_content = fs::read_to_string(&config_path).unwrap(); + toml::from_str::(&saved_content).unwrap(); + assert_snapshot!(saved_content, @r#" + [projects] + + [projects."example.com/org/repo"] # entry note + worktree-path = "../{{ repo }}.{{ branch }}" + + [commit.generation] + command = "llm -m claude-sonnet-4" + "#); +}