Skip to content

Commit 6892ec7

Browse files
yangshunclaude
andcommitted
Add gitignore support, enabled by default
Respect .gitignore files when listing directory trees inside a git repository. Uses the `ignore` npm package to parse patterns. Finds the git root, reads root and nested .gitignore files, and filters entries before stat'ing for performance. Use --no-gitignore to disable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bd53c37 commit 6892ec7

File tree

10 files changed

+397
-14
lines changed

10 files changed

+397
-14
lines changed

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Note: Symlinks are not followed. Directories that cannot be opened (e.g. due to
1111
Instantly execute the command in your current directory via `npx` / `pnpx` / `bunx` / `yarn dlx`:
1212

1313
```bash
14-
npx tree-node-cli -I "node_modules" # ignore node_modules
14+
npx tree-node-cli
1515
```
1616

1717
## Installation
@@ -87,6 +87,8 @@ Options:
8787
--files-first List files before directories.
8888
--sort <type> Sort the output by type: name, version, mtime,
8989
ctime, size.
90+
--gitignore Respect .gitignore files (enabled by default).
91+
Use --no-gitignore to disable.
9092
-h, --help Display this message
9193
--version Display version number
9294
```
@@ -158,6 +160,7 @@ console.log(result);
158160
| `dirsOnly` | `false` | `boolean` | List directories only. |
159161
| `sizes` | `false` | `boolean` | Print the size of each file in bytes along with the name. |
160162
| `du` | `false` | `boolean` | For each directory, report its size as the accumulation of sizes of all its files and sub-directories. Implies `sizes`. |
163+
| `gitignore` | `true` | `boolean` | Respect `.gitignore` files when inside a git repository. Use `--no-gitignore` to disable. |
161164
| `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\//`). |
162165
| `maxDepth` | `Number.POSITIVE_INFINITY` | `number` | Max display depth of the directory tree. |
163166
| `reverse` | `false` | `boolean` | Sort the output in reverse alphabetic order. |

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
},
5050
"dependencies": {
5151
"cac": "^7.0.0",
52+
"ignore": "^7.0.5",
5253
"pretty-bytes": "^7.1.0"
5354
},
5455
"devDependencies": {

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/cli.test.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { execFile } from 'child_process';
1+
import { execFile, execSync } from 'child_process';
22
import fs from 'fs';
33
import os from 'os';
44
import path from 'path';
5-
import { describe, expect, test } from 'vitest';
5+
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
66

77
const TSX_PATH = path.resolve(__dirname, '../node_modules/.bin/tsx');
88
const CLI_PATH = path.resolve(__dirname, 'cli.ts');
@@ -361,4 +361,37 @@ describe('cli', () => {
361361
expect(withoutAll).not.toContain('.gitkeep');
362362
expect(withAll).toContain('.gitkeep');
363363
});
364+
365+
test('help includes --gitignore option', async () => {
366+
const { stdout } = await run(['-h']);
367+
expect(stdout).toContain('--gitignore');
368+
});
369+
370+
describe('--no-gitignore', () => {
371+
let tmpDir: string;
372+
373+
beforeEach(() => {
374+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tree-cli-gitignore-'));
375+
execSync('git init', { cwd: tmpDir });
376+
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'secret.txt\n');
377+
fs.writeFileSync(path.join(tmpDir, 'secret.txt'), '');
378+
fs.writeFileSync(path.join(tmpDir, 'visible.txt'), '');
379+
});
380+
381+
afterEach(() => {
382+
fs.rmSync(tmpDir, { recursive: true });
383+
});
384+
385+
test('excludes gitignored files by default', async () => {
386+
const { stdout } = await run([tmpDir]);
387+
expect(stdout).not.toContain('secret.txt');
388+
expect(stdout).toContain('visible.txt');
389+
});
390+
391+
test('shows gitignored files with --no-gitignore', async () => {
392+
const { stdout } = await run(['--no-gitignore', tmpDir]);
393+
expect(stdout).toContain('secret.txt');
394+
expect(stdout).toContain('visible.txt');
395+
});
396+
});
364397
});

src/cli.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ cli
5050
.option(
5151
'--sort <type>',
5252
'Sort the output by type: name, version, mtime, ctime, size.',
53+
)
54+
.option(
55+
'--gitignore',
56+
'Respect .gitignore files (enabled by default). Use --no-gitignore to disable.',
5357
);
5458

5559
cli.help();
@@ -105,6 +109,7 @@ const options: Options = {
105109
}),
106110
...(opts.lineAscii != null && { lineAscii: opts.lineAscii as boolean }),
107111
...(opts.unsorted != null && { unsorted: opts.unsorted as boolean }),
112+
...(opts.gitignore != null && { gitignore: opts.gitignore as boolean }),
108113
};
109114

110115
if (opts.json) {

src/formats/text.test.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -181,10 +181,7 @@ describe('formatAsText', () => {
181181
makeDir('forbidden', [], { path: 'root/forbidden', openError: true }),
182182
]);
183183
const lines = formatAsText(node, opts({ trailingSlash: true }), '.');
184-
expect(lines).toEqual([
185-
'root/',
186-
'└── forbidden/ [error opening dir]',
187-
]);
184+
expect(lines).toEqual(['root/', '└── forbidden/ [error opening dir]']);
188185
});
189186

190187
test('vertical line for non-last siblings', () => {

src/index.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { execSync } from 'child_process';
12
import fs from 'fs';
23
import { isNotJunk } from 'junk';
34
import os from 'os';
@@ -774,3 +775,80 @@ describe('tree with special characters in filenames', () => {
774775
expect(result).toContain('file-with-dashes.txt');
775776
});
776777
});
778+
779+
describe('tree with gitignore', () => {
780+
let tmpDir: string;
781+
782+
beforeEach(() => {
783+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tree-gitignore-'));
784+
execSync('git init', { cwd: tmpDir });
785+
fs.writeFileSync(
786+
path.join(tmpDir, '.gitignore'),
787+
'ignored-dir/\nignored-file.txt\n',
788+
);
789+
fs.mkdirSync(path.join(tmpDir, 'ignored-dir'));
790+
fs.writeFileSync(path.join(tmpDir, 'ignored-dir', 'file.txt'), '');
791+
fs.writeFileSync(path.join(tmpDir, 'ignored-file.txt'), '');
792+
fs.writeFileSync(path.join(tmpDir, 'visible-file.txt'), '');
793+
fs.mkdirSync(path.join(tmpDir, 'visible-dir'));
794+
fs.writeFileSync(path.join(tmpDir, 'visible-dir', 'nested.txt'), '');
795+
});
796+
797+
afterEach(() => {
798+
fs.rmSync(tmpDir, { recursive: true });
799+
});
800+
801+
test('excludes gitignored files by default', () => {
802+
const result = tree(tmpDir);
803+
expect(result).not.toContain('ignored-dir');
804+
expect(result).not.toContain('ignored-file.txt');
805+
expect(result).toContain('visible-file.txt');
806+
expect(result).toContain('visible-dir');
807+
});
808+
809+
test('shows gitignored files when gitignore is false', () => {
810+
const result = tree(tmpDir, { gitignore: false });
811+
expect(result).toContain('ignored-dir');
812+
expect(result).toContain('ignored-file.txt');
813+
expect(result).toContain('visible-file.txt');
814+
});
815+
816+
test('works with nested .gitignore', () => {
817+
fs.writeFileSync(
818+
path.join(tmpDir, 'visible-dir', '.gitignore'),
819+
'nested.txt\n',
820+
);
821+
const result = tree(tmpDir, { allFiles: true });
822+
expect(result).not.toContain('nested.txt');
823+
expect(result).toContain('visible-dir');
824+
});
825+
826+
test('gitignore has no effect outside git repo', () => {
827+
const nonGitDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tree-nogit-'));
828+
fs.writeFileSync(path.join(nonGitDir, '.gitignore'), '*.txt\n');
829+
fs.writeFileSync(path.join(nonGitDir, 'file.txt'), '');
830+
831+
const result = tree(nonGitDir);
832+
expect(result).toContain('file.txt');
833+
834+
fs.rmSync(nonGitDir, { recursive: true });
835+
});
836+
837+
test('gitignore works together with exclude option', () => {
838+
const result = tree(tmpDir, { exclude: [/visible-dir/] });
839+
expect(result).not.toContain('ignored-dir');
840+
expect(result).not.toContain('ignored-file.txt');
841+
expect(result).not.toContain('visible-dir');
842+
expect(result).toContain('visible-file.txt');
843+
});
844+
845+
test('gitignore works with treeJson', () => {
846+
const result = treeJson(tmpDir);
847+
const dir = asDir(result);
848+
const names = dir.contents.map((c) => c.name);
849+
expect(names).not.toContain('ignored-dir');
850+
expect(names).not.toContain('ignored-file.txt');
851+
expect(names).toContain('visible-file.txt');
852+
expect(names).toContain('visible-dir');
853+
});
854+
});

src/index.ts

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import nodePath from 'path';
33

44
import { type TreeJsonEntry, formatAsJson } from './formats/json';
55
import { formatAsText } from './formats/text';
6+
import {
7+
type GitignoreFilter,
8+
createGitignoreFilter,
9+
findGitRoot,
10+
} from './utils/gitignore';
611

712
export type SortBy = 'name' | 'version' | 'mtime' | 'ctime' | 'size';
813

@@ -25,6 +30,7 @@ export interface Options {
2530
sortBy?: SortBy;
2631
trailingSlash?: boolean;
2732
lineAscii?: boolean;
33+
gitignore?: boolean;
2834
unsorted?: boolean;
2935
}
3036

@@ -44,6 +50,7 @@ const DEFAULT_OPTIONS: RequiredOptions = {
4450
quote: false,
4551
sizes: false,
4652
exclude: [],
53+
gitignore: true,
4754
maxDepth: Number.POSITIVE_INFINITY,
4855
reverse: false,
4956
sortBy: 'name',
@@ -67,6 +74,14 @@ export interface TreeNode {
6774
openError?: boolean;
6875
}
6976

77+
// ── Build context ─────────────────────────────────────────
78+
79+
interface BuildContext {
80+
sizeCache: Map<string, number>;
81+
gitignoreFilter: GitignoreFilter | null;
82+
gitRoot: string | null;
83+
}
84+
7085
// ── Build tree ─────────────────────────────────────────────
7186

7287
/**
@@ -196,6 +211,7 @@ function filterAndOrderContents(
196211
function getDirectoryContents(
197212
dirPath: string,
198213
options: RequiredOptions,
214+
ctx: BuildContext,
199215
): { entries: string[]; stats: Map<string, fs.Stats>; openError: boolean } {
200216
let entries: string[];
201217

@@ -210,8 +226,37 @@ function getDirectoryContents(
210226
entries = entries.filter((entry) => !isHiddenFile(entry));
211227
}
212228

229+
// Load nested .gitignore and filter ignored entries before stat'ing.
230+
if (ctx.gitignoreFilter && ctx.gitRoot) {
231+
const absDirPath = nodePath.resolve(dirPath);
232+
ctx.gitignoreFilter.loadNestedGitignore(absDirPath);
233+
entries = entries.filter((entry) => {
234+
const relPath = nodePath.relative(
235+
ctx.gitRoot!,
236+
nodePath.join(absDirPath, entry),
237+
);
238+
return !ctx.gitignoreFilter!.ignores(relPath, false);
239+
});
240+
}
241+
213242
const { stats, accessible } = statEntries(dirPath, entries);
214-
const sorted = sortContentsInPlace(accessible, stats, options);
243+
244+
// Secondary gitignore check for directory-only patterns (e.g. "logs/").
245+
let finalAccessible = accessible;
246+
if (ctx.gitignoreFilter && ctx.gitRoot) {
247+
const absDirPath = nodePath.resolve(dirPath);
248+
finalAccessible = accessible.filter((entry) => {
249+
const stat = stats.get(entry);
250+
if (!stat?.isDirectory()) return true;
251+
const relPath = nodePath.relative(
252+
ctx.gitRoot!,
253+
nodePath.join(absDirPath, entry),
254+
);
255+
return !ctx.gitignoreFilter!.ignores(relPath, true);
256+
});
257+
}
258+
259+
const sorted = sortContentsInPlace(finalAccessible, stats, options);
215260
const filtered = filterAndOrderContents(sorted, stats, options);
216261

217262
return { entries: filtered, stats, openError: false };
@@ -269,7 +314,7 @@ function buildTree(
269314
path: string,
270315
currentDepth: number,
271316
options: RequiredOptions,
272-
sizeCache: Map<string, number>,
317+
ctx: BuildContext,
273318
cachedStat?: fs.Stats,
274319
): TreeNode | null {
275320
const stat = cachedStat ?? fs.lstatSync(path);
@@ -286,15 +331,19 @@ function buildTree(
286331
isDirectory: isDir,
287332
mode: stat.mode,
288333
mtime: stat.mtime,
289-
size: sizeCache.get(path) ?? stat.size,
334+
size: ctx.sizeCache.get(path) ?? stat.size,
290335
children: [],
291336
};
292337

293338
if (isFile || currentDepth >= options.maxDepth) {
294339
return node;
295340
}
296341

297-
const { entries, stats, openError } = getDirectoryContents(path, options);
342+
const { entries, stats, openError } = getDirectoryContents(
343+
path,
344+
options,
345+
ctx,
346+
);
298347

299348
if (openError) {
300349
node.openError = true;
@@ -306,7 +355,7 @@ function buildTree(
306355
nodePath.join(path, entry),
307356
currentDepth + 1,
308357
options,
309-
sizeCache,
358+
ctx,
310359
stats.get(entry),
311360
);
312361

@@ -337,10 +386,21 @@ function buildTreeNode(
337386
computeSizes(path, sizeCache);
338387
}
339388

340-
const resolvedPath = nodePath.join(process.cwd(), path);
389+
const resolvedPath = nodePath.resolve(path);
341390
const rootName = nodePath.basename(resolvedPath);
342391

343-
return buildTree(rootName, path, 0, options, sizeCache);
392+
let gitignoreFilter: GitignoreFilter | null = null;
393+
let gitRoot: string | null = null;
394+
if (options.gitignore) {
395+
gitRoot = findGitRoot(resolvedPath);
396+
if (gitRoot) {
397+
gitignoreFilter = createGitignoreFilter(gitRoot);
398+
}
399+
}
400+
401+
const ctx: BuildContext = { sizeCache, gitignoreFilter, gitRoot };
402+
403+
return buildTree(rootName, path, 0, options, ctx);
344404
}
345405

346406
/**

0 commit comments

Comments
 (0)