@adhdev/daemon-core 0.9.53 → 0.9.55
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/dist/boot/daemon-lifecycle.d.ts +5 -0
- package/dist/cli-adapters/provider-cli-adapter.d.ts +1 -0
- package/dist/cli-adapters/provider-cli-config.d.ts +1 -0
- package/dist/cli-adapters/provider-cli-shared.d.ts +2 -0
- package/dist/commands/handler.d.ts +7 -0
- package/dist/git/git-commands.d.ts +139 -0
- package/dist/git/git-diff.d.ts +17 -0
- package/dist/git/git-executor.d.ts +34 -0
- package/dist/git/git-monitor.d.ts +57 -0
- package/dist/git/git-snapshot-store.d.ts +50 -0
- package/dist/git/git-status.d.ts +19 -0
- package/dist/git/git-summary.d.ts +10 -0
- package/dist/git/git-types.d.ts +113 -0
- package/dist/git/index.d.ts +16 -0
- package/dist/git/turn-snapshot-tracker.d.ts +16 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1772 -386
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1744 -383
- package/dist/index.mjs.map +1 -1
- package/dist/providers/contracts.d.ts +2 -0
- package/dist/shared-types.d.ts +7 -1
- package/dist/status/builders.d.ts +2 -0
- package/dist/status/snapshot.d.ts +2 -0
- package/node_modules/@adhdev/session-host-core/package.json +1 -1
- package/package.json +1 -1
- package/src/boot/daemon-lifecycle.ts +6 -0
- package/src/cli-adapters/provider-cli-adapter.ts +19 -0
- package/src/cli-adapters/provider-cli-config.d.ts +1 -0
- package/src/cli-adapters/provider-cli-config.ts +2 -0
- package/src/cli-adapters/provider-cli-shared.d.ts +2 -0
- package/src/cli-adapters/provider-cli-shared.ts +2 -0
- package/src/commands/handler.ts +25 -1
- package/src/git/git-commands.ts +582 -0
- package/src/git/git-diff.ts +303 -0
- package/src/git/git-executor.ts +268 -0
- package/src/git/git-monitor.ts +194 -0
- package/src/git/git-snapshot-store.ts +238 -0
- package/src/git/git-status.ts +193 -0
- package/src/git/git-summary.ts +43 -0
- package/src/git/git-types.ts +154 -0
- package/src/git/index.ts +75 -0
- package/src/git/turn-snapshot-tracker.ts +31 -0
- package/src/index.ts +4 -0
- package/src/providers/contracts.d.ts +8 -0
- package/src/providers/contracts.ts +2 -0
- package/src/providers/provider-schema.ts +1 -0
- package/src/shared-types.ts +33 -1
- package/src/status/builders.ts +26 -4
- package/src/status/snapshot.ts +10 -2
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { getGitDiffSummary, getGitFileDiff } from './git-diff.js';
|
|
4
|
+
import { GitCommandError, isPathInside, resolveGitRepository, runGit } from './git-executor.js';
|
|
5
|
+
import { createGitSnapshotStore } from './git-snapshot-store.js';
|
|
6
|
+
import { getGitRepoStatus } from './git-status.js';
|
|
7
|
+
import type {
|
|
8
|
+
GitCommandName,
|
|
9
|
+
GitDiffSummary,
|
|
10
|
+
GitFailureReason,
|
|
11
|
+
GitRepoIdentity,
|
|
12
|
+
GitRepoStatus,
|
|
13
|
+
GitSnapshot,
|
|
14
|
+
GitSnapshotCompareSummary,
|
|
15
|
+
GitSnapshotReason,
|
|
16
|
+
} from './git-types.js';
|
|
17
|
+
|
|
18
|
+
export interface GitFileDiff extends GitRepoIdentity {
|
|
19
|
+
path: string;
|
|
20
|
+
oldPath?: string;
|
|
21
|
+
staged?: boolean;
|
|
22
|
+
diff: string;
|
|
23
|
+
binary?: boolean;
|
|
24
|
+
truncated?: boolean;
|
|
25
|
+
lastCheckedAt: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GitLogEntry {
|
|
29
|
+
commit: string;
|
|
30
|
+
message: string;
|
|
31
|
+
authorName?: string;
|
|
32
|
+
authorEmail?: string;
|
|
33
|
+
authoredAt?: number;
|
|
34
|
+
committedAt?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface GitLogResult extends GitRepoIdentity {
|
|
38
|
+
entries: GitLogEntry[];
|
|
39
|
+
limit: number;
|
|
40
|
+
truncated: boolean;
|
|
41
|
+
lastCheckedAt: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface GitCheckpointResult extends GitRepoIdentity {
|
|
45
|
+
commit: string;
|
|
46
|
+
message: string;
|
|
47
|
+
lastCheckedAt: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface GitStashPushResult extends GitRepoIdentity {
|
|
51
|
+
stashRef: string;
|
|
52
|
+
message: string;
|
|
53
|
+
lastCheckedAt: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface GitCommandServices {
|
|
57
|
+
getStatus?: (params: { workspace: string }) => Promise<GitRepoStatus> | GitRepoStatus;
|
|
58
|
+
getDiffSummary?: (params: { workspace: string; staged?: boolean }) => Promise<GitDiffSummary> | GitDiffSummary;
|
|
59
|
+
getDiffFile?: (params: { workspace: string; path: string; staged?: boolean }) => Promise<GitFileDiff> | GitFileDiff;
|
|
60
|
+
createSnapshot?: (params: {
|
|
61
|
+
workspace: string;
|
|
62
|
+
reason: GitSnapshotReason;
|
|
63
|
+
sessionId?: string;
|
|
64
|
+
turnId?: string;
|
|
65
|
+
}) => Promise<GitSnapshot> | GitSnapshot;
|
|
66
|
+
compareSnapshots?: (params: {
|
|
67
|
+
workspace: string;
|
|
68
|
+
beforeSnapshotId: string;
|
|
69
|
+
afterSnapshotId: string;
|
|
70
|
+
}) => Promise<GitSnapshotCompareSummary> | GitSnapshotCompareSummary;
|
|
71
|
+
getLog?: (params: {
|
|
72
|
+
workspace: string;
|
|
73
|
+
limit: number;
|
|
74
|
+
path?: string;
|
|
75
|
+
since?: string;
|
|
76
|
+
until?: string;
|
|
77
|
+
}) => Promise<GitLogResult> | GitLogResult;
|
|
78
|
+
checkpoint?: (params: {
|
|
79
|
+
workspace: string;
|
|
80
|
+
message: string;
|
|
81
|
+
includeUntracked?: boolean;
|
|
82
|
+
}) => Promise<GitCheckpointResult> | GitCheckpointResult;
|
|
83
|
+
stashPush?: (params: {
|
|
84
|
+
workspace: string;
|
|
85
|
+
message: string;
|
|
86
|
+
includeUntracked?: boolean;
|
|
87
|
+
}) => Promise<GitStashPushResult> | GitStashPushResult;
|
|
88
|
+
stashPop?: (params: { workspace: string; stashRef?: string }) => Promise<void>;
|
|
89
|
+
checkoutFiles?: (params: { workspace: string; paths: string[] }) => Promise<{ checkedOut: string[] }>;
|
|
90
|
+
getRemoteUrl?: (params: { workspace: string; remote?: string }) => Promise<{ remoteUrl: string; remote: string }>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type GitCommandFailure = {
|
|
94
|
+
success: false;
|
|
95
|
+
reason: GitFailureReason;
|
|
96
|
+
error: string;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
type GitCommandSuccess =
|
|
100
|
+
| { success: true; status: GitRepoStatus }
|
|
101
|
+
| { success: true; diffSummary: GitDiffSummary }
|
|
102
|
+
| { success: true; diff: GitFileDiff }
|
|
103
|
+
| { success: true; snapshot: GitSnapshot }
|
|
104
|
+
| { success: true; compare: GitSnapshotCompareSummary }
|
|
105
|
+
| { success: true; log: GitLogResult }
|
|
106
|
+
| { success: true; checkpoint: GitCheckpointResult }
|
|
107
|
+
| { success: true; stash: GitStashPushResult }
|
|
108
|
+
| { success: true; stashPopped: true }
|
|
109
|
+
| { success: true; checkedOut: string[] }
|
|
110
|
+
| { success: true; remoteUrl: string; remote: string };
|
|
111
|
+
|
|
112
|
+
export type GitCommandResult = GitCommandSuccess | GitCommandFailure;
|
|
113
|
+
|
|
114
|
+
const GIT_COMMAND_NAMES = new Set<GitCommandName>([
|
|
115
|
+
'git_status',
|
|
116
|
+
'git_diff_summary',
|
|
117
|
+
'git_diff_file',
|
|
118
|
+
'git_snapshot_create',
|
|
119
|
+
'git_snapshot_compare',
|
|
120
|
+
'git_log',
|
|
121
|
+
'git_checkpoint',
|
|
122
|
+
'git_stash_push',
|
|
123
|
+
'git_stash_pop',
|
|
124
|
+
'git_checkout_files',
|
|
125
|
+
'git_remote_url',
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
const SNAPSHOT_REASONS = new Set<GitSnapshotReason>([
|
|
129
|
+
'session_baseline',
|
|
130
|
+
'before_user_input_dispatch',
|
|
131
|
+
'before_agent_work',
|
|
132
|
+
'after_agent_work',
|
|
133
|
+
'manual',
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
const FAILURE_REASONS = new Set<GitFailureReason>([
|
|
137
|
+
'not_git_repo',
|
|
138
|
+
'git_not_installed',
|
|
139
|
+
'timeout',
|
|
140
|
+
'path_outside_repo',
|
|
141
|
+
'dirty_index_required',
|
|
142
|
+
'conflict',
|
|
143
|
+
'invalid_args',
|
|
144
|
+
'git_command_failed',
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
function failure(reason: GitFailureReason, error: string): GitCommandFailure {
|
|
148
|
+
return { success: false, reason, error };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function serviceNotImplemented(command: string): GitCommandFailure {
|
|
152
|
+
return failure('invalid_args', `${command} is not implemented: daemon-core Git service is not configured`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const defaultSnapshotStore = createGitSnapshotStore({
|
|
156
|
+
getStatus: (workspace) => getGitRepoStatus(workspace),
|
|
157
|
+
getDiffSummary: (workspace) => getGitDiffSummary(workspace),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
export function createDefaultGitCommandServices(): GitCommandServices {
|
|
161
|
+
return {
|
|
162
|
+
getStatus: ({ workspace }) => getGitRepoStatus(workspace),
|
|
163
|
+
getDiffSummary: ({ workspace }) => getGitDiffSummary(workspace),
|
|
164
|
+
getDiffFile: ({ workspace, path: filePath }) => getGitFileDiff(workspace, filePath),
|
|
165
|
+
createSnapshot: ({ workspace, reason, sessionId, turnId }) => defaultSnapshotStore.create({
|
|
166
|
+
workspace,
|
|
167
|
+
reason,
|
|
168
|
+
sessionId,
|
|
169
|
+
turnId,
|
|
170
|
+
}),
|
|
171
|
+
compareSnapshots: ({ beforeSnapshotId, afterSnapshotId }) => defaultSnapshotStore.compare(beforeSnapshotId, afterSnapshotId),
|
|
172
|
+
getLog: ({ workspace, limit, path: filePath, since, until }) => getGitLog(workspace, { limit, path: filePath, since, until }),
|
|
173
|
+
checkpoint: async ({ workspace, message, includeUntracked = false }) => gitCheckpoint(workspace, message, includeUntracked),
|
|
174
|
+
stashPush: async ({ workspace, message, includeUntracked = false }) => gitStashPush(workspace, message, includeUntracked),
|
|
175
|
+
stashPop: async ({ workspace, stashRef }) => gitStashPop(workspace, stashRef),
|
|
176
|
+
checkoutFiles: async ({ workspace, paths }) => gitCheckoutFiles(workspace, paths),
|
|
177
|
+
getRemoteUrl: async ({ workspace, remote = 'origin' }) => gitGetRemoteUrl(workspace, remote),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const defaultGitCommandServices = createDefaultGitCommandServices();
|
|
182
|
+
|
|
183
|
+
function validateWorkspace(args: any): { workspace: string } | GitCommandFailure {
|
|
184
|
+
if (typeof args?.workspace !== 'string') {
|
|
185
|
+
return failure('invalid_args', 'workspace must be a non-empty absolute path');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const workspace = args.workspace.trim();
|
|
189
|
+
if (!workspace || !path.isAbsolute(workspace)) {
|
|
190
|
+
return failure('invalid_args', 'workspace must be a non-empty absolute path');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { workspace };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function validateRepoPath(args: any): { path: string } | GitCommandFailure {
|
|
197
|
+
if (typeof args?.path !== 'string' || !args.path.trim()) {
|
|
198
|
+
return failure('invalid_args', 'path must be a non-empty repository-relative path');
|
|
199
|
+
}
|
|
200
|
+
return { path: args.path.trim() };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function validateSnapshotId(args: any, key: 'beforeSnapshotId' | 'afterSnapshotId'): string | GitCommandFailure {
|
|
204
|
+
if (typeof args?.[key] !== 'string' || !args[key].trim()) {
|
|
205
|
+
return failure('invalid_args', `${key} must be a non-empty string`);
|
|
206
|
+
}
|
|
207
|
+
return args[key].trim();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function parseSnapshotReason(args: any): GitSnapshotReason | GitCommandFailure {
|
|
211
|
+
if (args?.reason === undefined || args?.reason === null || args?.reason === '') {
|
|
212
|
+
return 'manual';
|
|
213
|
+
}
|
|
214
|
+
if (typeof args.reason !== 'string' || !SNAPSHOT_REASONS.has(args.reason as GitSnapshotReason)) {
|
|
215
|
+
return failure('invalid_args', 'reason must be a valid GitSnapshotReason');
|
|
216
|
+
}
|
|
217
|
+
return args.reason as GitSnapshotReason;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function optionalString(value: unknown): string | undefined {
|
|
221
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function optionalBoolean(value: unknown): boolean | undefined {
|
|
225
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function boundedLogLimit(value: unknown): number {
|
|
229
|
+
if (value === undefined || value === null || value === '') return 50;
|
|
230
|
+
const numeric = typeof value === 'number' ? value : Number(value);
|
|
231
|
+
if (!Number.isFinite(numeric)) return 50;
|
|
232
|
+
return Math.max(1, Math.min(200, Math.floor(numeric)));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function failureReasonFromError(error: any): GitFailureReason {
|
|
236
|
+
return typeof error?.reason === 'string' && FAILURE_REASONS.has(error.reason)
|
|
237
|
+
? error.reason
|
|
238
|
+
: 'git_command_failed';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function runService<T>(fn: () => Promise<T> | T): Promise<T | GitCommandFailure> {
|
|
242
|
+
try {
|
|
243
|
+
return await fn();
|
|
244
|
+
} catch (error: any) {
|
|
245
|
+
return failure(failureReasonFromError(error), error?.message || 'Git command failed');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function isGitCommandName(command: string): command is GitCommandName {
|
|
250
|
+
return GIT_COMMAND_NAMES.has(command as GitCommandName);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function handleGitCommand(
|
|
254
|
+
command: GitCommandName,
|
|
255
|
+
args: any,
|
|
256
|
+
services?: GitCommandServices,
|
|
257
|
+
): Promise<GitCommandResult>;
|
|
258
|
+
export async function handleGitCommand(
|
|
259
|
+
command: string,
|
|
260
|
+
args: any,
|
|
261
|
+
services?: GitCommandServices,
|
|
262
|
+
): Promise<GitCommandResult>;
|
|
263
|
+
export async function handleGitCommand(
|
|
264
|
+
command: string,
|
|
265
|
+
args: any,
|
|
266
|
+
services: GitCommandServices = defaultGitCommandServices,
|
|
267
|
+
): Promise<GitCommandResult> {
|
|
268
|
+
if (!isGitCommandName(command)) {
|
|
269
|
+
return failure('invalid_args', `Unknown Git command: ${command}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const workspaceResult = validateWorkspace(args);
|
|
273
|
+
if ('success' in workspaceResult) return workspaceResult;
|
|
274
|
+
const { workspace } = workspaceResult;
|
|
275
|
+
|
|
276
|
+
switch (command) {
|
|
277
|
+
case 'git_status': {
|
|
278
|
+
if (!services.getStatus) return serviceNotImplemented(command);
|
|
279
|
+
const status = await runService(() => services.getStatus!({ workspace }));
|
|
280
|
+
return 'success' in status ? status : { success: true, status };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
case 'git_diff_summary': {
|
|
284
|
+
if (!services.getDiffSummary) return serviceNotImplemented(command);
|
|
285
|
+
const diffSummary = await runService(() => services.getDiffSummary!({ workspace, staged: optionalBoolean(args?.staged) }));
|
|
286
|
+
return 'success' in diffSummary ? diffSummary : { success: true, diffSummary };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
case 'git_diff_file': {
|
|
290
|
+
if (!services.getDiffFile) return serviceNotImplemented(command);
|
|
291
|
+
const pathResult = validateRepoPath(args);
|
|
292
|
+
if (typeof pathResult !== 'object' || 'success' in pathResult) return pathResult;
|
|
293
|
+
const diff = await runService(() => services.getDiffFile!({
|
|
294
|
+
workspace,
|
|
295
|
+
path: pathResult.path,
|
|
296
|
+
staged: optionalBoolean(args?.staged),
|
|
297
|
+
}));
|
|
298
|
+
return 'success' in diff ? diff : { success: true, diff };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
case 'git_snapshot_create': {
|
|
302
|
+
if (!services.createSnapshot) return serviceNotImplemented(command);
|
|
303
|
+
const reason = parseSnapshotReason(args);
|
|
304
|
+
if (typeof reason !== 'string') return reason;
|
|
305
|
+
const snapshot = await runService(() => services.createSnapshot!({
|
|
306
|
+
workspace,
|
|
307
|
+
reason,
|
|
308
|
+
sessionId: optionalString(args?.sessionId),
|
|
309
|
+
turnId: optionalString(args?.turnId),
|
|
310
|
+
}));
|
|
311
|
+
return 'success' in snapshot ? snapshot : { success: true, snapshot };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
case 'git_snapshot_compare': {
|
|
315
|
+
if (!services.compareSnapshots) return serviceNotImplemented(command);
|
|
316
|
+
const beforeSnapshotId = validateSnapshotId(args, 'beforeSnapshotId');
|
|
317
|
+
if (typeof beforeSnapshotId !== 'string') return beforeSnapshotId;
|
|
318
|
+
const afterSnapshotId = validateSnapshotId(args, 'afterSnapshotId');
|
|
319
|
+
if (typeof afterSnapshotId !== 'string') return afterSnapshotId;
|
|
320
|
+
const compare = await runService(() => services.compareSnapshots!({ workspace, beforeSnapshotId, afterSnapshotId }));
|
|
321
|
+
return 'success' in compare ? compare : { success: true, compare };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
case 'git_log': {
|
|
325
|
+
if (!services.getLog) {
|
|
326
|
+
return failure('invalid_args', 'git_log is not implemented: bounded daemon-core Git log service is not configured');
|
|
327
|
+
}
|
|
328
|
+
const log = await runService(() => services.getLog!({
|
|
329
|
+
workspace,
|
|
330
|
+
limit: boundedLogLimit(args?.limit),
|
|
331
|
+
path: optionalString(args?.path),
|
|
332
|
+
since: optionalString(args?.since),
|
|
333
|
+
until: optionalString(args?.until),
|
|
334
|
+
}));
|
|
335
|
+
return 'success' in log ? log : { success: true, log };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
case 'git_checkpoint': {
|
|
339
|
+
if (!services.checkpoint) return serviceNotImplemented(command);
|
|
340
|
+
const msg = validateMutatingMessage(args?.message);
|
|
341
|
+
if (typeof msg !== 'string') return msg;
|
|
342
|
+
const includeUntracked = Boolean(args?.includeUntracked);
|
|
343
|
+
const checkpoint = await runService(() => services.checkpoint!({ workspace, message: msg, includeUntracked }));
|
|
344
|
+
return 'success' in checkpoint ? checkpoint : { success: true, checkpoint };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
case 'git_stash_push': {
|
|
348
|
+
if (!services.stashPush) return serviceNotImplemented(command);
|
|
349
|
+
const msg = validateMutatingMessage(args?.message);
|
|
350
|
+
if (typeof msg !== 'string') return msg;
|
|
351
|
+
const includeUntracked = Boolean(args?.includeUntracked);
|
|
352
|
+
const stash = await runService(() => services.stashPush!({ workspace, message: msg, includeUntracked }));
|
|
353
|
+
return 'success' in stash ? stash : { success: true, stash };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
case 'git_stash_pop': {
|
|
357
|
+
if (!services.stashPop) return serviceNotImplemented(command);
|
|
358
|
+
const stashRef = optionalString(args?.stashRef);
|
|
359
|
+
if (stashRef !== undefined && !/^stash@\{\d+\}$/.test(stashRef)) {
|
|
360
|
+
return failure('invalid_args', 'stashRef must match stash@{N} format');
|
|
361
|
+
}
|
|
362
|
+
const popResult = await runService(() => services.stashPop!({ workspace, stashRef }));
|
|
363
|
+
if (popResult !== undefined && 'success' in (popResult as object)) return popResult as GitCommandFailure;
|
|
364
|
+
return { success: true, stashPopped: true };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
case 'git_checkout_files': {
|
|
368
|
+
if (!services.checkoutFiles) return serviceNotImplemented(command);
|
|
369
|
+
const paths = args?.paths;
|
|
370
|
+
if (!Array.isArray(paths) || paths.length === 0) {
|
|
371
|
+
return failure('invalid_args', 'paths must be a non-empty array');
|
|
372
|
+
}
|
|
373
|
+
if (paths.length > 50) {
|
|
374
|
+
return failure('invalid_args', 'paths array exceeds maximum of 50 entries');
|
|
375
|
+
}
|
|
376
|
+
const checkoutResult = await runService(() => services.checkoutFiles!({ workspace, paths }));
|
|
377
|
+
return 'success' in checkoutResult ? checkoutResult : { success: true, checkedOut: (checkoutResult as { checkedOut: string[] }).checkedOut };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
case 'git_remote_url': {
|
|
381
|
+
if (!services.getRemoteUrl) return serviceNotImplemented(command);
|
|
382
|
+
const remote = typeof args?.remote === 'string' && args.remote.trim() ? args.remote.trim() : 'origin';
|
|
383
|
+
const remoteResult = await runService(() => services.getRemoteUrl!({ workspace, remote }));
|
|
384
|
+
if ('success' in remoteResult) return remoteResult;
|
|
385
|
+
return { success: true, remoteUrl: remoteResult.remoteUrl, remote: remoteResult.remote };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
default:
|
|
389
|
+
return failure('invalid_args', `Unknown Git command: ${command}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function validateMutatingMessage(value: unknown): string | GitCommandFailure {
|
|
394
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
395
|
+
return failure('invalid_args', 'message must be a non-empty string');
|
|
396
|
+
}
|
|
397
|
+
const msg = value.trim();
|
|
398
|
+
if (msg.length > 200) {
|
|
399
|
+
return failure('invalid_args', 'message must be 200 characters or fewer');
|
|
400
|
+
}
|
|
401
|
+
return msg;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function gitCheckpoint(
|
|
405
|
+
workspace: string,
|
|
406
|
+
message: string,
|
|
407
|
+
includeUntracked: boolean,
|
|
408
|
+
): Promise<GitCheckpointResult> {
|
|
409
|
+
const repo = await resolveGitRepository(workspace);
|
|
410
|
+
const repoRoot = repo.repoRoot!;
|
|
411
|
+
|
|
412
|
+
const statusResult = await getGitRepoStatus(workspace);
|
|
413
|
+
if (statusResult.hasConflicts) {
|
|
414
|
+
throw new GitCommandError('conflict', 'Repository has conflicts — resolve before checkpointing');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const addArgs = includeUntracked ? ['-A'] : ['-u'];
|
|
418
|
+
await runGit(repo, ['add', ...addArgs], { cwd: repoRoot });
|
|
419
|
+
|
|
420
|
+
const fullMsg = `adhdev: checkpoint ${message}`;
|
|
421
|
+
let commitSha: string;
|
|
422
|
+
try {
|
|
423
|
+
await runGit(repo, ['commit', '-m', fullMsg], { cwd: repoRoot });
|
|
424
|
+
const revResult = await runGit(repo, ['rev-parse', 'HEAD'], { cwd: repoRoot });
|
|
425
|
+
commitSha = revResult.stdout.trim();
|
|
426
|
+
} catch (err: any) {
|
|
427
|
+
const output = (err?.stdout || '') + (err?.stderr || '');
|
|
428
|
+
if (/nothing to commit/i.test(output)) {
|
|
429
|
+
throw new GitCommandError('git_command_failed', 'Nothing to commit');
|
|
430
|
+
}
|
|
431
|
+
throw err;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
workspace: repo.workspace,
|
|
436
|
+
repoRoot,
|
|
437
|
+
isGitRepo: true,
|
|
438
|
+
commit: commitSha,
|
|
439
|
+
message: fullMsg,
|
|
440
|
+
lastCheckedAt: Date.now(),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function gitStashPush(
|
|
445
|
+
workspace: string,
|
|
446
|
+
message: string,
|
|
447
|
+
includeUntracked: boolean,
|
|
448
|
+
): Promise<GitStashPushResult> {
|
|
449
|
+
const repo = await resolveGitRepository(workspace);
|
|
450
|
+
const repoRoot = repo.repoRoot!;
|
|
451
|
+
|
|
452
|
+
const stashArgs = ['stash', 'push', '-m', message];
|
|
453
|
+
if (includeUntracked) stashArgs.push('--include-untracked');
|
|
454
|
+
|
|
455
|
+
const result = await runGit(repo, stashArgs, { cwd: repoRoot });
|
|
456
|
+
if (/No local changes to save/i.test(result.stdout + result.stderr)) {
|
|
457
|
+
throw new GitCommandError('git_command_failed', 'Nothing to stash');
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
workspace: repo.workspace,
|
|
462
|
+
repoRoot,
|
|
463
|
+
isGitRepo: true,
|
|
464
|
+
stashRef: 'stash@{0}',
|
|
465
|
+
message,
|
|
466
|
+
lastCheckedAt: Date.now(),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function gitStashPop(workspace: string, stashRef?: string): Promise<void> {
|
|
471
|
+
const repo = await resolveGitRepository(workspace);
|
|
472
|
+
const repoRoot = repo.repoRoot!;
|
|
473
|
+
|
|
474
|
+
const popArgs = stashRef ? ['stash', 'pop', stashRef] : ['stash', 'pop'];
|
|
475
|
+
await runGit(repo, popArgs, { cwd: repoRoot });
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function gitCheckoutFiles(workspace: string, paths: string[]): Promise<{ checkedOut: string[] }> {
|
|
479
|
+
const repo = await resolveGitRepository(workspace);
|
|
480
|
+
const repoRoot = repo.repoRoot!;
|
|
481
|
+
|
|
482
|
+
const normalizedPaths: string[] = [];
|
|
483
|
+
for (const p of paths) {
|
|
484
|
+
if (typeof p !== 'string' || !p.trim() || p.includes('\0')) {
|
|
485
|
+
throw new GitCommandError('invalid_args', `Invalid path: ${String(p)}`);
|
|
486
|
+
}
|
|
487
|
+
if (path.isAbsolute(p)) {
|
|
488
|
+
throw new GitCommandError('invalid_args', `Path must be repository-relative, not absolute: ${p}`);
|
|
489
|
+
}
|
|
490
|
+
const normalized = path.normalize(p.trim()).split(path.sep).join('/');
|
|
491
|
+
if (normalized.startsWith('../') || normalized === '..') {
|
|
492
|
+
throw new GitCommandError('path_outside_repo', `Path is outside repository root: ${p}`);
|
|
493
|
+
}
|
|
494
|
+
const absolutePath = path.resolve(repoRoot, normalized);
|
|
495
|
+
if (!isPathInside(repoRoot, absolutePath)) {
|
|
496
|
+
throw new GitCommandError('path_outside_repo', `Path is outside repository root: ${p}`);
|
|
497
|
+
}
|
|
498
|
+
normalizedPaths.push(normalized);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
await runGit(repo, ['checkout', '--', ...normalizedPaths], { cwd: repoRoot });
|
|
502
|
+
return { checkedOut: normalizedPaths };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function gitGetRemoteUrl(workspace: string, remote: string): Promise<{ remoteUrl: string; remote: string }> {
|
|
506
|
+
const repo = await resolveGitRepository(workspace);
|
|
507
|
+
const result = await runGit(repo, ['remote', 'get-url', remote], { cwd: repo.repoRoot! });
|
|
508
|
+
const remoteUrl = result.stdout.trim();
|
|
509
|
+
if (!remoteUrl) {
|
|
510
|
+
throw new GitCommandError('git_command_failed', `Remote '${remote}' has no URL`);
|
|
511
|
+
}
|
|
512
|
+
return { remoteUrl, remote };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function formatOptionalGitLogRangeArg(flag: '--since' | '--until', value: string | undefined): string[] {
|
|
516
|
+
return value ? [`${flag}=${value}`] : [];
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function getGitLog(
|
|
520
|
+
workspace: string,
|
|
521
|
+
options: { limit: number; path?: string; since?: string; until?: string },
|
|
522
|
+
): Promise<GitLogResult> {
|
|
523
|
+
const lastCheckedAt = Date.now();
|
|
524
|
+
const repo = await resolveGitRepository(workspace);
|
|
525
|
+
const repoRoot = repo.repoRoot!;
|
|
526
|
+
const boundedLimit = Math.max(1, Math.min(200, Math.floor(options.limit || 50)));
|
|
527
|
+
const selectedPath = options.path ? validateGitLogPath(repoRoot, options.path) : undefined;
|
|
528
|
+
const result = await runGit(
|
|
529
|
+
repo,
|
|
530
|
+
[
|
|
531
|
+
'log',
|
|
532
|
+
`--max-count=${boundedLimit}`,
|
|
533
|
+
'--format=%H%x00%an%x00%ae%x00%at%x00%ct%x00%s',
|
|
534
|
+
...formatOptionalGitLogRangeArg('--since', options.since),
|
|
535
|
+
...formatOptionalGitLogRangeArg('--until', options.until),
|
|
536
|
+
'--',
|
|
537
|
+
...(selectedPath ? [selectedPath] : []),
|
|
538
|
+
],
|
|
539
|
+
{ cwd: repoRoot },
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
const entries = result.stdout
|
|
543
|
+
.split('\n')
|
|
544
|
+
.filter((line) => line.trim().length > 0)
|
|
545
|
+
.map((line): GitLogEntry => {
|
|
546
|
+
const [commit = '', authorName, authorEmail, authoredAt, committedAt, ...messageParts] = line.split('\0');
|
|
547
|
+
return {
|
|
548
|
+
commit,
|
|
549
|
+
message: messageParts.join('\0'),
|
|
550
|
+
authorName: authorName || undefined,
|
|
551
|
+
authorEmail: authorEmail || undefined,
|
|
552
|
+
authoredAt: authoredAt ? Number.parseInt(authoredAt, 10) * 1000 : undefined,
|
|
553
|
+
committedAt: committedAt ? Number.parseInt(committedAt, 10) * 1000 : undefined,
|
|
554
|
+
};
|
|
555
|
+
})
|
|
556
|
+
.filter((entry) => entry.commit.length > 0);
|
|
557
|
+
|
|
558
|
+
return {
|
|
559
|
+
workspace: repo.workspace,
|
|
560
|
+
repoRoot,
|
|
561
|
+
isGitRepo: true,
|
|
562
|
+
entries,
|
|
563
|
+
limit: boundedLimit,
|
|
564
|
+
truncated: entries.length >= boundedLimit,
|
|
565
|
+
lastCheckedAt,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function validateGitLogPath(repoRoot: string, filePath: string): string {
|
|
570
|
+
if (!filePath.trim() || filePath.includes('\0')) {
|
|
571
|
+
throw new GitCommandError('invalid_args', 'path must be a non-empty repository-relative path');
|
|
572
|
+
}
|
|
573
|
+
if (path.isAbsolute(filePath)) {
|
|
574
|
+
throw new GitCommandError('invalid_args', 'path must be repository-relative');
|
|
575
|
+
}
|
|
576
|
+
const normalized = path.normalize(filePath).split(path.sep).join('/');
|
|
577
|
+
const absolutePath = path.resolve(repoRoot, normalized);
|
|
578
|
+
if (!isPathInside(repoRoot, absolutePath) || normalized.startsWith('../') || normalized === '..') {
|
|
579
|
+
throw new GitCommandError('path_outside_repo', 'Git log path is outside the repository root');
|
|
580
|
+
}
|
|
581
|
+
return normalized;
|
|
582
|
+
}
|