@yunxiaoyi/gitpack 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 yunxiaoyi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ ## gitpack
2
+
3
+ Pack a project into zip / tar.gz while respecting nested `.gitignore` rules. Provides both an interactive and a flag-based command-line mode. No `.git` directory required.
4
+
5
+ ## Setup
6
+
7
+ ```bash
8
+ npm install -g @yunxiaoyi/gitpack
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Interactive mode
14
+
15
+ ```bash
16
+ gitpack
17
+ # or
18
+ gpk
19
+ ```
20
+
21
+ You will be prompted for the project root, archive format, and output directory.
22
+
23
+ ### Flag-based mode
24
+
25
+ ```bash
26
+ gitpack --root <path> --format <zip|tar.gz> --out <dir>
27
+ ```
28
+
29
+ #### Options
30
+
31
+ | Option | Description |
32
+ | --- | --- |
33
+ | `--root <path>` | Project root directory. Repeatable to pack multiple roots. |
34
+ | `--roots <a,b,c>` | Comma- or semicolon-separated list of roots. |
35
+ | `--format zip\|tar.gz` | Output archive format. |
36
+ | `--out <dir>` | Output directory (file name is auto-generated). |
37
+ | `--out-file <path>` | Full path of the archive (overrides `--out` naming). |
38
+ | `--timestamp` | Append a `-<time>` suffix to the auto-generated file name. |
39
+ | `--no-timestamp` | Use `<folder>.<ext>` only (default; same as omitting both flags). `--source-timestamp` / `--no-source-timestamp` are also accepted. |
40
+ | `--force` | Overwrite an existing output file without prompting (non-interactive). |
41
+ | `-y, --yes, --no-interactive` | Skip prompts. Requires `--format`, `--out` or `--out-file`, and `--root` / `--roots`. |
42
+ | `-q, --quiet` | Suppress scan / compress progress output. |
43
+ | `-h, --help` | Show help. |
44
+
45
+ #### Examples
46
+
47
+ ```bash
48
+ # Interactive (defaults from CWD)
49
+ gitpack
50
+
51
+ # Non-interactive single root
52
+ gitpack --no-interactive --root . --out .. --format zip
53
+
54
+ # Pack multiple roots into one tar.gz
55
+ gitpack --roots "D:/a,D:/b" --out D:/dist --format tar.gz
56
+ ```
57
+
58
+ ## License
59
+
60
+ MIT
package/bin/gitpack.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../src/cli.mjs";
3
+
4
+ await main();
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@yunxiaoyi/gitpack",
3
+ "version": "0.1.1",
4
+ "description": "Pack a project into zip / tar.gz while respecting nested `.gitignore` rules. Provides both an interactive and a flag-based command-line mode. No `.git` directory required.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Yun Xiaoyi",
8
+ "keywords": [
9
+ "archive",
10
+ "zip",
11
+ "tar",
12
+ "tar.gz",
13
+ "gitignore",
14
+ "pack",
15
+ "cli"
16
+ ],
17
+ "bin": {
18
+ "gitpack": "bin/gitpack.js",
19
+ "gpk": "bin/gitpack.js"
20
+ },
21
+ "files": [
22
+ "bin",
23
+ "src"
24
+ ],
25
+ "bugs": "https://github.com/OneCalmCloud/gitpack/issues",
26
+ "scripts": {
27
+ "start": "node ./bin/gitpack.js"
28
+ },
29
+ "engines": {
30
+ "node": ">=20.0.0"
31
+ },
32
+ "dependencies": {
33
+ "@clack/prompts": "^0.11.0",
34
+ "archiver": "^8.0.0",
35
+ "globby": "^16.2.0"
36
+ }
37
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,445 @@
1
+ import * as p from "@clack/prompts";
2
+ import { existsSync, statSync } from "node:fs";
3
+ import { basename, join, resolve } from "node:path";
4
+ import { writeArchive } from "./pack.mjs";
5
+
6
+ function parseArgs(argv) {
7
+ const flags = {
8
+ roots: [],
9
+ format: null,
10
+ out: null,
11
+ outFile: null,
12
+ /** @type {boolean | undefined} undefined = default true */
13
+ timestamp: undefined,
14
+ force: false,
15
+ noInteractive: false,
16
+ quiet: false,
17
+ help: false,
18
+ };
19
+
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const a = argv[i];
22
+ if (a === "-h" || a === "--help") {
23
+ flags.help = true;
24
+ continue;
25
+ }
26
+ if (a === "-y" || a === "--yes" || a === "--no-interactive") {
27
+ flags.noInteractive = true;
28
+ continue;
29
+ }
30
+ if (a === "--quiet" || a === "-q") {
31
+ flags.quiet = true;
32
+ continue;
33
+ }
34
+ if (a === "--root") {
35
+ const next = argv[++i];
36
+ if (!next) throw new Error("--root requires a path");
37
+ flags.roots.push(resolve(next));
38
+ continue;
39
+ }
40
+ if (a === "--roots") {
41
+ const next = argv[++i] || "";
42
+ for (const part of next.split(/[,;]/)) {
43
+ const t = part.trim();
44
+ if (t) flags.roots.push(resolve(t));
45
+ }
46
+ continue;
47
+ }
48
+ if (a === "--format") {
49
+ flags.format = argv[++i];
50
+ continue;
51
+ }
52
+ if (a === "--out") {
53
+ flags.out = resolve(argv[++i]);
54
+ continue;
55
+ }
56
+ if (a === "--out-file") {
57
+ flags.outFile = resolve(argv[++i]);
58
+ continue;
59
+ }
60
+ if (a === "--timestamp" || a === "--source-timestamp") {
61
+ flags.timestamp = true;
62
+ continue;
63
+ }
64
+ if (a === "--no-timestamp" || a === "--no-source-timestamp") {
65
+ flags.timestamp = false;
66
+ continue;
67
+ }
68
+ if (a === "--force") {
69
+ flags.force = true;
70
+ continue;
71
+ }
72
+ throw new Error(`Unknown argument: ${a}`);
73
+ }
74
+ return flags;
75
+ }
76
+
77
+ function printHelp() {
78
+ console.log(`gitpack — pack directories into zip / tar.gz (respects nested .gitignore; no .git required)
79
+
80
+ Usage:
81
+ gitpack [options]
82
+
83
+ Options:
84
+ --root <path> Project root (repeatable)
85
+ --roots <a,b,c> Comma- or semicolon-separated roots
86
+ --format zip|tar.gz Output format
87
+ --out <dir> Output directory (file name is auto-generated)
88
+ --out-file <path> Full path of archive (overrides --out name)
89
+ --timestamp Auto name includes -<time> suffix
90
+ --no-timestamp Auto name is <folder>.zip only (default; same as omitting both flags)
91
+ (--source-timestamp / --no-source-timestamp still accepted)
92
+ --force Overwrite existing output file without prompting (non-interactive)
93
+ -y, --yes, --no-interactive Do not prompt; require --format, --out or --out-file, and --root/--roots
94
+ -q, --quiet Do not print progress (scan / compress) to stderr or update the spinner text
95
+ -h, --help Show help
96
+
97
+ Examples:
98
+ gitpack
99
+ gitpack --no-interactive --root . --out .. --format zip
100
+ gitpack --roots "D:/a,D:/b" --out D:/dist --format tar.gz
101
+ `);
102
+ }
103
+
104
+ /** Avoid `String(undefined)` becoming the literal `"undefined"` and being treated as a path. */
105
+ function trimmedTextOr(value, fallback) {
106
+ const t = value == null ? "" : String(value).trim();
107
+ return t === "" ? fallback : t;
108
+ }
109
+
110
+ function validateRoots(roots) {
111
+ if (roots.length === 0) throw new Error("At least one project root directory is required.");
112
+ for (const r of roots) {
113
+ if (!existsSync(r) || !statSync(r).isDirectory()) {
114
+ throw new Error(`Not a valid directory: ${r}`);
115
+ }
116
+ }
117
+ }
118
+
119
+ /** When no output directory is specified: use the current working directory (where `gitpack` is executed). */
120
+ function defaultOutDir() {
121
+ return resolve(process.cwd());
122
+ }
123
+
124
+ function stamp() {
125
+ return new Date()
126
+ .toISOString()
127
+ .replaceAll(":", "")
128
+ .replace("T", "-")
129
+ .slice(0, 15);
130
+ }
131
+
132
+ /**
133
+ * @param {string[]} roots
134
+ * @param {'zip' | 'tar.gz'} format
135
+ * @param {boolean} useTimestamp
136
+ */
137
+ function defaultArchiveName(roots, format, useTimestamp) {
138
+ const ext = format === "zip" ? "zip" : "tar.gz";
139
+ const mid = useTimestamp ? `-${stamp()}` : "";
140
+ if (roots.length === 1) {
141
+ return `${basename(roots[0])}${mid}.${ext}`;
142
+ }
143
+ return `bundle-${roots.length}-roots${mid}.${ext}`;
144
+ }
145
+
146
+ function fmtBytes(n) {
147
+ if (n < 1024) return `${n} B`;
148
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
149
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
150
+ }
151
+
152
+ /**
153
+ * @param {{ spinner: ReturnType<typeof p.spinner> | null; quiet: boolean; noPrompt: boolean }} opts
154
+ */
155
+ function createProgressHandler(opts) {
156
+ const { spinner, quiet, noPrompt } = opts;
157
+ const state = { stderrLine: false, lastCiLog: 0 };
158
+
159
+ /** @param {*} e */
160
+ return {
161
+ state,
162
+ onProgress(e) {
163
+ if (quiet) return;
164
+ if (e.phase === "collect") {
165
+ const name = basename(e.root);
166
+ if (e.filesInRoot === null) {
167
+ const msg = `Scanning ${e.index}/${e.total} · ${name}`;
168
+ if (spinner) spinner.message(msg);
169
+ else if (noPrompt && !process.stderr.isTTY) {
170
+ console.error(`[gitpack] ${msg}`);
171
+ } else if (noPrompt) {
172
+ process.stderr.write(`\r\x1b[K[gitpack] ${msg}`);
173
+ state.stderrLine = true;
174
+ }
175
+ } else {
176
+ const msg = `Listed ${e.filesInRoot} files · ${name} (${e.index}/${e.total})`;
177
+ if (spinner) spinner.message(msg);
178
+ else if (noPrompt && !process.stderr.isTTY) {
179
+ console.error(`[gitpack] ${msg}`);
180
+ } else if (noPrompt) {
181
+ process.stderr.write(`\r\x1b[K[gitpack] ${msg}`);
182
+ state.stderrLine = true;
183
+ }
184
+ }
185
+ } else if (e.phase === "collect_done") {
186
+ const msg = `Compressing · ${e.totalFiles} files total`;
187
+ if (spinner) spinner.message(msg);
188
+ else if (noPrompt && !process.stderr.isTTY) {
189
+ console.error(`[gitpack] ${msg}`);
190
+ } else if (noPrompt) {
191
+ process.stderr.write(`\r\x1b[K[gitpack] ${msg}`);
192
+ state.stderrLine = true;
193
+ }
194
+ } else if (e.phase === "compress") {
195
+ const { entries, fs } = e;
196
+ const t = entries.total || 0;
197
+ const c = entries.processed || 0;
198
+ const tb = fs.totalBytes || 0;
199
+ const pb = fs.processedBytes || 0;
200
+ const entryPct = t ? Math.min(100, Math.round((c / t) * 100)) : 0;
201
+ const bytePct = tb ? Math.min(100, Math.round((pb / tb) * 100)) : 0;
202
+ const msg = `Compress ${c}/${t} · entries ${entryPct}% · size ~${bytePct}% (${fmtBytes(pb)}${tb ? ` / ${fmtBytes(tb)}` : ""})`;
203
+ if (spinner) spinner.message(msg);
204
+ else if (noPrompt) {
205
+ if (process.stderr.isTTY) {
206
+ process.stderr.write(`\r\x1b[K[gitpack] ${msg}`);
207
+ state.stderrLine = true;
208
+ } else {
209
+ const now = Date.now();
210
+ if (c >= t || now - state.lastCiLog > 900) {
211
+ state.lastCiLog = now;
212
+ console.error(`[gitpack] ${msg}`);
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ },
219
+ };
220
+ }
221
+
222
+ /**
223
+ * @param {{ roots: string[]; format: 'zip' | 'tar.gz'; outDir: string; flags: Record<string, unknown>; noPrompt: boolean }} opts
224
+ */
225
+ async function resolveOutputArchivePath(opts) {
226
+ const { roots, format, outDir, flags, noPrompt } = opts;
227
+
228
+ let useTimestamp = flags.timestamp === true;
229
+
230
+ if (!noPrompt) {
231
+ const tsQ = await p.confirm({
232
+ message: "Include timestamp in file name?",
233
+ initialValue: false,
234
+ });
235
+ if (p.isCancel(tsQ)) process.exit(0);
236
+ useTimestamp = !!tsQ;
237
+ }
238
+
239
+ const ext = format === "zip" ? ".zip" : ".tar.gz";
240
+
241
+ for (;;) {
242
+ const name = defaultArchiveName(roots, format, useTimestamp);
243
+ const candidate = join(outDir, name);
244
+ if (!existsSync(candidate)) {
245
+ return candidate;
246
+ }
247
+ if (noPrompt) {
248
+ if (flags.force) {
249
+ return candidate;
250
+ }
251
+ console.error(
252
+ `Output file already exists: ${candidate}\nUse --force to overwrite, or delete the file and try again.`,
253
+ );
254
+ process.exit(1);
255
+ }
256
+ const ow = await p.confirm({
257
+ message: `The following file already exists. Overwrite it?\n${candidate}`,
258
+ initialValue: false,
259
+ });
260
+ if (p.isCancel(ow)) process.exit(0);
261
+ if (ow) {
262
+ return candidate;
263
+ }
264
+ const fix = await p.select({
265
+ message: "Choose an option (without overwriting):",
266
+ options: [
267
+ { value: "enable-ts", label: "Use a timestamped file name" },
268
+ { value: "custom", label: "Custom file name (without extension)" },
269
+ { value: "abort", label: "Cancel" },
270
+ ],
271
+ initialValue: "enable-ts",
272
+ });
273
+ if (p.isCancel(fix) || fix === "abort") {
274
+ p.outro("Canceled");
275
+ process.exit(0);
276
+ }
277
+ if (fix === "enable-ts") {
278
+ useTimestamp = true;
279
+ continue;
280
+ }
281
+ const base = await p.text({
282
+ message: `Enter a file name (without extension ${ext})`,
283
+ initialValue: basename(roots[0]),
284
+ });
285
+ if (p.isCancel(base)) process.exit(0);
286
+ const customPath = join(outDir, `${trimmedTextOr(base, basename(roots[0]))}${ext}`);
287
+ if (!existsSync(customPath)) {
288
+ return customPath;
289
+ }
290
+ p.log.warn(`This name also exists: ${customPath}`);
291
+ }
292
+ }
293
+
294
+ export async function main() {
295
+ let flags;
296
+ try {
297
+ flags = parseArgs(process.argv.slice(2));
298
+ } catch (e) {
299
+ console.error(e.message);
300
+ process.exit(1);
301
+ }
302
+
303
+ if (flags.help) {
304
+ printHelp();
305
+ process.exit(0);
306
+ }
307
+
308
+ const noPrompt =
309
+ flags.noInteractive ||
310
+ process.env.GITPACK_NO_PROMPT === "1" ||
311
+ process.env.CI === "true" ||
312
+ !process.stdin.isTTY;
313
+
314
+ let roots = [...flags.roots];
315
+ let format = flags.format;
316
+ let outDir = flags.out;
317
+ let outFile = flags.outFile;
318
+
319
+ if (!noPrompt) {
320
+ p.intro("gitpack");
321
+
322
+ if (roots.length > 0) {
323
+ validateRoots(roots);
324
+ }
325
+
326
+ if (roots.length === 0) {
327
+ const first = await p.text({
328
+ message: "Project root directory to pack",
329
+ placeholder: process.cwd(),
330
+ initialValue: "",
331
+ });
332
+ if (p.isCancel(first)) process.exit(0);
333
+ roots.push(resolve(trimmedTextOr(first, process.cwd())));
334
+ }
335
+
336
+ validateRoots(roots);
337
+
338
+ if (!format) {
339
+ const f = await p.select({
340
+ message: "Archive format",
341
+ options: [
342
+ { value: "zip", label: "zip" },
343
+ { value: "tar.gz", label: "tar.gz" },
344
+ ],
345
+ initialValue: "zip",
346
+ });
347
+ if (p.isCancel(f)) process.exit(0);
348
+ format = f;
349
+ }
350
+
351
+ if (!outFile && !outDir) {
352
+ const suggested = defaultOutDir();
353
+ const o = await p.text({
354
+ message: "Output directory",
355
+ placeholder: suggested,
356
+ initialValue: "",
357
+ });
358
+ if (p.isCancel(o)) process.exit(0);
359
+ outDir = resolve(trimmedTextOr(o, suggested));
360
+ }
361
+ } else {
362
+ if (roots.length === 0) {
363
+ console.error("Non-interactive mode requires --root or --roots.");
364
+ process.exit(1);
365
+ }
366
+ validateRoots(roots);
367
+ if (!format || (!outDir && !outFile)) {
368
+ console.error("Non-interactive mode requires --format and either --out or --out-file.");
369
+ process.exit(1);
370
+ }
371
+ if (!outFile && outDir) {
372
+ outDir = resolve(outDir);
373
+ }
374
+ }
375
+
376
+ if (format !== "zip" && format !== "tar.gz") {
377
+ console.error('format must be zip or tar.gz');
378
+ process.exit(1);
379
+ }
380
+
381
+ validateRoots(roots);
382
+
383
+ if (!outFile) {
384
+ outDir = resolve(outDir || defaultOutDir());
385
+ outFile = await resolveOutputArchivePath({
386
+ roots,
387
+ format,
388
+ outDir,
389
+ flags,
390
+ noPrompt,
391
+ });
392
+ } else {
393
+ outFile = resolve(outFile);
394
+ if (existsSync(outFile)) {
395
+ if (noPrompt) {
396
+ if (!flags.force) {
397
+ console.error(`File already exists: ${outFile}\nUse --force to overwrite.`);
398
+ process.exit(1);
399
+ }
400
+ } else {
401
+ const ow = await p.confirm({
402
+ message: `The following file already exists. Overwrite it?\n${outFile}`,
403
+ initialValue: false,
404
+ });
405
+ if (p.isCancel(ow)) process.exit(0);
406
+ if (!ow) {
407
+ p.outro("Canceled (not overwritten)");
408
+ process.exit(0);
409
+ }
410
+ }
411
+ }
412
+ }
413
+
414
+ const spinner = !noPrompt ? p.spinner() : null;
415
+ spinner?.start(flags.quiet ? "Packing…" : "Preparing to pack…");
416
+
417
+ const progress = createProgressHandler({
418
+ spinner: flags.quiet ? null : spinner,
419
+ quiet: flags.quiet,
420
+ noPrompt,
421
+ });
422
+
423
+ try {
424
+ const { bytes, fileCount } = await writeArchive({
425
+ roots,
426
+ format,
427
+ outFile,
428
+ onProgress: flags.quiet ? undefined : progress.onProgress,
429
+ });
430
+ if (progress.state.stderrLine) {
431
+ process.stderr.write("\n");
432
+ }
433
+ spinner?.stop(`Packed: ${fileCount} files → ${outFile} (${bytes} bytes)`);
434
+ if (!noPrompt) {
435
+ p.outro("Done");
436
+ } else {
437
+ console.log(outFile);
438
+ console.log(`${fileCount} files, ${bytes} bytes`);
439
+ }
440
+ } catch (e) {
441
+ spinner?.stop("Failed", 1);
442
+ console.error(e);
443
+ process.exit(1);
444
+ }
445
+ }
package/src/pack.mjs ADDED
@@ -0,0 +1,340 @@
1
+ import { globby, isIgnoredByIgnoreFiles } from "globby";
2
+ import { ZipArchive,TarArchive } from 'archiver'
3
+ import {
4
+ createWriteStream,
5
+ existsSync,
6
+ readFileSync,
7
+ statSync,
8
+ } from "node:fs";
9
+ import { mkdir, readdir } from "node:fs/promises";
10
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
11
+
12
+ const GITIGNORE = ".gitignore";
13
+
14
+ /** Relative POSIX path from `root` to `target`, or null if `target` is not under `root`. */
15
+ export function posixRelativeUnderRoot(rootAbs, targetAbs) {
16
+ const root = resolve(rootAbs);
17
+ const target = resolve(targetAbs);
18
+ const rel = relative(root, target);
19
+ if (rel === "" || rel.startsWith("..") || rel.startsWith(`..${sep}`)) {
20
+ return null;
21
+ }
22
+ return rel.split(sep).join("/");
23
+ }
24
+
25
+ /** Whether root `.gitignore` explicitly ignores itself (non-negated patterns). */
26
+ export function rootGitignoreExplicitlyIgnoresSelf(text) {
27
+ for (const raw of text.split(/\r?\n/)) {
28
+ let line = raw.trim();
29
+ if (!line || line.startsWith("#")) continue;
30
+ const hash = line.indexOf("#");
31
+ if (hash !== -1) {
32
+ line = line.slice(0, hash).trimEnd();
33
+ if (!line) continue;
34
+ }
35
+ if (line.startsWith("!")) continue;
36
+ if (line === GITIGNORE || line === `/${GITIGNORE}` || line === `**/${GITIGNORE}`) {
37
+ return true;
38
+ }
39
+ }
40
+ return false;
41
+ }
42
+
43
+ /**
44
+ * @param {string} rootAbs
45
+ * @param {{ ignoreRelative?: string[] }} [opts]
46
+ * @returns {Promise<string[]>} paths relative to root, POSIX separators
47
+ */
48
+ export async function listPackablePaths(rootAbs, opts = {}) {
49
+ const ignore = [...(opts.ignoreRelative || [])].filter(Boolean);
50
+
51
+ let relPaths = await globby(["**/*"], {
52
+ cwd: rootAbs,
53
+ gitignore: true,
54
+ dot: true,
55
+ onlyFiles: true,
56
+ ignore,
57
+ });
58
+
59
+ relPaths = relPaths.map((p) => p.split(sep).join("/")).sort();
60
+
61
+ const giAbs = join(rootAbs, GITIGNORE);
62
+ if (existsSync(giAbs)) {
63
+ let selfExplicit = false;
64
+ try {
65
+ selfExplicit = rootGitignoreExplicitlyIgnoresSelf(readFileSync(giAbs, "utf8"));
66
+ } catch {
67
+ /* unreadable */
68
+ }
69
+ if (!selfExplicit && !relPaths.includes(GITIGNORE)) {
70
+ relPaths.push(GITIGNORE);
71
+ relPaths.sort();
72
+ }
73
+ }
74
+
75
+ return relPaths;
76
+ }
77
+
78
+ const GITIGNORE_FILES = "**/.gitignore";
79
+
80
+ /**
81
+ * Leaf-empty directories under `rootAbs` (readdir has no entries), respecting the same
82
+ * `.gitignore` rules as {@link listPackablePaths} plus `ignoreRelative` glob entries.
83
+ *
84
+ * @param {string} rootAbs
85
+ * @param {{ ignoreRelative?: string[] }} [opts]
86
+ * @returns {Promise<string[]>} paths relative to root, POSIX separators, no trailing slash
87
+ */
88
+ export async function listEmptyDirectoryPaths(rootAbs, opts = {}) {
89
+ const ignore = [...(opts.ignoreRelative || [])].filter(Boolean);
90
+ const isIgnored = await isIgnoredByIgnoreFiles(GITIGNORE_FILES, {
91
+ cwd: rootAbs,
92
+ dot: true,
93
+ ignore,
94
+ });
95
+
96
+ /** Gitignore `dir/` patterns match `rel/` only, not plain `rel`. */
97
+ const dirIgnored = (dirAbs) => {
98
+ if (isIgnored(dirAbs)) return true;
99
+ const rel = posixRelativeUnderRoot(rootAbs, dirAbs);
100
+ if (rel === null || rel === "") return false;
101
+ return isIgnored(`${rel}/`);
102
+ };
103
+
104
+ /** @type {string[]} */
105
+ const out = [];
106
+
107
+ /**
108
+ * @param {string} dirAbs
109
+ */
110
+ async function walk(dirAbs) {
111
+ const relToRoot = posixRelativeUnderRoot(rootAbs, dirAbs);
112
+ if (relToRoot !== null && relToRoot !== "") {
113
+ if (dirIgnored(dirAbs)) return;
114
+ }
115
+
116
+ /** @type {import('node:fs').Dirent[]} */
117
+ let entries;
118
+ try {
119
+ entries = await readdir(dirAbs, { withFileTypes: true });
120
+ } catch {
121
+ return;
122
+ }
123
+
124
+ let hasNonDir = false;
125
+ /** @type {string[]} */
126
+ const subdirs = [];
127
+ for (const ent of entries) {
128
+ // Treat anything other than a "real subdirectory" as content, so FIFO/socket entries
129
+ // (which are neither isFile nor isDirectory) are not misclassified as empty directories.
130
+ if (ent.isDirectory()) {
131
+ subdirs.push(join(dirAbs, ent.name));
132
+ } else {
133
+ hasNonDir = true;
134
+ }
135
+ }
136
+
137
+ for (const s of subdirs) {
138
+ await walk(s);
139
+ }
140
+
141
+ if (hasNonDir) return;
142
+ if (subdirs.length > 0) return;
143
+
144
+ if (relToRoot !== null && relToRoot !== "" && !dirIgnored(dirAbs)) {
145
+ out.push(relToRoot);
146
+ }
147
+ }
148
+
149
+ await walk(rootAbs);
150
+ return [...new Set(out)].sort();
151
+ }
152
+
153
+ /**
154
+ * @param {() => void} fn
155
+ * @param {number} ms
156
+ */
157
+ function throttle(fn, ms) {
158
+ let last = 0;
159
+ /** @type {ReturnType<typeof setTimeout> | null} */
160
+ let pending = null;
161
+ return () => {
162
+ const now = Date.now();
163
+ const run = () => {
164
+ last = Date.now();
165
+ pending = null;
166
+ fn();
167
+ };
168
+ if (now - last >= ms) {
169
+ if (pending) {
170
+ clearTimeout(pending);
171
+ pending = null;
172
+ }
173
+ run();
174
+ return;
175
+ }
176
+ if (pending) clearTimeout(pending);
177
+ pending = setTimeout(run, ms - (now - last));
178
+ };
179
+ }
180
+
181
+ /**
182
+ * @typedef {{
183
+ * phase: 'collect';
184
+ * index: number;
185
+ * total: number;
186
+ * root: string;
187
+ * filesInRoot: number | null;
188
+ * }} ProgressCollect
189
+ * @typedef {{
190
+ * phase: 'collect_done';
191
+ * totalFiles: number;
192
+ * }} ProgressCollectDone
193
+ * @typedef {{
194
+ * phase: 'compress';
195
+ * entries: { total: number; processed: number };
196
+ * fs: { totalBytes: number; processedBytes: number };
197
+ * }} ProgressCompress
198
+ * @typedef {ProgressCollect | ProgressCollectDone | ProgressCompress} ProgressEvent
199
+ */
200
+
201
+ /**
202
+ * @param {{ roots: string[]; format: 'zip' | 'tar.gz'; outFile: string; onProgress?: (e: ProgressEvent) => void }} opts
203
+ * @returns {Promise<{ bytes: number; outFile: string; fileCount: number }>}
204
+ */
205
+ export async function writeArchive(opts) {
206
+ const { roots, format, outFile, onProgress } = opts;
207
+ const outDir = dirname(outFile);
208
+ await mkdir(outDir, { recursive: true });
209
+
210
+ /** When packing multiple roots, disambiguate identical basenames by index to avoid path collisions inside the archive. */
211
+ const baseCounts = new Map();
212
+ if (roots.length > 1) {
213
+ for (const r of roots) {
214
+ const b = basename(resolve(r)) || "root";
215
+ baseCounts.set(b, (baseCounts.get(b) ?? 0) + 1);
216
+ }
217
+ }
218
+
219
+ /** @type {Array<{ kind: 'file'; disk: string; nameInArchive: string } | { kind: 'dir'; nameInArchive: string }>} */
220
+ const entries = [];
221
+ let fileCount = 0;
222
+
223
+ for (let ri = 0; ri < roots.length; ri++) {
224
+ const root = roots[ri];
225
+ const ignoreRel = [];
226
+ // Only exclude the archive path being generated, to prevent the output from being packed into itself (output may live inside the project directory).
227
+ const outRel = posixRelativeUnderRoot(root, outFile);
228
+ if (outRel) ignoreRel.push(outRel);
229
+
230
+ onProgress?.({
231
+ phase: "collect",
232
+ index: ri + 1,
233
+ total: roots.length,
234
+ root,
235
+ filesInRoot: null,
236
+ });
237
+
238
+ const rels = await listPackablePaths(root, { ignoreRelative: ignoreRel });
239
+ const emptyDirs = await listEmptyDirectoryPaths(root, {
240
+ ignoreRelative: ignoreRel,
241
+ });
242
+ const base = basename(resolve(root)) || "root";
243
+ const prefix =
244
+ roots.length === 1
245
+ ? ""
246
+ : (baseCounts.get(base) ?? 0) > 1
247
+ ? `${ri + 1}-${base}/`
248
+ : `${base}/`;
249
+
250
+ let addedHere = 0;
251
+ for (const rel of emptyDirs) {
252
+ const nameInArchive = prefix ? `${prefix}${rel}/` : `${rel}/`;
253
+ entries.push({ kind: "dir", nameInArchive });
254
+ }
255
+
256
+ for (const rel of rels) {
257
+ const disk = join(root, rel);
258
+ if (!existsSync(disk) || !statSync(disk).isFile()) continue;
259
+ const nameInArchive = prefix ? `${prefix}${rel}` : rel;
260
+ entries.push({ kind: "file", disk, nameInArchive });
261
+ fileCount += 1;
262
+ addedHere += 1;
263
+ }
264
+
265
+ onProgress?.({
266
+ phase: "collect",
267
+ index: ri + 1,
268
+ total: roots.length,
269
+ root,
270
+ filesInRoot: addedHere,
271
+ });
272
+ }
273
+
274
+ entries.sort((a, b) => {
275
+ if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1;
276
+ if (a.kind === "dir") {
277
+ return (
278
+ a.nameInArchive.length - b.nameInArchive.length ||
279
+ a.nameInArchive.localeCompare(b.nameInArchive)
280
+ );
281
+ }
282
+ return a.nameInArchive.localeCompare(b.nameInArchive);
283
+ });
284
+
285
+ onProgress?.({ phase: "collect_done", totalFiles: entries.length });
286
+
287
+ const output = createWriteStream(outFile);
288
+ const archive =
289
+ format === "zip"
290
+ ? new ZipArchive({ zlib: { level: 9 } })
291
+ : new TarArchive({ gzipOptions: { level: 9 } });
292
+
293
+ /** @type {(() => void) | null} */
294
+ let flushProgress = null;
295
+ if (onProgress) {
296
+ /** @type {{ entries: { total: number; processed: number }; fs: { totalBytes: number; processedBytes: number } } | null} */
297
+ let last = null;
298
+ const throttled = throttle(() => {
299
+ if (last) {
300
+ onProgress({ phase: "compress", entries: last.entries, fs: last.fs });
301
+ }
302
+ }, 120);
303
+ archive.on("progress", (p) => {
304
+ last = p;
305
+ throttled();
306
+ });
307
+ flushProgress = () => {
308
+ if (last) onProgress({ phase: "compress", entries: last.entries, fs: last.fs });
309
+ };
310
+ }
311
+
312
+ await new Promise((resolvePromise, reject) => {
313
+ archive.on("error", reject);
314
+ output.on("error", reject);
315
+ archive.on("warning", (err) => {
316
+ if (err.code !== "ENOENT") {
317
+ console.warn("[archiver]", err.message);
318
+ }
319
+ });
320
+ output.once("finish", resolvePromise);
321
+ archive.pipe(output);
322
+
323
+ for (const e of entries) {
324
+ if (e.kind === "file") {
325
+ archive.file(e.disk, { name: e.nameInArchive });
326
+ } else {
327
+ archive.append(Buffer.alloc(0), {
328
+ name: e.nameInArchive,
329
+ type: "directory",
330
+ });
331
+ }
332
+ }
333
+ archive.finalize();
334
+ });
335
+
336
+ flushProgress?.();
337
+
338
+ const bytes = statSync(outFile).size;
339
+ return { bytes, outFile, fileCount };
340
+ }