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.
- package/README.md +44 -10
- package/package.json +34 -33
- package/src/cli/archive-name.ts +51 -0
- package/src/cli/cli-options.ts +166 -0
- package/src/{cli.ts → cli/cli.ts} +19 -8
- package/src/{git-handoff-selection.ts → git/git-handoff-selection.ts} +54 -2
- package/src/{git-handoff.ts → git/git-handoff.ts} +109 -28
- package/src/{git.ts → git/git.ts} +77 -0
- package/{index.ts → src/index.ts} +1 -1
- package/src/{file-policy.ts → pack/file-policy.ts} +1 -0
- package/src/pack/gitignore.ts +272 -0
- package/src/{repository-pack.ts → pack/repository-pack.ts} +149 -25
- package/src/zip/zip.ts +511 -0
- package/src/cli-options.ts +0 -93
- package/src/gitignore.ts +0 -139
- package/src/zip.ts +0 -327
- /package/src/{path-patterns.ts → pack/path-patterns.ts} +0 -0
|
@@ -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 '
|
|
19
|
-
import { createZipFile } from '
|
|
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
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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<
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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(
|
|
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
|
+
|
|
@@ -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
|
+
|