From 2fbb8f5711fd503126bcaf652d3627f64eb78c1a Mon Sep 17 00:00:00 2001 From: Mathjk Date: Wed, 30 Sep 2026 01:23:19 +0800 Subject: [PATCH 1/2] fix: don't fail on sole directory inputs beyond `MAX_PATH` on Windows A sole directory input is changed into so its entries can be listed and reported relative to it, but `set_current_dir` enforces `MAX_PATH` on Windows (os error 206) even though the directory itself can still be traversed through verbatim paths. When the change fails, expand the directory into absolute input paths directly instead of aborting, which makes a single long directory input work in list, aggregate, stacks and interactive modes alike. Reported paths are absolute then, consistent with how multiple inputs are already displayed. The expanded root is returned to callers so the interactive mode still knows which directory the inputs belong to, and recorded in the new `WalkOptions::base_dir`, which every traversal uses as the working directory stand-in for ignore-pattern matching. That keeps `--ignore-from` patterns matching descendants relative to the expanded root - identical to what a successful `chdir` would have produced. Fixes https://github.com/Byron/dua-cli/issues/410 --- src/aggregate.rs | 7 + src/clean.rs | 1 + src/common.rs | 68 +++++++- .../app/tests/journeys_with_writes.rs | 2 + src/interactive/app/tests/unit.rs | 1 + src/interactive/app/tests/utils.rs | 1 + src/main.rs | 148 ++++++++++++++---- src/stacks.rs | 1 + src/traverse.rs | 7 + src/tree.rs | 1 + 10 files changed, 206 insertions(+), 31 deletions(-) diff --git a/src/aggregate.rs b/src/aggregate.rs index a6cf3a19..7d3c1773 100644 --- a/src/aggregate.rs +++ b/src/aggregate.rs @@ -712,6 +712,7 @@ mod tests { ignore_dirs: std::collections::BTreeSet::default(), ignore_patterns: None, metadata_options: crate::TraversalOptions::default(), + base_dir: None, }, false, sort_by_size_in_bytes, @@ -790,6 +791,7 @@ mod tests { ignore_dirs: std::collections::BTreeSet::default(), ignore_patterns: None, metadata_options: crate::TraversalOptions::default(), + base_dir: None, }, true, false, @@ -919,6 +921,7 @@ mod tests { skip_metadata: false, apfs_clone_metadata: true, }, + base_dir: None, }, true, true, @@ -960,6 +963,7 @@ mod tests { skip_metadata: false, apfs_clone_metadata: true, }, + base_dir: None, }, true, true, @@ -1061,6 +1065,7 @@ mod tests { ignore_dirs: std::collections::BTreeSet::default(), ignore_patterns: None, metadata_options: crate::TraversalOptions::default(), + base_dir: None, }, true, true, @@ -1095,6 +1100,7 @@ mod tests { ignore_dirs: std::collections::BTreeSet::default(), ignore_patterns: None, metadata_options: crate::TraversalOptions::default(), + base_dir: None, }, false, true, @@ -1133,6 +1139,7 @@ mod tests { ignore_dirs: std::collections::BTreeSet::default(), ignore_patterns: crate::IgnorePatterns::from_files(ignore_from).unwrap(), metadata_options: crate::TraversalOptions::default(), + base_dir: None, }, false, true, diff --git a/src/clean.rs b/src/clean.rs index 3b2a32b5..881c80df 100644 --- a/src/clean.rs +++ b/src/clean.rs @@ -321,6 +321,7 @@ mod tests { ignore_dirs: BTreeSet::new(), ignore_patterns: None, metadata_options: crate::TraversalOptions::default(), + base_dir: None, } } diff --git a/src/common.rs b/src/common.rs index c5655c60..4b943b43 100644 --- a/src/common.rs +++ b/src/common.rs @@ -252,6 +252,12 @@ pub struct WalkOptions { /// Gitignore-style patterns whose matches are left out of the traversal entirely. /// `None` if no pattern was configured. pub ignore_patterns: Option, + /// The directory the traversal logically runs in, replacing the process working + /// directory as the base that ignore patterns and relative paths are matched + /// against. Set when a sole input directory was expanded into its entries instead + /// of being entered with `set_current_dir` - e.g. beyond `MAX_PATH` on Windows. + /// `None` uses the process working directory. + pub base_dir: Option, /// Platform-specific metadata requested during traversal. pub metadata_options: crate::TraversalOptions, } @@ -322,7 +328,15 @@ impl WalkOptions { }, ); let ignore_dirs = self.ignore_dirs.clone(); - let cwd = std::env::current_dir().unwrap_or_default(); + // `base_dir` stands in for the working directory when the input directory + // was expanded into its entries instead of being entered, so that ignore + // patterns see the same relative paths they would have seen after a + // successful `set_current_dir`. + let cwd = self + .base_dir + .clone() + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_default(); let cross_filesystems = self.cross_filesystems; // Excluding an entry means pruning it from the walk *and* from the emitted events, so the @@ -470,6 +484,7 @@ mod tests { ignore_dirs: BTreeSet::new(), ignore_patterns: None, metadata_options: crate::TraversalOptions::default(), + base_dir: None, }; #[cfg(unix)] let mut parameters = vec![ @@ -532,6 +547,7 @@ mod tests { ignore_dirs: canonicalize_ignore_dirs(&[root.path().to_owned()]), ignore_patterns: None, metadata_options: crate::TraversalOptions::default(), + base_dir: None, }; let paths = options @@ -713,6 +729,7 @@ mod tests { ignore_dirs: BTreeSet::default(), ignore_patterns: Some(patterns_from("nested/secret\n")), metadata_options: crate::TraversalOptions::default(), + base_dir: None, }; let paths = options @@ -738,6 +755,54 @@ mod tests { assert!(paths.iter().any(|path| path == "visible")); } + /// A directory that cannot be entered is expanded into absolute inputs, with + /// `base_dir` recording where those inputs came from. Entries found under them + /// must still match ignore patterns relative to that directory, exactly as they + /// would had changing into it succeeded - otherwise patterns like `sub/ignored` + /// stop excluding anything below the expanded roots. + #[test] + fn base_dir_anchors_ignore_patterns_of_expanded_roots() { + let root = tempfile::tempdir().unwrap(); + let sub = root.path().join("sub"); + std::fs::create_dir(&sub).unwrap(); + std::fs::write(sub.join("ignored.txt"), []).unwrap(); + std::fs::write(sub.join("kept.txt"), []).unwrap(); + let options = WalkOptions { + threads: 1, + count_hard_links: false, + apparent_size: false, + cross_filesystems: true, + ignore_dirs: BTreeSet::default(), + ignore_patterns: Some(patterns_from("sub/ignored.txt\n")), + base_dir: Some(root.path().to_owned()), + metadata_options: crate::TraversalOptions::default(), + }; + + let names = options + .iter_from_paths( + vec![WalkRoot { + index: 0, + // Walkers derive the pattern root from the input path itself, but + // `base_dir` takes precedence in matching. + pattern_root: Some(sub.clone()), + path: sub, + #[cfg(any(windows, target_os = "macos"))] + entry: None, + device_id: 0, + }], + false, + walk::Order::Completion, + ) + .filter_map(|(_, event)| match event { + walk::RootEvent::Entry(entry) => Some(entry.unwrap().file_name), + walk::RootEvent::Finished => None, + }) + .collect::>(); + + assert!(!names.iter().any(|name| name == "ignored.txt")); + assert!(names.iter().any(|name| name == "kept.txt")); + } + #[test] fn excluded_input_paths_are_dropped_before_the_walk() { // Relies on the crate directory being current, like `test_ignore_directories` above. @@ -769,6 +834,7 @@ mod tests { ignore_dirs: BTreeSet::default(), ignore_patterns: Some(patterns_from(contents)), metadata_options: crate::TraversalOptions::default(), + base_dir: None, }; let mut paths = options diff --git a/src/interactive/app/tests/journeys_with_writes.rs b/src/interactive/app/tests/journeys_with_writes.rs index cc64de55..aa44cf1f 100644 --- a/src/interactive/app/tests/journeys_with_writes.rs +++ b/src/interactive/app/tests/journeys_with_writes.rs @@ -235,6 +235,7 @@ $precious.tmp ignore_dirs: BTreeSet::default(), ignore_patterns: None, metadata_options: dua::TraversalOptions::default(), + base_dir: None, }; let (_key_send, key_receive) = crossbeam::channel::bounded(0); let mut app = TerminalApp::initialize( @@ -377,6 +378,7 @@ fn cleanup_candidates_are_marked_with_one_key_after_entering_project_dir() -> Re ignore_dirs: BTreeSet::default(), ignore_patterns: None, metadata_options: dua::TraversalOptions::default(), + base_dir: None, }; let (_key_send, key_receive) = crossbeam::channel::bounded(0); let mut app = TerminalApp::initialize( diff --git a/src/interactive/app/tests/unit.rs b/src/interactive/app/tests/unit.rs index 65206365..ddc58af4 100644 --- a/src/interactive/app/tests/unit.rs +++ b/src/interactive/app/tests/unit.rs @@ -208,6 +208,7 @@ fn it_can_sort_directory_mtimes_by_recursive_entries() { ignore_dirs: BTreeSet::default(), ignore_patterns: None, metadata_options: dua::TraversalOptions::default(), + base_dir: None, }, Vec::new(), None, diff --git a/src/interactive/app/tests/utils.rs b/src/interactive/app/tests/utils.rs index dc2686aa..080994be 100644 --- a/src/interactive/app/tests/utils.rs +++ b/src/interactive/app/tests/utils.rs @@ -205,6 +205,7 @@ pub fn untraversed_app_and_terminal_with_closure( ignore_dirs: BTreeSet::default(), ignore_patterns: None, metadata_options: dua::TraversalOptions::default(), + base_dir: None, }; let input_paths = fixture_paths.iter().map(|c| convert(c.as_ref())).collect(); diff --git a/src/main.rs b/src/main.rs index b6d84358..3672c69b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -210,17 +210,19 @@ fn main() -> Result<()> { None, ) } else { - let walk_options = walk_options_from(&traversal.scan)?; + let mut walk_options = walk_options_from(&traversal.scan)?; let (input_paths, root_path) = if clean_depth.is_some() { (clean_input_paths(traversal.scan.input)?, None) } else { let has_complete_root = traversal.scan.input.is_empty() || traversal.scan.input.len() == 1 && traversal.scan.input[0].is_dir(); - let input_paths = - extract_paths_maybe_set_cwd(traversal.scan.input, &walk_options)?; + let (input_paths, expanded_root) = + extract_paths_maybe_set_cwd(traversal.scan.input, &mut walk_options)?; ( input_paths, - has_complete_root.then(std::env::current_dir).transpose()?, + has_complete_root + .then(|| expanded_root.map_or_else(std::env::current_dir, Ok)) + .transpose()?, ) }; ( @@ -458,12 +460,12 @@ fn main() -> Result<()> { )? } } else { - let walk_options = walk_options_from(&traversal.scan)?; + let mut walk_options = walk_options_from(&traversal.scan)?; if let Some(depth) = depth { let config = dua::Config::load()?; let byte_format = traversal.byte_format(&config); - let paths = - extract_paths_maybe_set_cwd(traversal.scan.input, &walk_options)?; + let (paths, _expanded_root) = + extract_paths_maybe_set_cwd(traversal.scan.input, &mut walk_options)?; let stdout = io::stdout(); let out_supports_colors = stdout.is_terminal(); dua::aggregate_tree( @@ -481,7 +483,7 @@ fn main() -> Result<()> { let byte_format = traversal.byte_format(&config); let inputs = extract_aggregate_inputs_maybe_set_cwd( traversal.scan.input, - &walk_options, + &mut walk_options, )?; run_aggregation( inputs, @@ -514,9 +516,11 @@ fn main() -> Result<()> { None => { let config = dua::Config::load()?; let byte_format = global_traversal.byte_format(&config); - let walk_options = walk_options_from(&global_traversal.scan)?; - let inputs = - extract_aggregate_inputs_maybe_set_cwd(global_traversal.scan.input, &walk_options)?; + let mut walk_options = walk_options_from(&global_traversal.scan)?; + let inputs = extract_aggregate_inputs_maybe_set_cwd( + global_traversal.scan.input, + &mut walk_options, + )?; run_aggregation(inputs, walk_options, true, true, byte_format, false)? } }; @@ -549,8 +553,9 @@ fn run_stacks( dua::stacks_from_replay(out, &mut replay, max_depth) } else { let traversal = merge_scan_args(global, &traversal); - let walk_options = walk_options_from(&traversal)?; - let inputs = extract_paths_maybe_set_cwd(traversal.input, &walk_options)?; + let mut walk_options = walk_options_from(&traversal)?; + let (inputs, _expanded_root) = + extract_paths_maybe_set_cwd(traversal.input, &mut walk_options)?; dua::stacks(out, stderr_if_tty(), walk_options, inputs, max_depth) } } @@ -666,6 +671,7 @@ fn snapshot_walk_options() -> dua::WalkOptions { ignore_dirs: std::collections::BTreeSet::new(), ignore_patterns: None, metadata_options: dua::TraversalOptions::default(), + base_dir: None, } } @@ -723,6 +729,7 @@ fn walk_options_from(traversal: &options::ScanArgs) -> Result #[cfg(target_os = "macos")] apfs_clone_metadata: traversal.deduplicate_apfs_clones && !traversal.apparent_size, }, + base_dir: None, }; if walk_options.threads == 0 { @@ -734,12 +741,19 @@ fn walk_options_from(traversal: &options::ScanArgs) -> Result fn extract_aggregate_inputs_maybe_set_cwd( paths: Vec, - walk_options: &dua::WalkOptions, + walk_options: &mut dua::WalkOptions, ) -> Result { #[cfg(any(windows, target_os = "macos"))] if paths.is_empty() || paths.len() == 1 && paths[0].is_dir() { - if let Some(path) = paths.first() { - std::env::set_current_dir(path)?; + // `set_current_dir` enforces `MAX_PATH` on Windows (os error 206) even though + // the directory can still be traversed through verbatim paths. When it fails, + // expand the directory into absolute input paths instead of giving up. + if paths + .first() + .is_some_and(|path| std::env::set_current_dir(path).is_err()) + { + return extract_paths_maybe_set_cwd(paths, walk_options) + .map(|(paths, _root)| AggregateInputs::Paths(paths)); } let cwd = std::env::current_dir()?; @@ -792,49 +806,74 @@ fn extract_aggregate_inputs_maybe_set_cwd( return Ok(AggregateInputs::Entries(selected_entries)); } - extract_paths_maybe_set_cwd(paths, walk_options).map(AggregateInputs::Paths) + extract_paths_maybe_set_cwd(paths, walk_options) + .map(|(paths, _root)| AggregateInputs::Paths(paths)) } fn extract_paths_maybe_set_cwd( mut paths: Vec, - walk_options: &dua::WalkOptions, -) -> Result, io::Error> { + walk_options: &mut dua::WalkOptions, +) -> Result<(Vec, Option), io::Error> { let cross_filesystems = walk_options.cross_filesystems; // Paths were explicitly passed by the user on the command-line; per `--ignore-dirs`'s // documented behavior, these are never subject to `-i`/`--ignore-dirs` filtering, only // paths that we ourselves expand into roots below are. let paths_were_expanded = paths.is_empty() || (paths.len() == 1 && paths[0].is_dir()); - if paths.len() == 1 && paths[0].is_dir() { - std::env::set_current_dir(&paths[0])?; - paths.clear(); - } + // Changing into a sole directory input keeps the reported paths short. It can fail + // for directories that are otherwise perfectly traversable though - on Windows + // `set_current_dir` enforces `MAX_PATH` (os error 206) - in which case the directory + // is expanded into absolute input paths directly. The expanded root is returned so + // callers still know which directory the input paths belong to. + let expanded_root = if paths.len() == 1 && paths[0].is_dir() { + if std::env::set_current_dir(&paths[0]).is_ok() { + paths.clear(); + None + } else { + let root = std::path::absolute(&paths[0])?; + paths.clear(); + Some(root) + } + } else { + None + }; + // Downstream traversals must resolve ignore patterns as if the expanded directory + // had been entered - record it as their working-directory stand-in. + walk_options.base_dir.clone_from(&expanded_root); let cwd = std::env::current_dir()?; - let cwd_device = device_id(&cwd).ok(); + // With an expanded root, the working directory is unchanged, so its entries are + // resolved against the root instead for the ignore checks below. + let base_dir = expanded_root.as_deref().unwrap_or(&cwd); + let cwd_device = device_id(base_dir).ok(); let paths = if paths.is_empty() { - cwd_dirlist().map(|paths| match cwd_device { + let paths = match &expanded_root { + Some(root) => dirlist(root)?, + None => cwd_dirlist()?, + }; + match cwd_device { Some(cwd_device) if !cross_filesystems => paths .into_iter() .filter(|path| device_id(path).map_or(true, |device| device == cwd_device)) .collect(), _ => paths, - })? + } } else { paths }; // Drop excluded top-level paths here rather than during the walk, so they are left out of the // report entirely instead of showing up as being empty. - Ok(paths + let paths: Vec = paths .into_iter() .filter(|path| { walk_options .ignore_patterns .as_ref() - .is_none_or(|patterns| !patterns.excludes_input_path(path, &cwd)) + .is_none_or(|patterns| !patterns.excludes_input_path(path, base_dir)) }) - .filter(|path| !paths_were_expanded || !walk_options.is_ignored_directory(path, &cwd)) - .collect()) + .filter(|path| !paths_were_expanded || !walk_options.is_ignored_directory(path, base_dir)) + .collect(); + Ok((paths, expanded_root)) } #[cfg(feature = "tui-crossplatform")] @@ -890,6 +929,23 @@ fn cwd_dirlist() -> Result, io::Error> { Ok(entries) } +/// List the contents of `dir` the way [`cwd_dirlist`] does, keeping `dir` as the +/// prefix of each returned path so the entries remain usable without changing the +/// working directory. The directory's path is added to errors for context. +fn dirlist(dir: &Path) -> Result, io::Error> { + let mut entries: Vec<_> = fs::read_dir(dir) + .map_err(|err| io::Error::new(err.kind(), format!("{}: {err}", dir.display())))? + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| { + !path + .symlink_metadata() + .is_ok_and(|meta| meta.file_type().is_symlink()) + }) + .collect(); + entries.sort(); + Ok(entries) +} + fn edit_config() -> Result<()> { let path = dua::Config::path()?; @@ -1249,4 +1305,36 @@ mod tests { assert_eq!(merged.scan.ignore_dirs, subcommand.scan.ignore_dirs); } + + /// `set_current_dir` enforces `MAX_PATH` on Windows (os error 206) even for + /// directories that can still be traversed through verbatim paths. A sole directory + /// input like this must be expanded into absolute input paths, without changing + /// the working directory. + #[cfg(windows)] + #[test] + fn sole_directory_beyond_max_path_expands_into_absolute_inputs() { + let fixture = tempfile::tempdir().unwrap(); + let mut deep = fixture.path().to_owned(); + while deep.as_os_str().len() < 270 { + deep = deep.join("a-long-directory-name-to-exceed-max-path"); + } + let deep = PathBuf::from(format!(r"\\?\{}", deep.display())); + fs::create_dir_all(&deep).unwrap(); + fs::write(deep.join("file.txt"), b"x").unwrap(); + + let cwd = std::env::current_dir().unwrap(); + let mut options = super::walk_options_from(&scan_args()).unwrap(); + let (paths, expanded_root) = + super::extract_paths_maybe_set_cwd(vec![deep.clone()], &mut options).unwrap(); + + // The directory could not be entered, so the working directory is unchanged. + assert_eq!(std::env::current_dir().unwrap(), cwd); + assert_eq!(expanded_root.as_deref(), Some(deep.as_path())); + assert_eq!(paths, vec![deep.join("file.txt")]); + // Traversals resolve ignore patterns against the expanded directory. + assert_eq!(options.base_dir.as_deref(), Some(deep.as_path())); + + // Removing the deep tree only works through its verbatim path. + fs::remove_dir_all(&deep).ok(); + } } diff --git a/src/stacks.rs b/src/stacks.rs index 390206b3..e2a0f557 100644 --- a/src/stacks.rs +++ b/src/stacks.rs @@ -270,6 +270,7 @@ mod tests { ignore_dirs: std::collections::BTreeSet::default(), ignore_patterns: None, metadata_options: TraversalOptions::default(), + base_dir: None, } } diff --git a/src/traverse.rs b/src/traverse.rs index 78815ce3..6058a7e6 100644 --- a/src/traverse.rs +++ b/src/traverse.rs @@ -1888,6 +1888,7 @@ mod tests { ignore_dirs: std::collections::BTreeSet::new(), ignore_patterns: None, metadata_options: crate::TraversalOptions::default(), + base_dir: None, } } @@ -2269,6 +2270,7 @@ mod tests { ignore_dirs: std::collections::BTreeSet::default(), ignore_patterns: None, metadata_options: crate::TraversalOptions::default(), + base_dir: None, }, vec![dir.path().to_owned()], None, @@ -2323,6 +2325,7 @@ mod tests { ignore_dirs: std::collections::BTreeSet::default(), ignore_patterns: None, metadata_options: crate::TraversalOptions::default(), + base_dir: None, }, vec![dir.path().to_owned(), dir.path().to_owned()], None, @@ -2363,6 +2366,7 @@ mod tests { ignore_dirs: std::collections::BTreeSet::default(), ignore_patterns: None, metadata_options: crate::TraversalOptions::default(), + base_dir: None, }, vec![dir.path().to_owned()], None, @@ -2408,6 +2412,7 @@ mod tests { ignore_dirs: std::collections::BTreeSet::default(), ignore_patterns: None, metadata_options: crate::TraversalOptions::default(), + base_dir: None, }, vec![root_path.clone()], None, @@ -2460,6 +2465,7 @@ mod tests { skip_metadata: false, apfs_clone_metadata: deduplicate, }, + base_dir: None, }, vec![path.to_owned()], None, @@ -2510,6 +2516,7 @@ mod tests { ignore_dirs: std::collections::BTreeSet::default(), ignore_patterns: None, metadata_options: crate::TraversalOptions::default(), + base_dir: None, }, vec![root.clone(), valid.clone()], None, diff --git a/src/tree.rs b/src/tree.rs index 3de18a1e..51bff30b 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -297,6 +297,7 @@ mod tests { ignore_dirs: std::collections::BTreeSet::default(), ignore_patterns: None, metadata_options: crate::TraversalOptions::default(), + base_dir: None, } } From 1d6891802e5d9e1945a07562feabe00db1da1910 Mon Sep 17 00:00:00 2001 From: Byron Date: Wed, 30 Sep 2026 10:16:39 +0200 Subject: [PATCH 2/2] review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [P1] Use the expanded root for interactive refreshes — /Users/byron/dev/github.com/Byron/dua-cli.fix-long-path-single-dir/src/main.rs:223-225 When changing directory fails and the expanded directory has at most one entry, pressing `R` at the initial view scans the launch directory instead. `AppState::refresh` resolves the still-empty synthetic tree root to `.` unless `root_paths.len() > 1`; neither `root_path` nor `base_dir` is consulted, so unrelated files replace the requested listing. Make interactive path resolution use the expanded root. The same omission also points the initial title and Git-ignore discovery at the wrong directory. - [P2] Measure the Windows test path in UTF-16 code units — /Users/byron/dev/github.com/Byron/dua-cli.fix-long-path-single-dir/src/main.rs:1318-1320 With a sufficiently non-ASCII temporary-directory path, such as one containing a CJK username, `OsStr::len()` can exceed 270 bytes while the path remains below `MAX_PATH` in UTF-16 code units. The directory change then succeeds, failing the unchanged-CWD assertion and potentially leaving other tests with a changed working directory. Count `encode_wide()` units when extending the fixture so it reliably exercises the fallback. Assisted-by: GPT 6.0 Astra Co-authored-by: GPT 6.0 Astra --- src/interactive/app/terminal.rs | 5 + .../app/tests/journeys_with_writes.rs | 87 +++++++++++ src/main.rs | 137 ++++++++++++------ 3 files changed, 188 insertions(+), 41 deletions(-) diff --git a/src/interactive/app/terminal.rs b/src/interactive/app/terminal.rs index 342592b2..baeabaf8 100644 --- a/src/interactive/app/terminal.rs +++ b/src/interactive/app/terminal.rs @@ -101,6 +101,11 @@ impl TerminalApp { let display = DisplayOptions::new(byte_format); let window = MainWindow::default(); + // Resolve paths from the expanded directory when changing into it failed. + if let Some(base_dir) = &walk_options.base_dir { + traversal.tree.rename(traversal.root_index, base_dir)?; + } + let read_only = snapshot_load_duration.is_some(); let mut state = AppState::new(walk_options, input, root_path, read_only); if config.gitignore == Some(false) { diff --git a/src/interactive/app/tests/journeys_with_writes.rs b/src/interactive/app/tests/journeys_with_writes.rs index aa44cf1f..4c233111 100644 --- a/src/interactive/app/tests/journeys_with_writes.rs +++ b/src/interactive/app/tests/journeys_with_writes.rs @@ -29,6 +29,93 @@ fn marked_file_names(app: &TerminalApp, message: &str) -> BTreeSet { .collect() } +#[test] +fn expanded_root_anchors_refresh_title_and_gitignore() -> Result<()> { + for initial_name in [None, Some("existing.ignored")] { + let fixture = TempDir::new()?; + let repo = fixture.path().canonicalize()?; + gix::init(&repo)?; + fs::write(repo.join(".gitignore"), b"*.ignored\n")?; + let root = repo.join("expanded"); + fs::create_dir(&root)?; + let input_paths = initial_name + .into_iter() + .map(|name| root.join(name)) + .collect::>(); + for path in &input_paths { + fs::write(path, b"existing")?; + } + let mut expected_paths = input_paths.iter().cloned().collect::>(); + let mut terminal = new_test_terminal()?; + let mut app = TerminalApp::initialize( + &mut terminal, + WalkOptions { + base_dir: Some(root.clone()), + ..crate::snapshot_walk_options() + }, + ByteFormat::Metric, + true, + input_paths, + Some(root.clone()), + Config::default(), + dua::traverse::Traversal::new(), + None, + )?; + + let tree = app.state.tree_view(&mut app.traversal); + assert_eq!( + app.state.display_path(&tree), + root, + "initial_name={initial_name:?}: the initial title must show the expanded root, even without entries" + ); + app.traverse()?; + app.process_events_once(&mut terminal, into_events([]))?; + assert_eq!( + app.state.entries.len(), + expected_paths.len(), + "initial_name={initial_name:?}: the initial scan must contain only the supplied expanded input paths" + ); + assert_eq!( + app.state + .gitignored_entries + .as_ref() + .map(|entries| entries.len()), + Some(expected_paths.len()), + "initial_name={initial_name:?}: gitignore detection must be enabled and match all initial entries using the parent repository's .gitignore" + ); + + let added = root.join("new.ignored"); + fs::write(&added, b"new")?; + expected_paths.insert(added); + app.process_events_once(&mut terminal, into_codes("R"))?; + + let tree = app.state.tree_view(&mut app.traversal); + assert_eq!( + app.state.display_path(&tree), + root, + "initial_name={initial_name:?}: refresh must keep the title anchored to the expanded root" + ); + assert_eq!( + app.state + .entries + .iter() + .map(|entry| tree.path_of(entry.index)) + .collect::>(), + expected_paths, + "initial_name={initial_name:?}: refresh must rescan the expanded root, preserving existing entries and discovering the new file" + ); + assert_eq!( + app.state + .gitignored_entries + .as_ref() + .map(|entries| entries.len()), + Some(expected_paths.len()), + "initial_name={initial_name:?}: gitignore detection must remain enabled and match all refreshed entries using the parent repository's .gitignore" + ); + } + Ok(()) +} + #[test] fn refresh_discards_marks_before_reusing_tree_indices() -> Result<()> { let fixture = TempDir::new()?; diff --git a/src/main.rs b/src/main.rs index 3672c69b..12ab1d60 100644 --- a/src/main.rs +++ b/src/main.rs @@ -739,18 +739,34 @@ fn walk_options_from(traversal: &options::ScanArgs) -> Result Ok(walk_options) } +/// Change the working directory to `path`, returning `None` on success. +/// +/// On Windows, `ERROR_FILENAME_EXCED_RANGE` (os error 206) can prevent entering a +/// directory that is still traversable. In that case, leave the working directory +/// unchanged and return the absolute path for callers to expand into inputs instead. +/// All other errors are propagated. +fn set_current_dir(path: &Path) -> io::Result> { + #[cfg(windows)] + const ERROR_FILENAME_EXCED_RANGE: i32 = 206; + + match std::env::set_current_dir(path) { + Ok(()) => Ok(None), + #[cfg(windows)] + Err(err) if err.raw_os_error() == Some(ERROR_FILENAME_EXCED_RANGE) => { + std::path::absolute(path).map(Some) + } + Err(err) => Err(err), + } +} + fn extract_aggregate_inputs_maybe_set_cwd( paths: Vec, walk_options: &mut dua::WalkOptions, ) -> Result { #[cfg(any(windows, target_os = "macos"))] if paths.is_empty() || paths.len() == 1 && paths[0].is_dir() { - // `set_current_dir` enforces `MAX_PATH` on Windows (os error 206) even though - // the directory can still be traversed through verbatim paths. When it fails, - // expand the directory into absolute input paths instead of giving up. - if paths - .first() - .is_some_and(|path| std::env::set_current_dir(path).is_err()) + if let Some(path) = paths.first() + && set_current_dir(path)?.is_some() { return extract_paths_maybe_set_cwd(paths, walk_options) .map(|(paths, _root)| AggregateInputs::Paths(paths)); @@ -819,20 +835,11 @@ fn extract_paths_maybe_set_cwd( // documented behavior, these are never subject to `-i`/`--ignore-dirs` filtering, only // paths that we ourselves expand into roots below are. let paths_were_expanded = paths.is_empty() || (paths.len() == 1 && paths[0].is_dir()); - // Changing into a sole directory input keeps the reported paths short. It can fail - // for directories that are otherwise perfectly traversable though - on Windows - // `set_current_dir` enforces `MAX_PATH` (os error 206) - in which case the directory - // is expanded into absolute input paths directly. The expanded root is returned so - // callers still know which directory the input paths belong to. + // Changing into a sole directory input keeps the reported paths short. let expanded_root = if paths.len() == 1 && paths[0].is_dir() { - if std::env::set_current_dir(&paths[0]).is_ok() { - paths.clear(); - None - } else { - let root = std::path::absolute(&paths[0])?; - paths.clear(); - Some(root) - } + let root = set_current_dir(&paths[0])?; + paths.clear(); + root } else { None }; @@ -910,28 +917,15 @@ fn device_id(_path: &Path) -> io::Result { } fn cwd_dirlist() -> Result, io::Error> { - let mut entries: Vec<_> = fs::read_dir(".")? - .filter_map(|e| { - e.ok() - .and_then(|e| e.path().strip_prefix(".").ok().map(ToOwned::to_owned)) - }) - .filter(|p| { - if let Ok(meta) = p.symlink_metadata() - && meta.file_type().is_symlink() - { - return false; - } - true - }) - .collect(); - - entries.sort(); - Ok(entries) + Ok(dirlist(Path::new("."))? + .into_iter() + .filter_map(|path| path.strip_prefix(".").ok().map(ToOwned::to_owned)) + .collect()) } -/// List the contents of `dir` the way [`cwd_dirlist`] does, keeping `dir` as the -/// prefix of each returned path so the entries remain usable without changing the -/// working directory. The directory's path is added to errors for context. +/// List sorted, non-symlink entries of `dir`, keeping `dir` as the prefix of each +/// returned path so the entries remain usable without changing the working directory. +/// The directory's path is added to errors for context. fn dirlist(dir: &Path) -> Result, io::Error> { let mut entries: Vec<_> = fs::read_dir(dir) .map_err(|err| io::Error::new(err.kind(), format!("{}: {err}", dir.display())))? @@ -1306,6 +1300,40 @@ mod tests { assert_eq!(merged.scan.ignore_dirs, subcommand.scan.ignore_dirs); } + #[cfg(unix)] + #[test] + fn directory_change_errors_are_propagated_on_unix() { + use std::os::unix::fs::PermissionsExt as _; + + let fixture = tempfile::tempdir().unwrap(); + let root = fixture.path(); + let file = root.join("file.txt"); + fs::write(&file, b"data").unwrap(); + let cwd = std::env::current_dir().unwrap(); + let mut options = super::walk_options_from(&scan_args()).unwrap(); + fs::set_permissions(root, fs::Permissions::from_mode(0o400)).unwrap(); + if file.metadata().is_ok() { + // Privileged users can bypass the missing search permission. + fs::set_permissions(root, fs::Permissions::from_mode(0o700)).unwrap(); + return; + } + + let results = [ + super::extract_paths_maybe_set_cwd(vec![root.to_owned()], &mut options).map(|_| ()), + super::extract_aggregate_inputs_maybe_set_cwd(vec![root.to_owned()], &mut options) + .map(|_| ()), + ]; + fs::set_permissions(root, fs::Permissions::from_mode(0o700)).unwrap(); + + assert_eq!(std::env::current_dir().unwrap(), cwd); + for result in results { + assert_eq!( + result.expect_err("directory change must fail").kind(), + std::io::ErrorKind::PermissionDenied + ); + } + } + /// `set_current_dir` enforces `MAX_PATH` on Windows (os error 206) even for /// directories that can still be traversed through verbatim paths. A sole directory /// input like this must be expanded into absolute input paths, without changing @@ -1313,9 +1341,11 @@ mod tests { #[cfg(windows)] #[test] fn sole_directory_beyond_max_path_expands_into_absolute_inputs() { + use std::os::windows::ffi::OsStrExt as _; + let fixture = tempfile::tempdir().unwrap(); - let mut deep = fixture.path().to_owned(); - while deep.as_os_str().len() < 270 { + let mut deep = fixture.path().join("用户目录".repeat(20)); + while deep.as_os_str().encode_wide().count() < 270 { deep = deep.join("a-long-directory-name-to-exceed-max-path"); } let deep = PathBuf::from(format!(r"\\?\{}", deep.display())); @@ -1334,6 +1364,31 @@ mod tests { // Traversals resolve ignore patterns against the expanded directory. assert_eq!(options.base_dir.as_deref(), Some(deep.as_path())); + let mut aggregate_options = super::walk_options_from(&scan_args()).unwrap(); + let super::AggregateInputs::Paths(aggregate_paths) = + super::extract_aggregate_inputs_maybe_set_cwd( + vec![deep.clone()], + &mut aggregate_options, + ) + .unwrap() + else { + panic!("aggregation must fall back to paths when the directory exceeds MAX_PATH"); + }; + assert_eq!( + aggregate_paths, paths, + "aggregation must use the same absolute inputs after error 206" + ); + assert_eq!( + std::env::current_dir().unwrap(), + cwd, + "aggregation's fallback must leave the working directory unchanged" + ); + assert_eq!( + aggregate_options.base_dir.as_deref(), + Some(deep.as_path()), + "aggregation must anchor ignore matching to the expanded root" + ); + // Removing the deep tree only works through its verbatim path. fs::remove_dir_all(&deep).ok(); }