acker-dacker 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { chmod, lstat, mkdir, mkdtemp, readlink, realpath, rm, stat, symlink } from 'node:fs/promises';
1
+ import { chmod, lstat, mkdir, mkdtemp, open, readlink, realpath, rm, stat, symlink } from 'node:fs/promises';
2
2
  import { tmpdir } from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { resolveGitWorktree } from './git';
@@ -7,6 +7,7 @@ import {
7
7
  resolveGitHandoffProfile,
8
8
  snapshotFile,
9
9
  validateHandoffSymlink,
10
+ validateSourceAncestry,
10
11
  } from './git-handoff-selection';
11
12
  import type {
12
13
  FileSnapshot,
@@ -15,11 +16,12 @@ import type {
15
16
  GitHandoffProfile,
16
17
  GitHandoffSelection,
17
18
  } from './git-handoff-selection';
18
- import { compareStableStrings, isPathWithin } from './path-patterns';
19
- import { createZipFile } from './zip';
19
+ import { compareStableStrings, isPathWithin } from '../pack/path-patterns';
20
+ import { createZipFile } from '../zip/zip';
20
21
 
21
22
  type GitHandoffReport = {
22
23
  readonly excluded: {
24
+ readonly deletedTrackedCount: number;
23
25
  readonly explicit: {
24
26
  readonly total: number;
25
27
  readonly patterns: readonly GitHandoffExclusion[];
@@ -44,6 +46,7 @@ type GitHandoffReport = {
44
46
  readonly version: 1;
45
47
  };
46
48
 
49
+
47
50
  type GitHandoffStats = {
48
51
  bytes: number;
49
52
  executableCount: number;
@@ -85,11 +88,13 @@ const assertSourceUnchanged = (sourcePath: string, before: FileSnapshot, after:
85
88
  }
86
89
  };
87
90
 
88
- const stageGitHandoffEntry = async (
91
+ export const stageGitHandoffEntry = async (
89
92
  rootDir: string,
93
+ canonicalRoot: string,
90
94
  candidate: GitHandoffCandidate,
91
95
  destinationPath: string,
92
96
  ): Promise<StagedHandoffEntry> => {
97
+
93
98
  const sourceStats = await lstat(candidate.sourcePath).catch((error) => {
94
99
  throw new Error(`Git handoff could not read selected path ${candidate.relativePath}: ${error}`);
95
100
  });
@@ -98,6 +103,7 @@ const stageGitHandoffEntry = async (
98
103
  await mkdir(path.dirname(destinationPath), { recursive: true });
99
104
 
100
105
  if (sourceStats.isSymbolicLink()) {
106
+ await validateSourceAncestry(rootDir, candidate.sourcePath, canonicalRoot);
101
107
  const linkTarget = await readlink(candidate.sourcePath);
102
108
  await validateHandoffSymlink(rootDir, candidate.sourcePath, linkTarget);
103
109
  const afterStats = await lstat(candidate.sourcePath);
@@ -119,20 +125,42 @@ const stageGitHandoffEntry = async (
119
125
  throw new Error(`Git handoff refuses special or directory path: ${candidate.relativePath}`);
120
126
  }
121
127
 
122
- await Bun.write(destinationPath, Bun.file(candidate.sourcePath));
123
- const afterStats = await lstat(candidate.sourcePath);
124
- assertSourceUnchanged(candidate.sourcePath, before, snapshotFile(afterStats));
125
- assertSourceUnchanged(candidate.sourcePath, candidate.initialSnapshot, snapshotFile(afterStats));
126
- await chmod(destinationPath, sourceStats.mode & 0o7777);
127
- return {
128
- bytes: sourceStats.size,
129
- isSymlink: false,
130
- mode: sourceStats.mode,
131
- };
128
+ // f5: Bind regular-file copying to a checked open descriptor with matching fstat/snapshot checks
129
+ const handle = await open(candidate.sourcePath, 'r').catch((error) => {
130
+ throw new Error(`Git handoff could not read selected path ${candidate.relativePath}: ${error}`);
131
+ });
132
+ try {
133
+ const fdStats = await handle.stat();
134
+ if (!fdStats.isFile()) {
135
+ throw new Error(`Git handoff refuses special or directory path: ${candidate.relativePath}`);
136
+ }
137
+ const fdSnapshot = snapshotFile(fdStats);
138
+ assertSourceUnchanged(candidate.sourcePath, candidate.initialSnapshot, fdSnapshot);
139
+ assertSourceUnchanged(candidate.sourcePath, before, fdSnapshot);
140
+
141
+ await validateSourceAncestry(rootDir, candidate.sourcePath, canonicalRoot);
142
+
143
+ const content = await handle.readFile();
144
+ await Bun.write(destinationPath, content);
145
+ await chmod(destinationPath, fdStats.mode & 0o7777);
146
+
147
+ const afterStats = await lstat(candidate.sourcePath);
148
+ assertSourceUnchanged(candidate.sourcePath, before, snapshotFile(afterStats));
149
+ assertSourceUnchanged(candidate.sourcePath, candidate.initialSnapshot, snapshotFile(afterStats));
150
+
151
+ return {
152
+ bytes: fdStats.size,
153
+ isSymlink: false,
154
+ mode: fdStats.mode,
155
+ };
156
+ } finally {
157
+ await handle.close();
158
+ }
132
159
  };
133
160
 
134
161
  const stageGitHandoffFiles = async (
135
162
  rootDir: string,
163
+ canonicalRoot: string,
136
164
  stagingRoot: string,
137
165
  candidates: readonly GitHandoffCandidate[],
138
166
  ): Promise<GitHandoffStats> => {
@@ -148,7 +176,7 @@ const stageGitHandoffFiles = async (
148
176
 
149
177
  for (const candidate of candidates) {
150
178
  const destinationPath = path.join(archiveRoot, candidate.relativePath);
151
- const staged = await stageGitHandoffEntry(rootDir, candidate, destinationPath);
179
+ const staged = await stageGitHandoffEntry(rootDir, canonicalRoot, candidate, destinationPath);
152
180
  stats.bytes += staged.bytes;
153
181
  stats.includedPaths.push(candidate.archivePath);
154
182
  if (candidate.isTracked) {
@@ -171,6 +199,7 @@ const makeGitHandoffReport = (
171
199
  stats: GitHandoffStats,
172
200
  ): GitHandoffReport => ({
173
201
  excluded: {
202
+ deletedTrackedCount: selection.deletedTrackedCount,
174
203
  explicit: {
175
204
  patterns: selection.explicitExclusions,
176
205
  total: selection.explicitExclusionTotal,
@@ -203,7 +232,7 @@ const emitGitHandoffReport = async (report: GitHandoffReport, reportFile: string
203
232
  `[git-handoff] Metadata: ${report.included.executableCount} executable files, ${report.included.symlinkCount} symlinks.`,
204
233
  );
205
234
  console.log(
206
- `[git-handoff] Omitted ${report.excluded.explicit.total} explicit, ${report.excluded.filterCount} filter, ${report.excluded.profileFilteredCount} profile, ${report.excluded.sensitiveUntrackedCount} sensitive untracked, and ${report.excluded.safetyFilteredUntrackedCount} total safety-filtered untracked paths.`,
235
+ `[git-handoff] Omitted ${report.excluded.explicit.total} explicit, ${report.excluded.deletedTrackedCount} deleted tracked, ${report.excluded.filterCount} filter, ${report.excluded.profileFilteredCount} profile, ${report.excluded.sensitiveUntrackedCount} sensitive untracked, and ${report.excluded.safetyFilteredUntrackedCount} total safety-filtered untracked paths.`,
207
236
  );
208
237
  if (reportFile) {
209
238
  await mkdir(path.dirname(reportFile), { recursive: true });
@@ -212,24 +241,49 @@ const emitGitHandoffReport = async (report: GitHandoffReport, reportFile: string
212
241
  }
213
242
  };
214
243
 
244
+ export const resolveCanonicalArtifactPath = async (targetPath: string): Promise<string> => {
245
+ const absolutePath = path.resolve(targetPath);
246
+ let current = absolutePath;
247
+ const tailSegments: string[] = [];
248
+
249
+ while (true) {
250
+ const stats = await lstat(current).catch(() => null);
251
+ if (stats) {
252
+ const real = await realpath(current);
253
+ return tailSegments.length > 0 ? path.join(real, ...tailSegments.reverse()) : real;
254
+ }
255
+ const parent = path.dirname(current);
256
+ if (parent === current) {
257
+ return tailSegments.length > 0 ? path.join(current, ...tailSegments.reverse()) : current;
258
+ }
259
+ tailSegments.push(path.basename(current));
260
+ current = parent;
261
+ }
262
+ };
263
+
215
264
  const validateHandoffArtifactPath = async (
216
265
  rootDir: string,
266
+ canonicalRootDir: string,
217
267
  artifactPath: string,
218
268
  label: string,
219
- ): Promise<void> => {
269
+ ): Promise<string> => {
220
270
  const resolvedArtifactPath = path.resolve(artifactPath);
221
271
  const existingStats = await lstat(resolvedArtifactPath).catch(() => null);
222
272
  if (existingStats?.isSymbolicLink()) {
223
273
  throw new Error(`Git handoff refuses a symlink ${label} path: ${resolvedArtifactPath}`);
224
274
  }
225
275
 
226
- const resolvedParent = await realpath(path.dirname(resolvedArtifactPath)).catch(() =>
227
- path.dirname(resolvedArtifactPath),
228
- );
229
- const canonicalArtifactPath = path.join(resolvedParent, path.basename(resolvedArtifactPath));
230
- if (isPathWithin(resolvedArtifactPath, rootDir) || isPathWithin(canonicalArtifactPath, rootDir)) {
276
+ const canonicalArtifactPath = await resolveCanonicalArtifactPath(resolvedArtifactPath);
277
+ if (
278
+ isPathWithin(resolvedArtifactPath, rootDir) ||
279
+ isPathWithin(canonicalArtifactPath, rootDir) ||
280
+ isPathWithin(resolvedArtifactPath, canonicalRootDir) ||
281
+ isPathWithin(canonicalArtifactPath, canonicalRootDir)
282
+ ) {
231
283
  throw new Error(`Git handoff ${label} must be outside the Git worktree: ${resolvedArtifactPath}`);
232
284
  }
285
+
286
+ return canonicalArtifactPath;
233
287
  };
234
288
 
235
289
  export const runGitHandoff = async ({
@@ -242,16 +296,43 @@ export const runGitHandoff = async ({
242
296
  }: GitHandoffOptions): Promise<void> => {
243
297
  const resolvedProfile = resolveGitHandoffProfile(profile);
244
298
  const rootDir = await resolveGitWorktree(targetDir);
299
+ const canonicalRootDir = await realpath(rootDir);
245
300
  const resolvedOutputFile = path.resolve(
246
301
  outputFile ?? path.join(process.cwd(), `${path.basename(rootDir)}-git-handoff.zip`),
247
302
  );
303
+ const canonicalOutputFile = await validateHandoffArtifactPath(
304
+ rootDir,
305
+ canonicalRootDir,
306
+ resolvedOutputFile,
307
+ 'output',
308
+ );
309
+ let canonicalReportFile: string | null = null;
248
310
  const resolvedReportFile = reportFile ? path.resolve(reportFile) : null;
249
- if (resolvedReportFile === resolvedOutputFile) {
250
- throw new Error('Git handoff output and report paths must be different.');
251
- }
252
- await validateHandoffArtifactPath(rootDir, resolvedOutputFile, 'output');
253
311
  if (resolvedReportFile) {
254
- await validateHandoffArtifactPath(rootDir, resolvedReportFile, 'report');
312
+ canonicalReportFile = await validateHandoffArtifactPath(
313
+ rootDir,
314
+ canonicalRootDir,
315
+ resolvedReportFile,
316
+ 'report',
317
+ );
318
+ }
319
+ if (resolvedReportFile !== null && canonicalReportFile !== null) {
320
+ if (canonicalReportFile === canonicalOutputFile || resolvedReportFile === resolvedOutputFile) {
321
+ throw new Error('Git handoff output and report paths must be different.');
322
+ }
323
+
324
+ const [outputStats, reportStats] = await Promise.all([
325
+ lstat(resolvedOutputFile).catch(() => null),
326
+ lstat(resolvedReportFile).catch(() => null),
327
+ ]);
328
+ if (
329
+ outputStats &&
330
+ reportStats &&
331
+ outputStats.dev === reportStats.dev &&
332
+ outputStats.ino === reportStats.ino
333
+ ) {
334
+ throw new Error('Git handoff output and report paths must be different.');
335
+ }
255
336
  }
256
337
  console.log(`[git-handoff] Git worktree: ${rootDir}`);
257
338
  if (filters.length > 0) {
@@ -271,7 +352,7 @@ export const runGitHandoff = async ({
271
352
 
272
353
  const stagingRoot = await mkdtemp(path.join(tmpdir(), 'pack-repo-git-handoff-'));
273
354
  try {
274
- const stats = await stageGitHandoffFiles(rootDir, stagingRoot, selection.candidates);
355
+ const stats = await stageGitHandoffFiles(rootDir, canonicalRootDir, stagingRoot, selection.candidates);
275
356
  const report = makeGitHandoffReport(selection, stats);
276
357
  if (report.included.count === 0) {
277
358
  await emitGitHandoffReport(report, resolvedReportFile);
@@ -1,5 +1,6 @@
1
1
  import { realpath } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { compareStableStrings } from '../pack/path-patterns';
3
4
 
4
5
  export type ExternalCommandResult = {
5
6
  readonly exitCode: number;
@@ -82,3 +83,79 @@ export const resolveGitWorktree = async (targetDir: string): Promise<string> =>
82
83
  }
83
84
  return await realpath(path.resolve(rootOutput));
84
85
  };
86
+
87
+ export const getGitConfigValue = async (key: string, cwd: string): Promise<string | null> => {
88
+ try {
89
+ const result = await runExternalCommand('git', ['-C', cwd, 'config', '--get', key], cwd);
90
+ if (result.exitCode === 0) {
91
+ const value = new TextDecoder().decode(result.stdout).trim();
92
+ return value.length > 0 ? value : null;
93
+ }
94
+ } catch {
95
+ // Git command unavailable or directory not a worktree
96
+ }
97
+ return null;
98
+ };
99
+
100
+ export const getGitCheckoutTag = async (cwd: string): Promise<string | null> => {
101
+ try {
102
+ const result = await runExternalCommand('git', ['-C', cwd, 'tag', '--points-at', 'HEAD'], cwd);
103
+ if (result.exitCode === 0) {
104
+ const output = new TextDecoder().decode(result.stdout).trim();
105
+ if (output.length === 0) {
106
+ return null;
107
+ }
108
+ const tags = output
109
+ .split('\n')
110
+ .map((tag) => tag.trim())
111
+ .filter((tag) => tag.length > 0);
112
+
113
+ if (tags.length === 0) {
114
+ return null;
115
+ }
116
+
117
+ // Prefer tags that match version patterns (e.g. v2.33, 1.0.0, v0.1.0-alpha)
118
+ const versionPattern = /^v?\d+(\.\d+)*([.-].+)?$/;
119
+ const versionTags = tags.filter((tag) => versionPattern.test(tag));
120
+ if (versionTags.length > 0) {
121
+ // If multiple version tags, sort them and return the last (highest numeric version)
122
+ versionTags.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
123
+ return versionTags[versionTags.length - 1]!; // highest version
124
+ }
125
+ tags.sort(compareStableStrings);
126
+ return tags[0]!;
127
+ }
128
+ } catch {
129
+ // Git command unavailable or directory not a worktree
130
+ }
131
+ return null;
132
+ };
133
+
134
+ export const getGitShortCommitHash = async (cwd: string): Promise<string | null> => {
135
+ try {
136
+ const result = await runExternalCommand('git', ['-C', cwd, 'rev-parse', '--short', 'HEAD'], cwd);
137
+ if (result.exitCode === 0) {
138
+ const hash = new TextDecoder().decode(result.stdout).trim();
139
+ return hash.length > 0 ? hash : null;
140
+ }
141
+ } catch {
142
+ // Git command unavailable or directory not a worktree
143
+ }
144
+ return null;
145
+ };
146
+
147
+ export const getGitInfoExcludePath = async (cwd: string): Promise<string | null> => {
148
+ try {
149
+ const result = await runExternalCommand('git', ['-C', cwd, 'rev-parse', '--git-path', 'info/exclude'], cwd);
150
+ if (result.exitCode === 0) {
151
+ const relOrAbs = new TextDecoder().decode(result.stdout).trim();
152
+ if (relOrAbs.length > 0) {
153
+ return path.resolve(cwd, relOrAbs);
154
+ }
155
+ }
156
+ } catch {
157
+ // Git command unavailable or directory not a worktree
158
+ }
159
+ return null;
160
+ };
161
+
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- import { main } from './src/cli';
2
+ import { main } from './cli/cli';
3
3
 
4
4
  try {
5
5
  await main();
@@ -41,6 +41,7 @@ const ALLOWED_ENV_FILES = new Set(['.env.example', '.env.sample', '.env.template
41
41
 
42
42
  const EXCLUDED_FILE_NAMES = new Set([
43
43
  '.DS_Store',
44
+ '.git',
44
45
  '.gitignore',
45
46
  '.npmrc',
46
47
  '.pypirc',
@@ -0,0 +1,272 @@
1
+ import { homedir } from 'node:os';
2
+ import path from 'node:path';
3
+ import { getGitConfigValue, getGitInfoExcludePath } from '../git/git';
4
+ import { globPatternToRegexSource, isPathWithin, normalizeArchivePath } from './path-patterns';
5
+
6
+ type GitIgnoreRule = {
7
+ readonly directoryOnly: boolean;
8
+ readonly matcher: RegExp;
9
+ readonly negated: boolean;
10
+ };
11
+
12
+ const parseGitIgnoreRule = (line: string): GitIgnoreRule | null => {
13
+ let pattern = line.replace(/[ \t]+$/u, '');
14
+ if (pattern.length === 0 || pattern.startsWith('#')) {
15
+ return null;
16
+ }
17
+
18
+ const escapedLeadingMarker = pattern.startsWith('\\#') || pattern.startsWith('\\!');
19
+ if (escapedLeadingMarker) {
20
+ pattern = pattern.slice(1);
21
+ }
22
+
23
+ let negated = false;
24
+ if (!escapedLeadingMarker && pattern.startsWith('!')) {
25
+ negated = true;
26
+ pattern = pattern.slice(1);
27
+ }
28
+
29
+ const directoryOnly = pattern.endsWith('/') && !pattern.endsWith('\\/');
30
+ if (directoryOnly) {
31
+ pattern = pattern.slice(0, -1);
32
+ }
33
+
34
+ const anchored = pattern.startsWith('/');
35
+ if (anchored) {
36
+ pattern = pattern.slice(1);
37
+ }
38
+ if (pattern.length === 0) {
39
+ return null;
40
+ }
41
+
42
+ const hasSlash = pattern.includes('/');
43
+ const source = globPatternToRegexSource(pattern);
44
+ const matcher = new RegExp(anchored || hasSlash ? `^${source}$` : `(?:^|/)${source}(?:$|/)`);
45
+
46
+ return { directoryOnly, matcher, negated };
47
+ };
48
+
49
+ const parseGitIgnoreRules = (contents: string, filePath: string): readonly GitIgnoreRule[] => {
50
+ const rules: GitIgnoreRule[] = [];
51
+ const lines = contents.split(/\r?\n/u);
52
+ for (let index = 0; index < lines.length; index += 1) {
53
+ const line = lines[index]!;
54
+ try {
55
+ const rule = parseGitIgnoreRule(line);
56
+ if (rule) {
57
+ rules.push(rule);
58
+ }
59
+ } catch (error) {
60
+ const detail = error instanceof Error ? error.message : String(error);
61
+ throw new Error(`Invalid ignore rule in ${filePath}:${index + 1}: "${line}" (${detail})`);
62
+ }
63
+ }
64
+ return rules;
65
+ };
66
+
67
+ const matchesGitIgnoreRule = (rule: GitIgnoreRule, relativePath: string, isDirectory: boolean): boolean => {
68
+ if (!rule.directoryOnly || isDirectory) {
69
+ return rule.matcher.test(relativePath);
70
+ }
71
+
72
+ const segments = relativePath.split('/');
73
+ for (let end = 1; end < segments.length; end += 1) {
74
+ if (rule.matcher.test(segments.slice(0, end).join('/'))) {
75
+ return true;
76
+ }
77
+ }
78
+ return false;
79
+ };
80
+
81
+ export const resolveGlobalGitIgnorePaths = async (cwd?: string): Promise<readonly string[]> => {
82
+ if (process.env.ACKER_DACKER_GLOBAL_GITIGNORE !== undefined) {
83
+ const val = process.env.ACKER_DACKER_GLOBAL_GITIGNORE.trim();
84
+ if (val.length === 0 || val === 'none') {
85
+ return [];
86
+ }
87
+ return [path.resolve(process.cwd(), val)];
88
+ }
89
+
90
+ const home = process.env.HOME || homedir();
91
+ const effectiveHome = home && home.trim().length > 0 ? home : null;
92
+ const paths: string[] = [];
93
+
94
+ // 1. Check git config core.excludesfile
95
+ const searchDir = cwd ?? process.cwd();
96
+ const configPath = await getGitConfigValue('core.excludesfile', searchDir);
97
+ if (configPath !== null) {
98
+ // core.excludesfile was explicitly configured. Even if missing on disk,
99
+ // it suppresses the XDG / home fallback.
100
+ if (configPath.trim().length > 0) {
101
+ let resolved: string | null = null;
102
+ if (configPath.startsWith('~')) {
103
+ if (effectiveHome) {
104
+ resolved = path.resolve(effectiveHome, configPath.replace(/^~(?=$|\/|\\)/, ''));
105
+ }
106
+ } else {
107
+ resolved = path.resolve(searchDir, configPath);
108
+ }
109
+ if (resolved && (await Bun.file(resolved).exists())) {
110
+ paths.push(resolved);
111
+ }
112
+ }
113
+ return paths;
114
+ }
115
+
116
+ // 2. If no configured core.excludesfile was found, check standard XDG/home locations
117
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME;
118
+ const xdgIgnore = xdgConfigHome
119
+ ? path.join(xdgConfigHome, 'git', 'ignore')
120
+ : effectiveHome
121
+ ? path.join(effectiveHome, '.config', 'git', 'ignore')
122
+ : null;
123
+
124
+ if (xdgIgnore && (await Bun.file(xdgIgnore).exists())) {
125
+ paths.push(xdgIgnore);
126
+ }
127
+
128
+ return paths;
129
+ };
130
+
131
+ export type GitIgnoreMatcherOptions = {
132
+ readonly globalIgnorePaths?: readonly string[];
133
+ };
134
+
135
+ export class GitIgnoreMatcher {
136
+ private readonly rulesByDirectory = new Map<string, readonly GitIgnoreRule[]>();
137
+ private globalRules: readonly GitIgnoreRule[] | null = null;
138
+ private infoExcludeRules: readonly GitIgnoreRule[] | null = null;
139
+ private readonly explicitGlobalIgnorePaths?: readonly string[];
140
+
141
+ public constructor(
142
+ private readonly rootDir: string,
143
+ options?: GitIgnoreMatcherOptions,
144
+ ) {
145
+ this.explicitGlobalIgnorePaths = options?.globalIgnorePaths;
146
+ }
147
+
148
+ private async loadGlobalRules(): Promise<readonly GitIgnoreRule[]> {
149
+ if (this.globalRules !== null) {
150
+ return this.globalRules;
151
+ }
152
+
153
+ const candidatePaths =
154
+ this.explicitGlobalIgnorePaths !== undefined
155
+ ? [...this.explicitGlobalIgnorePaths]
156
+ : [...(await resolveGlobalGitIgnorePaths(this.rootDir))];
157
+
158
+ const rules: GitIgnoreRule[] = [];
159
+ for (const filePath of candidatePaths) {
160
+ const file = Bun.file(filePath);
161
+ if (await file.exists()) {
162
+ rules.push(...parseGitIgnoreRules(await file.text(), filePath));
163
+ }
164
+ }
165
+
166
+ this.globalRules = rules;
167
+ return rules;
168
+ }
169
+
170
+ private async loadInfoExcludeRules(): Promise<readonly GitIgnoreRule[]> {
171
+ if (this.infoExcludeRules !== null) {
172
+ return this.infoExcludeRules;
173
+ }
174
+
175
+ const candidatePath =
176
+ (await getGitInfoExcludePath(this.rootDir)) ?? path.join(this.rootDir, '.git', 'info', 'exclude');
177
+
178
+ const file = Bun.file(candidatePath);
179
+ if (!(await file.exists())) {
180
+ this.infoExcludeRules = [];
181
+ return [];
182
+ }
183
+
184
+ const rules = parseGitIgnoreRules(await file.text(), candidatePath);
185
+ this.infoExcludeRules = rules;
186
+ return rules;
187
+ }
188
+
189
+ private async rulesForDirectory(directory: string): Promise<readonly GitIgnoreRule[]> {
190
+ const cachedRules = this.rulesByDirectory.get(directory);
191
+ if (cachedRules) {
192
+ return cachedRules;
193
+ }
194
+
195
+ const ignorePath = path.join(directory, '.gitignore');
196
+ const ignoreFile = Bun.file(ignorePath);
197
+ if (!(await ignoreFile.exists())) {
198
+ this.rulesByDirectory.set(directory, []);
199
+ return [];
200
+ }
201
+
202
+ const rules = parseGitIgnoreRules(await ignoreFile.text(), ignorePath);
203
+ this.rulesByDirectory.set(directory, rules);
204
+ return rules;
205
+ }
206
+
207
+ public async isIgnored(absolutePath: string, isDirectory: boolean): Promise<boolean> {
208
+ const parentDirectory = path.dirname(absolutePath);
209
+ if (!isPathWithin(parentDirectory, this.rootDir)) {
210
+ return false;
211
+ }
212
+
213
+ const directories: string[] = [];
214
+ let directory = parentDirectory;
215
+ while (true) {
216
+ directories.push(directory);
217
+ if (directory === this.rootDir) {
218
+ break;
219
+ }
220
+
221
+ const parent = path.dirname(directory);
222
+ if (parent === directory || !isPathWithin(parent, this.rootDir)) {
223
+ return false;
224
+ }
225
+ directory = parent;
226
+ }
227
+
228
+ directories.reverse();
229
+
230
+ let ignored = false;
231
+ const rootRelativePath = normalizeArchivePath(path.relative(this.rootDir, absolutePath));
232
+
233
+ // 1. Evaluate global rules
234
+ if (rootRelativePath.length > 0 && rootRelativePath !== '..' && !rootRelativePath.startsWith('../')) {
235
+ const globalRules = await this.loadGlobalRules();
236
+ for (const rule of globalRules) {
237
+ if (matchesGitIgnoreRule(rule, rootRelativePath, isDirectory)) {
238
+ ignored = !rule.negated;
239
+ }
240
+ }
241
+ }
242
+
243
+ // 2. Evaluate repository info/exclude rules (precedence over global, lower than .gitignore)
244
+ if (rootRelativePath.length > 0 && rootRelativePath !== '..' && !rootRelativePath.startsWith('../')) {
245
+ const infoExcludeRules = await this.loadInfoExcludeRules();
246
+ for (const rule of infoExcludeRules) {
247
+ if (matchesGitIgnoreRule(rule, rootRelativePath, isDirectory)) {
248
+ ignored = !rule.negated;
249
+ }
250
+ }
251
+ }
252
+
253
+ // 3. Evaluate directory .gitignore rules from rootDir down to target path's directory
254
+ for (const ruleDirectory of directories) {
255
+ const relativePath = normalizeArchivePath(path.relative(ruleDirectory, absolutePath));
256
+ if (relativePath.length === 0 || relativePath === '..' || relativePath.startsWith('../')) {
257
+ continue;
258
+ }
259
+
260
+ const rules = await this.rulesForDirectory(ruleDirectory);
261
+ for (const rule of rules) {
262
+ if (matchesGitIgnoreRule(rule, relativePath, isDirectory)) {
263
+ ignored = !rule.negated;
264
+ }
265
+ }
266
+ }
267
+
268
+ return ignored;
269
+ }
270
+ }
271
+
272
+