Skip to content
Merged
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
119 changes: 118 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,9 @@ fn flatten_options(values: &[String]) -> Vec<String> {
.collect()
}

/// Folds `.` and `..` away lexically. Only meaningful on an absolute path:
/// applied to a relative one, `.` and a leading `..` have nothing to resolve
/// against and simply vanish, so callers go through [`parse_normalized_path`].
fn normalize_path(path: PathBuf) -> PathBuf {
path.components()
.fold(PathBuf::new(), |mut normalized, component| {
Expand All @@ -339,8 +342,21 @@ fn normalize_path(path: PathBuf) -> PathBuf {
})
}

/// Resolves a path argument against the process working directory, then
/// normalizes it.
///
/// Making the path absolute *first* is what keeps a relative argument
/// meaningful. Normalizing `.` on its own drops its only component and leaves
/// the empty path, which then fails every `Command::current_dir` with a
/// `chdir("")` ENOENT that names the Bazel binary rather than the workspace;
/// normalizing `../sibling` on its own pops nothing and silently yields
/// `sibling`. Anchoring to the working directory gives both the directory the
/// user meant, matching the Kotlin CLI. See
/// <https://github.com/Tinder/bazel-diff/issues/470>.
fn parse_normalized_path(value: &str) -> Result<PathBuf, String> {
Ok(normalize_path(PathBuf::from(value)))
let absolute = std::path::absolute(value)
.map_err(|error| format!("failed to resolve path {value:?}: {error}"))?;
Ok(normalize_path(absolute))
}

fn read_lines(path: Option<&Path>) -> Result<BTreeSet<PathBuf>> {
Expand Down Expand Up @@ -859,6 +875,107 @@ mod tests {
);
}

/// Regression test for <https://github.com/Tinder/bazel-diff/issues/470>.
///
/// `--workspacePath` and `--cacheDir` are anchored to the process working
/// directory before they are normalized. Without that, a path built only
/// out of `.`/`..` components collapsed to the empty path, and a leading
/// `..` was silently dropped.
#[test]
fn relative_path_arguments_resolve_against_the_working_directory() {
let working_directory = std::env::current_dir().unwrap();
for value in [".", "./", "./nested/..", "nested/.."] {
assert_eq!(
parse_normalized_path(value).unwrap(),
working_directory,
"--workspacePath {value} should be the working directory"
);
}
assert_eq!(
parse_normalized_path("nested").unwrap(),
working_directory.join("nested")
);
assert_eq!(
parse_normalized_path("./nested/inner/..").unwrap(),
working_directory.join("nested")
);

// A leading `..` has a parent to pop now that the path is absolute.
assert_eq!(
parse_normalized_path("../sibling").unwrap(),
working_directory.parent().unwrap().join("sibling")
);

// Absolute arguments keep the normalization they always had.
let absolute = working_directory.join("nested/inner/..");
assert_eq!(
parse_normalized_path(absolute.to_str().unwrap()).unwrap(),
working_directory.join("nested")
);

// The empty path never named a directory; it is now rejected instead of
// being passed to `chdir`.
assert!(parse_normalized_path("").is_err());
}

/// Regression test for <https://github.com/Tinder/bazel-diff/issues/470>:
/// the symptom users hit.
///
/// [`BazelOptions::command`] does `command.current_dir(&self.workspace)`.
/// When `-w .` normalized to the empty path the child's `chdir("")` failed,
/// and `std::process::Command` surfaced that as a spawn failure -- so the
/// error named the *Bazel binary*, which exists and is executable, instead
/// of the workspace:
///
/// ```text
/// [Error] failed to execute /path/to/bazel: No such file or directory (os error 2)
/// ```
#[cfg(unix)]
#[test]
fn relative_workspace_path_spawns_bazel_in_the_working_directory() {
use std::os::unix::fs::PermissionsExt;
use std::process::Command;

let directory = tempfile::tempdir().unwrap();
let stub_bazel = directory.path().join("bazel");
fs::write(&stub_bazel, "#!/bin/sh\npwd -P\n").unwrap();
fs::set_permissions(&stub_bazel, fs::Permissions::from_mode(0o755)).unwrap();

let cli = parse(&[
"bazel-diff",
"generate-hashes",
"-w",
".",
"-b",
stub_bazel.to_str().unwrap(),
"hashes.json",
]);
let Commands::GenerateHashes(args) = cli.command else {
panic!("expected generate-hashes");
};
let options = bazel_options(&args.hashing, false).unwrap();

// Mirrors BazelOptions::command().
let output = Command::new(&options.bazel)
.current_dir(&options.workspace)
.arg("version")
.output()
.unwrap_or_else(|error| {
panic!(
"`-w .` gave workspace {:?}, so spawning {} failed: {error}",
options.workspace,
options.bazel.display()
)
});

assert!(output.status.success());
assert_eq!(
PathBuf::from(String::from_utf8_lossy(&output.stdout).trim()),
std::env::current_dir().unwrap(),
"the child should run in the working directory `.` named"
);
}

#[test]
fn reads_and_trims_seed_and_repository_files() {
let directory = tempfile::tempdir().unwrap();
Expand Down
Loading