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 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 only for `--git-handoff`
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. Default-mode output is `./<repository-name>-pack.zip` unless `--output` is supplied.
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 or repository dependency in default mode. Repeatable. |
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, follows root and nested `.gitignore` files, and omits common generated output, Xcode build artifacts, and sensitive file names. It also supports explicit `--include` paths.
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
- 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. Absolute, escaping, dangling, and cyclic symlinks are rejected. `--exclude`, `--filter`, `--profile`, and `--report` are applied as documented above.
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 `index.ts` and `src/` as application files, plus npm's standard metadata files.
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
- "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.1.0",
11
- "description": "Create deterministic, privacy-aware repository ZIPs for AI handoffs and code review.",
12
- "type": "module",
13
- "bin": {
14
- "acker-dacker": "index.ts"
15
- },
16
- "files": [
17
- "index.ts",
18
- "src"
19
- ],
20
- "scripts": {
21
- "build": "bun build ./index.ts --target=bun --outfile ./dist/acker-dacker.js",
22
- "check": "bun run typecheck && bun test && bun build ./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.0",
29
- "node": ">=24.x"
30
- },
31
- "devDependencies": {
32
- "@types/bun": "^1.4.0",
33
- "typescript": "^7.0.2"
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 './git-handoff';
6
- import { globToRegex } from './path-patterns';
7
- import { copyRepositoryFiles, discoverRecursiveRepositories } from './repository-pack';
8
- import { createZipFile } from './zip';
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.replace(/^~(?=$|\/)/, process.env.HOME ?? ''));
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(), `${mainRepositoryName}-pack.zip`),
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 './file-policy';
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 './path-patterns';
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,