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
27 changes: 19 additions & 8 deletions src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ function _cj_is_repeated() {{
return 0
}}

# pwd and cj each emit one protocol LF. Append a marker before substitution,
# then strip only that LF and marker, preserving any trailing LFs in the path.
# Append the marker only on success so a failing command keeps its exit status.
function _cj_record_move() {{
local before="$1" after="$2" mode="${{3-other}}" parent physical previous
[[ "$before" != "$after" ]] || return 0
Expand All @@ -67,7 +70,8 @@ function _cj_record_move() {{
previous="$before"
[[ -n "$parent" ]] || parent=/
while [[ "$parent" != / ]]; do
physical="$(\builtin cd -- "$parent" && \builtin pwd -P)" || break
physical="$(\builtin cd -- "$parent" && \builtin pwd -P && printf .)" || break
physical="${{physical%$'\n.'}}"
[[ "$physical" != "$after" ]] || break
if [[ "$physical" != "$previous" ]]; then _cj_history+=("$physical"); fi
previous="$physical"
Expand All @@ -83,11 +87,13 @@ function _cj_record_move() {{

function _cj_builtin_cd() {{
local before after result
before="$(\builtin pwd -P)" || return
before="$(\builtin pwd -P && printf .)" || return
before="${{before%$'\n.'}}"
\{builtin} cd "$@"
result=$?
(( result == 0 )) || return "$result"
after="$(\builtin pwd -P)" || return
after="$(\builtin pwd -P && printf .)" || return
after="${{after%$'\n.'}}"
_cj_record_move "$before" "$after"
}}

Expand Down Expand Up @@ -157,9 +163,11 @@ function cd() {{
_cj_index=$_cj_remaining
[[ -n "${{ZSH_VERSION-}}" ]] && _cj_index=$((_cj_index + 1))
target="${{_cj_history[_cj_index]}}"
_cj_before="$(\builtin pwd -P)" || return
_cj_before="$(\builtin pwd -P && printf .)" || return
_cj_before="${{_cj_before%$'\n.'}}"
\{builtin} cd -- "$target" || return
_cj_after="$(\builtin pwd -P)" || return
_cj_after="$(\builtin pwd -P && printf .)" || return
_cj_after="${{_cj_after%$'\n.'}}"
[[ "$_cj_before" != "$_cj_after" ]] || return 0
_cj_history=("${{_cj_history[@]:0:_cj_remaining}}")
if typeset -f _cj_key_history_reset >/dev/null; then _cj_key_history_reset; fi
Expand All @@ -169,13 +177,16 @@ function cd() {{
-z|--zoxide|-Z|--no-zoxide) _cj_args=("$@") ;;
*) _cj_args=(-- "$@") ;;
esac
_cj_before="$(\builtin pwd -P)" || return
target="$(\command cj{config} "${{_cj_args[@]}}")"
_cj_before="$(\builtin pwd -P && printf .)" || return
_cj_before="${{_cj_before%$'\n.'}}"
target="$(\command cj{config} "${{_cj_args[@]}}" && printf .)"
_cj_status=$?
(( _cj_status == 0 )) || return "$_cj_status"
target="${{target%$'\n.'}}"
[[ -n "$target" ]] || return 1
\{builtin} cd -- "$target" || return
_cj_after="$(\builtin pwd -P)" || return
_cj_after="$(\builtin pwd -P && printf .)" || return
_cj_after="${{_cj_after%$'\n.'}}"
_cj_record_move "$_cj_before" "$_cj_after" "$_cj_nav_mode"
}}

Expand Down
242 changes: 242 additions & 0 deletions tests/history_newlines.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
#![cfg(unix)]
mod support;

use std::env;
use std::fs;
use std::os::unix::ffi::OsStrExt;
use std::path::Path;
use std::process::Command;
use support::{TempDir, assert_success, cj, write_executable};

/// Every fixture includes the trimmed sibling, so corruption cannot hide behind cd failure.
fn exercise(script: &str, check: impl Fn(&Path, &[Vec<u8>])) {
let temp = TempDir::new("history-newlines");
for relative in [
"name",
"name\n",
"name\n\n",
"parent",
"parent\n\n/child",
"parent\n\n/child\n",
] {
fs::create_dir_all(temp.path().join(relative)).unwrap();
}
let zoxide = temp.path().join("fake-zoxide");
write_executable(
&zoxide,
"if [ \"${CJ_FAIL-}\" = yes ]; then exit 19; fi\nprintf '%s\\n' \"$CJ_TEST_DEST\"",
);
let config = temp.path().join("config.toml");
fs::write(
&config,
format!("[programs]\nzoxide = {:?}\n", zoxide.to_str().unwrap()),
)
.unwrap();
let mut paths = vec![
Path::new(env!("CARGO_BIN_EXE_cj"))
.parent()
.unwrap()
.to_path_buf(),
];
paths.extend(env::split_paths(&env::var_os("PATH").unwrap_or_default()));
let path = env::join_paths(paths).unwrap();
let mut exercised = 0;
for shell in ["bash", "zsh"] {
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(),
"required shell unavailable: {shell}"
);
if !available {
continue;
}
// A preceding shell may have removed a history destination.
fs::create_dir_all(temp.path().join("name\n\n")).unwrap();
let source = cj(temp.path(), temp.path())
.arg("-C")
.arg(&config)
.args(["init", shell])
.output()
.unwrap();
assert_success(&source);
let integration = temp.path().join("init.sh");
fs::write(&integration, source.stdout).unwrap();
let setup = r#"set -e
. "$CJ_SOURCE"
snapshot() {
printf '%s\0' "$PWD" "${#_cj_history[@]}"
if (( ${#_cj_history[@]} )); then printf '%s\0' "${_cj_history[@]}"; fi
}
"#;
let output = Command::new(shell)
.args(if shell == "bash" {
&["--noprofile", "--norc", "-c"][..]
} else {
&["-f", "-c"][..]
})
.arg(format!("{setup}\n{script}"))
.current_dir(temp.path())
.env("PATH", &path)
.env("CJ_SOURCE", &integration)
.env("CJ_ROOT", temp.path())
.env("CJ_ONE", temp.path().join("name\n"))
.env("CJ_TWO", temp.path().join("name\n\n"))
.env("CJ_PARENT", temp.path().join("parent\n\n"))
.env("CJ_CHILD", temp.path().join("parent\n\n/child\n"))
.env("CJ_TEST_DEST", temp.path().join("name\n\n"))
.env("HOME", temp.path())
.env_remove("CJ_FAIL")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_COMMON_DIR")
.env_remove("GIT_INDEX_FILE")
.output()
.unwrap();
assert_success(&output);
assert!(output.stdout.ends_with(b"\0"), "{shell}: missing snapshot");
let fields: Vec<_> = output.stdout[..output.stdout.len() - 1]
.split(|byte| *byte == 0)
.map(<[u8]>::to_vec)
.collect();
check(temp.path(), &fields);
exercised += 1;
}
assert!(exercised > 0, "no POSIX shell available");
}

fn snapshot(root: &Path, cwd: &str, history: &[&str]) -> Vec<Vec<u8>> {
let bytes = |relative: &str| {
if relative.is_empty() {
root.to_path_buf()
} else {
root.join(relative)
}
.as_os_str()
.as_bytes()
.to_vec()
};
let mut fields = vec![bytes(cwd), history.len().to_string().into_bytes()];
fields.extend(history.iter().map(|relative| bytes(relative)));
fields
}

#[test]
fn raw_direct_moves_and_back_preserve_one_or_more_trailing_newlines() {
exercise(
r#"
cd -r "$CJ_ONE"; snapshot
cd "$CJ_TWO"; snapshot
cd v; snapshot
cd v; snapshot
"#,
|root, actual| {
let expected = [
snapshot(root, "name\n", &[""]),
snapshot(root, "name\n\n", &["", "name\n"]),
snapshot(root, "name\n", &[""]),
snapshot(root, "", &[]),
]
.concat();
assert_eq!(actual, expected);
},
);
}

#[test]
fn up_records_exact_intermediate_parent_paths() {
exercise(
r#"
builtin cd "$CJ_CHILD"
cd '^^'; snapshot
cd v; snapshot
cd v; snapshot
"#,
|root, actual| {
let expected = [
snapshot(root, "", &["parent\n\n/child\n", "parent\n\n"]),
snapshot(root, "parent\n\n", &["parent\n\n/child\n"]),
snapshot(root, "parent\n\n/child\n", &[]),
]
.concat();
assert_eq!(actual, expected);
},
);
}

#[test]
fn newline_noops_and_missing_history_destinations_preserve_the_stack() {
exercise(
r#"
cd "$CJ_TWO"
cd "$CJ_ONE"
cd .; cd -r .
if cd -r "$CJ_ROOT/missing" 2>/dev/null; then exit 1; fi
snapshot
builtin cd "$CJ_TWO"
cd v; snapshot
builtin cd "$CJ_ONE"
rmdir "$CJ_TWO"
if cd v 2>/dev/null; then exit 1; fi
snapshot
if cd vvv 2>/dev/null; then exit 1; else [[ $? == 2 ]]; fi
snapshot
"#,
|root, actual| {
let expected = [
snapshot(root, "name\n", &["", "name\n\n"]),
snapshot(root, "name\n\n", &["", "name\n\n"]),
snapshot(root, "name\n", &["", "name\n\n"]),
snapshot(root, "name\n", &["", "name\n\n"]),
]
.concat();
assert_eq!(actual, expected);
},
);
}

#[test]
fn resolver_output_preserves_path_newlines_and_failure_status() {
exercise(
r#"
cd -z project; snapshot
CJ_FAIL=yes
export CJ_FAIL
if cd -z project; then exit 1; else [[ $? == 2 ]]; fi
snapshot
cd v; snapshot
"#,
|root, actual| {
let expected = [
snapshot(root, "name\n\n", &[""]),
snapshot(root, "name\n\n", &[""]),
snapshot(root, "", &[]),
]
.concat();
assert_eq!(actual, expected);
},
);
}

#[test]
fn failed_physical_cwd_capture_keeps_its_status_and_history() {
exercise(
r#"
cd "$CJ_ONE"
# Fail pwd deterministically before any native move can happen.
function builtin() { return 37; }
if cd -r "$CJ_TWO"; then exit 1; else [[ $? == 37 ]]; fi
snapshot
if cd v; then exit 1; else [[ $? == 37 ]]; fi
snapshot
if cd -z project; then exit 1; else [[ $? == 37 ]]; fi
snapshot
"#,
|root, actual| {
let expected = vec![snapshot(root, "name\n", &[""]); 3].concat();
assert_eq!(actual, expected);
},
);
}
Loading