Files
max-sixty__worktrunk/templates/powershell.ps1
Worktrunk Bot 96c6c846f7 fix(shell): register completions under the --cmd name, not clap's (#3817)
## Problem

`wt config shell init <shell> --cmd <name>` renames the shell wrapper
and its lazy completion loader, but the registration that loader evals
comes from clap, which derives every identifier in it from its own
compile-time `Command` name (`wt`) — not from `argv[0]` and not from
`--cmd`. The two halves never agreed:

```console
$ wt config shell init zsh --cmd wot | grep _clap
        if ! (( $+functions[_clap_dynamic_completer_wot] )); then
        _clap_dynamic_completer_wot "$@"

$ COMPLETE=zsh wt | grep -oE '_clap_dynamic_completer_[a-z_]*' | sort -u
_clap_dynamic_completer_wt
```

Nothing completed, and because the guard never became true the
completion script was regenerated and re-evaluated on *every* TAB. Same
shape in bash (`_clap_complete_*`); PowerShell emitted
`Register-ArgumentCompleter -Native -CommandName wt`, so the `--cmd`
name was never registered at all. The documented `--cmd=git-wt` case
(the Windows Terminal conflict) was broken too — including for a binary
genuinely installed under that name, since clap's name comes from the
declaration rather than `argv[0]`.

There is a second, sharper edge: zsh's registration ends with `compdef
<completer> <cmd>`, so the first TAB on `wot` also bound worktrunk's
completer to plain `wt` — handing completions to the *other* `wt` that
`--cmd` exists to step around.

fish and nushell were unaffected. Both register a completer that shells
out to the binary rather than depending on a clap-emitted identifier, so
the reporter's "unverified" row for fish is a pass.

## Solution

The bash, zsh, and PowerShell loaders now pass the name they bind in
`WORKTRUNK_COMPLETE_NAME`, and `registration_name()` in
`src/completion.rs` emits the registration under that name (validated
through the same `validate_shell_command_name` guard `--cmd` uses, since
the value lands verbatim in generated shell code). The fallback is
`binary_name()`, which covers a binary installed as `git-wt` and invoked
directly. The templates apply clap's own `-` → `_` escaping to the
function they call, so `--cmd git-wt` guards on `_clap_complete_git_wt`
rather than the invalid `_clap_complete_git-wt`.

That fixes all four shells and the stray `compdef` in one place, rather
than pinning the templates to clap's internal naming:

```console
$ WORKTRUNK_COMPLETE_NAME=wot COMPLETE=zsh wt | grep -oE '_clap_dynamic_completer_[a-z_]*|compdef .*' | sort -u
_clap_dynamic_completer_wot
compdef _clap_dynamic_completer_wot wot
```

## Testing

Two reproduction tests in `tests/integration_tests/completion.rs`, both
failing before the change:

- `test_init_custom_cmd_defines_clap_completer_in_bash` drives the whole
chain through a real bash — generate the init script with `--cmd`, call
the loader it defines, then assert clap's completer function exists
afterwards. Printed `MISSING` before, `DEFINED` after. Cases for `wot`
and `git-wt`.
- `test_completion_registration_uses_shell_integration_cmd_name` covers
zsh and PowerShell, which CI can't drive: the identifier the init script
references must be the one the registration defines, and the `compdef` /
`-CommandName` target must be the `--cmd` name.

`cargo test --lib --bins` and `cargo test --test integration` are
otherwise green (one unrelated failure locally,
`test_copy_ignored_preserves_file_executable_permissions`, from this
sandbox's `umask 0002`), and `cargo clippy --all-targets --all-features`
/ `cargo fmt --check` are clean.

---
Closes #3816 — automated triage

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-08-13 16:44:02 -07:00

122 lines
5.5 KiB
PowerShell

# worktrunk shell integration for PowerShell
#
# Limitations compared to bash/zsh/fish:
# - Hooks using bash syntax won't work without Git Bash
#
# For full hook compatibility on Windows, install Git for Windows and use bash integration.
# Only initialize if wt is available (in PATH or via WORKTRUNK_BIN)
if ((Get-Command {{ cmd }} -ErrorAction SilentlyContinue) -or $env:WORKTRUNK_BIN) {
# wt wrapper function - uses split temp files for directives
#
# IMPORTANT: This function must remain a "simple function" (no [CmdletBinding()] or
# [Parameter()] attributes). Advanced functions add common parameters like -Debug,
# -Verbose, -ErrorAction, etc. that intercept short flags: e.g., -D is consumed as
# -Debug, -V as -Verbose, instead of being passed through to wt.exe. Using $args
# (automatic variable for simple functions) ensures all arguments reach the binary
# unchanged.
function {{ cmd }} {
# Use WORKTRUNK_BIN if set (for testing dev builds), otherwise find via Get-Command
# Select-Object -First 1 handles case where multiple binaries match (e.g., wt.exe from Windows Terminal)
if ($env:WORKTRUNK_BIN) {
$wtBin = $env:WORKTRUNK_BIN
} else {
$wtBin = (Get-Command {{ cmd }} -CommandType Application | Select-Object -First 1).Source
}
$cdFile = [System.IO.Path]::GetTempFileName()
$execFile = [System.IO.Path]::GetTempFileName()
try {
# Run wt with split directive env vars
# WORKTRUNK_SHELL selects PowerShell escaping for the EXEC directive payload
$env:WORKTRUNK_DIRECTIVE_CD_FILE = $cdFile
$env:WORKTRUNK_DIRECTIVE_EXEC_FILE = $execFile
$env:WORKTRUNK_SHELL = "powershell"
& $wtBin @args
$exitCode = $LASTEXITCODE
}
finally {
Remove-Item Env:\WORKTRUNK_DIRECTIVE_CD_FILE -ErrorAction SilentlyContinue
Remove-Item Env:\WORKTRUNK_DIRECTIVE_EXEC_FILE -ErrorAction SilentlyContinue
Remove-Item Env:\WORKTRUNK_SHELL -ErrorAction SilentlyContinue
}
# Process directive files and clean up in a single try/finally so both
# temp files are removed even if cd or exec throws.
try {
# cd file holds a raw path (no shell escaping needed)
if ((Test-Path $cdFile) -and (Get-Item $cdFile).Length -gt 0) {
$target = (Get-Content -Path $cdFile -Raw).Trim()
if ($target) {
Set-Location -LiteralPath $target
if ($exitCode -eq 0) {
$exitCode = $LASTEXITCODE
}
}
}
# exec file holds arbitrary shell (e.g. from --execute)
if ((Test-Path $execFile) -and (Get-Item $execFile).Length -gt 0) {
$script = Get-Content -Path $execFile -Raw
if ($script.Trim()) {
Invoke-Expression $script
if ($exitCode -eq 0) {
$exitCode = $LASTEXITCODE
}
}
}
}
finally {
Remove-Item $cdFile -ErrorAction SilentlyContinue
Remove-Item $execFile -ErrorAction SilentlyContinue
}
# Propagate the exit code so scripts/CI can read $LASTEXITCODE.
# Do NOT `return $exitCode`: in a PowerShell function `return <value>`
# writes the value to the output (success) stream, so it would append a
# stray exit-code line (e.g. `0`) after the real command output — that
# corrupts captures like `$out = wt list --format json`. Setting
# $global:LASTEXITCODE is what propagates the failure; the Write-Error
# below surfaces a visible error record. Note: because this is a simple
# function, Write-Error does NOT set the caller's $?, so don't rely on
# $? reflecting a wt failure.
$global:LASTEXITCODE = $exitCode
if ($exitCode -ne 0) {
# Surface a visible error record without throwing (does not set the
# caller's $? from a simple function; $LASTEXITCODE carries the code)
Write-Error "wt exited with code $exitCode" -ErrorAction SilentlyContinue
}
}
# Tab completion - generate clap's completer script and eval it
# This registers Register-ArgumentCompleter with proper handling
# Use WORKTRUNK_BIN if set (for testing), otherwise find via Get-Command
if ($env:WORKTRUNK_BIN) {
$wtBinForComplete = $env:WORKTRUNK_BIN
} else {
$wtBinForComplete = (Get-Command {{ cmd }} -CommandType Application | Select-Object -First 1).Source
}
$env:COMPLETE = "powershell"
# Register under the name this integration binds. clap names its
# Register-ArgumentCompleter target after its own command name, so without
# this the --cmd name is never registered and nothing completes (#3816).
$env:WORKTRUNK_COMPLETE_NAME = "{{ cmd }}"
try {
# Capture output first, then pipe - avoids "Cannot run a document in the middle of a pipeline"
# error that can occur in some PowerShell configurations/terminals
$completionScript = & $wtBinForComplete 2>$null
if ($completionScript) {
$completionScript | Out-String | Invoke-Expression
}
}
catch {
# Completion registration is optional - wrapper function still works without it
}
finally {
Remove-Item Env:\COMPLETE -ErrorAction SilentlyContinue
Remove-Item Env:\WORKTRUNK_COMPLETE_NAME -ErrorAction SilentlyContinue
}
}