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
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,10 @@ cj -w --format json
cj -w --relative
```

Choose a worktree with Tab completion:
Jump to the main worktree with Enter, or choose a worktree with Tab completion:

```console
cd -jw<Enter> # jump directly to the main worktree
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
Expand All @@ -176,8 +177,16 @@ 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. 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.
replaces the flag and whitespace together.

Pressing Enter on bare `cd -jw` or `cd --jump-worktree` jumps directly to the
primary worktree, whichever branch it currently has checked out. This shortcut
is independent of configured keyword names and local directories named `origin`
or `og`; it does not switch branches or launch fzf. A successful move records one
history entry, so `cd v` returns to the previous directory. Already at the primary
root is a no-op; outside a Git repository, the command reports an error and leaves
the directory and history unchanged. Trailing spaces before Enter behave the same.

Zsh completion menus, including fzf-tab, display paths under `$HOME` as `~/…`;
the inserted destination and preview path remain absolute.

Expand Down
70 changes: 69 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ pub enum Command {
jump: bool,
},
WorktreePaths0,
MainWorktree,
KeyBindingZoxide {
zoxide: PathBuf,
fzf: PathBuf,
Expand Down Expand Up @@ -110,6 +111,7 @@ impl Cli {
let mut relative = false;
let mut jump = false;
let mut worktree_paths0 = false;
let mut main_worktree = false;
let mut key_binding_zoxide = None;
let mut setup_key_binding = false;
let mut no_setup_key_binding = false;
Expand Down Expand Up @@ -159,6 +161,11 @@ impl Cli {
jump = true;
} else if options && text == Some("--worktree-paths0") {
worktree_paths0 = true;
} else if options && text == Some("--internal-main-worktree") {
if main_worktree {
return Err("--internal-main-worktree cannot be repeated".into());
}
main_worktree = true;
} else if options && text == Some("--internal-key-binding-zoxide") {
if key_binding_zoxide.is_some() {
return Err("--internal-key-binding-zoxide cannot be repeated".into());
Expand Down Expand Up @@ -200,7 +207,25 @@ impl Cli {
);
}

let command = if let Some((zoxide, fzf)) = key_binding_zoxide {
let command = if main_worktree {
if force_resolve
|| !targets.is_empty()
|| key_binding_zoxide.is_some()
|| worktree_paths0
|| worktree
|| format.is_some()
|| relative
|| resolver != ResolverOverride::Configured
|| setup_key_binding
|| preamp
|| config_path.is_some()
|| output.is_some()
|| verbose
{
return Err("--internal-main-worktree cannot be combined with another mode".into());
}
Command::MainWorktree
} else if let Some((zoxide, fzf)) = key_binding_zoxide {
if force_resolve
|| !targets.is_empty()
|| worktree_paths0
Expand Down Expand Up @@ -573,6 +598,49 @@ mod tests {
assert!(Cli::parse(["--worktree-paths0", "--"].map(Into::into)).is_err());
}

#[test]
fn main_worktree_protocol_is_hidden_and_rejects_other_modes() {
const FLAG: &str = "--internal-main-worktree";
assert_eq!(
Cli::parse([FLAG].map(Into::into)).unwrap().command,
Command::MainWorktree
);
assert!(!HELP.contains(FLAG));
assert_eq!(
Cli::parse(["--", FLAG].map(Into::into)).unwrap().command,
Command::Resolve {
targets: vec![FLAG.into()],
resolver: ResolverOverride::Configured,
}
);
for extra in [
vec![FLAG],
vec!["target"],
vec!["--"],
vec!["-jw"],
vec!["--worktree-paths0"],
vec!["--internal-key-binding-zoxide", "zoxide", "fzf"],
vec!["-z"],
vec!["-R"],
vec!["-f", "json"],
vec!["-C", "config.toml"],
vec!["-o", "out"],
vec!["--no-setup-key-binding"],
vec!["--preamp"],
vec!["-v"],
] {
assert!(
Cli::parse(
std::iter::once(FLAG)
.chain(extra.iter().copied())
.map(Into::into)
)
.is_err(),
"accepted incompatible arguments: {extra:?}"
);
}
}

#[test]
fn parses_hidden_key_binding_zoxide_protocol() {
assert_eq!(
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ fn execute(cli: Cli) -> Result<Execution, String> {
.unwrap_or_else(|| Ok(Execution::stdout(String::new())))
}
Command::WorktreePaths0 => worktree::paths0(&worktree::list()?).map(Execution::raw),
Command::MainWorktree => resolver::main_worktree().and_then(Execution::path),
Command::MountsScan { format } => {
let context = mounts::DiscoveryContext::from_process()?;
let report = mounts::scan(&context);
Expand Down
2 changes: 1 addition & 1 deletion src/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ fn component_eq(left: Component<'_>, right: Component<'_>) -> bool {
}
}

fn main_worktree() -> Result<PathBuf, String> {
pub fn main_worktree() -> Result<PathBuf, String> {
let output = Command::new("git")
.args(["worktree", "list", "--porcelain", "-z"])
.output()
Expand Down
46 changes: 38 additions & 8 deletions src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,18 @@ function cd() {{
_cj_nav_arg=
;;
-jw|--jump-worktree)
if [[ $# -eq 1 ]]; then
target="$(\command cj --internal-main-worktree && printf .)" || return
target="${{target%$'\n.'}}"
[[ -n "$target" ]] || return 1
_cj_builtin_cd -- "$target"
return
fi
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
printf '%s\n' 'cj: --jump-worktree accepts at most one non-empty destination' >&2
return 2
;;
-Z|--no-zoxide)
Expand Down Expand Up @@ -373,10 +380,21 @@ export def --env --wrapped __cj_cd [...args: string] {{
}}

if ($args.0 in ['-jw' '--jump-worktree']) {{
if (($args | length) != 2) or ($args.1 | is-empty) {{
error make {{ msg: 'cj: type cd -jw and press Tab to select a worktree' }}
let target = if ($args | length) == 1 {{
let result = (^cj --internal-main-worktree | complete)
if $result.exit_code != 0 {{
if not ($result.stderr | is-empty) {{ print --stderr --no-newline $result.stderr }}
error make {{ msg: $'cj exited with status ($result.exit_code)' }}
}}
let path = ($result.stdout | str replace --regex '\n$' '')
if ($path | is-empty) {{ error make {{ msg: 'cj returned an empty destination' }} }}
$path
}} else if (($args | length) == 2) and (not ($args.1 | is-empty)) {{
$args.1
}} else {{
error make {{ msg: 'cj: --jump-worktree accepts at most one non-empty destination' }}
}}
cd $args.1
cd $target
_cj-record-move $before
return
}}
Expand Down Expand Up @@ -548,8 +566,19 @@ function global:cd {{
$first = [string]$cjArgs[0]

if (($first -ceq '-jw') -or ($first -ceq '--jump-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
if ($cjArgs.Count -eq 1) {{
$executable = $script:__cj_executable.Path
$target = @(& $executable --internal-main-worktree)
$status = $LASTEXITCODE
if ($status -ne 0) {{ throw "cj exited with status $status" }}
if ($target.Count -ne 1 -or [string]::IsNullOrEmpty($target[0])) {{ throw 'cj returned an invalid destination' }}
$destination = $target[0]
}} elseif ($cjArgs.Count -eq 2 -and -not [string]::IsNullOrEmpty([string]$cjArgs[1])) {{
$destination = $cjArgs[1]
}} else {{
throw 'cj: --jump-worktree accepts at most one non-empty destination'
}}
Microsoft.PowerShell.Management\Set-Location -LiteralPath $destination -ErrorAction Stop
Add-CjHistory $before
return
}}
Expand Down Expand Up @@ -739,10 +768,11 @@ mod tests {
}

#[test]
fn wrappers_explain_that_jump_tokens_require_tab() {
fn wrappers_resolve_bare_jump_tokens_and_validate_selected_targets() {
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"));
assert!(source.contains("--internal-main-worktree"));
assert!(source.contains("--jump-worktree accepts at most one non-empty destination"));
}
}

Expand Down
24 changes: 11 additions & 13 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_require_tab_for_worktree_selection() {
let fixture = PickerFixture::new("worktree-tab-only");
fn generated_wrappers_enter_main_worktree_without_the_picker() {
let fixture = PickerFixture::new("worktree-enter-main");
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 Down Expand Up @@ -257,28 +257,26 @@ fn generated_wrappers_require_tab_for_worktree_selection() {
.env("CJ_FAKE_ARGS", &fixture.args)
.env("CJ_FAKE_STDIN", &fixture.stdin)
.env("CJ_FAKE_MODE", "success")
.env("CJ_FAKE_SELECTION", "1");
.env("CJ_FAKE_SELECTION", "1")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_COMMON_DIR")
.env_remove("GIT_INDEX_FILE");
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}"),
};
exercised += 1;
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")
);
assert_success(&output);
assert_eq!(output.stdout, path_output(&fixture.git.main));
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"
"bare worktree Enter must not invoke fzf"
);
}

Expand Down
Loading
Loading