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
23 changes: 12 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,24 +144,25 @@ cj -w --format json
cj -w --relative
```

Jump to a worktree interactively with fzf:
Choose a worktree with Tab completion:

```console
cj -jw # print the selected worktree path
cj --jump-worktree # long form of the same command
cd -jw # select a worktree and change directory
cd -jw<Tab> # complete a worktree path, then press Enter
cd --jump-worktree<Tab> # long form of the same completion trigger
```

`-w/--worktree` only lists worktrees; `-jw/--jump-worktree` selects exactly one.
The `cd` form requires the generated shell integration so the parent shell can
perform the directory change. In Bash, Zsh, Nushell, and PowerShell, pressing Tab
`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
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. Running `cd -jw` directly
remains the explicit interactive fzf picker.
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.

The standalone `cj -jw` / `cj --jump-worktree` command remains available to print
a path selected through fzf; it also powers the optional worktree key binding.

To install an fzf worktree jump key together with the `cd` wrapper, add the setup
flag to shell initialization:
Expand All @@ -172,8 +173,8 @@ eval "$(cj init zsh --setup-key-binding)"

The default picker key is <kbd>Ctrl</kbd>+<kbd>O</kbd> on macOS and Windows, and
<kbd>Alt</kbd>+<kbd>O</kbd> on Linux. Pressing it invokes the same interactive
worktree jump as `cd -jw`. The jump requires `fzf`; ordinary directory jumps and
worktree listing do not.
worktree picker as `cj -jw` and changes directory to the selected path. The key
binding requires `fzf`; ordinary directory jumps and worktree listing do not.

## Configuration

Expand Down
42 changes: 20 additions & 22 deletions src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ function cd() {{
_cj_nav_arg=
;;
-jw|--jump-worktree)
_cj_nav_arg=
printf '%s\n' 'cj: type cd -jw and press Tab to select a worktree' >&2
return 2
;;
-Z|--no-zoxide)
if [[ $# -eq 2 ]]; then
Expand Down Expand Up @@ -112,7 +113,7 @@ function cd() {{
_cj_nav_mode=down
fi
case "${{1-}}" in
-z|--zoxide|-Z|--no-zoxide|-jw|--jump-worktree) _cj_args=("$@") ;;
-z|--zoxide|-Z|--no-zoxide) _cj_args=("$@") ;;
*) _cj_args=(-- "$@") ;;
esac
_cj_before="$(\builtin pwd -P)" || return
Expand Down Expand Up @@ -312,6 +313,10 @@ export def --env --wrapped __cj_cd [...args: string] {{
return
}}

if ($args.0 in ['-jw' '--jump-worktree']) {{
error make {{ msg: 'cj: type cd -jw and press Tab to select a worktree' }}
}}

if (($args.0 == '-r') or ($args.0 == '--raw')) {{
if ($args | length) != 2 {{
error make {{ msg: 'cj: --raw requires exactly one target' }}
Expand Down Expand Up @@ -345,7 +350,7 @@ export def --env --wrapped __cj_cd [...args: string] {{
$env.CJ_INTERNAL_DOWN_ROUTE = ''
return
}}
}} else if ($args.0 | str starts-with '-') and (not ($args.0 in ['-z' '--zoxide' '-Z' '--no-zoxide' '-jw' '--jump-worktree'])) {{
}} else if ($args.0 | str starts-with '-') and (not ($args.0 in ['-z' '--zoxide' '-Z' '--no-zoxide'])) {{
if ($args | length) != 1 {{ error make {{ msg: 'cj: Nushell cd accepts one target' }} }}
cd $args.0
$env.CJ_INTERNAL_DOWN_ROUTE = ''
Expand All @@ -354,7 +359,7 @@ export def --env --wrapped __cj_cd [...args: string] {{

let nav_arg = if (($args | length) == 1) {{ $args.0 }} else if (($args | length) == 2) and (($args.0 == '-Z') or ($args.0 == '--no-zoxide')) {{ $args.1 }} else {{ '' }}
let before = ($env.PWD | path expand)
let invoke_args = if ($args.0 in ['-z' '--zoxide' '-Z' '--no-zoxide' '-jw' '--jump-worktree']) {{ $args }} else {{ ['--'] | append $args }}
let invoke_args = if ($args.0 in ['-z' '--zoxide' '-Z' '--no-zoxide']) {{ $args }} else {{ ['--'] | append $args }}
let result = with-env {{ CJ_INTERNAL_DOWN_ROUTE: $env.CJ_INTERNAL_DOWN_ROUTE }} {{
^cj ...$config ...$invoke_args | complete
}}
Expand Down Expand Up @@ -476,6 +481,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 (($first -ceq '-r') -or ($first -ceq '--raw')) {{
if ($cjArgs.Count -ne 2) {{ throw 'cj: --raw requires exactly one target' }}
Microsoft.PowerShell.Management\Set-Location -LiteralPath $cjArgs[1] -ErrorAction Stop
Expand Down Expand Up @@ -503,11 +512,11 @@ function global:cd {{
$global:__cj_down_route = $null
return
}}
if ($first.StartsWith('-') -and -not (($first -ceq '-z') -or ($first -ceq '--zoxide') -or ($first -ceq '-Z') -or ($first -ceq '--no-zoxide') -or ($first -ceq '-jw') -or ($first -ceq '--jump-worktree'))) {{ throw "cj: unsupported PowerShell cd option: $first" }}
if ($first.StartsWith('-') -and -not (($first -ceq '-z') -or ($first -ceq '--zoxide') -or ($first -ceq '-Z') -or ($first -ceq '--no-zoxide'))) {{ throw "cj: unsupported PowerShell cd option: $first" }}

$navArg = if ($cjArgs.Count -eq 1) {{ $first }} elseif (($cjArgs.Count -eq 2) -and (($first -ceq '-Z') -or ($first -ceq '--no-zoxide'))) {{ [string]$cjArgs[1] }} else {{ '' }}
$before = (Microsoft.PowerShell.Management\Get-Location).ProviderPath
[string[]]$invokeArgs = if (($first -ceq '-z') -or ($first -ceq '--zoxide') -or ($first -ceq '-Z') -or ($first -ceq '--no-zoxide') -or ($first -ceq '-jw') -or ($first -ceq '--jump-worktree')) {{ $cjArgs }} else {{ @('--') + $cjArgs }}
[string[]]$invokeArgs = if (($first -ceq '-z') -or ($first -ceq '--zoxide') -or ($first -ceq '-Z') -or ($first -ceq '--no-zoxide')) {{ $cjArgs }} else {{ @('--') + $cjArgs }}
$hadRoute = Test-Path Env:CJ_INTERNAL_DOWN_ROUTE
$oldRoute = $env:CJ_INTERNAL_DOWN_ROUTE
$configArgs = $script:__cj_config
Expand Down Expand Up @@ -694,22 +703,11 @@ mod tests {
}

#[test]
fn wrappers_forward_jump_worktree_to_cj() {
let bash = render(Shell::Bash, None, None, &Config::default()).unwrap();
assert!(bash.contains("-jw|--jump-worktree)"));
assert!(bash.contains("-jw|--jump-worktree) _cj_args=(\"$@\")"));
assert!(!bash.contains("pick-worktree"));

let nu = render(Shell::Nu, None, None, &Config::default()).unwrap();
assert!(nu.contains("'-jw' '--jump-worktree'"));
assert!(nu.contains("^cj ...$config ...$invoke_args | complete"));
assert!(!nu.contains("pick-worktree"));

let powershell = render(Shell::Pwsh, None, None, &Config::default()).unwrap();
assert!(powershell.contains("($first -ceq '-jw')"));
assert!(powershell.contains("($first -ceq '--jump-worktree')"));
assert!(powershell.contains("[string[]]$invokeArgs"));
assert!(!powershell.contains("pick-worktree"));
fn wrappers_explain_that_jump_tokens_require_tab() {
for shell in [Shell::Bash, Shell::Zsh, Shell::Nu, Shell::Pwsh] {
let source = render(shell, None, None, &Config::default()).unwrap();
assert!(source.contains("type cd -jw and press Tab to select a worktree"));
}
}

#[test]
Expand Down
42 changes: 16 additions & 26 deletions tests/external_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,8 @@ fn fzf_cancellation_and_failure_are_controlled() {
}

#[test]
fn generated_wrappers_jump_to_the_selected_worktree() {
let fixture = PickerFixture::new("fzf-shell-jump");
fn generated_wrappers_require_tab_for_worktree_selection() {
let fixture = PickerFixture::new("worktree-tab-only");
let binary_dir = fixture.git.temp.path().join("binary dir");
fs::create_dir_all(&binary_dir).expect("create binary directory");
symlink(env!("CARGO_BIN_EXE_cj"), binary_dir.join("cj")).expect("link cj binary");
Expand All @@ -248,7 +248,7 @@ fn generated_wrappers_jump_to_the_selected_worktree() {
let mut command = shell_with_setup(
shell,
&init.stdout,
"eval \"$1\"; cd \"$2\"; printf '%s\\n' \"$PWD\"",
"eval \"$1\"; cd \"$2\"; result=$?; printf '%s\\n' \"$PWD\"; exit \"$result\"",
);
command
.arg(flag)
Expand All @@ -264,32 +264,22 @@ fn generated_wrappers_jump_to_the_selected_worktree() {
Err(error) => panic!("cannot run {shell}: {error}"),
};
exercised += 1;
assert_success(&output);
assert_eq!(output.stdout, path_output(&fixture.git.linked));
assert!(output.stderr.is_empty());
assert!(
!output.status.success(),
"{shell} accepted {flag} without Tab"
);
assert_eq!(output.stdout, path_output(&fixture.git.main_nested));
assert!(
String::from_utf8_lossy(&output.stderr)
.contains("type cd -jw and press Tab to select a worktree")
);
}

let mut command = shell_with_setup(
shell,
&init.stdout,
"eval \"$1\"; cd -jw || :; printf '%s\\n' \"$PWD\"",
);
command
.current_dir(&fixture.git.main_nested)
.env("PATH", &path)
.env("CJ_FAKE_ARGS", &fixture.args)
.env("CJ_FAKE_STDIN", &fixture.stdin)
.env("CJ_FAKE_MODE", "cancel");
let output = match command.output() {
Ok(output) => output,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => panic!("cannot run {shell}: {error}"),
};
assert_success(&output);
assert_eq!(output.stdout, path_output(&fixture.git.main_nested));
assert!(output.stderr.is_empty());
}
assert!(exercised > 0, "neither bash nor zsh is available");
assert!(
!fixture.args.exists() && !fixture.stdin.exists(),
"executing the Tab token must not invoke fzf"
);
}

#[test]
Expand Down
33 changes: 25 additions & 8 deletions tests/windows_external_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ fn powershell_wrapper_changes_its_calling_session_with_zoxide() {
}

#[test]
fn powershell_wrapper_jumps_to_the_selected_worktree() {
fn powershell_wrapper_requires_tab_for_worktree_selection() {
let git = GitFixture::new("windows-powershell-fzf");
let tool = copy_tool(git.temp.path(), "fzf.exe");
let config = git.temp.path().join("fzf config.toml");
Expand All @@ -118,26 +118,43 @@ fn powershell_wrapper_jumps_to_the_selected_worktree() {
assert_success(&init);
fs::write(&source, init.stdout).expect("write PowerShell integration");

let output = Command::new("pwsh")
for flag in ["-jw", "--jump-worktree"] {
let output = Command::new("pwsh")
.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
". $env:CJ_SOURCE; cd -jw; [Console]::Out.Write((Get-Location).ProviderPath)",
". $env:CJ_SOURCE; $result = 0; try { cd $env:CJ_JUMP_FLAG } catch { [Console]::Error.Write($_.Exception.Message); $result = 2 }; [Console]::Out.Write((Get-Location).ProviderPath); exit $result",
])
.current_dir(&git.main_nested)
.env("CJ_SOURCE", source)
.env("CJ_SOURCE", &source)
.env("CJ_JUMP_FLAG", flag)
.env("CJ_FAKE_TOOL", "fzf")
.env("CJ_FAKE_MODE", "success")
.env("CJ_FAKE_SELECTION", "1")
.env("CJ_FAKE_ARGS", args)
.env("CJ_FAKE_STDIN", stdin)
.env("CJ_FAKE_ARGS", &args)
.env("CJ_FAKE_STDIN", &stdin)
.env("PATH", path_with_cj())
.output()
.expect("run PowerShell integration");
assert_success(&output);
assert_eq!(output.stdout, git.linked.as_os_str().as_encoded_bytes());
assert!(
!output.status.success(),
"PowerShell accepted {flag} without Tab"
);
assert_eq!(
output.stdout,
git.main_nested.as_os_str().as_encoded_bytes()
);
assert!(
String::from_utf8_lossy(&output.stderr)
.contains("type cd -jw and press Tab to select a worktree")
);
}
assert!(
!args.exists() && !stdin.exists(),
"executing the Tab token must not invoke fzf"
);
}

#[test]
Expand Down
Loading