Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,18 +163,23 @@ Choose a worktree with Tab completion:

```console
cd -jw<Tab> # complete a worktree path, then press Enter
cd -jw <Tab> # a space before Tab also works
cd --jump-worktree<Tab> # 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.
Expand Down
58 changes: 45 additions & 13 deletions src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
;;
Expand Down Expand Up @@ -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 || :
Expand All @@ -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

Expand Down Expand Up @@ -287,13 +304,20 @@ def --env _cj-record-move [before: string, mode: string = 'other', logical: stri

def _cj-complete-cd [spans: list<string>] {{
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 {{ [] }})
}}
Expand All @@ -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')) {{
Expand Down Expand Up @@ -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')) {{
Expand Down Expand Up @@ -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+(?<flag>-jw|--jump-worktree)$')
$jump = [regex]::Match($beforeCursor, '^\s*cd\s+(?<flag>-jw|--jump-worktree)[ \t]*$')
if ($jump.Success) {{
$matches = @(Complete-CjCdArgument $jump.Groups['flag'].Value)
$results = [System.Collections.ObjectModel.Collection[System.Management.Automation.CompletionResult]]::new()
Expand All @@ -579,7 +611,7 @@ if (Test-Path Function:TabExpansion2) {{
$results,
-1,
$jump.Groups['flag'].Index,
$jump.Groups['flag'].Length
($cursorColumn - $jump.Groups['flag'].Index)
)
}}
}}
Expand Down Expand Up @@ -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"));
Expand Down
191 changes: 186 additions & 5 deletions tests/external_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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
}
Expand Down Expand Up @@ -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")
Expand All @@ -457,15 +528,125 @@ 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!(
destination == fixture.git.main || destination == fixture.git.linked,
"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");
}
Expand Down
Loading
Loading