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,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 { compareStableStrings, normalizeArchivePath } from './path-patterns';
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 resolveIncludePath = async (includeSpec: string, targetDir: string): Promise<string | null> => {
79
- const rawPath = includeSpec.replace(/^~(?=$|\/)/, process.env.HOME ?? '');
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
- const candidates = [
82
- path.resolve(parentDirectory, rawPath),
83
- path.resolve(targetDir, rawPath),
84
- path.resolve(process.cwd(), rawPath),
85
- path.resolve(rawPath),
86
- ];
87
-
88
- for (const candidate of candidates) {
89
- const candidateStats = await stat(candidate).catch(() => null);
90
- if (candidateStats?.isDirectory()) {
91
- return candidate;
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
- return null;
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 resolvedInclude = await resolveIncludePath(include, canonicalTarget);
112
- if (resolvedInclude) {
113
- queue.push(resolvedInclude);
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
- if (!visited.has(resolvedDependency)) {
147
- queue.push(resolvedDependency);
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
- while (candidate !== path.dirname(candidate)) {
166
- if (allPaths.every((repositoryPath) => repositoryPath === candidate || repositoryPath.startsWith(`${candidate}${path.sep}`))) {
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 = path.dirname(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: normalizeArchivePath(path.relative(commonParent, canonicalPath)),
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
- const ignoreMatcher = new GitIgnoreMatcher(repository.path);
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;