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,9 +1,11 @@
|
|
|
1
1
|
import type { Dirent } from 'node:fs';
|
|
2
2
|
import { mkdir, readdir, stat } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { expandTildePath } from '../cli/cli';
|
|
4
5
|
import { detectXcodeProject, isExcludedDirectory, isExcludedFile } from './file-policy';
|
|
5
6
|
import { GitIgnoreMatcher } from './gitignore';
|
|
6
|
-
import {
|
|
7
|
+
import type { GitIgnoreMatcherOptions } from './gitignore';
|
|
8
|
+
import { compareStableStrings, isPathWithin, normalizeArchivePath } from './path-patterns';
|
|
7
9
|
|
|
8
10
|
export type RepositoryInfo = {
|
|
9
11
|
readonly archivePrefix: string;
|
|
@@ -75,23 +77,97 @@ const findPackageLocalDependencies = (parsed: Record<string, unknown>): readonly
|
|
|
75
77
|
return localPaths;
|
|
76
78
|
};
|
|
77
79
|
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
+
const splitGlobPath = (inputPath: string): { dirPrefix: string; globPattern: string } => {
|
|
81
|
+
const firstGlobIndex = inputPath.search(/[*?\[{]/);
|
|
82
|
+
if (firstGlobIndex === -1) {
|
|
83
|
+
return { dirPrefix: inputPath, globPattern: '' };
|
|
84
|
+
}
|
|
85
|
+
const lastSepBeforeGlob = Math.max(
|
|
86
|
+
inputPath.lastIndexOf('/', firstGlobIndex),
|
|
87
|
+
inputPath.lastIndexOf('\\', firstGlobIndex),
|
|
88
|
+
);
|
|
89
|
+
if (lastSepBeforeGlob === -1) {
|
|
90
|
+
return { dirPrefix: '.', globPattern: inputPath };
|
|
91
|
+
}
|
|
92
|
+
const dirPrefix = inputPath.slice(0, lastSepBeforeGlob) || (inputPath.startsWith('/') ? '/' : '.');
|
|
93
|
+
const globPattern = inputPath.slice(lastSepBeforeGlob + 1);
|
|
94
|
+
return { dirPrefix, globPattern };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const resolveIncludePaths = async (includeSpec: string, targetDir: string): Promise<readonly string[]> => {
|
|
98
|
+
const rawPath = expandTildePath(includeSpec);
|
|
80
99
|
const parentDirectory = path.dirname(targetDir);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
100
|
+
|
|
101
|
+
const { dirPrefix, globPattern } = splitGlobPath(rawPath);
|
|
102
|
+
if (!globPattern) {
|
|
103
|
+
const candidates = [
|
|
104
|
+
path.resolve(parentDirectory, rawPath),
|
|
105
|
+
path.resolve(targetDir, rawPath),
|
|
106
|
+
path.resolve(process.cwd(), rawPath),
|
|
107
|
+
path.resolve(rawPath),
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
for (const candidate of candidates) {
|
|
111
|
+
const candidateStats = await stat(candidate).catch(() => null);
|
|
112
|
+
if (candidateStats?.isDirectory()) {
|
|
113
|
+
return [candidate];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const baseDirectories = path.isAbsolute(rawPath)
|
|
120
|
+
? ['']
|
|
121
|
+
: [parentDirectory, targetDir, process.cwd()];
|
|
122
|
+
|
|
123
|
+
const glob = new Bun.Glob(globPattern);
|
|
124
|
+
|
|
125
|
+
for (const base of baseDirectories) {
|
|
126
|
+
const effectiveDir = base ? path.resolve(base, dirPrefix) : dirPrefix;
|
|
127
|
+
const dirStats = await stat(effectiveDir).catch(() => null);
|
|
128
|
+
if (!dirStats?.isDirectory()) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const matchedDirectories: string[] = [];
|
|
133
|
+
try {
|
|
134
|
+
for (const match of glob.scanSync({ cwd: effectiveDir, onlyFiles: false })) {
|
|
135
|
+
const fullCandidate = path.resolve(effectiveDir, match);
|
|
136
|
+
const itemStats = await stat(fullCandidate).catch(() => null);
|
|
137
|
+
if (itemStats?.isDirectory()) {
|
|
138
|
+
matchedDirectories.push(fullCandidate);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (matchedDirectories.length > 0) {
|
|
146
|
+
matchedDirectories.sort(compareStableStrings);
|
|
147
|
+
return matchedDirectories;
|
|
92
148
|
}
|
|
93
149
|
}
|
|
94
|
-
|
|
150
|
+
|
|
151
|
+
return [];
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const resolveEnclosingRepositoryRoot = async (dirPath: string): Promise<string> => {
|
|
155
|
+
let current = path.resolve(dirPath);
|
|
156
|
+
let foundGitRoot: string | null = null;
|
|
157
|
+
while (true) {
|
|
158
|
+
const gitPath = path.join(current, '.git');
|
|
159
|
+
const gitStats = await stat(gitPath).catch(() => null);
|
|
160
|
+
if (gitStats) {
|
|
161
|
+
foundGitRoot = current;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
const parent = path.dirname(current);
|
|
165
|
+
if (parent === current) {
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
current = parent;
|
|
169
|
+
}
|
|
170
|
+
return foundGitRoot ?? path.resolve(dirPath);
|
|
95
171
|
};
|
|
96
172
|
|
|
97
173
|
export const discoverRecursiveRepositories = async (
|
|
@@ -108,9 +184,11 @@ export const discoverRecursiveRepositories = async (
|
|
|
108
184
|
const queue: string[] = [canonicalTarget];
|
|
109
185
|
|
|
110
186
|
for (const include of includes) {
|
|
111
|
-
const
|
|
112
|
-
if (
|
|
113
|
-
|
|
187
|
+
const resolvedIncludes = await resolveIncludePaths(include, canonicalTarget);
|
|
188
|
+
if (resolvedIncludes.length > 0) {
|
|
189
|
+
for (const resolved of resolvedIncludes) {
|
|
190
|
+
queue.push(resolved);
|
|
191
|
+
}
|
|
114
192
|
} else {
|
|
115
193
|
console.warn(`Warning: Included path not found or not a directory: ${include}`);
|
|
116
194
|
}
|
|
@@ -143,8 +221,9 @@ export const discoverRecursiveRepositories = async (
|
|
|
143
221
|
|
|
144
222
|
for (const localDependency of findPackageLocalDependencies(packageJson)) {
|
|
145
223
|
const resolvedDependency = path.resolve(canonicalPath, localDependency);
|
|
146
|
-
|
|
147
|
-
|
|
224
|
+
const repoRoot = await resolveEnclosingRepositoryRoot(resolvedDependency);
|
|
225
|
+
if (!visited.has(repoRoot)) {
|
|
226
|
+
queue.push(repoRoot);
|
|
148
227
|
}
|
|
149
228
|
}
|
|
150
229
|
} catch {
|
|
@@ -158,23 +237,58 @@ export const discoverRecursiveRepositories = async (
|
|
|
158
237
|
});
|
|
159
238
|
}
|
|
160
239
|
|
|
240
|
+
// If any discovered repository is a descendant of another discovered repository,
|
|
241
|
+
// prune the descendant repository because the parent repository already contains all its files.
|
|
242
|
+
const allDiscoveredPaths = [...discoveredRepositories.keys()];
|
|
243
|
+
for (const childPath of allDiscoveredPaths) {
|
|
244
|
+
for (const parentPath of allDiscoveredPaths) {
|
|
245
|
+
if (childPath !== parentPath && isPathWithin(childPath, parentPath)) {
|
|
246
|
+
discoveredRepositories.delete(childPath);
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
161
252
|
const allPaths = [...discoveredRepositories.keys()];
|
|
162
253
|
let commonParent = path.dirname(canonicalTarget);
|
|
163
254
|
if (allPaths.length > 1) {
|
|
164
255
|
let candidate = commonParent;
|
|
165
|
-
|
|
166
|
-
|
|
256
|
+
let found = false;
|
|
257
|
+
while (true) {
|
|
258
|
+
const isParent = allPaths.every((repositoryPath) =>
|
|
259
|
+
repositoryPath === candidate ||
|
|
260
|
+
repositoryPath.startsWith(candidate.endsWith(path.sep) ? candidate : `${candidate}${path.sep}`),
|
|
261
|
+
);
|
|
262
|
+
if (isParent) {
|
|
167
263
|
commonParent = candidate;
|
|
264
|
+
found = true;
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
const nextParent = path.dirname(candidate);
|
|
268
|
+
if (nextParent === candidate) {
|
|
168
269
|
break;
|
|
169
270
|
}
|
|
170
|
-
candidate =
|
|
271
|
+
candidate = nextParent;
|
|
272
|
+
}
|
|
273
|
+
if (!found) {
|
|
274
|
+
throw new Error(`Discovered repositories do not share a common ancestor: ${allPaths.join(', ')}`);
|
|
171
275
|
}
|
|
172
276
|
}
|
|
173
277
|
|
|
174
278
|
const repositories: RepositoryInfo[] = [];
|
|
175
279
|
for (const [canonicalPath, repository] of discoveredRepositories.entries()) {
|
|
280
|
+
const relativeToCommon = path.relative(commonParent, canonicalPath);
|
|
281
|
+
const archivePrefix = normalizeArchivePath(relativeToCommon || path.basename(canonicalPath));
|
|
282
|
+
if (
|
|
283
|
+
archivePrefix.length === 0 ||
|
|
284
|
+
archivePrefix === '..' ||
|
|
285
|
+
archivePrefix.startsWith('../') ||
|
|
286
|
+
path.isAbsolute(archivePrefix)
|
|
287
|
+
) {
|
|
288
|
+
throw new Error(`Unsafe archive prefix computed for repository ${canonicalPath}: ${archivePrefix}`);
|
|
289
|
+
}
|
|
176
290
|
repositories.push({
|
|
177
|
-
archivePrefix
|
|
291
|
+
archivePrefix,
|
|
178
292
|
isXcodeProject: repository.isXcodeProject,
|
|
179
293
|
name: repository.name,
|
|
180
294
|
path: canonicalPath,
|
|
@@ -192,6 +306,7 @@ const processWalkEntry = async ({
|
|
|
192
306
|
isXcodeProject,
|
|
193
307
|
item,
|
|
194
308
|
queue,
|
|
309
|
+
stagingRoot,
|
|
195
310
|
}: {
|
|
196
311
|
readonly destinationDirectory: string;
|
|
197
312
|
readonly entry: Dirent;
|
|
@@ -200,6 +315,7 @@ const processWalkEntry = async ({
|
|
|
200
315
|
readonly isXcodeProject: boolean;
|
|
201
316
|
readonly item: WalkItem;
|
|
202
317
|
readonly queue: WalkItem[];
|
|
318
|
+
readonly stagingRoot: string;
|
|
203
319
|
}): Promise<{ bytes: number; isFile: boolean }> => {
|
|
204
320
|
if (entry.isSymbolicLink()) {
|
|
205
321
|
return { bytes: 0, isFile: false };
|
|
@@ -233,6 +349,9 @@ const processWalkEntry = async ({
|
|
|
233
349
|
}
|
|
234
350
|
|
|
235
351
|
const destinationPath = path.join(destinationDirectory, relativePath);
|
|
352
|
+
if (!isPathWithin(destinationPath, stagingRoot)) {
|
|
353
|
+
throw new Error(`Destination path escapes staging root: ${destinationPath}`);
|
|
354
|
+
}
|
|
236
355
|
await mkdir(path.dirname(destinationPath), { recursive: true });
|
|
237
356
|
const sourceFile = Bun.file(sourcePath);
|
|
238
357
|
await Bun.write(destinationPath, sourceFile);
|
|
@@ -244,11 +363,15 @@ export const copyRepositoryFiles = async (
|
|
|
244
363
|
repository: RepositoryInfo,
|
|
245
364
|
stagingRoot: string,
|
|
246
365
|
filterMatchers: readonly RegExp[],
|
|
366
|
+
ignoreOptions?: GitIgnoreMatcherOptions,
|
|
247
367
|
): Promise<RepositoryPackStats> => {
|
|
248
368
|
let fileCount = 0;
|
|
249
369
|
let totalBytes = 0;
|
|
250
370
|
const destinationDirectory = path.join(stagingRoot, repository.archivePrefix);
|
|
251
|
-
|
|
371
|
+
if (!isPathWithin(destinationDirectory, stagingRoot)) {
|
|
372
|
+
throw new Error(`Destination directory escapes staging root: ${destinationDirectory}`);
|
|
373
|
+
}
|
|
374
|
+
const ignoreMatcher = new GitIgnoreMatcher(repository.path, ignoreOptions);
|
|
252
375
|
const queue: WalkItem[] = [{ relativePath: '', sourcePath: repository.path }];
|
|
253
376
|
|
|
254
377
|
while (queue.length > 0) {
|
|
@@ -264,6 +387,7 @@ export const copyRepositoryFiles = async (
|
|
|
264
387
|
isXcodeProject: repository.isXcodeProject,
|
|
265
388
|
item,
|
|
266
389
|
queue,
|
|
390
|
+
stagingRoot,
|
|
267
391
|
});
|
|
268
392
|
if (result.isFile) {
|
|
269
393
|
fileCount += 1;
|