From bbfb766d05f931c57d7b13093069c25b6c26855c Mon Sep 17 00:00:00 2001 From: benbenbang Date: Thu, 10 Sep 2026 11:40:25 +0800 Subject: [PATCH 1/3] fix(shell): support jump completion after whitespace Support Tab after `cd -jw` and `--jump-worktree` with spaces or tabs across shells while preserving attached triggers. Preserve literal selected paths and history recording, quote Nushell suggestions, and extend PowerShell replacement ranges. Document the behavior and add POSIX real-terminal regressions isolated from the Git hook environment. Further cross-shell regression tests will follow separately due to commit size limits. Refs #58 --- README.md | 11 +-- src/shell.rs | 42 +++++++++--- tests/external_integration.rs | 124 ++++++++++++++++++++++++++++++++-- 3 files changed, 158 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 2c265ae..aeadf04 100644 --- a/README.md +++ b/README.md @@ -163,18 +163,21 @@ Choose a worktree with Tab completion: ```console cd -jw # complete a worktree path, then press Enter +cd -jw # a space before Tab also works cd --jump-worktree # long form of the same completion trigger ``` `cj -w/--worktree` lists worktrees. The `cd` completion requires the generated shell integration so the parent shell can perform the directory change. In Bash, Zsh, Nushell, and PowerShell, pressing Tab -immediately after `-jw` or `--jump-worktree` offers only this repository's -worktree paths through the shell's native completion system. A completion frontend +after `-jw` or `--jump-worktree`, with or without trailing whitespace, offers only +this repository's worktree paths through the shell's native completion system. A completion frontend such as fzf-tab may render those candidates with fzf, but cj does not launch a nested picker during completion and works without fzf. Choosing a candidate only -inserts its path; press Enter to change directory. Pressing Enter while the jump -token is still present shows a reminder to use Tab and leaves the directory unchanged. +inserts its path; press Enter to change directory. After whitespace, Bash, Zsh, +and Nushell keep the flag and complete its literal destination argument; PowerShell +replaces the flag and whitespace together. Pressing Enter with a jump flag but +no destination shows a reminder to use Tab and leaves the directory unchanged. The standalone `cj -jw` / `cj --jump-worktree` command remains available to print a worktree path selected through fzf. diff --git a/src/shell.rs b/src/shell.rs index 3737a40..74e5462 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -116,6 +116,10 @@ function cd() {{ _cj_nav_arg= ;; -jw|--jump-worktree) + if [[ $# -eq 2 && -n "$2" ]]; then + _cj_builtin_cd -- "$2" + return + fi printf '%s\n' 'cj: type cd -jw and press Tab to select a worktree' >&2 return 2 ;; @@ -182,9 +186,11 @@ function _cj_complete_cd() {{ if [[ -n "${{BASH_VERSION-}}" ]]; then current="${{COMP_WORDS[COMP_CWORD]}}" - if (( COMP_CWORD == 1 )) && [[ "$current" == -jw || "$current" == --jump-worktree ]]; then + if {{ (( COMP_CWORD == 1 )) && [[ "$current" == -jw || "$current" == --jump-worktree ]]; }} || + {{ (( COMP_CWORD == 2 )) && [[ "${{COMP_WORDS[1]}}" == -jw || "${{COMP_WORDS[1]}}" == --jump-worktree ]]; }}; then COMPREPLY=() while IFS= read -r -d '' candidate; do + (( COMP_CWORD == 1 )) || [[ "$candidate" == "$current"* ]] || continue COMPREPLY+=("$candidate") done < <(\command cj{config} --worktree-paths0 2>/dev/null) compopt -o filenames 2>/dev/null || : @@ -205,14 +211,15 @@ function _cj_complete_cd() {{ fi current="${{words[CURRENT]}}" - if (( CURRENT == 2 )) && [[ "$current" == -jw || "$current" == --jump-worktree ]]; then + if {{ (( CURRENT == 2 )) && [[ "$current" == -jw || "$current" == --jump-worktree ]]; }} || + {{ (( CURRENT == 3 )) && [[ "${{words[2]}}" == -jw || "${{words[2]}}" == --jump-worktree ]]; }}; then candidates=() while IFS= read -r -d '' candidate; do candidates+=("$candidate") done < <(\command cj{config} --worktree-paths0 2>/dev/null) (( ${{#candidates[@]}} )) || return 0 # Replace the jump token instead of matching paths against it. - PREFIX='' SUFFIX='' + if (( CURRENT == 2 )); then PREFIX='' SUFFIX=''; fi # Offer complete destinations instead of inserting their common parent. compstate[insert]=menu compadd -f -- "${{candidates[@]}}" @@ -287,13 +294,20 @@ def --env _cj-record-move [before: string, mode: string = 'other', logical: stri def _cj-complete-cd [spans: list] {{ let current = ($spans | last) - if (($spans | length) == 2) and ($current in ['-jw' '--jump-worktree']) {{ + let attached = (($spans | length) == 2) and ($current in ['-jw' '--jump-worktree']) + let separated = (($spans | length) == 3) and ($spans.1 in ['-jw' '--jump-worktree']) + if $attached or $separated {{ + let prefix = if $separated {{ + try {{ $current | from nuon | into string }} catch {{ + $current | str trim --char '`' | str trim --char '"' | str trim --char "'" + }} + }} else {{ '' }} let config = {config} let result = (^cj ...$config --worktree --format json | complete) if $result.exit_code != 0 {{ return [] }} return (try {{ - $result.stdout | from json | each {{ |row| - {{ value: $row.path, description: 'Git worktree' }} + $result.stdout | from json | where {{ |row| $row.path | str starts-with $prefix }} | each {{ |row| + {{ value: ($row.path | to nuon), description: 'Git worktree' }} }} }} catch {{ [] }}) }} @@ -319,7 +333,12 @@ export def --env --wrapped __cj_cd [...args: string] {{ }} if ($args.0 in ['-jw' '--jump-worktree']) {{ - error make {{ msg: 'cj: type cd -jw and press Tab to select a worktree' }} + if (($args | length) != 2) or ($args.1 | is-empty) {{ + error make {{ msg: 'cj: type cd -jw and press Tab to select a worktree' }} + }} + cd $args.1 + _cj-record-move $before + return }} if (($args.0 == '-r') or ($args.0 == '--raw')) {{ @@ -489,7 +508,10 @@ function global:cd {{ $first = [string]$cjArgs[0] if (($first -ceq '-jw') -or ($first -ceq '--jump-worktree')) {{ - throw 'cj: type cd -jw and press Tab to select a worktree' + if ($cjArgs.Count -ne 2 -or [string]::IsNullOrEmpty([string]$cjArgs[1])) {{ throw 'cj: type cd -jw and press Tab to select a worktree' }} + Microsoft.PowerShell.Management\Set-Location -LiteralPath $cjArgs[1] -ErrorAction Stop + Add-CjHistory $before + return }} if (($first -ceq '-r') -or ($first -ceq '--raw')) {{ @@ -570,7 +592,7 @@ if (Test-Path Function:TabExpansion2) {{ if ($PSCmdlet.ParameterSetName -ceq 'ScriptInputSet') {{ $beforeCursor = $inputScript.Substring(0, $cursorColumn) - $jump = [regex]::Match($beforeCursor, '^\s*cd\s+(?-jw|--jump-worktree)$') + $jump = [regex]::Match($beforeCursor, '^\s*cd\s+(?-jw|--jump-worktree)[ \t]*$') if ($jump.Success) {{ $matches = @(Complete-CjCdArgument $jump.Groups['flag'].Value) $results = [System.Collections.ObjectModel.Collection[System.Management.Automation.CompletionResult]]::new() @@ -579,7 +601,7 @@ if (Test-Path Function:TabExpansion2) {{ $results, -1, $jump.Groups['flag'].Index, - $jump.Groups['flag'].Length + ($cursorColumn - $jump.Groups['flag'].Index) ) }} }} diff --git a/tests/external_integration.rs b/tests/external_integration.rs index 486f25e..92451b3 100644 --- a/tests/external_integration.rs +++ b/tests/external_integration.rs @@ -406,16 +406,20 @@ _test_complete() { } zle -C _test_complete_widget complete-word _test_complete zle-line-init() { - local flag + local flag before=$PWD local -a parsed - for flag in -jw --jump-worktree -jw; do + for flag in -jw --jump-worktree '-jw ' '--jump-worktree ' $'-jw\t' '--jump-worktree ' -jw; do BUFFER="cd $flag" CURSOR=$#BUFFER # Also exercise a cursor inside the token, where SUFFIX is nonempty. [[ -e "$CJ_TEST_RESULTS" && "$flag" == -jw ]] && (( CURSOR-- )) zle _test_complete_widget parsed=(${(z)BUFFER}) - printf '%s\000' "${(Q)parsed[2]}" "$PWD" >> "$CJ_TEST_RESULTS" + printf '%s\000' "${(Q)parsed[-1]}" "$PWD" >> "$CJ_TEST_RESULTS" + eval "$BUFFER" || exit 3 + printf '%s\000' "$PWD" "${#_cj_history[@]}" >> "$CJ_TEST_RESULTS" + builtin cd "$before" + _cj_history=() done exit } @@ -457,8 +461,8 @@ exit $timed_out"#, assert_success(&output); let results = fs::read(results).expect("completion widget produced results"); let fields = nul_strings(&results); - assert_eq!(fields.len(), 9); - for result in fields.chunks_exact(3) { + assert_eq!(fields.len(), 35); + for result in fields.chunks_exact(5) { assert_eq!(result[0], "2", "real compadd must accept both worktrees"); let destination = Path::new(result[1]); assert!( @@ -466,6 +470,116 @@ exit $timed_out"#, "completion must insert one quoted worktree path, got {destination:?}" ); assert_eq!(result[2], fixture.git.main_nested.to_str().unwrap()); + assert_eq!(result[3], result[1], "Enter must execute the selected path"); + assert_eq!(result[4], "1", "Enter must record one history departure"); + } + assert!(!fixture.args.exists(), "Tab must not run cj's fzf picker"); +} + +#[test] +fn generated_bash_completion_accepts_worktrees_after_whitespace_in_readline() { + if Command::new("zsh").arg("--version").output().is_err() { + assert!(env::var_os("CJ_REQUIRE_SHELLS").is_none()); + return; + } + let bash = if Path::new("/opt/homebrew/bin/bash").exists() { + "/opt/homebrew/bin/bash" + } else { + "bash" + }; + if !Command::new(bash) + .args(["-c", "(( BASH_VERSINFO[0] >= 4 ))"]) + .status() + .is_ok_and(|status| status.success()) + { + assert!(env::var_os("CJ_REQUIRE_SHELLS").is_none()); + return; + } + let fixture = PickerFixture::new("readline-worktree-completion-雪"); + let init = cj(&fixture.git.main_nested, fixture.git.temp.path()) + .arg("-C") + .arg(&fixture.config) + .args(["init", "bash"]) + .output() + .unwrap(); + assert_success(&init); + let script = fixture.git.temp.path().join("completion.bash"); + let results = fixture.git.temp.path().join("completion-results"); + let mut setup = init.stdout; + setup.extend_from_slice( + br#" +set -o emacs +bind '"\C-i":menu-complete' +_test_record() { + printf '%s\000' "$READLINE_LINE" "$PWD" "${#_cj_history[@]}" > "$CJ_TEST_RESULTS" + _test_recorded=1 +} +bind -x '"\C-g":_test_record' +_test_after_enter() { + if [[ ${_test_recorded-} == 1 ]]; then + printf '%s\000' "$PWD" "${#_cj_history[@]}" >> "$CJ_TEST_RESULTS" + exit + fi +} +PROMPT_COMMAND=_test_after_enter +PS1='CJ_WORKTREE_READY> ' +"#, + ); + fs::write(&script, setup).unwrap(); + let binary = PathBuf::from(env!("CARGO_BIN_EXE_cj")); + let mut paths = vec![binary.parent().unwrap().to_path_buf()]; + paths.extend(env::split_paths(&env::var_os("PATH").unwrap_or_default())); + for input in ["cd -jw ", "cd --jump-worktree ", "cd -jw "] { + let output = Command::new("zsh") + .args([ + "-fc", + r#"zmodload zsh/zpty || exit 1 +zpty -b cj_test "$CJ_TEST_BASH" --noprofile --rcfile "$CJ_TEST_SCRIPT" -i || exit 1 +for attempt in {1..200}; do + if zpty -r cj_test startup; then + [[ "$startup" == *'CJ_WORKTREE_READY> '* ]] && break + fi + sleep 0.02 +done +zpty -w -n cj_test "$CJ_TEST_INPUT"$'\t\x07\n' +for attempt in {1..200}; do + while zpty -r cj_test output; do print -rn -- "$output"; done + zpty -t cj_test || break + sleep 0.05 +done +timed_out=0 +zpty -t cj_test && timed_out=1 +while zpty -r cj_test output; do print -rn -- "$output"; done +zpty -d cj_test +exit $timed_out"#, + ]) + .current_dir(&fixture.git.main_nested) + .env("PATH", env::join_paths(&paths).unwrap()) + .env("TERM", "xterm") + .env("INPUTRC", "/dev/null") + .env("CJ_TEST_BASH", bash) + .env("CJ_TEST_SCRIPT", &script) + .env("CJ_TEST_RESULTS", &results) + .env("CJ_TEST_INPUT", input) + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .env_remove("GIT_COMMON_DIR") + .output() + .unwrap(); + assert_success(&output); + let bytes = fs::read(&results).unwrap(); + let fields = nul_strings(&bytes); + assert_eq!(fields.len(), 5, "{input}: {fields:?}"); + assert!(fields[0].starts_with(input)); + assert_eq!(fields[1], fixture.git.main_nested.to_str().unwrap()); + assert_eq!(fields[2], "0", "Tab must not consume history"); + let destination = Path::new(fields[3]); + assert!( + destination == fixture.git.main || destination == fixture.git.linked, + "{input}: {fields:?}; {}", + String::from_utf8_lossy(&output.stdout) + ); + assert_eq!(fields[4], "1", "Enter must record one departure"); } assert!(!fixture.args.exists(), "Tab must not run cj's fzf picker"); } From b0d0ef989c44ae749a63623a4603fa69e7bb525d Mon Sep 17 00:00:00 2001 From: benbenbang Date: Thu, 10 Sep 2026 11:41:05 +0800 Subject: [PATCH 2/3] test(shell): add cross-shell worktree completion regressions Cover Nushell and PowerShell trailing-whitespace completion, literal quoting, and invalid-argument and history safety for worktree commands. Exercise real Nushell Reedline Tab insertion plus Enter and back navigation in an isolated PTY. Validation: 127 local tests and Windows-target Clippy passed. Native Windows runtime coverage remains for CI after push. Closes #58 --- tests/support/nu_worktree_pty.py | 118 +++++++++++++++++ tests/worktree_space_cross_shell.rs | 188 ++++++++++++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 tests/support/nu_worktree_pty.py create mode 100644 tests/worktree_space_cross_shell.rs diff --git a/tests/support/nu_worktree_pty.py b/tests/support/nu_worktree_pty.py new file mode 100644 index 0000000..07d4057 --- /dev/null +++ b/tests/support/nu_worktree_pty.py @@ -0,0 +1,118 @@ +"""Verify actual Reedline Tab insertion, Enter execution, and browser history.""" +import fcntl +import json +import os +import pathlib +import pty +import select +import signal +import struct +import subprocess +import sys +import termios +import time + +root = pathlib.Path(sys.argv[1]) +binary = pathlib.Path(sys.argv[2]) +for name in ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR"]: + os.environ.pop(name, None) +repo = root / "repo with a ' quote 雪 [dir] $()" +nested = repo / "nested" +nested.mkdir(parents=True) +subprocess.run(["git", "init", "--quiet", str(repo)], check=True) +source = root / "init.nu" +settings = root / "config.toml" +settings.write_text("") +source.write_bytes(subprocess.check_output( + [str(binary), "-C", str(settings), "init", "nu", "--no-setup-key-binding"], cwd=nested)) +records = root / "records.jsonl" + + +def quote(value): + hashes = "#" + while "'" + hashes in str(value): + hashes += "#" + return "r" + hashes + "'" + str(value) + "'" + hashes + + +config = root / "terminal.nu" +config.write_text(f"""use {quote(source)} * +$env.config.show_banner = false +$env.config.edit_mode = 'emacs' +$env.config.shell_integration.osc133 = false +$env.PROMPT_COMMAND = {{|| 'CJPTY> '}} +$env.PROMPT_COMMAND_RIGHT = {{|| ''}} +def capture [] {{ + let row = {{buffer: (commandline), cwd: $env.PWD, history: $env.__cj_history}} + (($row | to json -r) + (char nl)) | save --append {quote(records)} +}} +$env.config.keybindings ++= [{{name: capture, modifier: none, keycode: f2, + mode: emacs, event: {{send: executehostcommand, cmd: 'capture'}}}}] +""") +pid, fd = pty.fork() +if pid == 0: + os.chdir(nested) + os.environ["TERM"] = "xterm-256color" + os.environ["PATH"] = str(binary.parent) + os.pathsep + os.environ["PATH"] + os.environ["XDG_CONFIG_HOME"] = str(root / "xdg") + os.environ["HOME"] = str(root) + os.execvp("nu", ["nu", "--no-history", "--config", str(config)]) + +fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 30, 150, 0, 0)) +transcript = bytearray() + + +def wait_until(predicate): + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + if select.select([fd], [], [], 0.02)[0]: + data = os.read(fd, 65536) + transcript.extend(data) + if b"\x1b[6n" in data: + os.write(fd, b"\x1b[1;1R") + if predicate(): + return + raise AssertionError(transcript.decode(errors="replace")) + + +def rows(): + try: + return [json.loads(line) for line in records.read_text().splitlines()] + except (FileNotFoundError, json.JSONDecodeError): + return [] + + +def capture(): + count = len(rows()) + start = len(transcript) + os.write(fd, b"\x1bOQ") + wait_until(lambda: len(rows()) > count) + wait_until(lambda: b"\x1b[?2004h" in transcript[start:]) + return rows()[-1] + + +def enter(text=""): + start = len(transcript) + os.write(fd, text.encode() + b"\r") + wait_until(lambda: b"\x1b[?2004h" in transcript[start:]) + + +try: + wait_until(lambda: b"CJPTY> " in transcript) + for flag in ["-jw", "--jump-worktree"]: + for space in ["", " ", " "]: + os.write(fd, ("cd " + flag + space + "\t").encode()) + row = capture() + assert row["cwd"] == str(nested) and row["history"] == [], row + assert str(repo) in row["buffer"], row + enter() + row = capture() + assert row["cwd"] == str(repo) and row["history"] == [str(nested)], row + enter("cd v") + row = capture() + assert row["cwd"] == str(nested) and row["history"] == [], row +finally: + (root / "terminal.log").write_bytes(transcript) + os.kill(pid, signal.SIGKILL) + os.close(fd) + os.waitpid(pid, 0) diff --git a/tests/worktree_space_cross_shell.rs b/tests/worktree_space_cross_shell.rs new file mode 100644 index 0000000..b47d7d0 --- /dev/null +++ b/tests/worktree_space_cross_shell.rs @@ -0,0 +1,188 @@ +mod support; + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::Value; +use support::{GitFixture, assert_success, cj, write_config}; + +fn available(shell: &str) -> bool { + let available = Command::new(shell) + .arg("--version") + .output() + .is_ok_and(|output| output.status.success()); + assert!( + available + || (env::var_os("CJ_REQUIRE_SHELLS").is_none() + && !(shell == "pwsh" && env::var_os("CJ_REQUIRE_POWERSHELL").is_some())), + "required shell unavailable: {shell}" + ); + available +} + +fn integration(fixture: &GitFixture, shell: &str) -> PathBuf { + let config = fixture.temp.path().join("completion config.toml"); + write_config( + &config, + Path::new("missing-worktree-test-zoxide"), + Path::new("missing-worktree-test-fzf"), + ); + let output = cj(&fixture.main_nested, fixture.temp.path()) + .arg("-C") + .arg(config) + .args(["init", shell, "--no-setup-key-binding"]) + .output() + .unwrap(); + assert_success(&output); + let path = fixture.temp.path().join(if shell == "nu" { + "completion.nu" + } else { + "completion.ps1" + }); + fs::write(&path, output.stdout).unwrap(); + path +} + +fn command(fixture: &GitFixture, shell: &str) -> Command { + let binary = Path::new(env!("CARGO_BIN_EXE_cj")); + let mut paths = vec![binary.parent().unwrap().to_path_buf()]; + paths.extend(env::split_paths(&env::var_os("PATH").unwrap_or_default())); + let mut command = Command::new(shell); + command + .current_dir(&fixture.main_nested) + .env("PATH", env::join_paths(paths).unwrap()) + .env("CJ_EXPECTED", &fixture.linked) + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .env_remove("GIT_COMMON_DIR"); + command +} + +#[test] +fn nushell_worktree_completion_after_spaces_executes_and_records_history() { + if !available("nu") { + return; + } + let fixture = GitFixture::new("nu-spaced-worktree-雪"); + let source = integration(&fixture, "nu"); + let prelude = format!("use r#'{}'# *; ", source.display()); + for flag in ["-jw", "--jump-worktree"] { + for space in ["", " ", " "] { + let line = format!("cd {flag}{space}"); + let script = format!( + "{prelude}let before = $env.PWD; let matches = ($env.CJ_LINE | commandline complete --detailed); if $env.PWD != $before or ($env.__cj_history | length) != 0 {{ error make {{msg: 'completion changed navigation state'}} }}; $matches | to json -r" + ); + let output = command(&fixture, "nu") + .args(["--no-config-file", "-c", &script]) + .env("CJ_LINE", &line) + .output() + .unwrap(); + assert_success(&output); + let matches: Vec = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(matches.len(), 2, "{line}: {matches:?}"); + let selected = matches + .iter() + .find(|entry| entry["value"].as_str().unwrap().contains("feature")) + .unwrap(); + let start = selected["span"]["start"].as_u64().unwrap() as usize; + let end = selected["span"]["end"].as_u64().unwrap() as usize; + let completed = format!( + "{}{}{}", + &line[..start], + selected["value"].as_str().unwrap(), + &line[end..] + ); + let script = format!( + "{prelude}let before = $env.PWD; {completed}; if ($env.PWD | path expand) != ($env.CJ_EXPECTED | path expand) or ($env.__cj_history | length) != 1 {{ error make {{msg: 'selected completion did not navigate and record history'}} }}; cd v; if $env.PWD != $before or ($env.__cj_history | length) != 0 {{ error make {{msg: 'back did not restore original directory'}} }}" + ); + let output = command(&fixture, "nu") + .args(["--no-config-file", "-c", &script]) + .output() + .unwrap(); + assert_success(&output); + } + let script = format!( + "{prelude}let before = $env.PWD; let matches = ($env.CJ_LINE | commandline complete --detailed); if ($matches | length) != 1 {{ error make {{msg: 'partial worktree path did not filter candidates'}} }}; for args in [[$env.CJ_FLAG] [$env.CJ_FLAG ''] [$env.CJ_FLAG 'missing-worktree'] [$env.CJ_FLAG $env.CJ_EXPECTED 'extra']] {{ let failed = (try {{ __cj_cd ...$args; false }} catch {{ true }}); if not $failed {{ error make {{msg: 'invalid worktree invocation accepted'}} }} }}; if $env.PWD != $before or ($env.__cj_history | length) != 0 {{ error make {{msg: 'failed navigation changed state'}} }}" + ); + let output = command(&fixture, "nu") + .args(["--no-config-file", "-c", &script]) + .env("CJ_FLAG", flag) + .env( + "CJ_LINE", + format!("cd {flag} `{}`", fixture.linked.display()), + ) + .output() + .unwrap(); + assert_success(&output); + } +} + +#[test] +fn powershell_worktree_completion_replaces_flag_and_spaces() { + if !available("pwsh") { + return; + } + let fixture = GitFixture::new("pwsh-spaced-worktree-雪"); + let source = integration(&fixture, "pwsh"); + let script = r#"$ErrorActionPreference = 'Stop' +. $env:CJ_SOURCE +$before = (Get-Location).ProviderPath +foreach ($flag in @('-jw', '--jump-worktree')) { + foreach ($space in @('', ' ', ' ', "`t")) { + $line = "cd $flag$space" + $completion = TabExpansion2 -inputScript $line -cursorColumn $line.Length + if ($completion.CompletionMatches.Count -ne 2) { throw "wrong candidate count: $line" } + if ($completion.ReplacementIndex -ne 3 -or $completion.ReplacementLength -ne ($line.Length - 3)) { throw "wrong replacement span: $line" } + if ((Get-Location).ProviderPath -cne $before -or $global:__cj_history.Count -ne 0) { throw 'completion changed navigation state' } + $selected = $completion.CompletionMatches | Where-Object { $_.ListItemText.Contains('feature') } | Select-Object -First 1 + $completed = $line.Remove($completion.ReplacementIndex, $completion.ReplacementLength).Insert($completion.ReplacementIndex, $selected.CompletionText) + if (-not $completed.StartsWith("cd '")) { throw "jump flag remained in completed command: $completed" } + # Test execution models pressing Enter on the shell-produced completion. + Invoke-Expression $completed + if (-not (Get-Location).ProviderPath.Equals($env:CJ_EXPECTED, [System.StringComparison]::OrdinalIgnoreCase) -or $global:__cj_history.Count -ne 1) { throw 'completion did not navigate and record history' } + cd v + if ((Get-Location).ProviderPath -cne $before -or $global:__cj_history.Count -ne 0) { throw 'back did not restore original directory' } + } + cd $flag $env:CJ_EXPECTED + if ($global:__cj_history.Count -ne 1) { throw 'literal selected argument did not record history' } + cd v + foreach ($arguments in @(@($flag), @($flag, ''), @($flag, 'missing-worktree'), @($flag, $env:CJ_EXPECTED, 'extra'))) { + $failed = $false + try { cd @arguments } catch { $failed = $true } + if (-not $failed) { throw 'invalid worktree invocation accepted' } + } + if ((Get-Location).ProviderPath -cne $before -or $global:__cj_history.Count -ne 0) { throw 'failed navigation changed state' } +} +"#; + let output = command(&fixture, "pwsh") + .args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + script, + ]) + .env("CJ_SOURCE", source) + .output() + .unwrap(); + assert_success(&output); +} + +#[cfg(unix)] +#[test] +fn nushell_reedline_inserts_and_executes_quoted_worktree_paths() { + if !available("nu") { + return; + } + let temp = support::TempDir::new("nu-worktree-tab-pty"); + let output = Command::new("python3") + .arg("-c") + .arg(include_str!("support/nu_worktree_pty.py")) + .arg(temp.path()) + .arg(env!("CARGO_BIN_EXE_cj")) + .output() + .unwrap(); + assert_success(&output); +} From 2cc6111814f9b8d545d3644048eadafaced65922 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Thu, 10 Sep 2026 13:24:19 +0800 Subject: [PATCH 3/3] fix(shell): abbreviate HOME paths in Zsh worktree labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use `compadd -d` to display paths under `HOME` as `~/…` while preserving absolute insertion and fzf-tab preview paths. Cover exact `HOME`, trailing slashes, empty values, directory prefix boundaries, and actual ZLE absolute insertion. Independently verify installed fzf-tab capture, query, and apply behavior. Refs #58 --- README.md | 2 ++ src/shell.rs | 16 +++++++-- tests/external_integration.rs | 67 +++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index aeadf04..5a06116 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,8 @@ inserts its path; press Enter to change directory. After whitespace, Bash, Zsh, and Nushell keep the flag and complete its literal destination argument; PowerShell replaces the flag and whitespace together. Pressing Enter with a jump flag but no destination shows a reminder to use Tab and leaves the directory unchanged. +Zsh completion menus, including fzf-tab, display paths under `$HOME` as `~/…`; +the inserted destination and preview path remain absolute. The standalone `cj -jw` / `cj --jump-worktree` command remains available to print a worktree path selected through fzf. diff --git a/src/shell.rs b/src/shell.rs index 74e5462..c62f335 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -181,7 +181,7 @@ function cd() {{ function _cj_complete_cd() {{ local current candidate - local -a destinations candidates + local -a destinations candidates display_paths destinations=({destinations}) if [[ -n "${{BASH_VERSION-}}" ]]; then @@ -214,15 +214,25 @@ function _cj_complete_cd() {{ if {{ (( CURRENT == 2 )) && [[ "$current" == -jw || "$current" == --jump-worktree ]]; }} || {{ (( CURRENT == 3 )) && [[ "${{words[2]}}" == -jw || "${{words[2]}}" == --jump-worktree ]]; }}; then candidates=() + display_paths=() + local _cj_display_home="${{HOME-}}" + _cj_display_home="${{_cj_display_home%/}}" while IFS= read -r -d '' candidate; do candidates+=("$candidate") + if [[ -n "$_cj_display_home" && "$candidate" == "$_cj_display_home" ]]; then + display_paths+=('~') + elif [[ -n "$_cj_display_home" && "$candidate" == "$_cj_display_home/"* ]]; then + display_paths+=("~${{candidate#"$_cj_display_home"}}") + else + display_paths+=("$candidate") + fi done < <(\command cj{config} --worktree-paths0 2>/dev/null) (( ${{#candidates[@]}} )) || return 0 # Replace the jump token instead of matching paths against it. if (( CURRENT == 2 )); then PREFIX='' SUFFIX=''; fi # Offer complete destinations instead of inserting their common parent. compstate[insert]=menu - compadd -f -- "${{candidates[@]}}" + compadd -f -d display_paths -- "${{candidates[@]}}" return fi @@ -720,7 +730,7 @@ mod tests { let zsh = render(Shell::Zsh, None, None, &Config::default()).unwrap(); assert!(zsh.contains("(( CURRENT == 2 ))")); - assert!(zsh.contains("compadd -f -- \"${candidates[@]}\"")); + assert!(zsh.contains("compadd -f -d display_paths -- \"${candidates[@]}\"")); assert!(zsh.contains("_cj_cd_completion_fallback")); assert!(zsh.contains("compdef _cj_complete_cd cd")); assert!(zsh.contains("add-zsh-hook precmd _cj_register_cd_completion")); diff --git a/tests/external_integration.rs b/tests/external_integration.rs index 92451b3..9c710c0 100644 --- a/tests/external_integration.rs +++ b/tests/external_integration.rs @@ -380,6 +380,72 @@ fn generated_posix_tab_completion_offers_native_worktree_candidates() { ); } +#[test] +fn zsh_worktree_display_shortens_home_without_changing_destinations() { + if Command::new("zsh").arg("--version").output().is_err() { + assert!(env::var_os("CJ_REQUIRE_SHELLS").is_none()); + return; + } + let fixture = PickerFixture::new("worktree-home-labels-雪"); + let init = cj(&fixture.git.main_nested, fixture.git.temp.path()) + .arg("-C") + .arg(&fixture.config) + .args(["init", "zsh"]) + .output() + .unwrap(); + assert_success(&init); + let binary = Path::new(env!("CARGO_BIN_EXE_cj")); + let mut paths = vec![binary.parent().unwrap().to_owned()]; + paths.extend(env::split_paths(&env::var_os("PATH").unwrap_or_default())); + let main = fixture.git.main.to_str().unwrap(); + let linked = fixture.git.linked.to_str().unwrap(); + let parent = fixture.git.main.parent().unwrap().to_str().unwrap(); + for (home, labels) in [ + (parent.to_owned(), ["~/main repo", "~/feature's worktree"]), + ( + format!("{parent}/"), + ["~/main repo", "~/feature's worktree"], + ), + (main.to_owned(), ["~", linked]), + (format!("{parent}/main"), [main, linked]), + (String::new(), [main, linked]), + ] { + let output = shell_with_setup( + "zsh", + &init.stdout, + r#"eval "$1" +typeset -A compstate +typeset -ga words=(cd -jw '') +integer CURRENT=3 +compadd() { + [[ $1 == -f && $2 == -d ]] || return 1 + local label_array=$3 + local -a labels=("${(@P)label_array}") + shift 3 + [[ $1 == -- ]] || return 1 + shift + local i=1 candidate + for candidate in "$@"; do + printf '%s\000' "$labels[i]" "$candidate" + (( i++ )) + done +} +_cj_complete_cd"#, + ) + .current_dir(&fixture.git.main_nested) + .env("PATH", env::join_paths(&paths).unwrap()) + .env("HOME", &home) + .output() + .unwrap(); + assert_success(&output); + assert_eq!( + nul_strings(&output.stdout), + [labels[0], main, labels[1], linked], + "HOME={home:?}" + ); + } +} + #[test] fn generated_zsh_completion_replaces_jump_tokens_in_real_zle() { if Command::new("zsh").arg("--version").output().is_err() { @@ -453,6 +519,7 @@ exit $timed_out"#, .env("TERM", "xterm") .env("CJ_TEST_SCRIPT", &script) .env("CJ_TEST_RESULTS", &results) + .env("HOME", fixture.git.main.parent().unwrap()) .env_remove("GIT_DIR") .env_remove("GIT_WORK_TREE") .env_remove("GIT_COMMON_DIR")