@adhdev/daemon-core 0.9.52 → 0.9.54

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,303 @@
1
+ import { readFile, realpath } from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import type { GitDiffSummary, GitFileChange, GitFileChangeStatus } from './git-types.js';
4
+ import { GitCommandError, isPathInside, resolveGitRepository, runGit } from './git-executor.js';
5
+
6
+ const DEFAULT_MAX_FILES = 200;
7
+ const DEFAULT_MAX_BYTES = 200_000;
8
+
9
+ export interface GitDiffOptions {
10
+ timeoutMs?: number;
11
+ maxFiles?: number;
12
+ maxBytes?: number;
13
+ }
14
+
15
+ export interface GitFileDiffResult {
16
+ workspace: string;
17
+ repoRoot: string;
18
+ isGitRepo: true;
19
+ path: string;
20
+ diff: string;
21
+ truncated: boolean;
22
+ lastCheckedAt: number;
23
+ }
24
+
25
+ interface NameStatusEntry {
26
+ path: string;
27
+ oldPath?: string;
28
+ status: GitFileChangeStatus;
29
+ }
30
+
31
+ interface NumstatEntry {
32
+ path: string;
33
+ insertions: number;
34
+ deletions: number;
35
+ binary: boolean;
36
+ }
37
+
38
+ export async function getGitDiffSummary(
39
+ workspace: string,
40
+ options: GitDiffOptions = {},
41
+ ): Promise<GitDiffSummary> {
42
+ const lastCheckedAt = Date.now();
43
+
44
+ try {
45
+ const repo = await resolveGitRepository(workspace, options);
46
+ const repoRoot = repo.repoRoot!;
47
+ const [unstagedNameStatus, unstagedNumstat, stagedNameStatus, stagedNumstat, untracked] = await Promise.all([
48
+ runGit(repo, ['diff', '--no-ext-diff', '--name-status'], { ...options, cwd: repoRoot }),
49
+ runGit(repo, ['diff', '--no-ext-diff', '--numstat'], { ...options, cwd: repoRoot }),
50
+ runGit(repo, ['diff', '--cached', '--no-ext-diff', '--name-status'], { ...options, cwd: repoRoot }),
51
+ runGit(repo, ['diff', '--cached', '--no-ext-diff', '--numstat'], { ...options, cwd: repoRoot }),
52
+ runGit(repo, ['ls-files', '--others', '--exclude-standard'], { ...options, cwd: repoRoot }),
53
+ ]);
54
+
55
+ const outputBytes = byteLength(
56
+ unstagedNameStatus.stdout + unstagedNumstat.stdout + stagedNameStatus.stdout + stagedNumstat.stdout + untracked.stdout,
57
+ );
58
+ const changes = [
59
+ ...combineDiffEntries(unstagedNameStatus.stdout, unstagedNumstat.stdout, false),
60
+ ...combineDiffEntries(stagedNameStatus.stdout, stagedNumstat.stdout, true),
61
+ ...parseUntrackedFiles(untracked.stdout),
62
+ ];
63
+
64
+ const maxFiles = normalizePositiveInteger(options.maxFiles, DEFAULT_MAX_FILES);
65
+ const maxBytes = normalizePositiveInteger(options.maxBytes, DEFAULT_MAX_BYTES);
66
+ const files = changes.slice(0, maxFiles);
67
+ const truncated = changes.length > files.length || outputBytes > maxBytes;
68
+
69
+ return {
70
+ workspace: repo.workspace,
71
+ repoRoot,
72
+ isGitRepo: true,
73
+ files,
74
+ totalInsertions: files.reduce((sum, file) => sum + file.insertions, 0),
75
+ totalDeletions: files.reduce((sum, file) => sum + file.deletions, 0),
76
+ truncated,
77
+ lastCheckedAt,
78
+ };
79
+ } catch (error) {
80
+ const gitError = error instanceof GitCommandError
81
+ ? error
82
+ : new GitCommandError('git_command_failed', 'Failed to read Git diff summary', { cause: error });
83
+ return {
84
+ workspace,
85
+ repoRoot: null,
86
+ isGitRepo: false,
87
+ files: [],
88
+ totalInsertions: 0,
89
+ totalDeletions: 0,
90
+ truncated: false,
91
+ lastCheckedAt,
92
+ error: gitError.stderr || gitError.message,
93
+ reason: gitError.reason,
94
+ };
95
+ }
96
+ }
97
+
98
+ export async function getGitFileDiff(
99
+ workspace: string,
100
+ filePath: string,
101
+ options: GitDiffOptions = {},
102
+ ): Promise<GitFileDiffResult> {
103
+ const lastCheckedAt = Date.now();
104
+ const repo = await resolveGitRepository(workspace, options);
105
+ const repoRoot = repo.repoRoot!;
106
+ const selected = await resolveRepoFilePath(repoRoot, filePath);
107
+ const maxBytes = normalizePositiveInteger(options.maxBytes, DEFAULT_MAX_BYTES);
108
+
109
+ const [unstaged, staged] = await Promise.all([
110
+ runGit(repo, ['diff', '--no-ext-diff', '--', selected.relativePath], { ...options, cwd: repoRoot }),
111
+ runGit(repo, ['diff', '--cached', '--no-ext-diff', '--', selected.relativePath], { ...options, cwd: repoRoot }),
112
+ ]);
113
+
114
+ let diff = [unstaged.stdout, staged.stdout].filter((part) => part.length > 0).join('\n');
115
+
116
+ if (!diff) {
117
+ const untracked = await runGit(repo, ['ls-files', '--others', '--exclude-standard', '--', selected.relativePath], {
118
+ ...options,
119
+ cwd: repoRoot,
120
+ });
121
+ const untrackedFiles = untracked.stdout.split('\n').filter(Boolean);
122
+ if (untrackedFiles.includes(selected.relativePath)) {
123
+ diff = await buildUntrackedDiff(selected.absolutePath, selected.relativePath, maxBytes + 1);
124
+ }
125
+ }
126
+
127
+ const bounded = truncateText(diff, maxBytes);
128
+ return {
129
+ workspace: repo.workspace,
130
+ repoRoot,
131
+ isGitRepo: true,
132
+ path: selected.relativePath,
133
+ diff: bounded.text,
134
+ truncated: bounded.truncated,
135
+ lastCheckedAt,
136
+ };
137
+ }
138
+
139
+ function combineDiffEntries(nameStatusOutput: string, numstatOutput: string, staged: boolean): GitFileChange[] {
140
+ const statusEntries = parseNameStatus(nameStatusOutput);
141
+ const numstatEntries = parseNumstat(numstatOutput);
142
+
143
+ return statusEntries.map((entry, index) => {
144
+ const stats = numstatEntries[index];
145
+ return {
146
+ path: entry.path,
147
+ oldPath: entry.oldPath,
148
+ status: entry.status,
149
+ staged,
150
+ insertions: stats?.insertions ?? 0,
151
+ deletions: stats?.deletions ?? 0,
152
+ binary: stats?.binary || undefined,
153
+ };
154
+ });
155
+ }
156
+
157
+ function parseNameStatus(output: string): NameStatusEntry[] {
158
+ return output
159
+ .split('\n')
160
+ .filter(Boolean)
161
+ .map((line) => {
162
+ const fields = line.split('\t');
163
+ const code = fields[0] ?? '';
164
+ const statusLetter = code[0] ?? 'M';
165
+
166
+ if (statusLetter === 'R') {
167
+ return {
168
+ oldPath: fields[1] ?? '',
169
+ path: fields[2] ?? fields[1] ?? '',
170
+ status: 'renamed' as const,
171
+ };
172
+ }
173
+
174
+ if (statusLetter === 'C') {
175
+ return {
176
+ oldPath: fields[1] ?? '',
177
+ path: fields[2] ?? fields[1] ?? '',
178
+ status: 'copied' as const,
179
+ };
180
+ }
181
+
182
+ return {
183
+ path: fields[1] ?? '',
184
+ status: mapNameStatus(statusLetter),
185
+ };
186
+ })
187
+ .filter((entry) => entry.path.length > 0);
188
+ }
189
+
190
+ function parseNumstat(output: string): NumstatEntry[] {
191
+ return output
192
+ .split('\n')
193
+ .filter(Boolean)
194
+ .map((line) => {
195
+ const fields = line.split('\t');
196
+ const insertionsText = fields[0] ?? '0';
197
+ const deletionsText = fields[1] ?? '0';
198
+ const binary = insertionsText === '-' || deletionsText === '-';
199
+ return {
200
+ path: fields.slice(2).join('\t'),
201
+ insertions: binary ? 0 : Number.parseInt(insertionsText, 10) || 0,
202
+ deletions: binary ? 0 : Number.parseInt(deletionsText, 10) || 0,
203
+ binary,
204
+ };
205
+ });
206
+ }
207
+
208
+ function parseUntrackedFiles(output: string): GitFileChange[] {
209
+ return output
210
+ .split('\n')
211
+ .filter(Boolean)
212
+ .map((filePath) => ({
213
+ path: filePath,
214
+ status: 'untracked',
215
+ staged: false,
216
+ insertions: 0,
217
+ deletions: 0,
218
+ }));
219
+ }
220
+
221
+ function mapNameStatus(status: string): GitFileChangeStatus {
222
+ switch (status) {
223
+ case 'A':
224
+ return 'added';
225
+ case 'D':
226
+ return 'deleted';
227
+ case 'R':
228
+ return 'renamed';
229
+ case 'C':
230
+ return 'copied';
231
+ case 'U':
232
+ return 'conflict';
233
+ case 'M':
234
+ case 'T':
235
+ default:
236
+ return 'modified';
237
+ }
238
+ }
239
+
240
+ async function resolveRepoFilePath(repoRoot: string, filePath: string): Promise<{ absolutePath: string; relativePath: string }> {
241
+ if (typeof filePath !== 'string' || filePath.length === 0 || filePath.includes('\0')) {
242
+ throw new GitCommandError('invalid_args', 'File path must be a non-empty path');
243
+ }
244
+
245
+ const canonicalRepoRoot = await realpath(repoRoot).catch(() => path.resolve(repoRoot));
246
+ const absolutePath = path.isAbsolute(filePath)
247
+ ? path.resolve(filePath)
248
+ : path.resolve(repoRoot, filePath);
249
+ const checkPath = await realpath(absolutePath).catch(() => absolutePath);
250
+ const relativeBase = isPathInside(canonicalRepoRoot, checkPath) ? canonicalRepoRoot : path.resolve(repoRoot);
251
+
252
+ if (!isPathInside(canonicalRepoRoot, checkPath) && !isPathInside(repoRoot, absolutePath)) {
253
+ throw new GitCommandError('path_outside_repo', 'Selected file path is outside the repository root', {
254
+ cwd: repoRoot,
255
+ });
256
+ }
257
+
258
+ const relativePath = path.relative(relativeBase, checkPath).split(path.sep).join('/');
259
+ if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
260
+ throw new GitCommandError('path_outside_repo', 'Selected file path is outside the repository root', {
261
+ cwd: repoRoot,
262
+ });
263
+ }
264
+
265
+ return { absolutePath, relativePath };
266
+ }
267
+
268
+ async function buildUntrackedDiff(absolutePath: string, relativePath: string, readLimit: number): Promise<string> {
269
+ const content = await readFile(absolutePath, 'utf8');
270
+ const limitedContent = content.length > readLimit ? content.slice(0, readLimit) : content;
271
+ const lines = limitedContent.length > 0 ? limitedContent.split('\n') : [];
272
+ const plusLines = lines
273
+ .filter((line, index) => index < lines.length - 1 || line.length > 0)
274
+ .map((line) => `+${line}`)
275
+ .join('\n');
276
+ const lineCount = plusLines ? plusLines.split('\n').length : 0;
277
+
278
+ return [
279
+ `diff --git a/${relativePath} b/${relativePath}`,
280
+ 'new file mode 100644',
281
+ 'index 0000000..0000000',
282
+ '--- /dev/null',
283
+ `+++ b/${relativePath}`,
284
+ `@@ -0,0 +1,${lineCount} @@`,
285
+ plusLines,
286
+ ]
287
+ .filter((line) => line.length > 0)
288
+ .join('\n');
289
+ }
290
+
291
+ function truncateText(text: string, maxBytes: number): { text: string; truncated: boolean } {
292
+ if (byteLength(text) <= maxBytes) return { text, truncated: false };
293
+ return { text: Buffer.from(text, 'utf8').subarray(0, maxBytes).toString('utf8'), truncated: true };
294
+ }
295
+
296
+ function byteLength(text: string): number {
297
+ return Buffer.byteLength(text, 'utf8');
298
+ }
299
+
300
+ function normalizePositiveInteger(value: number | undefined, fallback: number): number {
301
+ if (!Number.isFinite(value) || value == null || value <= 0) return fallback;
302
+ return Math.floor(value);
303
+ }
@@ -0,0 +1,268 @@
1
+ import { execFile, type ExecFileException } from 'node:child_process';
2
+ import { constants } from 'node:fs';
3
+ import { access, realpath, stat } from 'node:fs/promises';
4
+ import * as path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import type { GitFailureReason, GitRepoIdentity } from './git-types.js';
7
+
8
+ const execFileAsync = promisify(execFile);
9
+
10
+ const DEFAULT_TIMEOUT_MS = 5_000;
11
+ const DEFAULT_MAX_BUFFER = 1024 * 1024;
12
+
13
+ export interface GitExecutorOptions {
14
+ timeoutMs?: number;
15
+ maxBuffer?: number;
16
+ }
17
+
18
+ export interface RunGitOptions extends GitExecutorOptions {
19
+ cwd?: string;
20
+ }
21
+
22
+ export interface GitCommandResult {
23
+ stdout: string;
24
+ stderr: string;
25
+ }
26
+
27
+ export class GitCommandError extends Error {
28
+ readonly reason: GitFailureReason;
29
+ readonly stdout: string;
30
+ readonly stderr: string;
31
+ readonly exitCode?: number | string;
32
+ readonly signal?: NodeJS.Signals | string;
33
+ readonly argv?: string[];
34
+ readonly cwd?: string;
35
+
36
+ constructor(
37
+ reason: GitFailureReason,
38
+ message: string,
39
+ details: {
40
+ stdout?: unknown;
41
+ stderr?: unknown;
42
+ exitCode?: number | string;
43
+ signal?: NodeJS.Signals | string;
44
+ argv?: readonly string[];
45
+ cwd?: string;
46
+ cause?: unknown;
47
+ } = {},
48
+ ) {
49
+ super(message);
50
+ if (details.cause !== undefined) {
51
+ (this as Error & { cause?: unknown }).cause = details.cause;
52
+ }
53
+ this.name = 'GitCommandError';
54
+ this.reason = reason;
55
+ this.stdout = normalizeGitOutput(details.stdout);
56
+ this.stderr = normalizeGitOutput(details.stderr);
57
+ this.exitCode = details.exitCode;
58
+ this.signal = details.signal;
59
+ this.argv = details.argv ? [...details.argv] : undefined;
60
+ this.cwd = details.cwd;
61
+ }
62
+ }
63
+
64
+ export async function resolveGitRepository(
65
+ workspace: string,
66
+ options: GitExecutorOptions = {},
67
+ ): Promise<GitRepoIdentity> {
68
+ const normalizedWorkspace = await validateWorkspace(workspace);
69
+ const result = await execGitRaw(normalizedWorkspace, ['rev-parse', '--show-toplevel'], options, {
70
+ mapNotGitRepo: true,
71
+ });
72
+ const repoRoot = path.resolve(result.stdout.trim());
73
+
74
+ if (!repoRoot) {
75
+ throw new GitCommandError('not_git_repo', 'Git did not return a repository root', {
76
+ stdout: result.stdout,
77
+ stderr: result.stderr,
78
+ argv: ['rev-parse', '--show-toplevel'],
79
+ cwd: normalizedWorkspace,
80
+ });
81
+ }
82
+
83
+ return {
84
+ workspace: normalizedWorkspace,
85
+ repoRoot,
86
+ isGitRepo: true,
87
+ };
88
+ }
89
+
90
+ export async function runGit(
91
+ repoOrWorkspace: GitRepoIdentity | string,
92
+ argv: readonly string[],
93
+ options: RunGitOptions = {},
94
+ ): Promise<GitCommandResult> {
95
+ validateGitArgv(argv);
96
+
97
+ const repo = typeof repoOrWorkspace === 'string'
98
+ ? await resolveGitRepository(repoOrWorkspace, options)
99
+ : repoOrWorkspace;
100
+
101
+ if (!repo.repoRoot || !repo.isGitRepo) {
102
+ throw new GitCommandError('not_git_repo', 'Workspace is not a Git repository', {
103
+ argv,
104
+ cwd: repo.workspace,
105
+ });
106
+ }
107
+
108
+ const cwd = options.cwd ? await validateWorkspace(options.cwd) : await validateWorkspace(repo.workspace);
109
+ const canonicalRepoRoot = await realpath(repo.repoRoot);
110
+ const canonicalCwd = await realpath(cwd);
111
+ if (!isPathInside(canonicalRepoRoot, canonicalCwd)) {
112
+ throw new GitCommandError('path_outside_repo', 'Git cwd is outside the repository root', {
113
+ argv,
114
+ cwd,
115
+ });
116
+ }
117
+
118
+ return execGitRaw(cwd, argv, options);
119
+ }
120
+
121
+ export function normalizeGitOutput(value: unknown): string {
122
+ if (typeof value === 'string') return value.replace(/\r\n/g, '\n');
123
+ if (Buffer.isBuffer(value)) return value.toString('utf8').replace(/\r\n/g, '\n');
124
+ if (value == null) return '';
125
+ return String(value).replace(/\r\n/g, '\n');
126
+ }
127
+
128
+ export function isPathInside(parent: string, child: string): boolean {
129
+ const relative = path.relative(path.resolve(parent), path.resolve(child));
130
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
131
+ }
132
+
133
+ async function validateWorkspace(workspace: string): Promise<string> {
134
+ if (typeof workspace !== 'string' || workspace.length === 0 || workspace.includes('\0')) {
135
+ throw new GitCommandError('invalid_args', 'Workspace must be a non-empty path');
136
+ }
137
+ if (!path.isAbsolute(workspace)) {
138
+ throw new GitCommandError('invalid_args', 'Workspace must be an absolute path', { cwd: workspace });
139
+ }
140
+
141
+ const normalizedWorkspace = path.resolve(workspace);
142
+ try {
143
+ const info = await stat(normalizedWorkspace);
144
+ if (!info.isDirectory()) {
145
+ throw new GitCommandError('invalid_args', 'Workspace must be an existing directory', {
146
+ cwd: normalizedWorkspace,
147
+ });
148
+ }
149
+ await access(normalizedWorkspace, constants.R_OK);
150
+ } catch (error) {
151
+ if (error instanceof GitCommandError) throw error;
152
+ throw new GitCommandError('invalid_args', 'Workspace must be an existing directory', {
153
+ cwd: normalizedWorkspace,
154
+ cause: error,
155
+ });
156
+ }
157
+
158
+ return normalizedWorkspace;
159
+ }
160
+
161
+ function validateGitArgv(argv: readonly string[]): void {
162
+ if (!Array.isArray(argv) || argv.length === 0) {
163
+ throw new GitCommandError('invalid_args', 'Git argv must be a non-empty string array', { argv });
164
+ }
165
+
166
+ for (const arg of argv) {
167
+ if (typeof arg !== 'string' || arg.length === 0 || arg.includes('\0')) {
168
+ throw new GitCommandError('invalid_args', 'Git argv contains an invalid argument', { argv });
169
+ }
170
+ }
171
+
172
+ if (argv.includes('-C') || argv.some((arg) => arg.startsWith('--git-dir') || arg.startsWith('--work-tree'))) {
173
+ throw new GitCommandError('invalid_args', 'Git argv contains unsafe repository override arguments', {
174
+ argv,
175
+ });
176
+ }
177
+ }
178
+
179
+ async function execGitRaw(
180
+ cwd: string,
181
+ argv: readonly string[],
182
+ options: GitExecutorOptions,
183
+ behavior: { mapNotGitRepo?: boolean } = {},
184
+ ): Promise<GitCommandResult> {
185
+ validateGitArgv(argv);
186
+
187
+ try {
188
+ const result = await execFileAsync('git', [...argv], {
189
+ cwd,
190
+ encoding: 'utf8',
191
+ timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
192
+ maxBuffer: options.maxBuffer ?? DEFAULT_MAX_BUFFER,
193
+ windowsHide: true,
194
+ });
195
+ return {
196
+ stdout: normalizeGitOutput(result.stdout),
197
+ stderr: normalizeGitOutput(result.stderr),
198
+ };
199
+ } catch (error) {
200
+ throw mapExecError(error, cwd, argv, behavior);
201
+ }
202
+ }
203
+
204
+ function mapExecError(
205
+ error: unknown,
206
+ cwd: string,
207
+ argv: readonly string[],
208
+ behavior: { mapNotGitRepo?: boolean },
209
+ ): GitCommandError {
210
+ const execError = error as ExecFileException & {
211
+ stdout?: unknown;
212
+ stderr?: unknown;
213
+ killed?: boolean;
214
+ code?: number | string;
215
+ signal?: NodeJS.Signals | string;
216
+ };
217
+ const stdout = normalizeGitOutput(execError.stdout);
218
+ const stderr = normalizeGitOutput(execError.stderr);
219
+ const code = execError.code;
220
+ const signal = execError.signal;
221
+ const message = [stderr.trim(), execError.message].filter(Boolean).join('\n');
222
+
223
+ if (code === 'ENOENT') {
224
+ return new GitCommandError('git_not_installed', 'Git executable was not found', {
225
+ stdout,
226
+ stderr,
227
+ exitCode: code,
228
+ signal,
229
+ argv,
230
+ cwd,
231
+ cause: error,
232
+ });
233
+ }
234
+
235
+ if (execError.killed || /timed out/i.test(execError.message)) {
236
+ return new GitCommandError('timeout', 'Git command timed out', {
237
+ stdout,
238
+ stderr,
239
+ exitCode: code,
240
+ signal,
241
+ argv,
242
+ cwd,
243
+ cause: error,
244
+ });
245
+ }
246
+
247
+ if (behavior.mapNotGitRepo && /not a git repository/i.test(stderr + '\n' + execError.message)) {
248
+ return new GitCommandError('not_git_repo', 'Workspace is not a Git repository', {
249
+ stdout,
250
+ stderr,
251
+ exitCode: code,
252
+ signal,
253
+ argv,
254
+ cwd,
255
+ cause: error,
256
+ });
257
+ }
258
+
259
+ return new GitCommandError('git_command_failed', message || 'Git command failed', {
260
+ stdout,
261
+ stderr,
262
+ exitCode: code,
263
+ signal,
264
+ argv,
265
+ cwd,
266
+ cause: error,
267
+ });
268
+ }