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
package/README.md
CHANGED
|
@@ -4,8 +4,8 @@ A Bun CLI for making deterministic repository ZIPs for AI handoffs and code revi
|
|
|
4
4
|
|
|
5
5
|
## Requirements
|
|
6
6
|
|
|
7
|
-
- [Bun](https://bun.sh/) 1.0 or newer
|
|
8
|
-
- Git
|
|
7
|
+
- [Bun](https://bun.sh/) 1.4.0 or newer
|
|
8
|
+
- Git: required for `--git-handoff`; optional for default mode (used for tag/hash archive naming and `core.excludesfile` discovery, with graceful fallback when Git is unavailable)
|
|
9
9
|
|
|
10
10
|
## Use it
|
|
11
11
|
|
|
@@ -35,7 +35,18 @@ bunx acker-dacker ../codex --git-handoff --profile rust-stage3 \
|
|
|
35
35
|
--output ./rust-stage3.zip --report ./rust-stage3.json
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
If `<repository-path>` is omitted, the current directory is used.
|
|
38
|
+
If `<repository-path>` is omitted, the current directory is used.
|
|
39
|
+
|
|
40
|
+
### Archive output naming
|
|
41
|
+
|
|
42
|
+
Unless `--output` is supplied, archive names resolve as follows:
|
|
43
|
+
|
|
44
|
+
- In `--git-handoff` mode, the default name is `./<repository-name>-git-handoff.zip`. Artifacts (`--output` and `--report`) must be outside the Git worktree.
|
|
45
|
+
- In default mode, naming follows this order of preference:
|
|
46
|
+
1. **Git tag version** for the current checkout (e.g. `./<repository-name>-v2.33.zip`). If multiple version tags point to HEAD, the highest numeric version is selected. If only non-version tags exist, a deterministic lexical tie-break is used regardless of repository `tag.sort` configuration.
|
|
47
|
+
2. **`package.json` version** if present (e.g. `./<repository-name>-0.1.0.zip`).
|
|
48
|
+
3. **Short Git commit hash** for the current checkout (e.g. `./<repository-name>-abcdef.zip`).
|
|
49
|
+
4. Fallback to `./<repository-name>.zip` if none of the above are present or if Git is unavailable.
|
|
39
50
|
|
|
40
51
|
Run `bunx acker-dacker --help` for the current usage text. The options are:
|
|
41
52
|
|
|
@@ -44,19 +55,42 @@ Run `bunx acker-dacker --help` for the current usage text. The options are:
|
|
|
44
55
|
| `--git-handoff` | Use Git's tracked plus eligible-untracked file set and preserve modes and safe symlinks. |
|
|
45
56
|
| `--profile <name>` | Select a Git handoff profile. Currently `rust-stage3`; requires `--git-handoff`. |
|
|
46
57
|
| `--filter, -f <pattern>` | Include only matching paths or file names. Repeatable. |
|
|
47
|
-
| `--include, -i <path>` | Add a local directory
|
|
58
|
+
| `--include, -i <path\|glob>` | Add a local directory, repository dependency, or wildcard glob in default mode. Repeatable. |
|
|
48
59
|
| `--exclude <path\|glob>` | Exclude a root-relative path or glob in Git handoff mode. Repeatable. |
|
|
49
|
-
| `--output, -o <path>` | Set the ZIP output path. |
|
|
60
|
+
| `--output, -o <path>` | Set the ZIP output path (default: `./<name>-<tag\|version\|hash>.zip` in default mode, or `./<name>-git-handoff.zip` in `--git-handoff` mode; handoff artifacts must be outside the worktree). |
|
|
50
61
|
| `--report <path>` | Write a content-free Git handoff JSON report. Requires `--git-handoff`. |
|
|
51
62
|
| `--help, -h` | Show help. |
|
|
52
63
|
|
|
53
64
|
## Modes and safety
|
|
54
65
|
|
|
55
|
-
Default mode recursively discovers local `file:` and `link:` package dependencies,
|
|
66
|
+
Default mode recursively discovers local `file:` and `link:` package dependencies, honors global Git ignore files alongside root and nested `.gitignore` files and `.git/info/exclude`, and omits common generated output, Xcode build artifacts, and sensitive file names. It also supports explicit `--include` paths.
|
|
67
|
+
|
|
68
|
+
### Global Git ignore configuration
|
|
69
|
+
|
|
70
|
+
Global ignore files are discovered in the following order:
|
|
71
|
+
1. `ACKER_DACKER_GLOBAL_GITIGNORE` environment variable:
|
|
72
|
+
- When unset, discovery follows Git conventions below.
|
|
73
|
+
- When set to empty string `""` or `'none'`, global ignore file discovery is disabled.
|
|
74
|
+
- Any other value is resolved as a file path relative to `process.cwd()`.
|
|
75
|
+
2. `core.excludesfile` from Git configuration (if Git is available). If configured, it is used (or contributes no rules if the file is missing), suppressing fallback to XDG/home ignore files.
|
|
76
|
+
3. Standard fallback locations: `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore`.
|
|
77
|
+
|
|
78
|
+
In all cases, repository `.git/info/exclude` rules and local/nested `.gitignore` rules apply with higher precedence, allowing repositories to negate global ignore rules.
|
|
79
|
+
|
|
80
|
+
### Git handoff mode
|
|
81
|
+
|
|
82
|
+
Git handoff mode asks Git for tracked files and standard-ignore-aware untracked files. Tracked files remain eligible by design; sensitive and generated files are safety-filtered only when untracked. Deleted tracked files missing from the worktree are omitted from the archive and recorded in the report. Absolute, escaping, dangling, and cyclic symlinks are rejected, as are symlinked ancestor directories. Output and report artifact paths must reside outside the worktree and cannot alias each other via directory symlinks or hard links. `--exclude`, `--filter`, `--profile`, and `--report` are applied as documented above.
|
|
83
|
+
|
|
84
|
+
### Determinism and format limits
|
|
85
|
+
|
|
86
|
+
Archives are deterministic: file ordering, ZIP timestamps (normalized to 1980-01-01), and compression choices do not depend on the current clock. ZIP files are created using maximum DEFLATE compression (`level: 9, memLevel: 9`), with streaming deflation spooled for entries over 64 MiB (falling back to STORE only if compression does not reduce size). Archive files are written atomically via temporary siblings to ensure existing files are never corrupted on failure.
|
|
56
87
|
|
|
57
|
-
|
|
88
|
+
All archives adhere to the classic ZIP format (non-ZIP64 ceiling):
|
|
89
|
+
- Maximum 65,535 total entries.
|
|
90
|
+
- Maximum 4 GB uncompressed/compressed entry size and 4 GB total archive size / offset.
|
|
91
|
+
- Maximum 65,535 bytes path length.
|
|
92
|
+
- Paths are encoded using UTF-8 with General Purpose bit 11 (`0x0800`) set in both local and central directory headers.
|
|
58
93
|
|
|
59
|
-
Archives are deterministic: file ordering, ZIP timestamps, and compression choices do not depend on the current clock.
|
|
60
94
|
|
|
61
95
|
## Development
|
|
62
96
|
|
|
@@ -68,7 +102,7 @@ bun run check
|
|
|
68
102
|
bun run build
|
|
69
103
|
```
|
|
70
104
|
|
|
71
|
-
The CLI entrypoint is `index.ts`. Workflow orchestration lives in `src/cli.ts`; argument parsing is in `src/cli-options.ts`; default discovery and policy are in `src/repository-pack.ts`, `src/gitignore.ts`, and `src/file-policy.ts`; Git handoff selection and staging are in `src/git-handoff-selection.ts` and `src/git-handoff.ts`; deterministic ZIP writing is in `src/zip.ts`.
|
|
105
|
+
The CLI entrypoint is `src/index.ts`. Workflow orchestration lives in `src/cli/cli.ts`; argument parsing is in `src/cli/cli-options.ts`; archive output naming is in `src/cli/archive-name.ts`; default discovery and policy are in `src/pack/repository-pack.ts`, `src/pack/gitignore.ts`, and `src/pack/file-policy.ts`; Git handoff selection and staging are in `src/git/git-handoff-selection.ts` and `src/git/git-handoff.ts`; deterministic ZIP writing is in `src/zip/zip.ts`.
|
|
72
106
|
|
|
73
107
|
To inspect the npm package before publishing:
|
|
74
108
|
|
|
@@ -76,4 +110,4 @@ To inspect the npm package before publishing:
|
|
|
76
110
|
npm pack --dry-run
|
|
77
111
|
```
|
|
78
112
|
|
|
79
|
-
The package exposes the `acker-dacker` bin and intentionally ships only `
|
|
113
|
+
The package exposes the `acker-dacker` bin and intentionally ships only `src/` (excluding tests) as application files, plus npm's standard metadata files.
|
package/package.json
CHANGED
|
@@ -1,35 +1,36 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
2
|
+
"name": "acker-dacker",
|
|
3
|
+
"author": {
|
|
4
|
+
"name": "Ragaeeb Haq",
|
|
5
|
+
"url": "https://github.com/ragaeeb"
|
|
6
|
+
},
|
|
7
|
+
"bugs": {
|
|
8
|
+
"url": "https://github.com/ragaeeb/acker-dacker/issues"
|
|
9
|
+
},
|
|
10
|
+
"version": "0.2.0",
|
|
11
|
+
"description": "Create deterministic, privacy-aware repository ZIPs for AI handoffs and code review.",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"bin": {
|
|
14
|
+
"acker-dacker": "src/index.ts"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"!src/__tests__"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "bun build ./src/index.ts --compile --minify --bytecode --format=esm --outfile ./dist/acker-dacker",
|
|
22
|
+
"check": "bun run typecheck && bun test && bun build ./src/index.ts --target=bun --outfile /tmp/acker-dacker-check.js",
|
|
23
|
+
"prepublishOnly": "bun run check",
|
|
24
|
+
"test": "bun test",
|
|
25
|
+
"typecheck": "tsc --noEmit"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"bun": ">=1.4.2",
|
|
29
|
+
"node": ">=26.x"
|
|
30
|
+
},
|
|
31
|
+
"packageManager": "bun@1.4.2",
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/bun": "^1.4.2",
|
|
34
|
+
"typescript": "^7.0.2"
|
|
35
|
+
}
|
|
35
36
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { getGitCheckoutTag, getGitShortCommitHash } from '../git/git';
|
|
3
|
+
|
|
4
|
+
export const sanitizeArchiveSuffix = (suffix: string): string => {
|
|
5
|
+
return suffix
|
|
6
|
+
.trim()
|
|
7
|
+
.replace(/[\\/]/g, '-')
|
|
8
|
+
.replace(/[^a-zA-Z0-9_.-]/g, '_');
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const getPackageJsonVersion = async (targetDir: string): Promise<string | null> => {
|
|
12
|
+
const packageJsonPath = path.join(targetDir, 'package.json');
|
|
13
|
+
const file = Bun.file(packageJsonPath);
|
|
14
|
+
if (!(await file.exists())) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const parsed = (await file.json()) as Record<string, unknown>;
|
|
19
|
+
if (typeof parsed.version === 'string' && parsed.version.trim().length > 0) {
|
|
20
|
+
return parsed.version.trim();
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
// Invalid JSON or unreadable file
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const resolveDefaultPackOutputName = async (targetDir: string): Promise<string> => {
|
|
29
|
+
const baseName = path.basename(path.resolve(targetDir)) || 'archive';
|
|
30
|
+
|
|
31
|
+
// 1. Current checkout has a git tag version
|
|
32
|
+
const gitTag = await getGitCheckoutTag(targetDir);
|
|
33
|
+
if (gitTag) {
|
|
34
|
+
return `${baseName}-${sanitizeArchiveSuffix(gitTag)}.zip`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 2. package.json exists with a "version" field
|
|
38
|
+
const packageVersion = await getPackageJsonVersion(targetDir);
|
|
39
|
+
if (packageVersion) {
|
|
40
|
+
return `${baseName}-${sanitizeArchiveSuffix(packageVersion)}.zip`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 3. Short git commit hash for current checkout
|
|
44
|
+
const gitShortHash = await getGitShortCommitHash(targetDir);
|
|
45
|
+
if (gitShortHash) {
|
|
46
|
+
return `${baseName}-${sanitizeArchiveSuffix(gitShortHash)}.zip`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 4. Fallback if neither git tag, version, nor commit hash is present
|
|
50
|
+
return `${baseName}.zip`;
|
|
51
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
export type CliOptions = {
|
|
2
|
+
readonly excludes: readonly string[];
|
|
3
|
+
readonly filters: readonly string[];
|
|
4
|
+
readonly gitHandoff: boolean;
|
|
5
|
+
readonly includes: readonly string[];
|
|
6
|
+
readonly outputFile: string | null;
|
|
7
|
+
readonly profile: string | null;
|
|
8
|
+
readonly reportFile: string | null;
|
|
9
|
+
readonly showHelp: boolean;
|
|
10
|
+
readonly targetDir: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const parseCliArgs = (args: readonly string[]): CliOptions => {
|
|
14
|
+
const excludes: string[] = [];
|
|
15
|
+
const filters: string[] = [];
|
|
16
|
+
const includes: string[] = [];
|
|
17
|
+
let gitHandoff = false;
|
|
18
|
+
let outputFile: string | null = null;
|
|
19
|
+
let profile: string | null = null;
|
|
20
|
+
let reportFile: string | null = null;
|
|
21
|
+
let showHelp = false;
|
|
22
|
+
let targetDir = process.cwd();
|
|
23
|
+
let hasTargetDir = false;
|
|
24
|
+
let positionalOnly = false;
|
|
25
|
+
|
|
26
|
+
const getValue = (
|
|
27
|
+
name: string,
|
|
28
|
+
index: number,
|
|
29
|
+
inlineValue?: string,
|
|
30
|
+
): { value: string; nextIndex: number } => {
|
|
31
|
+
if (inlineValue !== undefined) {
|
|
32
|
+
if (inlineValue.length === 0) {
|
|
33
|
+
throw new Error(`Option ${name} requires a non-empty value`);
|
|
34
|
+
}
|
|
35
|
+
return { value: inlineValue, nextIndex: index };
|
|
36
|
+
}
|
|
37
|
+
const nextIndex = index + 1;
|
|
38
|
+
if (nextIndex >= args.length) {
|
|
39
|
+
throw new Error(`Option ${name} requires a value`);
|
|
40
|
+
}
|
|
41
|
+
const nextArg = args[nextIndex]!;
|
|
42
|
+
if (nextArg.startsWith('-')) {
|
|
43
|
+
throw new Error(`Option ${name} requires a value`);
|
|
44
|
+
}
|
|
45
|
+
if (nextArg.length === 0) {
|
|
46
|
+
throw new Error(`Option ${name} requires a non-empty value`);
|
|
47
|
+
}
|
|
48
|
+
return { value: nextArg, nextIndex };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
52
|
+
const argument = args[index]!;
|
|
53
|
+
if (positionalOnly) {
|
|
54
|
+
if (!hasTargetDir) {
|
|
55
|
+
targetDir = argument;
|
|
56
|
+
hasTargetDir = true;
|
|
57
|
+
} else {
|
|
58
|
+
includes.push(argument);
|
|
59
|
+
}
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (argument === '--') {
|
|
64
|
+
positionalOnly = true;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (argument === '--help' || argument === '-h') {
|
|
69
|
+
showHelp = true;
|
|
70
|
+
} else if (argument === '--git-handoff') {
|
|
71
|
+
gitHandoff = true;
|
|
72
|
+
} else if (argument.startsWith('--profile=')) {
|
|
73
|
+
const res = getValue('--profile', index, argument.slice(10));
|
|
74
|
+
profile = res.value;
|
|
75
|
+
index = res.nextIndex;
|
|
76
|
+
} else if (argument === '--profile') {
|
|
77
|
+
const res = getValue('--profile', index);
|
|
78
|
+
profile = res.value;
|
|
79
|
+
index = res.nextIndex;
|
|
80
|
+
} else if (argument.startsWith('--exclude=')) {
|
|
81
|
+
const res = getValue('--exclude', index, argument.slice(10));
|
|
82
|
+
excludes.push(res.value);
|
|
83
|
+
index = res.nextIndex;
|
|
84
|
+
} else if (argument === '--exclude') {
|
|
85
|
+
const res = getValue('--exclude', index);
|
|
86
|
+
excludes.push(res.value);
|
|
87
|
+
index = res.nextIndex;
|
|
88
|
+
} else if (argument.startsWith('--output=')) {
|
|
89
|
+
const res = getValue('--output', index, argument.slice(9));
|
|
90
|
+
outputFile = res.value;
|
|
91
|
+
index = res.nextIndex;
|
|
92
|
+
} else if (argument.startsWith('-o=')) {
|
|
93
|
+
const res = getValue('-o', index, argument.slice(3));
|
|
94
|
+
outputFile = res.value;
|
|
95
|
+
index = res.nextIndex;
|
|
96
|
+
} else if (argument === '--output' || argument === '-o') {
|
|
97
|
+
const res = getValue(argument, index);
|
|
98
|
+
outputFile = res.value;
|
|
99
|
+
index = res.nextIndex;
|
|
100
|
+
} else if (argument.startsWith('--include=')) {
|
|
101
|
+
const res = getValue('--include', index, argument.slice(10));
|
|
102
|
+
includes.push(res.value);
|
|
103
|
+
index = res.nextIndex;
|
|
104
|
+
} else if (argument.startsWith('-i=')) {
|
|
105
|
+
const res = getValue('-i', index, argument.slice(3));
|
|
106
|
+
includes.push(res.value);
|
|
107
|
+
index = res.nextIndex;
|
|
108
|
+
} else if (argument === '--include' || argument === '-i') {
|
|
109
|
+
const res = getValue(argument, index);
|
|
110
|
+
includes.push(res.value);
|
|
111
|
+
index = res.nextIndex;
|
|
112
|
+
} else if (argument.startsWith('--filter=')) {
|
|
113
|
+
const res = getValue('--filter', index, argument.slice(9));
|
|
114
|
+
filters.push(res.value);
|
|
115
|
+
index = res.nextIndex;
|
|
116
|
+
} else if (argument.startsWith('-f=')) {
|
|
117
|
+
const res = getValue('-f', index, argument.slice(3));
|
|
118
|
+
filters.push(res.value);
|
|
119
|
+
index = res.nextIndex;
|
|
120
|
+
} else if (argument === '--filter' || argument === '-f') {
|
|
121
|
+
const res = getValue(argument, index);
|
|
122
|
+
filters.push(res.value);
|
|
123
|
+
index = res.nextIndex;
|
|
124
|
+
} else if (argument.startsWith('--report=')) {
|
|
125
|
+
const res = getValue('--report', index, argument.slice(9));
|
|
126
|
+
reportFile = res.value;
|
|
127
|
+
index = res.nextIndex;
|
|
128
|
+
} else if (argument === '--report') {
|
|
129
|
+
const res = getValue('--report', index);
|
|
130
|
+
reportFile = res.value;
|
|
131
|
+
index = res.nextIndex;
|
|
132
|
+
} else if (argument.startsWith('-')) {
|
|
133
|
+
throw new Error(`Unrecognized option: ${argument}`);
|
|
134
|
+
} else {
|
|
135
|
+
if (!hasTargetDir) {
|
|
136
|
+
targetDir = argument;
|
|
137
|
+
hasTargetDir = true;
|
|
138
|
+
} else {
|
|
139
|
+
includes.push(argument);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { excludes, filters, gitHandoff, includes, outputFile, profile, reportFile, showHelp, targetDir };
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export const printUsage = (): void => {
|
|
148
|
+
console.log(`
|
|
149
|
+
Usage: acker-dacker [targetRepoPath] [options]
|
|
150
|
+
|
|
151
|
+
Options:
|
|
152
|
+
--git-handoff Use Git's tracked + eligible-untracked set and preserve modes/symlinks
|
|
153
|
+
--profile <name> Select a named Git handoff profile (currently rust-stage3; requires --git-handoff)
|
|
154
|
+
--filter, -f <pattern> Include ONLY files matching pattern (e.g. *.md, *.ts; can be specified multiple times)
|
|
155
|
+
--include, -i <path|glob> Additional folder path, repo name, or glob pattern to include (can be specified multiple times)
|
|
156
|
+
--exclude <path|glob> Repeatable root-relative exclusion (only with --git-handoff)
|
|
157
|
+
--output, -o <path> Output zip file path (default: ./<targetRepoName>-<tag|version|hash>.zip, or ./<worktreeName>-git-handoff.zip with --git-handoff; handoff output must be outside worktree)
|
|
158
|
+
--report <path> Write a content-free Git handoff JSON report (only with --git-handoff; must be outside worktree)
|
|
159
|
+
--help, -h Show this help message
|
|
160
|
+
|
|
161
|
+
Examples:
|
|
162
|
+
acker-dacker ../ushman --filter "*.md"
|
|
163
|
+
acker-dacker ../ushman --include ../ushman-spector -f "*.md" -f "*.ts"
|
|
164
|
+
acker-dacker ../kodeguard --output=./kodeguard.zip
|
|
165
|
+
`);
|
|
166
|
+
};
|
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
import { mkdtemp, rm, stat } from 'node:fs/promises';
|
|
2
|
-
import { tmpdir } from 'node:os';
|
|
2
|
+
import { homedir, tmpdir } from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { resolveDefaultPackOutputName } from './archive-name';
|
|
4
5
|
import { parseCliArgs, printUsage } from './cli-options';
|
|
5
|
-
import { runGitHandoff } from '
|
|
6
|
-
import { globToRegex } from '
|
|
7
|
-
import { copyRepositoryFiles, discoverRecursiveRepositories } from '
|
|
8
|
-
import { createZipFile } from '
|
|
6
|
+
import { runGitHandoff } from '../git/git-handoff';
|
|
7
|
+
import { globToRegex } from '../pack/path-patterns';
|
|
8
|
+
import { copyRepositoryFiles, discoverRecursiveRepositories } from '../pack/repository-pack';
|
|
9
|
+
import { createZipFile } from '../zip/zip';
|
|
10
|
+
|
|
11
|
+
export const expandTildePath = (inputPath: string): string => {
|
|
12
|
+
if (!inputPath.startsWith('~') || (inputPath.length > 1 && !inputPath.startsWith('~/') && !inputPath.startsWith('~\\'))) {
|
|
13
|
+
return inputPath;
|
|
14
|
+
}
|
|
15
|
+
const home = process.env.HOME || homedir();
|
|
16
|
+
if (!home || home.trim().length === 0) {
|
|
17
|
+
throw new Error(`Cannot expand tilde in path '${inputPath}': home directory is not available`);
|
|
18
|
+
}
|
|
19
|
+
return inputPath === '~' ? home : path.join(home, inputPath.slice(2));
|
|
20
|
+
};
|
|
9
21
|
|
|
10
22
|
const resolveTargetDirectory = (targetDir: string): string => {
|
|
11
|
-
return path.resolve(targetDir
|
|
23
|
+
return path.resolve(expandTildePath(targetDir));
|
|
12
24
|
};
|
|
13
25
|
|
|
14
26
|
const runDefaultPack = async ({
|
|
@@ -40,9 +52,8 @@ const runDefaultPack = async ({
|
|
|
40
52
|
);
|
|
41
53
|
}
|
|
42
54
|
|
|
43
|
-
const mainRepositoryName = path.basename(targetDir);
|
|
44
55
|
const resolvedOutputFile = path.resolve(
|
|
45
|
-
outputFile ?? path.join(process.cwd(),
|
|
56
|
+
outputFile ?? path.join(process.cwd(), await resolveDefaultPackOutputName(targetDir)),
|
|
46
57
|
);
|
|
47
58
|
const stagingRoot = await mkdtemp(path.join(tmpdir(), 'pack-repo-staging-'));
|
|
48
59
|
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import type { Stats } from 'node:fs';
|
|
2
2
|
import { lstat, readlink, realpath } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { detectXcodeProject, isSensitiveUntrackedPath, isUntrackedSafetyExcludedPath } from '
|
|
4
|
+
import { detectXcodeProject, isSensitiveUntrackedPath, isUntrackedSafetyExcludedPath } from '../pack/file-policy';
|
|
5
5
|
import { decodeGitPathList, requireSuccessfulCommand, runExternalCommand } from './git';
|
|
6
6
|
import {
|
|
7
7
|
compareStableStrings,
|
|
8
8
|
globPatternToRegexSource,
|
|
9
9
|
isPathWithin,
|
|
10
10
|
normalizeArchivePath,
|
|
11
|
-
} from '
|
|
11
|
+
} from '../pack/path-patterns';
|
|
12
12
|
|
|
13
13
|
export type FileSnapshot = {
|
|
14
14
|
readonly ctimeMs: number;
|
|
@@ -36,6 +36,7 @@ export type GitHandoffProfile = 'rust-stage3';
|
|
|
36
36
|
|
|
37
37
|
export type GitHandoffSelection = {
|
|
38
38
|
readonly candidates: readonly GitHandoffCandidate[];
|
|
39
|
+
readonly deletedTrackedCount: number;
|
|
39
40
|
readonly explicitExclusions: readonly GitHandoffExclusion[];
|
|
40
41
|
readonly explicitExclusionTotal: number;
|
|
41
42
|
readonly filterCount: number;
|
|
@@ -46,6 +47,7 @@ export type GitHandoffSelection = {
|
|
|
46
47
|
readonly safetyFilteredUntrackedCount: number;
|
|
47
48
|
};
|
|
48
49
|
|
|
50
|
+
|
|
49
51
|
type HandoffExcludeMatcher = {
|
|
50
52
|
readonly matcher: RegExp;
|
|
51
53
|
readonly pattern: string;
|
|
@@ -152,12 +154,37 @@ export const validateHandoffSymlink = async (
|
|
|
152
154
|
}
|
|
153
155
|
};
|
|
154
156
|
|
|
157
|
+
export const validateSourceAncestry = async (
|
|
158
|
+
rootDir: string,
|
|
159
|
+
sourcePath: string,
|
|
160
|
+
canonicalRootDir?: string,
|
|
161
|
+
): Promise<void> => {
|
|
162
|
+
const canonicalRoot = canonicalRootDir ?? (await realpath(rootDir));
|
|
163
|
+
let current = path.dirname(path.resolve(sourcePath));
|
|
164
|
+
const resolvedRoot = path.resolve(rootDir);
|
|
165
|
+
|
|
166
|
+
while (current.length >= resolvedRoot.length) {
|
|
167
|
+
if (!isPathWithin(current, resolvedRoot)) {
|
|
168
|
+
throw new Error(`Git handoff refused path outside root: ${sourcePath}`);
|
|
169
|
+
}
|
|
170
|
+
const resolvedCurrent = await realpath(current).catch(() => null);
|
|
171
|
+
if (!resolvedCurrent || !isPathWithin(resolvedCurrent, canonicalRoot)) {
|
|
172
|
+
throw new Error(`Git handoff refused escaping ancestor path for ${sourcePath}`);
|
|
173
|
+
}
|
|
174
|
+
if (current === resolvedRoot) {
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
current = path.dirname(current);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
155
181
|
export const collectGitHandoffSelection = async (
|
|
156
182
|
rootDir: string,
|
|
157
183
|
filters: readonly RegExp[],
|
|
158
184
|
excludePatterns: readonly string[],
|
|
159
185
|
profile: GitHandoffProfile | null,
|
|
160
186
|
): Promise<GitHandoffSelection> => {
|
|
187
|
+
const canonicalRoot = await realpath(rootDir);
|
|
161
188
|
const excludeMatchers = excludePatterns.map(compileHandoffExclude);
|
|
162
189
|
const allArgs = ['-C', rootDir, 'ls-files', '--cached', '--others', '--exclude-standard', '-z'];
|
|
163
190
|
const allResult = await runExternalCommand('git', allArgs, rootDir);
|
|
@@ -167,6 +194,10 @@ export const collectGitHandoffSelection = async (
|
|
|
167
194
|
const trackedResult = await runExternalCommand('git', trackedArgs, rootDir);
|
|
168
195
|
const trackedPaths = new Set(decodeGitPathList(requireSuccessfulCommand('git', trackedArgs, trackedResult)));
|
|
169
196
|
|
|
197
|
+
const deletedArgs = ['-C', rootDir, 'ls-files', '--deleted', '-z'];
|
|
198
|
+
const deletedResult = await runExternalCommand('git', deletedArgs, rootDir);
|
|
199
|
+
const deletedTrackedPaths = new Set(decodeGitPathList(requireSuccessfulCommand('git', deletedArgs, deletedResult)));
|
|
200
|
+
|
|
170
201
|
const untrackedArgs = ['-C', rootDir, 'ls-files', '--others', '--exclude-standard', '-z'];
|
|
171
202
|
const untrackedResult = await runExternalCommand('git', untrackedArgs, rootDir);
|
|
172
203
|
const untrackedPaths = new Set(decodeGitPathList(requireSuccessfulCommand('git', untrackedArgs, untrackedResult)));
|
|
@@ -178,6 +209,7 @@ export const collectGitHandoffSelection = async (
|
|
|
178
209
|
let profileFilteredCount = 0;
|
|
179
210
|
let sensitiveUntrackedCount = 0;
|
|
180
211
|
let safetyFilteredUntrackedCount = 0;
|
|
212
|
+
let deletedTrackedCount = 0;
|
|
181
213
|
|
|
182
214
|
for (const rawPath of allPaths) {
|
|
183
215
|
const relativePath = validateGitRelativePath(rawPath);
|
|
@@ -217,6 +249,25 @@ export const collectGitHandoffSelection = async (
|
|
|
217
249
|
}
|
|
218
250
|
|
|
219
251
|
const sourcePath = path.join(rootDir, relativePath);
|
|
252
|
+
|
|
253
|
+
// f9: Identify already-deleted tracked worktree paths explicitly using Git's deleted-path set
|
|
254
|
+
if (isTracked && deletedTrackedPaths.has(rawPath)) {
|
|
255
|
+
const statError = await lstat(sourcePath).then(
|
|
256
|
+
() => null,
|
|
257
|
+
(error) => error,
|
|
258
|
+
);
|
|
259
|
+
if (statError && (statError as { code?: string }).code === 'ENOENT') {
|
|
260
|
+
deletedTrackedCount += 1;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (statError) {
|
|
264
|
+
throw new Error(`Git handoff could not read selected path ${relativePath}: ${statError}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// f5: Validate every selected source's ancestor chain and canonical containment before accepting
|
|
269
|
+
await validateSourceAncestry(rootDir, sourcePath, canonicalRoot);
|
|
270
|
+
|
|
220
271
|
const sourceStats = await lstat(sourcePath).catch((error) => {
|
|
221
272
|
throw new Error(`Git handoff could not read selected path ${relativePath}: ${error}`);
|
|
222
273
|
});
|
|
@@ -243,6 +294,7 @@ export const collectGitHandoffSelection = async (
|
|
|
243
294
|
|
|
244
295
|
return {
|
|
245
296
|
candidates,
|
|
297
|
+
deletedTrackedCount,
|
|
246
298
|
explicitExclusionTotal,
|
|
247
299
|
explicitExclusions,
|
|
248
300
|
filterCount,
|