Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
9600176
fix(api): skip mounted directories by comparing device IDs
claude Mar 22, 2026
02edc48
refactor(api): use pub(crate) visibility for device_id items
claude Mar 22, 2026
7b4889d
docs(readme): remove outdated mounted filesystems limitation
claude Mar 22, 2026
ab0aae8
feat(cli): add -x/--one-file-system flag to skip mounted directories
claude Mar 22, 2026
e7df5d8
chore(cli): regenerate completions, help text, and usage docs
claude Mar 22, 2026
ad79436
fix(one-file-system): return UnsupportedFeature error on non-unix pla…
claude Mar 22, 2026
3f7db4b
docs(cli): hide `-x` on windows
KSXGitHub Mar 22, 2026
184872b
fix(cli): remove redundant visible_alias for --one-file-system
claude Mar 22, 2026
81414f6
test(one-file-system): add unit and integration tests for -x flag
claude Mar 22, 2026
e7aed33
fix(test): gate platform-specific tests with target_os
claude Mar 22, 2026
33f096e
refactor(test): improve one_file_system test style
claude Mar 22, 2026
f7d1405
refactor(test): replace brittle section parsing with NUL-delimited ou…
claude Mar 22, 2026
1518b5e
Merge origin/master into claude/fix-mounted-directories-pEvuV
claude Mar 22, 2026
b21eea3
chore(git): merge from master
claude Mar 22, 2026
a168848
test(one-file-system): fail loudly when unshare is unavailable
claude Mar 22, 2026
e6702a9
fix(test): probe tmpfs mount in unshare availability check
claude Mar 22, 2026
51af07b
refactor(test): return Result<(), String> from unshare_available
claude Mar 22, 2026
d81a960
refactor(test): replace `unshare` with `fuse2fs` for cross-device test
claude Mar 23, 2026
2c0e4ba
chore(git): merge from master
claude Mar 23, 2026
a33c417
ci(test): install FUSE dependencies for cross-device test
claude Mar 23, 2026
6506aa3
ci(test): add fuse2fs package to FUSE dependency installation
claude Mar 23, 2026
02d93a5
refactor(test): return probe results from fuse_probe instead of disca…
claude Mar 23, 2026
18b0d06
fix(test): use fakeroot option for fuse2fs mount
claude Mar 23, 2026
a535b40
chore(git): merge from master
claude Mar 23, 2026
c931b42
refactor(test): replace fuse2fs with squashfuse for cross-device test
claude Mar 23, 2026
bbbef2a
ci: update FUSE dependencies from e2fsprogs to squashfs-tools
claude Mar 23, 2026
1af976d
fix(ci): add apt update and use generic error messages
claude Mar 23, 2026
f3a73e0
refactor(test): improve FUSE probe and cleanup in cross-device test
claude Mar 23, 2026
d0690c4
chore(git): merge from master
claude Mar 23, 2026
17a7c1d
fix(test): replace fixed sleep with exponential backoff for FUSE mount
claude Mar 23, 2026
43e80b9
refactor(test): use cfg_attr(ignore) for cross-device test skipping
claude Mar 23, 2026
2582245
test: remove the `cfg`
KSXGitHub Mar 23, 2026
b8fc756
refactor: unify `use`
KSXGitHub Mar 23, 2026
9dc7465
refactor: use `with_arg`
KSXGitHub Mar 23, 2026
e653d9b
docs: remove some useless comments
KSXGitHub Mar 23, 2026
884bca2
refactor(test): use long flags and full tree comparison in cross-devi…
claude Mar 23, 2026
368f1ff
fix(test): match root name between CLI output and expected tree
claude Mar 23, 2026
1d064d2
refactor: reduce verbose control flow into clever iterator chain
KSXGitHub Mar 23, 2026
0b57e7e
refactor: stop qualifying
KSXGitHub Mar 23, 2026
5cc4cf2
refactor: reduce allocation, reduce import
KSXGitHub Mar 23, 2026
175f73d
refactor: rename some variables
KSXGitHub Mar 23, 2026
c763557
test(one-file-system): add contains assertions and FuseMount document…
claude Mar 23, 2026
90f9e20
docs(test): fix inaccurate skip description in cross-device test
claude Mar 23, 2026
1a9b4b6
docs(ci): document external test dependencies and clean up CI steps
claude Mar 23, 2026
e4fe9f4
docs: generalize skip flag references and fix fuse_probe doc
claude Mar 23, 2026
bf6ee32
docs: convert external dependencies table to bullet list
claude Mar 23, 2026
9e1e135
refactor: trim docs and include retry count in poll message
claude Mar 24, 2026
4c5bad0
refactor(test): use pipe to inline conditional arg in method chain
claude Mar 24, 2026
ddd221f
refactor: simplify conditional argument addition
KSXGitHub Mar 24, 2026
d066ce2
ci(devcontainer): expose FUSE device and install test dependencies
claude Mar 24, 2026
ee76a99
ci(devcontainer): skip FUSE and fs-error tests in Codespaces
claude Mar 24, 2026
be7c805
ci(devcontainer): only skip cross-device test, not fs-errors
claude Mar 24, 2026
f422c66
fix: preserve directory size on read_dir failure and strengthen FUSE …
claude Mar 24, 2026
9d8c235
chore(git): merge from master
claude Mar 25, 2026
d516699
chore(git): merge from master
claude Mar 25, 2026
60de739
docs(contributing): fix stale test-skip references
claude Mar 25, 2026
b0abbaa
refactor(app): move `one_file_system` between `hardlinks_handler` and…
claude Mar 27, 2026
66dbe59
refactor(fs_tree_builder): simplify visibility of device_id items to …
claude Mar 27, 2026
1729ef4
refactor: replace `one_file_system: bool` with `DeviceBoundary` enum
claude Mar 27, 2026
4863a60
refactor: arguments should stay together
KSXGitHub Mar 27, 2026
6c6cfe4
refactor: stop taking `bool`
KSXGitHub Mar 27, 2026
b48921b
refactor: use `pipe`
KSXGitHub Mar 27, 2026
15768f6
refactor(args): move `one_file_system` between `deduplicate_hardlinks…
claude Mar 27, 2026
876739a
chore(git): merge from master
claude Mar 27, 2026
4744905
feat(cli): set conflict
KSXGitHub Mar 27, 2026
27c740c
fix: completions
KSXGitHub Mar 27, 2026
077cbf0
test: set `--min-ratio`
KSXGitHub Mar 27, 2026
4e6f084
refactor(device): rename `from_1fs` to `from_one_file_system`
claude Mar 27, 2026
8b06f9e
refactor: reduce verbosity
KSXGitHub Mar 27, 2026
88c7d8d
refactor: use pipe
KSXGitHub Mar 27, 2026
30f37bc
refactor: rename some variables
KSXGitHub Mar 27, 2026
52d95d7
docs: remove the obvious error message
KSXGitHub Mar 27, 2026
cfddd52
style: add an empty line
KSXGitHub Mar 27, 2026
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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,4 @@ pretty_assertions = "1.4.1"
rand = "0.10.0"

[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(pdu_test_skip_fs_errors)'] }
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(pdu_test_skip_fs_errors)', 'cfg(pdu_test_skip_cross_device)'] }
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ The benchmark was generated by [a GitHub Workflow](https://github.com/KSXGitHub/

* Ignorant of reflinks (from COW filesystems such as BTRFS and ZFS).
* Does not follow symbolic links.
* Does not differentiate filesystems: Mounted folders are counted as normal folders.
* The runtime is optimized at the expense of binary size.

## Usage
Expand Down
7 changes: 7 additions & 0 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ Do not sort the branches in the tree.

Prevent filesystem error messages from appearing in stderr.

<a id="option-x" name="option-x"></a><a id="one-file-system" name="one-file-system"></a>
### `--one-file-system`

* _Aliases:_ `-x`.

Skip directories on different filesystems.

<a id="option-p" name="option-p"></a><a id="progress" name="progress"></a>
### `--progress`

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 -x -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 --one-file-system --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
2 changes: 2 additions & 0 deletions exports/completion.elv
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ set edit:completion:arg-completer[pdu] = {|@words|
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'
cand -x 'Skip directories on different filesystems'
cand --one-file-system 'Skip directories on different filesystems'
cand -p 'Report progress being made at the expense of performance'
cand --progress 'Report progress being made at the expense of performance'
cand --omit-json-shared-details 'Do not output `.shared.details` in the JSON output'
Expand Down
1 change: 1 addition & 0 deletions exports/completion.fish
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ 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 -s s -l silent-errors -l no-errors -d 'Prevent filesystem error messages from appearing in stderr'
complete -c pdu -s x -l one-file-system -d 'Skip directories on different filesystems'
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'
complete -c pdu -l omit-json-shared-summary -d 'Do not output `.shared.summary` in the JSON output'
Expand Down
2 changes: 2 additions & 0 deletions exports/completion.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ Register-ArgumentCompleter -Native -CommandName 'pdu' -ScriptBlock {
[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')
[CompletionResult]::new('-x', '-x', [CompletionResultType]::ParameterName, 'Skip directories on different filesystems')
[CompletionResult]::new('--one-file-system', '--one-file-system', [CompletionResultType]::ParameterName, 'Skip directories on different filesystems')
[CompletionResult]::new('-p', '-p', [CompletionResultType]::ParameterName, 'Report progress being made at the expense of performance')
[CompletionResult]::new('--progress', '--progress', [CompletionResultType]::ParameterName, 'Report progress being made at the expense of performance')
[CompletionResult]::new('--omit-json-shared-details', '--omit-json-shared-details', [CompletionResultType]::ParameterName, 'Do not output `.shared.details` in the JSON output')
Expand Down
2 changes: 2 additions & 0 deletions exports/completion.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ block-count\:"Count numbers of blocks"))' \
'-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]' \
'-x[Skip directories on different filesystems]' \
'--one-file-system[Skip directories on different filesystems]' \
'-p[Report progress being made at the expense of performance]' \
'--progress[Report progress being made at the expense of performance]' \
'--omit-json-shared-details[Do not output \`.shared.details\` in the JSON output]' \
Expand Down
3 changes: 3 additions & 0 deletions exports/long.help
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ Options:

[aliases: --no-errors]

-x, --one-file-system
Skip directories on different filesystems

-p, --progress
Report progress being made at the expense of performance

Expand Down
2 changes: 2 additions & 0 deletions exports/short.help
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ Options:
Do not sort the branches in the tree
-s, --silent-errors
Prevent filesystem error messages from appearing in stderr [aliases: --no-errors]
-x, --one-file-system
Skip directories on different filesystems
-p, --progress
Report progress being made at the expense of performance
--threads <THREADS>
Expand Down
9 changes: 9 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,13 @@ impl App {
.pipe(Err);
}

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

let threads = match self.args.threads {
Threads::Auto => {
let disks = Disks::new_with_refreshed_list();
Expand Down Expand Up @@ -288,6 +295,7 @@ impl App {
bytes_format,
top_down,
align_right,
one_file_system,
max_depth,
min_ratio,
no_sort,
Expand All @@ -304,6 +312,7 @@ impl App {
files,
json_output: JsonOutputParam::from_cli_flags(json_output, omit_json_shared_details, omit_json_shared_summary),
column_width_distribution,
one_file_system,
max_depth,
min_ratio,
no_sort,
Expand Down
4 changes: 4 additions & 0 deletions src/app/sub.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ where
pub bar_alignment: BarAlignment,
/// Distribution and number of characters/blocks can be placed in a line.
pub column_width_distribution: ColumnWidthDistribution,
/// Skip directories on different filesystems.
pub one_file_system: bool,
/// Maximum number of levels that should be visualized.
pub max_depth: Depth,
/// [Get the size](GetSize) of files/directories.
Expand Down Expand Up @@ -68,6 +70,7 @@ where
direction,
bar_alignment,
column_width_distribution,
one_file_system,
max_depth,
size_getter,
hardlinks_handler,
Expand All @@ -86,6 +89,7 @@ where
root,
size_getter,
hardlinks_recorder: &hardlinks_handler,
one_file_system,
max_depth,
}
.into()
Expand Down
5 changes: 5 additions & 0 deletions src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ pub struct Args {
#[clap(long, short, visible_alias = "no-errors")]
pub silent_errors: bool,

/// Skip directories on different filesystems.
#[clap(long, short = 'x')]
#[cfg_attr(not(unix), clap(hide = true))]
pub one_file_system: bool,

/// Report progress being made at the expense of performance.
#[clap(long, short)]
pub progress: bool,
Expand Down
33 changes: 30 additions & 3 deletions src/fs_tree_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use super::{
size,
tree_builder::{Info, TreeBuilder},
};
use device_id::get_device_id;
use pipe_trait::Pipe;
use std::{
fs::{read_dir, symlink_metadata},
Expand All @@ -32,6 +33,7 @@ use std::{
/// hardlinks_recorder: &HardlinkIgnorant,
/// size_getter: GetApparentSize,
/// reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT),
/// one_file_system: false,
/// max_depth: 10,
/// };
/// let data_tree: DataTree<OsStringDisplay, Bytes> = builder.into();
Expand All @@ -52,6 +54,8 @@ where
pub hardlinks_recorder: &'a HardlinksRecorder,
/// Reports progress to external system.
pub reporter: &'a Report,
/// Skip directories on different filesystems.
pub one_file_system: bool,
/// Deepest level of descendant display in the graph. The sizes beyond the max depth still count toward total.
pub max_depth: u64,
Comment thread
KSXGitHub marked this conversation as resolved.
}
Expand All @@ -72,16 +76,35 @@ where
size_getter,
hardlinks_recorder,
reporter,
one_file_system,
max_depth,
} = builder;

// `root` would be inspected multiple times, but its impact on performance is insignificant
// before the (usually) massive fs tree `root` contains.
let root_dev = if one_file_system {
match symlink_metadata(&root) {
Err(error) => {
reporter.report(Event::EncounterError(ErrorReport {
operation: SymlinkMetadata,
path: &root,
error,
}));
return DataTree::file(OsStringDisplay::os_string_from(&root), Size::default());
Comment thread
KSXGitHub marked this conversation as resolved.
Comment thread
KSXGitHub marked this conversation as resolved.
}
Ok(stats) => Some(get_device_id(&stats)),
}
} else {
None
};

TreeBuilder::<PathBuf, OsStringDisplay, Size, _, _> {
name: OsStringDisplay::os_string_from(&root),

path: root,

get_info: |path| {
let (is_dir, size) = match symlink_metadata(path) {
let (is_dir, size, same_device) = match symlink_metadata(path) {
Err(error) => {
reporter.report(Event::EncounterError(ErrorReport {
operation: SymlinkMetadata,
Expand All @@ -96,18 +119,20 @@ where
Ok(stats) => {
// `stats` should be dropped ASAP to avoid piling up kernel memory usage
let is_dir = stats.is_dir();
let same_device =
root_dev.is_none_or(|root_dev| get_device_id(&stats) == root_dev);
Comment thread
KSXGitHub marked this conversation as resolved.
let size = size_getter.get_size(&stats);
reporter.report(Event::ReceiveData(size));
hardlinks_recorder
.record_hardlinks(RecordHardlinksArgument::new(
path, &stats, size, reporter,
))
.ok(); // ignore the error for now
(is_dir, size)
(is_dir, size, same_device)
}
};

let children: Vec<_> = if is_dir {
let children: Vec<_> = if is_dir && same_device {
match read_dir(path) {
Err(error) => {
reporter.report(Event::EncounterError(ErrorReport {
Expand Down Expand Up @@ -145,3 +170,5 @@ where
.into()
}
}

mod device_id;
69 changes: 69 additions & 0 deletions src/fs_tree_builder/device_id.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/// Unique identifier for a device or filesystem.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct DeviceId(Inner);

#[cfg(unix)]
type Inner = u64;

#[cfg(not(unix))]
type Inner = ();

/// Retrieve the [`DeviceId`] from filesystem metadata.
#[cfg(unix)]
pub(crate) fn get_device_id(stats: &std::fs::Metadata) -> DeviceId {
use std::os::unix::fs::MetadataExt;
DeviceId(stats.dev())
}

/// Retrieve the [`DeviceId`] from filesystem metadata.
///
/// On unsupported platforms, all entries share the same [`DeviceId`],
/// effectively disabling cross-device detection.
#[cfg(not(unix))]
pub(crate) fn get_device_id(_stats: &std::fs::Metadata) -> DeviceId {
DeviceId(())
}
Comment thread
KSXGitHub marked this conversation as resolved.

#[cfg(test)]
#[cfg(unix)]
mod tests {
use super::get_device_id;
use std::fs::symlink_metadata;

#[test]
fn same_filesystem_returns_equal_ids() {
let root_stats = symlink_metadata("/").expect("stat /");
let root_stats2 = symlink_metadata("/").expect("stat / again");
assert_eq!(
get_device_id(&root_stats),
get_device_id(&root_stats2),
"same path should yield the same DeviceId",
);
}

/// `/proc` is a virtual filesystem mounted separately from `/` on Linux.
#[test]
#[cfg(target_os = "linux")]
fn different_filesystem_returns_different_ids() {
let root_stats = symlink_metadata("/").expect("stat /");
let proc_stats = symlink_metadata("/proc").expect("stat /proc");
assert_ne!(
get_device_id(&root_stats),
get_device_id(&proc_stats),
"/ and /proc should be on different devices",
);
}

/// `/dev` is a separate filesystem (`devfs`) from `/` on macOS.
#[test]
#[cfg(target_os = "macos")]
fn different_filesystem_returns_different_ids() {
let root_stats = symlink_metadata("/").expect("stat /");
let dev_stats = symlink_metadata("/dev").expect("stat /dev");
assert_ne!(
get_device_id(&root_stats),
get_device_id(&dev_stats),
"/ and /dev should be on different devices",
);
}
}
4 changes: 4 additions & 0 deletions src/runtime_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ pub enum UnsupportedFeature {
#[cfg(not(unix))]
#[display("Feature --deduplicate-hardlinks is not available on this platform")]
DeduplicateHardlink,
/// Using `--one-file-system` on non-POSIX.
#[cfg(not(unix))]
#[display("Feature --one-file-system is not available on this platform")]
Comment thread
KSXGitHub marked this conversation as resolved.
OneFileSystem,
Comment thread
KSXGitHub marked this conversation as resolved.
}

impl From<Infallible> for RuntimeError {
Expand Down
1 change: 1 addition & 0 deletions tests/_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ where
panic!("Unexpected call to report_error: {error:?}")
}),
root: root.join(suffix),
one_file_system: false,
max_depth: 10,
}
Comment thread
KSXGitHub marked this conversation as resolved.
.pipe(DataTree::<OsStringDisplay, Size>::from)
Expand Down
1 change: 1 addition & 0 deletions tests/cli_errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ fn fs_errors() {
size_getter: GetApparentSize,
hardlinks_recorder: &HardlinkIgnorant,
reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT),
one_file_system: false,
max_depth: 10,
};
let mut data_tree: DataTree<OsStringDisplay, _> = builder.into();
Expand Down
1 change: 1 addition & 0 deletions tests/json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ fn json_output() {
size_getter: GetApparentSize,
hardlinks_recorder: &HardlinkIgnorant,
reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT),
one_file_system: false,
max_depth: 10,
};
let expected = builder
Expand Down
Loading
Loading