acker-dacker 0.1.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.
@@ -0,0 +1,255 @@
1
+ import type { Stats } from 'node:fs';
2
+ import { lstat, readlink, realpath } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { detectXcodeProject, isSensitiveUntrackedPath, isUntrackedSafetyExcludedPath } from './file-policy';
5
+ import { decodeGitPathList, requireSuccessfulCommand, runExternalCommand } from './git';
6
+ import {
7
+ compareStableStrings,
8
+ globPatternToRegexSource,
9
+ isPathWithin,
10
+ normalizeArchivePath,
11
+ } from './path-patterns';
12
+
13
+ export type FileSnapshot = {
14
+ readonly ctimeMs: number;
15
+ readonly dev: number;
16
+ readonly ino: number;
17
+ readonly mode: number;
18
+ readonly mtimeMs: number;
19
+ readonly size: number;
20
+ };
21
+
22
+ export type GitHandoffCandidate = {
23
+ readonly archivePath: string;
24
+ readonly initialSnapshot: FileSnapshot;
25
+ readonly isTracked: boolean;
26
+ readonly relativePath: string;
27
+ readonly sourcePath: string;
28
+ };
29
+
30
+ export type GitHandoffExclusion = {
31
+ readonly count: number;
32
+ readonly pattern: string;
33
+ };
34
+
35
+ export type GitHandoffProfile = 'rust-stage3';
36
+
37
+ export type GitHandoffSelection = {
38
+ readonly candidates: readonly GitHandoffCandidate[];
39
+ readonly explicitExclusions: readonly GitHandoffExclusion[];
40
+ readonly explicitExclusionTotal: number;
41
+ readonly filterCount: number;
42
+ readonly gitSelectedCount: number;
43
+ readonly profile: GitHandoffProfile | null;
44
+ readonly profileFilteredCount: number;
45
+ readonly sensitiveUntrackedCount: number;
46
+ readonly safetyFilteredUntrackedCount: number;
47
+ };
48
+
49
+ type HandoffExcludeMatcher = {
50
+ readonly matcher: RegExp;
51
+ readonly pattern: string;
52
+ };
53
+
54
+ const validateGitRelativePath = (relativePath: string): string => {
55
+ const normalized = normalizeArchivePath(relativePath);
56
+ const segments = normalized.split('/');
57
+ if (
58
+ normalized.length === 0 ||
59
+ normalized.includes('\0') ||
60
+ normalized.startsWith('/') ||
61
+ normalized === '.' ||
62
+ normalized === '..' ||
63
+ normalized.startsWith('../') ||
64
+ normalized.includes('/../') ||
65
+ segments.some((segment) => segment.length === 0 || segment === '.')
66
+ ) {
67
+ throw new Error(`Git handoff refused unsafe repository path: ${JSON.stringify(relativePath)}`);
68
+ }
69
+ return normalized;
70
+ };
71
+
72
+ const compileHandoffExclude = (pattern: string): HandoffExcludeMatcher => {
73
+ let normalized = pattern;
74
+ if (normalized.startsWith('./')) {
75
+ normalized = normalized.slice(2);
76
+ }
77
+ if (normalized.endsWith('/')) {
78
+ normalized = normalized.slice(0, -1);
79
+ }
80
+ const segments = normalized.split('/');
81
+ if (
82
+ normalized.length === 0 ||
83
+ normalized.includes('\0') ||
84
+ normalized.startsWith('/') ||
85
+ path.isAbsolute(normalized) ||
86
+ segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..')
87
+ ) {
88
+ throw new Error(`--exclude must be a root-relative path or glob: ${JSON.stringify(pattern)}`);
89
+ }
90
+
91
+ return {
92
+ matcher: new RegExp(`^${globPatternToRegexSource(normalized)}(?:/.*)?$`),
93
+ pattern,
94
+ };
95
+ };
96
+
97
+ export const snapshotFile = (fileStats: Stats): FileSnapshot => ({
98
+ ctimeMs: fileStats.ctimeMs,
99
+ dev: fileStats.dev,
100
+ ino: fileStats.ino,
101
+ mode: fileStats.mode,
102
+ mtimeMs: fileStats.mtimeMs,
103
+ size: fileStats.size,
104
+ });
105
+
106
+ export const resolveGitHandoffProfile = (profile: string | null): GitHandoffProfile | null => {
107
+ if (profile === null) {
108
+ return null;
109
+ }
110
+ if (profile === 'rust-stage3') {
111
+ return profile;
112
+ }
113
+ throw new Error(`Unknown Git handoff profile: ${profile}`);
114
+ };
115
+
116
+ // Named profiles use validated root-relative paths. Generic --filter matching intentionally
117
+ // retains the historical path-or-basename behavior in collectGitHandoffSelection.
118
+ const matchesRustStage3Profile = (relativePath: string): boolean => {
119
+ return (
120
+ relativePath === 'AGENTS.md' ||
121
+ relativePath === '.gitignore' ||
122
+ relativePath === 'justfile' ||
123
+ relativePath === 'scripts/format.py' ||
124
+ relativePath === 'scripts/just-shell.py' ||
125
+ relativePath === 'codex-rs' ||
126
+ relativePath.startsWith('codex-rs/')
127
+ );
128
+ };
129
+
130
+ const matchesGitHandoffProfile = (profile: GitHandoffProfile, relativePath: string): boolean => {
131
+ return profile === 'rust-stage3' && matchesRustStage3Profile(relativePath);
132
+ };
133
+
134
+ // Relative symlinks are preserved only when their resolved target exists inside the worktree.
135
+ export const validateHandoffSymlink = async (
136
+ rootDir: string,
137
+ sourcePath: string,
138
+ linkTarget: string,
139
+ ): Promise<void> => {
140
+ if (linkTarget.length === 0 || linkTarget.includes('\0') || path.isAbsolute(linkTarget)) {
141
+ throw new Error(`Git handoff refused unsafe symlink ${sourcePath}`);
142
+ }
143
+
144
+ const lexicalTarget = path.resolve(path.dirname(sourcePath), linkTarget);
145
+ if (!isPathWithin(lexicalTarget, rootDir)) {
146
+ throw new Error(`Git handoff refused escaping symlink ${sourcePath}`);
147
+ }
148
+
149
+ const resolvedTarget = await realpath(sourcePath).catch(() => null);
150
+ if (!resolvedTarget || !isPathWithin(resolvedTarget, rootDir)) {
151
+ throw new Error(`Git handoff refused unresolved or escaping symlink ${sourcePath}`);
152
+ }
153
+ };
154
+
155
+ export const collectGitHandoffSelection = async (
156
+ rootDir: string,
157
+ filters: readonly RegExp[],
158
+ excludePatterns: readonly string[],
159
+ profile: GitHandoffProfile | null,
160
+ ): Promise<GitHandoffSelection> => {
161
+ const excludeMatchers = excludePatterns.map(compileHandoffExclude);
162
+ const allArgs = ['-C', rootDir, 'ls-files', '--cached', '--others', '--exclude-standard', '-z'];
163
+ const allResult = await runExternalCommand('git', allArgs, rootDir);
164
+ const allPaths = [...new Set(decodeGitPathList(requireSuccessfulCommand('git', allArgs, allResult)))];
165
+
166
+ const trackedArgs = ['-C', rootDir, 'ls-files', '--cached', '-z'];
167
+ const trackedResult = await runExternalCommand('git', trackedArgs, rootDir);
168
+ const trackedPaths = new Set(decodeGitPathList(requireSuccessfulCommand('git', trackedArgs, trackedResult)));
169
+
170
+ const untrackedArgs = ['-C', rootDir, 'ls-files', '--others', '--exclude-standard', '-z'];
171
+ const untrackedResult = await runExternalCommand('git', untrackedArgs, rootDir);
172
+ const untrackedPaths = new Set(decodeGitPathList(requireSuccessfulCommand('git', untrackedArgs, untrackedResult)));
173
+ const isXcodeProject = await detectXcodeProject(rootDir);
174
+ const exclusionCounts = new Map<string, number>(excludePatterns.map((pattern) => [pattern, 0]));
175
+ const candidates: GitHandoffCandidate[] = [];
176
+ let explicitExclusionTotal = 0;
177
+ let filterCount = 0;
178
+ let profileFilteredCount = 0;
179
+ let sensitiveUntrackedCount = 0;
180
+ let safetyFilteredUntrackedCount = 0;
181
+
182
+ for (const rawPath of allPaths) {
183
+ const relativePath = validateGitRelativePath(rawPath);
184
+ const isTracked = trackedPaths.has(rawPath);
185
+ if (!isTracked && !untrackedPaths.has(rawPath)) {
186
+ throw new Error(`Git handoff could not classify selected path: ${JSON.stringify(relativePath)}`);
187
+ }
188
+
189
+ const matchingExclude = excludeMatchers.find((exclude) => exclude.matcher.test(relativePath));
190
+ if (matchingExclude) {
191
+ exclusionCounts.set(matchingExclude.pattern, (exclusionCounts.get(matchingExclude.pattern) ?? 0) + 1);
192
+ explicitExclusionTotal += 1;
193
+ continue;
194
+ }
195
+
196
+ if (profile && !matchesGitHandoffProfile(profile, relativePath)) {
197
+ profileFilteredCount += 1;
198
+ continue;
199
+ }
200
+
201
+ if (
202
+ filters.length > 0 &&
203
+ !filters.some(
204
+ (matcher) => matcher.test(relativePath) || matcher.test(path.posix.basename(relativePath)),
205
+ )
206
+ ) {
207
+ filterCount += 1;
208
+ continue;
209
+ }
210
+
211
+ if (!isTracked && isUntrackedSafetyExcludedPath(relativePath, isXcodeProject)) {
212
+ safetyFilteredUntrackedCount += 1;
213
+ if (isSensitiveUntrackedPath(relativePath)) {
214
+ sensitiveUntrackedCount += 1;
215
+ }
216
+ continue;
217
+ }
218
+
219
+ const sourcePath = path.join(rootDir, relativePath);
220
+ const sourceStats = await lstat(sourcePath).catch((error) => {
221
+ throw new Error(`Git handoff could not read selected path ${relativePath}: ${error}`);
222
+ });
223
+ if (!sourceStats.isFile() && !sourceStats.isSymbolicLink()) {
224
+ throw new Error(`Git handoff refuses special or directory path: ${relativePath}`);
225
+ }
226
+ if (sourceStats.isSymbolicLink()) {
227
+ await validateHandoffSymlink(rootDir, sourcePath, await readlink(sourcePath));
228
+ }
229
+
230
+ candidates.push({
231
+ archivePath: normalizeArchivePath(path.join(path.basename(rootDir), relativePath)),
232
+ initialSnapshot: snapshotFile(sourceStats),
233
+ isTracked,
234
+ relativePath,
235
+ sourcePath,
236
+ });
237
+ }
238
+
239
+ candidates.sort((left, right) => compareStableStrings(left.relativePath, right.relativePath));
240
+ const explicitExclusions = [...exclusionCounts.entries()]
241
+ .sort(([left], [right]) => compareStableStrings(left, right))
242
+ .map(([pattern, count]) => ({ count, pattern }));
243
+
244
+ return {
245
+ candidates,
246
+ explicitExclusionTotal,
247
+ explicitExclusions,
248
+ filterCount,
249
+ gitSelectedCount: allPaths.length,
250
+ profile,
251
+ profileFilteredCount,
252
+ safetyFilteredUntrackedCount,
253
+ sensitiveUntrackedCount,
254
+ };
255
+ };
@@ -0,0 +1,294 @@
1
+ import { chmod, lstat, mkdir, mkdtemp, readlink, realpath, rm, stat, symlink } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { resolveGitWorktree } from './git';
5
+ import {
6
+ collectGitHandoffSelection,
7
+ resolveGitHandoffProfile,
8
+ snapshotFile,
9
+ validateHandoffSymlink,
10
+ } from './git-handoff-selection';
11
+ import type {
12
+ FileSnapshot,
13
+ GitHandoffCandidate,
14
+ GitHandoffExclusion,
15
+ GitHandoffProfile,
16
+ GitHandoffSelection,
17
+ } from './git-handoff-selection';
18
+ import { compareStableStrings, isPathWithin } from './path-patterns';
19
+ import { createZipFile } from './zip';
20
+
21
+ type GitHandoffReport = {
22
+ readonly excluded: {
23
+ readonly explicit: {
24
+ readonly total: number;
25
+ readonly patterns: readonly GitHandoffExclusion[];
26
+ };
27
+ readonly filterCount: number;
28
+ readonly profileFilteredCount: number;
29
+ readonly sensitiveUntrackedCount: number;
30
+ readonly safetyFilteredUntrackedCount: number;
31
+ };
32
+ readonly included: {
33
+ readonly bytes: number;
34
+ readonly count: number;
35
+ readonly executableCount: number;
36
+ readonly symlinkCount: number;
37
+ readonly trackedCount: number;
38
+ readonly untrackedCount: number;
39
+ };
40
+ readonly includedPaths: readonly string[];
41
+ readonly gitSelectionCount: number;
42
+ readonly mode: 'git-handoff';
43
+ readonly profile: GitHandoffProfile | null;
44
+ readonly version: 1;
45
+ };
46
+
47
+ type GitHandoffStats = {
48
+ bytes: number;
49
+ executableCount: number;
50
+ includedPaths: string[];
51
+ symlinkCount: number;
52
+ trackedCount: number;
53
+ untrackedCount: number;
54
+ };
55
+
56
+ export type GitHandoffOptions = {
57
+ readonly excludes: readonly string[];
58
+ readonly filters: readonly RegExp[];
59
+ readonly outputFile: string | null;
60
+ readonly profile: string | null;
61
+ readonly reportFile: string | null;
62
+ readonly targetDir: string;
63
+ };
64
+
65
+ type StagedHandoffEntry = {
66
+ readonly bytes: number;
67
+ readonly isSymlink: boolean;
68
+ readonly mode: number;
69
+ };
70
+
71
+ const snapshotsMatch = (left: FileSnapshot, right: FileSnapshot): boolean => {
72
+ return (
73
+ left.ctimeMs === right.ctimeMs &&
74
+ left.dev === right.dev &&
75
+ left.ino === right.ino &&
76
+ left.mode === right.mode &&
77
+ left.mtimeMs === right.mtimeMs &&
78
+ left.size === right.size
79
+ );
80
+ };
81
+
82
+ const assertSourceUnchanged = (sourcePath: string, before: FileSnapshot, after: FileSnapshot): void => {
83
+ if (!snapshotsMatch(before, after)) {
84
+ throw new Error(`Git handoff aborted because a source changed during packaging: ${sourcePath}`);
85
+ }
86
+ };
87
+
88
+ const stageGitHandoffEntry = async (
89
+ rootDir: string,
90
+ candidate: GitHandoffCandidate,
91
+ destinationPath: string,
92
+ ): Promise<StagedHandoffEntry> => {
93
+ const sourceStats = await lstat(candidate.sourcePath).catch((error) => {
94
+ throw new Error(`Git handoff could not read selected path ${candidate.relativePath}: ${error}`);
95
+ });
96
+ const before = snapshotFile(sourceStats);
97
+ assertSourceUnchanged(candidate.sourcePath, candidate.initialSnapshot, before);
98
+ await mkdir(path.dirname(destinationPath), { recursive: true });
99
+
100
+ if (sourceStats.isSymbolicLink()) {
101
+ const linkTarget = await readlink(candidate.sourcePath);
102
+ await validateHandoffSymlink(rootDir, candidate.sourcePath, linkTarget);
103
+ const afterStats = await lstat(candidate.sourcePath);
104
+ const afterTarget = await readlink(candidate.sourcePath);
105
+ assertSourceUnchanged(candidate.sourcePath, before, snapshotFile(afterStats));
106
+ assertSourceUnchanged(candidate.sourcePath, candidate.initialSnapshot, snapshotFile(afterStats));
107
+ if (linkTarget !== afterTarget) {
108
+ throw new Error(`Git handoff aborted because a symlink changed during packaging: ${candidate.sourcePath}`);
109
+ }
110
+ await symlink(linkTarget, destinationPath);
111
+ return {
112
+ bytes: new TextEncoder().encode(linkTarget).byteLength,
113
+ isSymlink: true,
114
+ mode: sourceStats.mode,
115
+ };
116
+ }
117
+
118
+ if (!sourceStats.isFile()) {
119
+ throw new Error(`Git handoff refuses special or directory path: ${candidate.relativePath}`);
120
+ }
121
+
122
+ await Bun.write(destinationPath, Bun.file(candidate.sourcePath));
123
+ const afterStats = await lstat(candidate.sourcePath);
124
+ assertSourceUnchanged(candidate.sourcePath, before, snapshotFile(afterStats));
125
+ assertSourceUnchanged(candidate.sourcePath, candidate.initialSnapshot, snapshotFile(afterStats));
126
+ await chmod(destinationPath, sourceStats.mode & 0o7777);
127
+ return {
128
+ bytes: sourceStats.size,
129
+ isSymlink: false,
130
+ mode: sourceStats.mode,
131
+ };
132
+ };
133
+
134
+ const stageGitHandoffFiles = async (
135
+ rootDir: string,
136
+ stagingRoot: string,
137
+ candidates: readonly GitHandoffCandidate[],
138
+ ): Promise<GitHandoffStats> => {
139
+ const stats: GitHandoffStats = {
140
+ bytes: 0,
141
+ executableCount: 0,
142
+ includedPaths: [],
143
+ symlinkCount: 0,
144
+ trackedCount: 0,
145
+ untrackedCount: 0,
146
+ };
147
+ const archiveRoot = path.join(stagingRoot, path.basename(rootDir));
148
+
149
+ for (const candidate of candidates) {
150
+ const destinationPath = path.join(archiveRoot, candidate.relativePath);
151
+ const staged = await stageGitHandoffEntry(rootDir, candidate, destinationPath);
152
+ stats.bytes += staged.bytes;
153
+ stats.includedPaths.push(candidate.archivePath);
154
+ if (candidate.isTracked) {
155
+ stats.trackedCount += 1;
156
+ } else {
157
+ stats.untrackedCount += 1;
158
+ }
159
+ if (staged.isSymlink) {
160
+ stats.symlinkCount += 1;
161
+ } else if ((staged.mode & 0o111) !== 0) {
162
+ stats.executableCount += 1;
163
+ }
164
+ }
165
+
166
+ return stats;
167
+ };
168
+
169
+ const makeGitHandoffReport = (
170
+ selection: GitHandoffSelection,
171
+ stats: GitHandoffStats,
172
+ ): GitHandoffReport => ({
173
+ excluded: {
174
+ explicit: {
175
+ patterns: selection.explicitExclusions,
176
+ total: selection.explicitExclusionTotal,
177
+ },
178
+ filterCount: selection.filterCount,
179
+ profileFilteredCount: selection.profileFilteredCount,
180
+ safetyFilteredUntrackedCount: selection.safetyFilteredUntrackedCount,
181
+ sensitiveUntrackedCount: selection.sensitiveUntrackedCount,
182
+ },
183
+ gitSelectionCount: selection.gitSelectedCount,
184
+ included: {
185
+ bytes: stats.bytes,
186
+ count: stats.trackedCount + stats.untrackedCount,
187
+ executableCount: stats.executableCount,
188
+ symlinkCount: stats.symlinkCount,
189
+ trackedCount: stats.trackedCount,
190
+ untrackedCount: stats.untrackedCount,
191
+ },
192
+ includedPaths: [...stats.includedPaths].sort(compareStableStrings),
193
+ mode: 'git-handoff',
194
+ profile: selection.profile,
195
+ version: 1,
196
+ });
197
+
198
+ const emitGitHandoffReport = async (report: GitHandoffReport, reportFile: string | null): Promise<void> => {
199
+ console.log(
200
+ `[git-handoff] Included ${report.included.count} files (${report.included.trackedCount} tracked, ${report.included.untrackedCount} untracked, ${report.included.bytes} bytes).`,
201
+ );
202
+ console.log(
203
+ `[git-handoff] Metadata: ${report.included.executableCount} executable files, ${report.included.symlinkCount} symlinks.`,
204
+ );
205
+ console.log(
206
+ `[git-handoff] Omitted ${report.excluded.explicit.total} explicit, ${report.excluded.filterCount} filter, ${report.excluded.profileFilteredCount} profile, ${report.excluded.sensitiveUntrackedCount} sensitive untracked, and ${report.excluded.safetyFilteredUntrackedCount} total safety-filtered untracked paths.`,
207
+ );
208
+ if (reportFile) {
209
+ await mkdir(path.dirname(reportFile), { recursive: true });
210
+ await Bun.write(reportFile, `${JSON.stringify(report, null, 2)}\n`);
211
+ console.log(`[git-handoff] Report: ${reportFile}`);
212
+ }
213
+ };
214
+
215
+ const validateHandoffArtifactPath = async (
216
+ rootDir: string,
217
+ artifactPath: string,
218
+ label: string,
219
+ ): Promise<void> => {
220
+ const resolvedArtifactPath = path.resolve(artifactPath);
221
+ const existingStats = await lstat(resolvedArtifactPath).catch(() => null);
222
+ if (existingStats?.isSymbolicLink()) {
223
+ throw new Error(`Git handoff refuses a symlink ${label} path: ${resolvedArtifactPath}`);
224
+ }
225
+
226
+ const resolvedParent = await realpath(path.dirname(resolvedArtifactPath)).catch(() =>
227
+ path.dirname(resolvedArtifactPath),
228
+ );
229
+ const canonicalArtifactPath = path.join(resolvedParent, path.basename(resolvedArtifactPath));
230
+ if (isPathWithin(resolvedArtifactPath, rootDir) || isPathWithin(canonicalArtifactPath, rootDir)) {
231
+ throw new Error(`Git handoff ${label} must be outside the Git worktree: ${resolvedArtifactPath}`);
232
+ }
233
+ };
234
+
235
+ export const runGitHandoff = async ({
236
+ excludes,
237
+ filters,
238
+ outputFile,
239
+ profile,
240
+ reportFile,
241
+ targetDir,
242
+ }: GitHandoffOptions): Promise<void> => {
243
+ const resolvedProfile = resolveGitHandoffProfile(profile);
244
+ const rootDir = await resolveGitWorktree(targetDir);
245
+ const resolvedOutputFile = path.resolve(
246
+ outputFile ?? path.join(process.cwd(), `${path.basename(rootDir)}-git-handoff.zip`),
247
+ );
248
+ const resolvedReportFile = reportFile ? path.resolve(reportFile) : null;
249
+ if (resolvedReportFile === resolvedOutputFile) {
250
+ throw new Error('Git handoff output and report paths must be different.');
251
+ }
252
+ await validateHandoffArtifactPath(rootDir, resolvedOutputFile, 'output');
253
+ if (resolvedReportFile) {
254
+ await validateHandoffArtifactPath(rootDir, resolvedReportFile, 'report');
255
+ }
256
+ console.log(`[git-handoff] Git worktree: ${rootDir}`);
257
+ if (filters.length > 0) {
258
+ console.log(`[git-handoff] File filters applied: ${filters.join(', ')}`);
259
+ }
260
+ if (excludes.length > 0) {
261
+ console.log(`[git-handoff] Explicit exclusions: ${excludes.join(', ')}`);
262
+ }
263
+ if (resolvedProfile) {
264
+ console.log(`[git-handoff] Profile selected: ${resolvedProfile}`);
265
+ }
266
+
267
+ const selection = await collectGitHandoffSelection(rootDir, filters, excludes, resolvedProfile);
268
+ console.log(
269
+ `[git-handoff] Git selected ${selection.gitSelectedCount} paths; ${selection.candidates.length} remain after filters and exclusions.`,
270
+ );
271
+
272
+ const stagingRoot = await mkdtemp(path.join(tmpdir(), 'pack-repo-git-handoff-'));
273
+ try {
274
+ const stats = await stageGitHandoffFiles(rootDir, stagingRoot, selection.candidates);
275
+ const report = makeGitHandoffReport(selection, stats);
276
+ if (report.included.count === 0) {
277
+ await emitGitHandoffReport(report, resolvedReportFile);
278
+ console.warn('[git-handoff] No files matched the requested handoff criteria; ZIP was not created.');
279
+ return;
280
+ }
281
+
282
+ console.log(`[git-handoff] Creating ZIP at: ${resolvedOutputFile}`);
283
+ await createZipFile(stagingRoot, resolvedOutputFile, {
284
+ preserveSymlinks: true,
285
+ preserveUnixMetadata: true,
286
+ });
287
+ await emitGitHandoffReport(report, resolvedReportFile);
288
+ const zipStats = await stat(resolvedOutputFile);
289
+ console.log(`[git-handoff] ZIP size: ${zipStats.size} bytes`);
290
+ console.log(`[git-handoff] Output archive: ${resolvedOutputFile}`);
291
+ } finally {
292
+ await rm(stagingRoot, { force: true, recursive: true });
293
+ }
294
+ };
package/src/git.ts ADDED
@@ -0,0 +1,84 @@
1
+ import { realpath } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export type ExternalCommandResult = {
5
+ readonly exitCode: number;
6
+ readonly stderr: string;
7
+ readonly stdout: Uint8Array;
8
+ };
9
+
10
+ export const runExternalCommand = async (
11
+ command: string,
12
+ args: readonly string[],
13
+ cwd: string,
14
+ ): Promise<ExternalCommandResult> => {
15
+ const processHandle = (() => {
16
+ try {
17
+ return Bun.spawn([command, ...args], { cwd, stderr: 'pipe', stdout: 'pipe' } as const);
18
+ } catch (error) {
19
+ throw new Error(`Unable to run ${command}: ${error instanceof Error ? error.message : String(error)}`);
20
+ }
21
+ })();
22
+
23
+ const stdoutPromise = new Response(processHandle.stdout).arrayBuffer();
24
+ const stderrPromise = new Response(processHandle.stderr).text();
25
+ await processHandle.exited;
26
+ return {
27
+ exitCode: processHandle.exitCode ?? -1,
28
+ stderr: await stderrPromise,
29
+ stdout: new Uint8Array(await stdoutPromise),
30
+ };
31
+ };
32
+
33
+ export const requireSuccessfulCommand = (
34
+ command: string,
35
+ args: readonly string[],
36
+ result: ExternalCommandResult,
37
+ ): Uint8Array => {
38
+ if (result.exitCode !== 0) {
39
+ const detail = result.stderr.trim();
40
+ throw new Error(`${command} ${args.join(' ')} failed${detail.length > 0 ? `: ${detail}` : ''}`);
41
+ }
42
+ return result.stdout;
43
+ };
44
+
45
+ export const decodeGitPathList = (bytes: Uint8Array): readonly string[] => {
46
+ let contents: string;
47
+ try {
48
+ contents = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
49
+ } catch (error) {
50
+ throw new Error(`Git returned a non-UTF-8 path: ${error instanceof Error ? error.message : String(error)}`);
51
+ }
52
+
53
+ if (contents.length === 0) {
54
+ return [];
55
+ }
56
+ if (!contents.endsWith('\0')) {
57
+ throw new Error('Git returned an unterminated path list');
58
+ }
59
+ return contents.split('\0').filter((entry) => entry.length > 0);
60
+ };
61
+
62
+ export const resolveGitWorktree = async (targetDir: string): Promise<string> => {
63
+ const resolvedTarget = await realpath(path.resolve(targetDir)).catch(() => path.resolve(targetDir));
64
+ const worktreeResult = await runExternalCommand(
65
+ 'git',
66
+ ['-C', resolvedTarget, 'rev-parse', '--is-inside-work-tree'],
67
+ resolvedTarget,
68
+ );
69
+ if (worktreeResult.exitCode !== 0) {
70
+ throw new Error(`Git handoff requires a Git worktree: ${resolvedTarget}`);
71
+ }
72
+ const worktreeValue = new TextDecoder().decode(worktreeResult.stdout).trim();
73
+ if (worktreeValue !== 'true') {
74
+ throw new Error(`Git handoff requires a Git worktree: ${resolvedTarget}`);
75
+ }
76
+
77
+ const rootArgs = ['-C', resolvedTarget, 'rev-parse', '--show-toplevel'];
78
+ const rootResult = await runExternalCommand('git', rootArgs, resolvedTarget);
79
+ const rootOutput = new TextDecoder().decode(requireSuccessfulCommand('git', rootArgs, rootResult)).trim();
80
+ if (rootOutput.length === 0 || rootOutput.includes('\n') || rootOutput.includes('\0')) {
81
+ throw new Error(`Git returned an unsafe worktree root for ${resolvedTarget}`);
82
+ }
83
+ return await realpath(path.resolve(rootOutput));
84
+ };