Skip to content

Commit b4e524a

Browse files
yangshunclaude
andcommitted
Add --du flag, fix -s to match Linux tree behavior, and clean up codebase
- Add --du flag for recursive directory size accumulation (like du -c) - Fix -s/sizes to use stat.size directly instead of recursive sums, matching Linux tree -s behavior - Remove dirsOnly check from isExcluded (already handled by filterAndOrderContents) - Add JSDoc comments documenting assumptions to all functions - Fix terminology inconsistencies across CLI, README, and source - Extract resolveOptions() to deduplicate option merging logic - Unify callback parameter names in filterAndOrderContents to 'entry' Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 43f0dca commit b4e524a

9 files changed

Lines changed: 263 additions & 65 deletions

File tree

README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ Usage:
5959
$ tree <command> [options]
6060
6161
Options:
62-
-a, --all-files All files, include hidden files, are printed.
62+
-a, --all-files All files, including hidden files, are printed.
6363
-D, --date Show last modification time for each entry.
6464
-f, --full-path Print the full path prefix for each file.
6565
-i, --no-indent Print entries without tree indentation lines.
@@ -70,6 +70,8 @@ Options:
7070
--dirs-first List directories before files.
7171
-d, --dirs-only List directories only.
7272
-s, --sizes Print the size of each file in bytes along with the name.
73+
--du For each directory report its size as the accumulation
74+
of sizes of all its files and sub-directories. Implies -s.
7375
-I, --exclude <patterns> Exclude files that match the pattern. | separates
7476
alternate patterns. Wrap your entire pattern in double
7577
quotes. E.g. "node_modules|coverage".
@@ -144,7 +146,7 @@ console.log(result);
144146

145147
| Field | Default | Type | Description |
146148
| --------------- | -------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------- |
147-
| `allFiles` | `false` | `boolean` | All files are printed. By default, tree does not print hidden files (those beginning with a dot). |
149+
| `allFiles` | `false` | `boolean` | All files, including hidden files, are printed. |
148150
| `date` | `false` | `boolean` | Show last modification time for each entry. |
149151
| `dirsFirst` | `false` | `boolean` | List directories before files. |
150152
| `fullPath` | `false` | `boolean` | Print the full path prefix for each file. |
@@ -153,11 +155,12 @@ console.log(result);
153155
| `quote` | `false` | `boolean` | Quote filenames in double quotes. |
154156
| `prune` | `false` | `boolean` | Remove empty directories from output. |
155157
| `dirsOnly` | `false` | `boolean` | List directories only. |
156-
| `sizes` | `false` | `boolean` | Show filesizes as well. |
158+
| `sizes` | `false` | `boolean` | Print the size of each file in bytes along with the name. |
159+
| `du` | `false` | `boolean` | For each directory, report its size as the accumulation of sizes of all its files and sub-directories. Implies `sizes`. |
157160
| `exclude` | `[]` | `RegExp[]` | An array of regex to test each filename against. Matching files will be excluded and matching directories will not be traversed into. To exclude a directory's contents while still showing the directory itself, use a regex that matches the path with a trailing slash (e.g., `/node_modules\//`). |
158161
| `maxDepth` | `Number.POSITIVE_INFINITY` | `number` | Max display depth of the directory tree. |
159162
| `reverse` | `false` | `boolean` | Sort the output in reverse alphabetic order. |
160-
| `trailingSlash` | `false` | `boolean` | Appends a trailing slash behind directories. |
163+
| `trailingSlash` | `false` | `boolean` | Append a `/` for directories. |
161164
| `lineAscii` | `false` | `boolean` | Turn on ASCII line graphics. |
162165
| `filesFirst` | `false` | `boolean` | List files before directories. |
163166
| `sortBy` | `'name'` | `string` | Sort the output. Options: `'name'`, `'version'`, `'mtime'`, `'ctime'`, `'size'`. |

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ Track progress of implementing Linux `tree` options in tree-node-cli.
5959
## Code Improvements
6060

6161
- [x] **Filter before stat**`statEntries` stats all entries including hidden files that will be filtered out when `allFiles: false`. Filter hidden files before stat'ing to avoid unnecessary syscalls.
62-
- [ ] **Match Linux `tree -s` size behavior**`computeSizes` recursively sums file sizes into parent directories, but Linux `tree -s` shows each entry's own `stat.size` (inode size for directories, not recursive sum). Remove `computeSizes` and use `stat.size` directly.
62+
- [x] **Match Linux `tree -s` size behavior**`computeSizes` recursively sums file sizes into parent directories, but Linux `tree -s` shows each entry's own `stat.size` (inode size for directories, not recursive sum). `-s` now uses `stat.size` directly; recursive sums moved to new `--du` flag.
6363
- [x] **Merge `EXCLUDED_PATTERNS` into exclude** — The hardcoded `.DS_Store` pattern is checked in a separate loop for every entry. Merge it into the user's `exclude` array at initialization to simplify `isExcluded`.
6464
- [x] **Clarify `dirsOnly` filtering**`dirsOnly` is checked in both `isExcluded` (skips files early) and `filterAndOrderContents` (filters file entries). The `isExcluded` check is a performance optimization that avoids stat'ing files, but the dual responsibility could be clearer.
6565
- [x] **Avoid mutating input arrays**`sortContents` calls `contents.sort()` which mutates the caller's array in place. Using `[...contents].sort(...)` would be safer, though not currently a bug since callers don't reuse the array.

src/__snapshots__/index.test.ts.snap

Lines changed: 135 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,9 @@ exports[`tree > combined: filesFirst + reverse 1`] = `
157157
`;
158158

159159
exports[`tree > combined: trailingSlash + lineAscii + sizes 1`] = `
160-
"13B single-level/
160+
"128B single-level/
161161
|-- 13B alpha.txt
162-
\`-- 0B beta/"
162+
\`-- 96B beta/"
163163
`;
164164

165165
exports[`tree > default 1`] = `
@@ -421,6 +421,108 @@ exports[`tree > dirsOnly 11`] = `
421421

422422
exports[`tree > dirsOnly 12`] = `"varied-sizes"`;
423423

424+
exports[`tree > du 1`] = `
425+
"24B chain
426+
└── 24B alpha
427+
└── 24B alpha
428+
└── 24B alpha
429+
└── 24B alpha
430+
└── 24B alpha
431+
└── 24B alpha
432+
└── 24B alpha
433+
└── 24B alpha
434+
└── 24B alpha.txt"
435+
`;
436+
437+
exports[`tree > du 2`] = `
438+
"29B double-level
439+
├── 9B alpha.txt
440+
└── 20B beta
441+
└── 20B alpha.txt"
442+
`;
443+
444+
exports[`tree > du 3`] = `"0B empty-dir"`;
445+
446+
exports[`tree > du 4`] = `
447+
"30B hidden-files
448+
├── 8B another.txt
449+
└── 8B visible.txt"
450+
`;
451+
452+
exports[`tree > du 5`] = `
453+
"5B mixed-content
454+
├── 1B alpha.txt
455+
├── 1B bravo-dir
456+
│ └── 1B inner.txt
457+
├── 1B charlie.txt
458+
├── 1B delta-dir
459+
│ └── 1B inner.txt
460+
└── 1B echo.txt"
461+
`;
462+
463+
exports[`tree > du 6`] = `
464+
"68B multi-files
465+
├── 5B alpha.txt
466+
├── 17B beta.txt
467+
└── 46B charlie.txt"
468+
`;
469+
470+
exports[`tree > du 7`] = `
471+
"127B multi-level
472+
├── 10B alpha.txt
473+
├── 48B beta
474+
│ ├── 27B alpha
475+
│ │ ├── 16B alpha.txt
476+
│ │ └── 11B beta
477+
│ │ └── 11B alpha.txt
478+
│ ├── 9B beta.txt
479+
│ └── 12B charlie
480+
│ └── 12B alpha.txt
481+
├── 54B charlie
482+
│ ├── 13B alpha.txt
483+
│ ├── 18B beta
484+
│ │ └── 18B alpha.txt
485+
│ └── 23B charlie.txt
486+
└── 15B delta.txt"
487+
`;
488+
489+
exports[`tree > du 8`] = `
490+
"0B numbered-files
491+
├── 0B file1.txt
492+
├── 0B file10.txt
493+
├── 0B file2.txt
494+
├── 0B file20.txt
495+
└── 0B file3.txt"
496+
`;
497+
498+
exports[`tree > du 9`] = `
499+
"5B single-file
500+
└── 5B alpha.txt"
501+
`;
502+
503+
exports[`tree > du 10`] = `
504+
"13B single-level
505+
├── 13B alpha.txt
506+
└── 0B beta"
507+
`;
508+
509+
exports[`tree > du 11`] = `
510+
"4B unicode-names
511+
├── 1B café.txt
512+
├── 1B emoji-🎉.txt
513+
├── 1B über.txt
514+
└── 1B 日本語
515+
└── 1B ファイル.txt"
516+
`;
517+
518+
exports[`tree > du 12`] = `
519+
"11.1kB varied-sizes
520+
├── 10kB large.txt
521+
├── 1kB medium.txt
522+
├── 100B small.txt
523+
└── 1B tiny.txt"
524+
`;
525+
424526
exports[`tree > exclude 1`] = `
425527
"chain
426528
└── alpha
@@ -1161,72 +1263,72 @@ exports[`tree > reverse 12`] = `
11611263
`;
11621264

11631265
exports[`tree > sizes 1`] = `
1164-
"24B chain
1165-
└── 24B alpha
1166-
└── 24B alpha
1167-
└── 24B alpha
1168-
└── 24B alpha
1169-
└── 24B alpha
1170-
└── 24B alpha
1171-
└── 24B alpha
1172-
└── 24B alpha
1266+
"96B chain
1267+
└── 96B alpha
1268+
└── 96B alpha
1269+
└── 96B alpha
1270+
└── 96B alpha
1271+
└── 96B alpha
1272+
└── 96B alpha
1273+
└── 96B alpha
1274+
└── 96B alpha
11731275
└── 24B alpha.txt"
11741276
`;
11751277

11761278
exports[`tree > sizes 2`] = `
1177-
"29B double-level
1279+
"128B double-level
11781280
├── 9B alpha.txt
1179-
└── 20B beta
1281+
└── 96B beta
11801282
└── 20B alpha.txt"
11811283
`;
11821284

1183-
exports[`tree > sizes 3`] = `"0B empty-dir"`;
1285+
exports[`tree > sizes 3`] = `"96B empty-dir"`;
11841286

11851287
exports[`tree > sizes 4`] = `
1186-
"30B hidden-files
1288+
"192B hidden-files
11871289
├── 8B another.txt
11881290
└── 8B visible.txt"
11891291
`;
11901292

11911293
exports[`tree > sizes 5`] = `
1192-
"5B mixed-content
1294+
"224B mixed-content
11931295
├── 1B alpha.txt
1194-
├── 1B bravo-dir
1296+
├── 96B bravo-dir
11951297
│ └── 1B inner.txt
11961298
├── 1B charlie.txt
1197-
├── 1B delta-dir
1299+
├── 96B delta-dir
11981300
│ └── 1B inner.txt
11991301
└── 1B echo.txt"
12001302
`;
12011303

12021304
exports[`tree > sizes 6`] = `
1203-
"68B multi-files
1305+
"160B multi-files
12041306
├── 5B alpha.txt
12051307
├── 17B beta.txt
12061308
└── 46B charlie.txt"
12071309
`;
12081310

12091311
exports[`tree > sizes 7`] = `
1210-
"127B multi-level
1312+
"192B multi-level
12111313
├── 10B alpha.txt
1212-
├── 48B beta
1213-
│ ├── 27B alpha
1314+
├── 160B beta
1315+
│ ├── 128B alpha
12141316
│ │ ├── 16B alpha.txt
1215-
│ │ └── 11B beta
1317+
│ │ └── 128B beta
12161318
│ │ └── 11B alpha.txt
12171319
│ ├── 9B beta.txt
1218-
│ └── 12B charlie
1320+
│ └── 96B charlie
12191321
│ └── 12B alpha.txt
1220-
├── 54B charlie
1322+
├── 192B charlie
12211323
│ ├── 13B alpha.txt
1222-
│ ├── 18B beta
1324+
│ ├── 96B beta
12231325
│ │ └── 18B alpha.txt
12241326
│ └── 23B charlie.txt
12251327
└── 15B delta.txt"
12261328
`;
12271329

12281330
exports[`tree > sizes 8`] = `
1229-
"0B numbered-files
1331+
"224B numbered-files
12301332
├── 0B file1.txt
12311333
├── 0B file10.txt
12321334
├── 0B file2.txt
@@ -1235,27 +1337,27 @@ exports[`tree > sizes 8`] = `
12351337
`;
12361338

12371339
exports[`tree > sizes 9`] = `
1238-
"5B single-file
1340+
"96B single-file
12391341
└── 5B alpha.txt"
12401342
`;
12411343

12421344
exports[`tree > sizes 10`] = `
1243-
"13B single-level
1345+
"128B single-level
12441346
├── 13B alpha.txt
1245-
└── 0B beta"
1347+
└── 96B beta"
12461348
`;
12471349

12481350
exports[`tree > sizes 11`] = `
1249-
"4B unicode-names
1351+
"192B unicode-names
12501352
├── 1B café.txt
12511353
├── 1B emoji-🎉.txt
12521354
├── 1B über.txt
1253-
└── 1B 日本語
1355+
└── 96B 日本語
12541356
└── 1B ファイル.txt"
12551357
`;
12561358

12571359
exports[`tree > sizes 12`] = `
1258-
"11.1kB varied-sizes
1360+
"192B varied-sizes
12591361
├── 10kB large.txt
12601362
├── 1kB medium.txt
12611363
├── 100B small.txt

src/cli.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,18 @@ describe('cli', () => {
241241
}
242242
});
243243

244+
test('respects --du', async () => {
245+
const { stdout } = await run([
246+
'--du',
247+
path.join(FIXTURES_PATH, 'single-file'),
248+
]);
249+
// --du implies -s, so sizes should be shown
250+
const lines = stdout.split('\n');
251+
for (const line of lines) {
252+
expect(line).toMatch(/\dB/);
253+
}
254+
});
255+
244256
test('respects -v version sort', async () => {
245257
const { stdout } = await run([
246258
'-v',

src/cli.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const PATTERN_SEPARATOR = '|';
1212
const cli = cac('tree');
1313

1414
cli
15-
.option('-a, --all-files', 'All files, include hidden files, are printed.')
15+
.option('-a, --all-files', 'All files, including hidden files, are printed.')
1616
.option('-D, --date', 'Show last modification time for each entry.')
1717
.option('-f, --full-path', 'Print the full path prefix for each file.')
1818
.option('-i, --no-indent', 'Print entries without tree indentation lines.')
@@ -26,6 +26,10 @@ cli
2626
'-s, --sizes',
2727
'Print the size of each file in bytes along with the name.',
2828
)
29+
.option(
30+
'--du',
31+
'For each directory report its size as the accumulation of sizes of all its files and sub-directories. Implies -s.',
32+
)
2933
.option(
3034
'-I, --exclude <patterns>',
3135
'Exclude files that match the pattern. | separates alternate patterns. ' +
@@ -87,6 +91,7 @@ const options: Options = {
8791
...(opts.prune != null && { prune: opts.prune as boolean }),
8892
...(opts.quote != null && { quote: opts.quote as boolean }),
8993
...(opts.sizes != null && { sizes: opts.sizes as boolean }),
94+
...(opts.du != null && { du: opts.du as boolean }),
9095
...(opts.exclude != null && {
9196
exclude: (opts.exclude as string)
9297
.split(PATTERN_SEPARATOR)

src/formats/json.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const DEFAULT_OPTIONS: RequiredOptions = {
88
date: false,
99
dirsFirst: false,
1010
dirsOnly: false,
11+
du: false,
1112
filesFirst: false,
1213
fullPath: false,
1314
noIndent: false,

src/formats/text.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const DEFAULT_OPTIONS: RequiredOptions = {
88
date: false,
99
dirsFirst: false,
1010
dirsOnly: false,
11+
du: false,
1112
filesFirst: false,
1213
fullPath: false,
1314
noIndent: false,

0 commit comments

Comments
 (0)