Skip to content
Draft
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
5 changes: 5 additions & 0 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ Minimal size proportion required to appear.

Do not sort the branches in the tree.

<a id="dev" name="dev"></a>
### `--dev`

Stay on the same filesystem, do not cross mount points (POSIX only).

<a id="option-s" name="option-s"></a><a id="silent-errors" name="silent-errors"></a><a id="no-errors" name="no-errors"></a>
### `--silent-errors`

Expand Down
2 changes: 1 addition & 1 deletion exports/completion.bash
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ _pdu() {

case "${cmd}" in
pdu)
opts="-b -H -q -d -w -m -s -p -h -V --json-input --json-output --bytes-format --detect-links --dedupe-links --deduplicate-hardlinks --top-down --align-right --quantity --depth --max-depth --width --total-width --column-width --min-ratio --no-sort --no-errors --silent-errors --progress --threads --omit-json-shared-details --omit-json-shared-summary --help --version [FILES]..."
opts="-b -H -q -d -w -m -s -p -h -V --json-input --json-output --bytes-format --detect-links --dedupe-links --deduplicate-hardlinks --top-down --align-right --quantity --depth --max-depth --width --total-width --column-width --min-ratio --no-sort --dev --no-errors --silent-errors --progress --threads --omit-json-shared-details --omit-json-shared-summary --help --version [FILES]..."
if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
Expand Down
1 change: 1 addition & 0 deletions exports/completion.elv
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ set edit:completion:arg-completer[pdu] = {|@words|
cand --top-down 'Print the tree top-down instead of bottom-up'
cand --align-right 'Set the root of the bars to the right'
cand --no-sort 'Do not sort the branches in the tree'
cand --dev 'Stay on the same filesystem, do not cross mount points (POSIX only)'
cand -s 'Prevent filesystem error messages from appearing in stderr'
cand --silent-errors 'Prevent filesystem error messages from appearing in stderr'
cand --no-errors 'Prevent filesystem error messages from appearing in stderr'
Expand Down
1 change: 1 addition & 0 deletions exports/completion.fish
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ complete -c pdu -s H -l deduplicate-hardlinks -l detect-links -l dedupe-links -d
complete -c pdu -l top-down -d 'Print the tree top-down instead of bottom-up'
complete -c pdu -l align-right -d 'Set the root of the bars to the right'
complete -c pdu -l no-sort -d 'Do not sort the branches in the tree'
complete -c pdu -l dev -d 'Stay on the same filesystem, do not cross mount points (POSIX only)'
complete -c pdu -s s -l silent-errors -l no-errors -d 'Prevent filesystem error messages from appearing in stderr'
complete -c pdu -s p -l progress -d 'Report progress being made at the expense of performance'
complete -c pdu -l omit-json-shared-details -d 'Do not output `.shared.details` in the JSON output'
Expand Down
1 change: 1 addition & 0 deletions exports/completion.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Register-ArgumentCompleter -Native -CommandName 'pdu' -ScriptBlock {
[CompletionResult]::new('--top-down', '--top-down', [CompletionResultType]::ParameterName, 'Print the tree top-down instead of bottom-up')
[CompletionResult]::new('--align-right', '--align-right', [CompletionResultType]::ParameterName, 'Set the root of the bars to the right')
[CompletionResult]::new('--no-sort', '--no-sort', [CompletionResultType]::ParameterName, 'Do not sort the branches in the tree')
[CompletionResult]::new('--dev', '--dev', [CompletionResultType]::ParameterName, 'Stay on the same filesystem, do not cross mount points (POSIX only)')
[CompletionResult]::new('-s', '-s', [CompletionResultType]::ParameterName, 'Prevent filesystem error messages from appearing in stderr')
[CompletionResult]::new('--silent-errors', '--silent-errors', [CompletionResultType]::ParameterName, 'Prevent filesystem error messages from appearing in stderr')
[CompletionResult]::new('--no-errors', '--no-errors', [CompletionResultType]::ParameterName, 'Prevent filesystem error messages from appearing in stderr')
Expand Down
1 change: 1 addition & 0 deletions exports/completion.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ block-count\:"Count numbers of blocks"))' \
'--top-down[Print the tree top-down instead of bottom-up]' \
'--align-right[Set the root of the bars to the right]' \
'--no-sort[Do not sort the branches in the tree]' \
'--dev[Stay on the same filesystem, do not cross mount points (POSIX only)]' \
'-s[Prevent filesystem error messages from appearing in stderr]' \
'--silent-errors[Prevent filesystem error messages from appearing in stderr]' \
'--no-errors[Prevent filesystem error messages from appearing in stderr]' \
Expand Down
3 changes: 3 additions & 0 deletions exports/long.help
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ Options:
--no-sort
Do not sort the branches in the tree

--dev
Stay on the same filesystem, do not cross mount points (POSIX only)

-s, --silent-errors
Prevent filesystem error messages from appearing in stderr

Expand Down
2 changes: 2 additions & 0 deletions exports/short.help
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ Options:
Minimal size proportion required to appear [default: 0.01]
--no-sort
Do not sort the branches in the tree
--dev
Stay on the same filesystem, do not cross mount points (POSIX only)
-s, --silent-errors
Prevent filesystem error messages from appearing in stderr [aliases: --no-errors]
-p, --progress
Expand Down
108 changes: 79 additions & 29 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use crate::{
use clap::Parser;
use hdd::any_path_is_in_hdd;
use pipe_trait::Pipe;
use std::{io::stdin, time::Duration};
use std::{io::stdin, path::PathBuf, time::Duration};
use sub::JsonOutputParam;
use sysinfo::{Disk, Disks};

Expand Down Expand Up @@ -133,6 +133,17 @@ impl App {
.pipe(Err);
}

#[cfg(not(unix))]
if self.args.dev {
return crate::runtime_error::UnsupportedFeature::Dev
.pipe(RuntimeError::UnsupportedFeature)
.pipe(Err);
}

if self.args.dev && self.args.files.len() > 1 {
return Err(RuntimeError::DevArgConflict);
}
Comment on lines +143 to +145
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New behavior is introduced here (--dev validation and the DevArgConflict error), but there doesn’t appear to be a corresponding CLI test ensuring the error triggers (and that --dev is wired through the run! matrix). Adding a small test case (e.g., pdu --dev path1 path2 expecting DevArgConflict) would help prevent regressions when the macro arm list changes.

Copilot uses AI. Check for mistakes.

let threads = match self.args.threads {
Threads::Auto => {
let disks = Disks::new_with_refreshed_list();
Expand Down Expand Up @@ -275,14 +286,16 @@ impl App {
macro_rules! run {
($(
$(#[$variant_attrs:meta])*
$size_getter:ident, $progress:literal, $hardlinks:ident;
$size_getter:ident, $progress:literal, $hardlinks:ident, $dev:ident;
)*) => { match self.args {$(
$(#[$variant_attrs])*
Args {
quantity: <$size_getter as GetSizeUtils>::QUANTITY,
progress: $progress,
#[cfg(unix)] deduplicate_hardlinks: $hardlinks,
#[cfg(not(unix))] deduplicate_hardlinks: _,
#[cfg(unix)] dev: $dev,
#[cfg(not(unix))] dev: _,
files,
json_output,
bytes_format,
Expand All @@ -294,41 +307,78 @@ impl App {
omit_json_shared_details,
omit_json_shared_summary,
..
} => Sub {
direction: Direction::from_top_down(top_down),
bar_alignment: BarAlignment::from_align_right(align_right),
size_getter: <$size_getter as GetSizeUtils>::INSTANCE,
hardlinks_handler: <$size_getter as CreateHardlinksHandler<{ cfg!(unix) && $hardlinks }, $progress>>::create_hardlinks_handler(),
reporter: <$size_getter as CreateReporter<$progress>>::create_reporter(report_error),
bytes_format: <$size_getter as GetSizeUtils>::formatter(bytes_format),
files,
json_output: JsonOutputParam::from_cli_flags(json_output, omit_json_shared_details, omit_json_shared_summary),
column_width_distribution,
max_depth,
min_ratio,
no_sort,
}
.run(),
} => {
let root_dev = if cfg!(unix) && $dev {
get_root_dev(&files)
} else {
None
};
Sub {
direction: Direction::from_top_down(top_down),
bar_alignment: BarAlignment::from_align_right(align_right),
size_getter: <$size_getter as GetSizeUtils>::INSTANCE,
hardlinks_handler: <$size_getter as CreateHardlinksHandler<{ cfg!(unix) && $hardlinks }, $progress>>::create_hardlinks_handler(),
reporter: <$size_getter as CreateReporter<$progress>>::create_reporter(report_error),
bytes_format: <$size_getter as GetSizeUtils>::formatter(bytes_format),
files,
json_output: JsonOutputParam::from_cli_flags(json_output, omit_json_shared_details, omit_json_shared_summary),
column_width_distribution,
max_depth,
min_ratio,
no_sort,
root_dev,
}
.run()
},
)*} };
}

run! {
GetApparentSize, false, false;
GetApparentSize, true, false;
#[cfg(unix)] GetBlockSize, false, false;
#[cfg(unix)] GetBlockSize, true, false;
#[cfg(unix)] GetBlockCount, false, false;
#[cfg(unix)] GetBlockCount, true, false;
#[cfg(unix)] GetApparentSize, false, true;
#[cfg(unix)] GetApparentSize, true, true;
#[cfg(unix)] GetBlockSize, false, true;
#[cfg(unix)] GetBlockSize, true, true;
#[cfg(unix)] GetBlockCount, false, true;
#[cfg(unix)] GetBlockCount, true, true;
GetApparentSize, false, false, false;
GetApparentSize, true, false, false;
#[cfg(unix)] GetBlockSize, false, false, false;
#[cfg(unix)] GetBlockSize, true, false, false;
#[cfg(unix)] GetBlockCount, false, false, false;
#[cfg(unix)] GetBlockCount, true, false, false;
#[cfg(unix)] GetApparentSize, false, true, false;
#[cfg(unix)] GetApparentSize, true, true, false;
#[cfg(unix)] GetBlockSize, false, true, false;
#[cfg(unix)] GetBlockSize, true, true, false;
#[cfg(unix)] GetBlockCount, false, true, false;
#[cfg(unix)] GetBlockCount, true, true, false;
#[cfg(unix)] GetApparentSize, false, false, true;
#[cfg(unix)] GetApparentSize, true, false, true;
#[cfg(unix)] GetBlockSize, false, false, true;
#[cfg(unix)] GetBlockSize, true, false, true;
#[cfg(unix)] GetBlockCount, false, false, true;
#[cfg(unix)] GetBlockCount, true, false, true;
#[cfg(unix)] GetApparentSize, false, true, true;
#[cfg(unix)] GetApparentSize, true, true, true;
#[cfg(unix)] GetBlockSize, false, true, true;
#[cfg(unix)] GetBlockSize, true, true, true;
#[cfg(unix)] GetBlockCount, false, true, true;
#[cfg(unix)] GetBlockCount, true, true, true;
}
}
}

/// Get the device ID of the root path for `--dev` filtering.
#[cfg(unix)]
fn get_root_dev(files: &[PathBuf]) -> Option<u64> {
use std::os::unix::fs::MetadataExt;
let root_path = files
.first()
.map(|p| p.as_path())
.unwrap_or(std::path::Path::new("."));
std::fs::symlink_metadata(root_path).ok().map(|m| m.dev())
}

/// Get the device ID of the root path for `--dev` filtering.
#[cfg(not(unix))]
fn get_root_dev(_files: &[PathBuf]) -> Option<u64> {
None
}

mod hdd;
mod mount_point;
mod overlapping_arguments;
4 changes: 4 additions & 0 deletions src/app/sub.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ where
pub min_ratio: Fraction,
/// Preserve order of entries.
pub no_sort: bool,
/// Device ID of the root directory. When `Some`, entries on different devices are skipped.
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

root_dev is documented as skipping entries on different devices, but on non-Unix platforms the dev-based filtering is not compiled in (and --dev is rejected). Consider clarifying in this field doc comment that it’s only meaningful on Unix, to keep the public Sub API accurate.

Suggested change
/// Device ID of the root directory. When `Some`, entries on different devices are skipped.
/// Device ID of the root directory. On Unix, when `Some`, entries on different devices are
/// skipped. On non-Unix platforms this field is ignored and has no effect.

Copilot uses AI. Check for mistakes.
pub root_dev: Option<u64>,
}

impl<Size, SizeGetter, HardlinksHandler, Report> Sub<Size, SizeGetter, HardlinksHandler, Report>
Expand All @@ -74,6 +76,7 @@ where
reporter,
min_ratio,
no_sort,
root_dev,
} = self;

let max_depth = max_depth.get();
Expand All @@ -87,6 +90,7 @@ where
size_getter,
hardlinks_recorder: &hardlinks_handler,
max_depth,
root_dev,
}
.into()
});
Expand Down
5 changes: 5 additions & 0 deletions src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ pub struct Args {
#[clap(long)]
pub no_sort: bool,

/// Stay on the same filesystem, do not cross mount points (POSIX only).
#[clap(long)]
#[cfg_attr(not(unix), clap(hide = true))]
pub dev: bool,

/// Prevent filesystem error messages from appearing in stderr.
#[clap(long, short, visible_alias = "no-errors")]
pub silent_errors: bool,
Expand Down
16 changes: 16 additions & 0 deletions src/fs_tree_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ use std::{
/// size_getter: GetApparentSize,
/// reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT),
/// max_depth: 10,
/// root_dev: None,
/// };
/// let data_tree: DataTree<OsStringDisplay, Bytes> = builder.into();
/// ```
Expand All @@ -54,6 +55,8 @@ where
pub reporter: &'a Report,
/// Deepest level of descendant display in the graph. The sizes beyond the max depth still count toward total.
pub max_depth: u64,
/// Device ID of the root directory. When `Some`, entries on different devices are skipped.
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The root_dev field doc comment implies it works on all platforms, but the actual filtering logic is behind #[cfg(unix)] and is a no-op on non-Unix builds. Please clarify in the doc comment that this filtering is Unix-only (or gate the field/docs accordingly) to avoid misleading library users on Windows.

Suggested change
/// Device ID of the root directory. When `Some`, entries on different devices are skipped.
/// Device ID of the root directory.
///
/// On Unix, when this is `Some`, entries on different devices are skipped.
/// On non-Unix platforms this field is currently ignored and has no effect.

Copilot uses AI. Check for mistakes.
pub root_dev: Option<u64>,
}

impl<'a, Size, SizeGetter, HardlinksRecorder, Report>
Expand All @@ -67,12 +70,14 @@ where
{
/// Create a [`DataTree`] from an [`FsTreeBuilder`].
fn from(builder: FsTreeBuilder<Size, SizeGetter, HardlinksRecorder, Report>) -> Self {
#[allow(unused_variables)] // root_dev is only used in #[cfg(unix)] block below
let FsTreeBuilder {
root,
size_getter,
hardlinks_recorder,
reporter,
max_depth,
root_dev,
} = builder;

TreeBuilder::<PathBuf, OsStringDisplay, Size, _, _> {
Expand All @@ -94,6 +99,17 @@ where
};
}
Ok(stats) => {
// When --dev is active, skip entries on different filesystems
#[cfg(unix)]
if let Some(root_dev) = root_dev {
use std::os::unix::fs::MetadataExt;
if stats.dev() != root_dev {
return Info {
size: Size::default(),
children: Vec::new(),
};
}
Comment on lines +102 to +111
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This --dev path check returns an empty Info (size=0, no children), which stops traversal but still keeps the mountpoint entry in the resulting DataTree with a zero size. If the intended behavior is to truly skip the entry (omit it from output entirely), the filtering likely needs to happen at the parent read_dir stage (or TreeBuilder/Info needs an explicit “skip node” mechanism). If keeping the node is intended, consider adjusting the comment to say “do not traverse into” rather than “skip entries”.

Copilot uses AI. Check for mistakes.
}
// `stats` should be dropped ASAP to avoid piling up kernel memory usage
let is_dir = stats.is_dir();
let size = size_getter.get_size(&stats);
Expand Down
10 changes: 9 additions & 1 deletion src/runtime_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ pub enum RuntimeError {
/// When input JSON data is not a valid tree.
#[display("InvalidInputReflection: {_0}")]
InvalidInputReflection(#[error(not(source))] String),
/// When `--dev` is used with more than one argument.
#[display("DevArgConflict: --dev cannot be used with more than one path argument")]
DevArgConflict,
/// When the user attempts to use unavailable platform-specific features.
#[display("UnsupportedFeature: {_0}")]
UnsupportedFeature(UnsupportedFeature),
Expand All @@ -32,6 +35,10 @@ pub enum UnsupportedFeature {
#[cfg(not(unix))]
#[display("Feature --deduplicate-hardlinks is not available on this platform")]
DeduplicateHardlink,
/// Using `--dev` on non-POSIX.
#[cfg(not(unix))]
#[display("Feature --dev is not available on this platform")]
Dev,
}

impl From<Infallible> for RuntimeError {
Expand All @@ -48,7 +55,8 @@ impl RuntimeError {
RuntimeError::DeserializationFailure(_) => 3,
RuntimeError::JsonInputArgConflict => 4,
RuntimeError::InvalidInputReflection(_) => 5,
RuntimeError::UnsupportedFeature(_) => 6,
RuntimeError::DevArgConflict => 6,
RuntimeError::UnsupportedFeature(_) => 7,
})
}
}
1 change: 1 addition & 0 deletions tests/_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@ where
}),
root: root.join(suffix),
max_depth: 10,
root_dev: None,
}
.pipe(DataTree::<OsStringDisplay, Size>::from)
.into_par_sorted(|left, right| left.name().cmp(right.name()))
Expand Down
1 change: 1 addition & 0 deletions tests/cli_errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ fn fs_errors() {
hardlinks_recorder: &HardlinkIgnorant,
reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT),
max_depth: 10,
root_dev: None,
};
let mut data_tree: DataTree<OsStringDisplay, _> = builder.into();
data_tree.par_sort_by(|left, right| left.size().cmp(&right.size()).reverse());
Expand Down
1 change: 1 addition & 0 deletions tests/json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ fn json_output() {
hardlinks_recorder: &HardlinkIgnorant,
reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT),
max_depth: 10,
root_dev: None,
};
let expected = builder
.pipe(DataTree::<_, Bytes>::from)
Expand Down
Loading
Loading