/*
* Copyright 2025 the original author or authors.
*
* Licensed under the Moderne Source Available License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://docs.moderne.io/licensing/moderne-source-available-license
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as fs from "fs";
import * as fsp from "fs/promises";
import * as os from "os";
import * as path from "path";
import {spawnSync} from "child_process";
/**
* Default directory exclusions for file/directory discovery.
*/
export const DEFAULT_DIR_EXCLUSIONS = new Set([
"node_modules",
".git",
".svn",
".hg",
"dist",
"build",
"out",
"coverage",
".next",
".nuxt",
".output"
]);
/**
* Checks if a path is ignored by Git.
* Returns true if the path is ignored, false otherwise.
* Falls back to false if not in a git repository.
*/
export function isGitIgnored(filePath: string, cwd?: string): boolean {
const result = spawnSync("git", ["check-ignore", "-q", filePath], {
cwd: cwd || path.dirname(filePath),
encoding: "utf8",
...(os.platform() === 'win32' ? { shell: true } : {})
});
// Exit code 0 means the file IS ignored
// Exit code 1 means the file is NOT ignored
// Other exit codes indicate git errors (not a repo, etc.)
return result.status === 0;
}
/**
* Checks if we're in a git repository.
*/
export function isInGitRepo(dir: string): boolean {
const result = spawnSync("git", ["rev-parse", "--git-dir"], {
cwd: dir,
encoding: "utf8",
...(os.platform() === 'win32' ? { shell: true } : {})
});
return result.status === 0;
}
export interface WalkDirsOptions {
/**
* Maximum depth to descend. 0 means only immediate children.
* undefined means unlimited.
*/
maxDepth?: number;
/**
* Directory names to exclude. Defaults to DEFAULT_DIR_EXCLUSIONS.
*/
excludeDirs?: Set;
/**
* Whether to respect .gitignore. Defaults to false.
*/
respectGitIgnore?: boolean;
/**
* Only include directories containing a file with this name.
* Useful for finding package directories by looking for package.json.
*/
mustContainFile?: string;
}
/**
* Recursively walks directories starting from a given path.
* Returns all subdirectory paths (not including the root).
*/
export async function walkDirs(
rootDir: string,
options: WalkDirsOptions = {}
): Promise {
const {
maxDepth,
excludeDirs = DEFAULT_DIR_EXCLUSIONS,
respectGitIgnore = false,
mustContainFile
} = options;
const results: string[] = [];
const inGitRepo = respectGitIgnore && isInGitRepo(rootDir);
async function walk(dir: string, depth: number): Promise {
if (maxDepth !== undefined && depth > maxDepth) {
return;
}
let entries: fs.Dirent[];
try {
entries = await fsp.readdir(dir, {withFileTypes: true});
} catch {
return;
}
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
// Skip excluded directories
if (excludeDirs.has(entry.name)) {
continue;
}
// Skip hidden directories
if (entry.name.startsWith('.')) {
continue;
}
const fullPath = path.join(dir, entry.name);
// Check git ignore if enabled
if (inGitRepo && isGitIgnored(fullPath, rootDir)) {
continue;
}
// Check for required file if specified
if (mustContainFile) {
const requiredFile = path.join(fullPath, mustContainFile);
if (fs.existsSync(requiredFile)) {
results.push(fullPath);
}
} else {
results.push(fullPath);
}
// Recurse into subdirectory
await walk(fullPath, depth + 1);
}
}
await walk(rootDir, 0);
return results;
}
/**
* Gets files tracked by git (and untracked but not ignored files).
* Falls back to empty array if not in a git repository.
*/
export function getGitTrackedFiles(dir: string): string[] {
const files: string[] = [];
// Get tracked files
const tracked = spawnSync("git", ["ls-files"], {
cwd: dir,
encoding: "utf8",
...(os.platform() === 'win32' ? { shell: true } : {})
});
if (tracked.status !== 0 || tracked.error) {
return [];
}
if (tracked.stdout) {
for (const line of tracked.stdout.split("\n")) {
const trimmed = line.trim();
if (trimmed) {
const fullPath = path.join(dir, trimmed);
if (fs.existsSync(fullPath)) {
files.push(fullPath);
}
}
}
}
// Get untracked but not ignored files
const untracked = spawnSync("git", ["ls-files", "--others", "--exclude-standard"], {
cwd: dir,
encoding: "utf8",
...(os.platform() === 'win32' ? { shell: true } : {})
});
if (untracked.stdout) {
for (const line of untracked.stdout.split("\n")) {
const trimmed = line.trim();
if (trimmed) {
const fullPath = path.join(dir, trimmed);
if (fs.existsSync(fullPath)) {
files.push(fullPath);
}
}
}
}
return files;
}