diff --git a/README.md b/README.md index 2c265ae..5a06116 100644 --- a/README.md +++ b/README.md @@ -163,18 +163,23 @@ 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. +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 3737a40..c62f335 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 ;; @@ -177,14 +181,16 @@ 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 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,17 +211,28 @@ 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=() + 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. - PREFIX='' SUFFIX='' + 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 @@ -287,13 +304,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 +343,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 +518,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 +602,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 +611,7 @@ if (Test-Path Function:TabExpansion2) {{ $results, -1, $jump.Groups['flag'].Index, - $jump.Groups['flag'].Length + ($cursorColumn - $jump.Groups['flag'].Index) ) }} }} @@ -698,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 486f25e..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() { @@ -406,16 +472,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 } @@ -449,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") @@ -457,8 +528,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 +537,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"); } 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); +}