@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,194 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GitCompactSummary,
|
|
3
|
+
GitDiffSummary,
|
|
4
|
+
GitRepoStatus,
|
|
5
|
+
GitWorkspaceUpdate,
|
|
6
|
+
WorkspaceGitSubscriptionParams,
|
|
7
|
+
} from './git-types.js';
|
|
8
|
+
import { getGitDiffSummary } from './git-diff.js';
|
|
9
|
+
import { getGitRepoStatus } from './git-status.js';
|
|
10
|
+
import { createGitCompactSummary } from './git-summary.js';
|
|
11
|
+
import type { GitDiffSummaryProvider, GitStatusProvider } from './git-snapshot-store.js';
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_GIT_WORKSPACE_POLL_INTERVAL_MS = 5000;
|
|
14
|
+
export const MIN_GIT_WORKSPACE_POLL_INTERVAL_MS = 1000;
|
|
15
|
+
|
|
16
|
+
export interface NormalizeGitWorkspaceSubscriptionOptions {
|
|
17
|
+
defaultIntervalMs?: number;
|
|
18
|
+
minIntervalMs?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface NormalizedWorkspaceGitSubscriptionParams extends Required<WorkspaceGitSubscriptionParams> {}
|
|
22
|
+
|
|
23
|
+
export interface GitWorkspaceCacheEntry {
|
|
24
|
+
key: string;
|
|
25
|
+
workspace: string;
|
|
26
|
+
status: GitRepoStatus;
|
|
27
|
+
diffSummary?: GitDiffSummary;
|
|
28
|
+
compactSummary: GitCompactSummary;
|
|
29
|
+
seq: number;
|
|
30
|
+
timestamp: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type GitWorkspaceUpdateListener = (update: GitWorkspaceUpdate, cacheEntry: GitWorkspaceCacheEntry) => void;
|
|
34
|
+
|
|
35
|
+
export interface GitWorkspaceMonitorOptions {
|
|
36
|
+
getStatus?: GitStatusProvider;
|
|
37
|
+
getDiffSummary?: GitDiffSummaryProvider;
|
|
38
|
+
now?: () => number;
|
|
39
|
+
minIntervalMs?: number;
|
|
40
|
+
defaultIntervalMs?: number;
|
|
41
|
+
keyPrefix?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface GitWorkspaceSubscription {
|
|
45
|
+
params: NormalizedWorkspaceGitSubscriptionParams;
|
|
46
|
+
refresh(): Promise<GitWorkspaceUpdate>;
|
|
47
|
+
getCached(): GitWorkspaceCacheEntry | undefined;
|
|
48
|
+
dispose(): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function defaultStatusProvider(workspace: string): Promise<GitRepoStatus> {
|
|
52
|
+
return getGitRepoStatus(workspace);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function defaultDiffSummaryProvider(workspace: string): Promise<GitDiffSummary> {
|
|
56
|
+
return getGitDiffSummary(workspace);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeIntervalMs(value: number | undefined, defaultIntervalMs: number, minIntervalMs: number): number {
|
|
60
|
+
const requested = Number.isFinite(value) ? Math.floor(value as number) : defaultIntervalMs;
|
|
61
|
+
return Math.max(minIntervalMs, requested > 0 ? requested : defaultIntervalMs);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function normalizeGitWorkspaceSubscriptionParams(
|
|
65
|
+
params: WorkspaceGitSubscriptionParams,
|
|
66
|
+
options: NormalizeGitWorkspaceSubscriptionOptions = {},
|
|
67
|
+
): NormalizedWorkspaceGitSubscriptionParams {
|
|
68
|
+
const minIntervalMs = Math.max(1, Math.floor(options.minIntervalMs ?? MIN_GIT_WORKSPACE_POLL_INTERVAL_MS));
|
|
69
|
+
const defaultIntervalMs = Math.max(minIntervalMs, Math.floor(options.defaultIntervalMs ?? DEFAULT_GIT_WORKSPACE_POLL_INTERVAL_MS));
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
workspace: params.workspace,
|
|
73
|
+
includeDiffSummary: Boolean(params.includeDiffSummary),
|
|
74
|
+
intervalMs: normalizeIntervalMs(params.intervalMs, defaultIntervalMs, minIntervalMs),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class GitWorkspaceMonitor {
|
|
79
|
+
private readonly getStatusProvider: GitStatusProvider;
|
|
80
|
+
private readonly getDiffSummaryProvider: GitDiffSummaryProvider;
|
|
81
|
+
private readonly now: () => number;
|
|
82
|
+
private readonly minIntervalMs: number;
|
|
83
|
+
private readonly defaultIntervalMs: number;
|
|
84
|
+
private readonly keyPrefix: string;
|
|
85
|
+
private readonly cache = new Map<string, GitWorkspaceCacheEntry>();
|
|
86
|
+
private readonly listeners = new Set<GitWorkspaceUpdateListener>();
|
|
87
|
+
private seq = 0;
|
|
88
|
+
|
|
89
|
+
constructor(options: GitWorkspaceMonitorOptions = {}) {
|
|
90
|
+
this.getStatusProvider = options.getStatus ?? defaultStatusProvider;
|
|
91
|
+
this.getDiffSummaryProvider = options.getDiffSummary ?? defaultDiffSummaryProvider;
|
|
92
|
+
this.now = options.now ?? Date.now;
|
|
93
|
+
this.minIntervalMs = Math.max(1, Math.floor(options.minIntervalMs ?? MIN_GIT_WORKSPACE_POLL_INTERVAL_MS));
|
|
94
|
+
this.defaultIntervalMs = Math.max(
|
|
95
|
+
this.minIntervalMs,
|
|
96
|
+
Math.floor(options.defaultIntervalMs ?? DEFAULT_GIT_WORKSPACE_POLL_INTERVAL_MS),
|
|
97
|
+
);
|
|
98
|
+
this.keyPrefix = options.keyPrefix ?? 'git';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async refresh(params: string | WorkspaceGitSubscriptionParams): Promise<GitWorkspaceUpdate> {
|
|
102
|
+
const normalized = this.normalize(typeof params === 'string' ? { workspace: params } : params);
|
|
103
|
+
const status = await this.getStatusProvider(normalized.workspace);
|
|
104
|
+
const diffSummary = normalized.includeDiffSummary
|
|
105
|
+
? await this.getDiffSummaryProvider(normalized.workspace, status)
|
|
106
|
+
: undefined;
|
|
107
|
+
const compactSummary = createGitCompactSummary(status, diffSummary);
|
|
108
|
+
const timestamp = this.now();
|
|
109
|
+
const seq = ++this.seq;
|
|
110
|
+
const key = this.keyForWorkspace(normalized.workspace);
|
|
111
|
+
const update: GitWorkspaceUpdate = {
|
|
112
|
+
topic: 'workspace.git',
|
|
113
|
+
key,
|
|
114
|
+
workspace: normalized.workspace,
|
|
115
|
+
status,
|
|
116
|
+
diffSummary,
|
|
117
|
+
seq,
|
|
118
|
+
timestamp,
|
|
119
|
+
};
|
|
120
|
+
const cacheEntry: GitWorkspaceCacheEntry = {
|
|
121
|
+
key,
|
|
122
|
+
workspace: normalized.workspace,
|
|
123
|
+
status,
|
|
124
|
+
diffSummary,
|
|
125
|
+
compactSummary,
|
|
126
|
+
seq,
|
|
127
|
+
timestamp,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
this.cache.set(normalized.workspace, cacheEntry);
|
|
131
|
+
this.emit(update, cacheEntry);
|
|
132
|
+
return update;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
poll(params: string | WorkspaceGitSubscriptionParams): Promise<GitWorkspaceUpdate> {
|
|
136
|
+
return this.refresh(params);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
getCached(workspace: string): GitWorkspaceCacheEntry | undefined {
|
|
140
|
+
return this.cache.get(workspace);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
getCompactSummary(workspace: string): GitCompactSummary | undefined {
|
|
144
|
+
return this.cache.get(workspace)?.compactSummary;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
onUpdate(listener: GitWorkspaceUpdateListener): () => void {
|
|
148
|
+
this.listeners.add(listener);
|
|
149
|
+
return () => {
|
|
150
|
+
this.listeners.delete(listener);
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
createSubscription(
|
|
155
|
+
params: WorkspaceGitSubscriptionParams,
|
|
156
|
+
listener?: GitWorkspaceUpdateListener,
|
|
157
|
+
): GitWorkspaceSubscription {
|
|
158
|
+
const normalized = this.normalize(params);
|
|
159
|
+
const scopedListener: GitWorkspaceUpdateListener | undefined = listener
|
|
160
|
+
? (update, cacheEntry) => {
|
|
161
|
+
if (update.workspace === normalized.workspace) listener(update, cacheEntry);
|
|
162
|
+
}
|
|
163
|
+
: undefined;
|
|
164
|
+
const unsubscribe = scopedListener ? this.onUpdate(scopedListener) : () => undefined;
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
params: normalized,
|
|
168
|
+
refresh: () => this.refresh(normalized),
|
|
169
|
+
getCached: () => this.getCached(normalized.workspace),
|
|
170
|
+
dispose: unsubscribe,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
normalize(params: WorkspaceGitSubscriptionParams): NormalizedWorkspaceGitSubscriptionParams {
|
|
175
|
+
return normalizeGitWorkspaceSubscriptionParams(params, {
|
|
176
|
+
defaultIntervalMs: this.defaultIntervalMs,
|
|
177
|
+
minIntervalMs: this.minIntervalMs,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private keyForWorkspace(workspace: string): string {
|
|
182
|
+
return `${this.keyPrefix}:${workspace}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private emit(update: GitWorkspaceUpdate, cacheEntry: GitWorkspaceCacheEntry): void {
|
|
186
|
+
for (const listener of this.listeners) {
|
|
187
|
+
listener(update, cacheEntry);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function createGitWorkspaceMonitor(options: GitWorkspaceMonitorOptions = {}): GitWorkspaceMonitor {
|
|
193
|
+
return new GitWorkspaceMonitor(options);
|
|
194
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GitDiffSummary,
|
|
3
|
+
GitFileChange,
|
|
4
|
+
GitRepoStatus,
|
|
5
|
+
GitSnapshot,
|
|
6
|
+
GitSnapshotCompareSummary,
|
|
7
|
+
GitSnapshotReason,
|
|
8
|
+
} from './git-types.js';
|
|
9
|
+
|
|
10
|
+
export type MaybePromise<T> = T | Promise<T>;
|
|
11
|
+
|
|
12
|
+
export type GitStatusProvider = (workspace: string) => MaybePromise<GitRepoStatus>;
|
|
13
|
+
export type GitDiffSummaryProvider = (workspace: string, status: GitRepoStatus) => MaybePromise<GitDiffSummary>;
|
|
14
|
+
|
|
15
|
+
export interface GitSnapshotStoreOptions {
|
|
16
|
+
capacity?: number;
|
|
17
|
+
getStatus?: GitStatusProvider;
|
|
18
|
+
getDiffSummary?: GitDiffSummaryProvider;
|
|
19
|
+
now?: () => number;
|
|
20
|
+
idPrefix?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GitSnapshotCreateInput {
|
|
24
|
+
workspace: string;
|
|
25
|
+
reason: GitSnapshotReason;
|
|
26
|
+
sessionId?: string;
|
|
27
|
+
turnId?: string;
|
|
28
|
+
getStatus?: GitStatusProvider;
|
|
29
|
+
getDiffSummary?: GitDiffSummaryProvider;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface GitSnapshotListQuery {
|
|
33
|
+
workspace?: string;
|
|
34
|
+
sessionId?: string;
|
|
35
|
+
limit?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GitSnapshotStore {
|
|
39
|
+
create(input: GitSnapshotCreateInput): Promise<GitSnapshot>;
|
|
40
|
+
get(id: string): GitSnapshot | undefined;
|
|
41
|
+
compare(beforeSnapshotId: string, afterSnapshotId: string): GitSnapshotCompareSummary;
|
|
42
|
+
list(query?: GitSnapshotListQuery): GitSnapshot[];
|
|
43
|
+
clear(): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeCapacity(capacity: number | undefined): number {
|
|
47
|
+
return Math.max(1, Math.floor(capacity ?? 100));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createEmptyDiffSummary(status: GitRepoStatus): GitDiffSummary {
|
|
51
|
+
return {
|
|
52
|
+
workspace: status.workspace,
|
|
53
|
+
repoRoot: status.repoRoot,
|
|
54
|
+
isGitRepo: status.isGitRepo,
|
|
55
|
+
files: [],
|
|
56
|
+
totalInsertions: 0,
|
|
57
|
+
totalDeletions: 0,
|
|
58
|
+
truncated: false,
|
|
59
|
+
lastCheckedAt: status.lastCheckedAt,
|
|
60
|
+
error: status.error,
|
|
61
|
+
reason: status.reason,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function changedFileKey(file: GitFileChange): string {
|
|
66
|
+
return `${file.oldPath ?? ''}\u0000${file.path}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function uniqueSorted(values: Iterable<string>): string[] {
|
|
70
|
+
return Array.from(new Set(Array.from(values).filter(Boolean))).sort((a, b) => a.localeCompare(b));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function plural(count: number, singular: string, pluralText = `${singular}s`): string {
|
|
74
|
+
return count === 1 ? singular : pluralText;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function compareGitSnapshots(before: GitSnapshot, after: GitSnapshot): GitSnapshotCompareSummary {
|
|
78
|
+
const beforeFileKeys = new Set(before.diffSummary.files.map(changedFileKey));
|
|
79
|
+
const changedAfterFiles = after.diffSummary.files.filter((file) => !beforeFileKeys.has(changedFileKey(file)));
|
|
80
|
+
|
|
81
|
+
const addedFiles: string[] = [];
|
|
82
|
+
const modifiedFiles: string[] = [];
|
|
83
|
+
const deletedFiles: string[] = [];
|
|
84
|
+
const renamedFiles: Array<{ oldPath: string; path: string }> = [];
|
|
85
|
+
const untrackedFiles: string[] = [];
|
|
86
|
+
const conflictFilesFromDiff: string[] = [];
|
|
87
|
+
|
|
88
|
+
let totalInsertions = 0;
|
|
89
|
+
let totalDeletions = 0;
|
|
90
|
+
|
|
91
|
+
for (const file of changedAfterFiles) {
|
|
92
|
+
totalInsertions += file.insertions;
|
|
93
|
+
totalDeletions += file.deletions;
|
|
94
|
+
|
|
95
|
+
switch (file.status) {
|
|
96
|
+
case 'added':
|
|
97
|
+
case 'copied':
|
|
98
|
+
addedFiles.push(file.path);
|
|
99
|
+
break;
|
|
100
|
+
case 'modified':
|
|
101
|
+
modifiedFiles.push(file.path);
|
|
102
|
+
break;
|
|
103
|
+
case 'deleted':
|
|
104
|
+
deletedFiles.push(file.path);
|
|
105
|
+
break;
|
|
106
|
+
case 'renamed':
|
|
107
|
+
renamedFiles.push({ oldPath: file.oldPath ?? file.path, path: file.path });
|
|
108
|
+
break;
|
|
109
|
+
case 'untracked':
|
|
110
|
+
untrackedFiles.push(file.path);
|
|
111
|
+
break;
|
|
112
|
+
case 'conflict':
|
|
113
|
+
conflictFilesFromDiff.push(file.path);
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
renamedFiles.sort((a, b) => `${a.oldPath}\u0000${a.path}`.localeCompare(`${b.oldPath}\u0000${b.path}`));
|
|
119
|
+
const conflictFiles = uniqueSorted([...after.status.conflictFiles, ...conflictFilesFromDiff]);
|
|
120
|
+
const changedFiles = changedAfterFiles.length;
|
|
121
|
+
const hasConflicts = after.status.hasConflicts || conflictFiles.length > 0;
|
|
122
|
+
const summaryParts: string[] = [];
|
|
123
|
+
|
|
124
|
+
if (changedFiles > 0) summaryParts.push(`${changedFiles} ${plural(changedFiles, 'file')} changed`);
|
|
125
|
+
if (addedFiles.length > 0) summaryParts.push(`${addedFiles.length} added`);
|
|
126
|
+
if (modifiedFiles.length > 0) summaryParts.push(`${modifiedFiles.length} modified`);
|
|
127
|
+
if (deletedFiles.length > 0) summaryParts.push(`${deletedFiles.length} deleted`);
|
|
128
|
+
if (renamedFiles.length > 0) summaryParts.push(`${renamedFiles.length} renamed`);
|
|
129
|
+
if (untrackedFiles.length > 0) summaryParts.push(`${untrackedFiles.length} untracked`);
|
|
130
|
+
if (hasConflicts) summaryParts.push(`${conflictFiles.length || 1} ${plural(conflictFiles.length || 1, 'conflict')}`);
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
beforeSnapshotId: before.id,
|
|
134
|
+
afterSnapshotId: after.id,
|
|
135
|
+
workspace: after.workspace,
|
|
136
|
+
repoRoot: after.repoRoot,
|
|
137
|
+
changedFiles,
|
|
138
|
+
addedFiles: uniqueSorted(addedFiles),
|
|
139
|
+
modifiedFiles: uniqueSorted(modifiedFiles),
|
|
140
|
+
deletedFiles: uniqueSorted(deletedFiles),
|
|
141
|
+
renamedFiles,
|
|
142
|
+
untrackedFiles: uniqueSorted(untrackedFiles),
|
|
143
|
+
conflictFiles,
|
|
144
|
+
totalInsertions,
|
|
145
|
+
totalDeletions,
|
|
146
|
+
hasConflicts,
|
|
147
|
+
currentStatus: after.status,
|
|
148
|
+
summaryText: summaryParts.length > 0 ? summaryParts.join(', ') : 'No file-set changes between snapshots.',
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export class InMemoryGitSnapshotStore implements GitSnapshotStore {
|
|
153
|
+
private readonly snapshots = new Map<string, GitSnapshot>();
|
|
154
|
+
private readonly order: string[] = [];
|
|
155
|
+
private readonly capacity: number;
|
|
156
|
+
private readonly now: () => number;
|
|
157
|
+
private readonly idPrefix: string;
|
|
158
|
+
private readonly getStatusProvider?: GitStatusProvider;
|
|
159
|
+
private readonly getDiffSummaryProvider?: GitDiffSummaryProvider;
|
|
160
|
+
private counter = 0;
|
|
161
|
+
|
|
162
|
+
constructor(options: GitSnapshotStoreOptions = {}) {
|
|
163
|
+
this.capacity = normalizeCapacity(options.capacity);
|
|
164
|
+
this.now = options.now ?? Date.now;
|
|
165
|
+
this.idPrefix = options.idPrefix ?? 'git-snapshot';
|
|
166
|
+
this.getStatusProvider = options.getStatus;
|
|
167
|
+
this.getDiffSummaryProvider = options.getDiffSummary;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async create(input: GitSnapshotCreateInput): Promise<GitSnapshot> {
|
|
171
|
+
const getStatus = input.getStatus ?? this.getStatusProvider;
|
|
172
|
+
if (!getStatus) {
|
|
173
|
+
throw new Error('GitSnapshotStore requires an injected getStatus provider');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const status = await getStatus(input.workspace);
|
|
177
|
+
const getDiffSummary = input.getDiffSummary ?? this.getDiffSummaryProvider;
|
|
178
|
+
const diffSummary = getDiffSummary ? await getDiffSummary(input.workspace, status) : createEmptyDiffSummary(status);
|
|
179
|
+
const createdAt = this.now();
|
|
180
|
+
const id = `${this.idPrefix}-${createdAt}-${++this.counter}`;
|
|
181
|
+
const snapshot: GitSnapshot = {
|
|
182
|
+
id,
|
|
183
|
+
workspace: input.workspace,
|
|
184
|
+
repoRoot: status.repoRoot ?? input.workspace,
|
|
185
|
+
sessionId: input.sessionId,
|
|
186
|
+
turnId: input.turnId,
|
|
187
|
+
reason: input.reason,
|
|
188
|
+
status,
|
|
189
|
+
diffSummary,
|
|
190
|
+
createdAt,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
this.snapshots.set(id, snapshot);
|
|
194
|
+
this.order.push(id);
|
|
195
|
+
this.enforceCapacity();
|
|
196
|
+
return snapshot;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
get(id: string): GitSnapshot | undefined {
|
|
200
|
+
return this.snapshots.get(id);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
compare(beforeSnapshotId: string, afterSnapshotId: string): GitSnapshotCompareSummary {
|
|
204
|
+
const before = this.snapshots.get(beforeSnapshotId);
|
|
205
|
+
if (!before) throw new Error(`Unknown before snapshot: ${beforeSnapshotId}`);
|
|
206
|
+
|
|
207
|
+
const after = this.snapshots.get(afterSnapshotId);
|
|
208
|
+
if (!after) throw new Error(`Unknown after snapshot: ${afterSnapshotId}`);
|
|
209
|
+
|
|
210
|
+
return compareGitSnapshots(before, after);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
list(query: GitSnapshotListQuery = {}): GitSnapshot[] {
|
|
214
|
+
const limit = Math.max(1, Math.floor(query.limit ?? this.capacity));
|
|
215
|
+
return this.order
|
|
216
|
+
.map((id) => this.snapshots.get(id))
|
|
217
|
+
.filter((snapshot): snapshot is GitSnapshot => Boolean(snapshot))
|
|
218
|
+
.filter((snapshot) => !query.workspace || snapshot.workspace === query.workspace)
|
|
219
|
+
.filter((snapshot) => !query.sessionId || snapshot.sessionId === query.sessionId)
|
|
220
|
+
.slice(-limit);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
clear(): void {
|
|
224
|
+
this.snapshots.clear();
|
|
225
|
+
this.order.splice(0, this.order.length);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private enforceCapacity(): void {
|
|
229
|
+
while (this.order.length > this.capacity) {
|
|
230
|
+
const evictedId = this.order.shift();
|
|
231
|
+
if (evictedId) this.snapshots.delete(evictedId);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function createGitSnapshotStore(options: GitSnapshotStoreOptions = {}): GitSnapshotStore {
|
|
237
|
+
return new InMemoryGitSnapshotStore(options);
|
|
238
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { GitRepoStatus } from './git-types.js';
|
|
2
|
+
import { GitCommandError, resolveGitRepository, runGit } from './git-executor.js';
|
|
3
|
+
|
|
4
|
+
export interface GitStatusOptions {
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function getGitRepoStatus(
|
|
9
|
+
workspace: string,
|
|
10
|
+
options: GitStatusOptions = {},
|
|
11
|
+
): Promise<GitRepoStatus> {
|
|
12
|
+
const lastCheckedAt = Date.now();
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const repo = await resolveGitRepository(workspace, options);
|
|
16
|
+
const statusOutput = await runGit(repo, ['status', '--porcelain=v2', '--branch'], options);
|
|
17
|
+
const parsed = parsePorcelainV2Status(statusOutput.stdout);
|
|
18
|
+
const head = await readHead(repo, options);
|
|
19
|
+
const stashCount = await readStashCount(repo, options);
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
workspace: repo.workspace,
|
|
23
|
+
repoRoot: repo.repoRoot,
|
|
24
|
+
isGitRepo: true,
|
|
25
|
+
branch: parsed.branch,
|
|
26
|
+
headCommit: head.commit,
|
|
27
|
+
headMessage: head.message,
|
|
28
|
+
upstream: parsed.upstream,
|
|
29
|
+
ahead: parsed.ahead,
|
|
30
|
+
behind: parsed.behind,
|
|
31
|
+
staged: parsed.staged,
|
|
32
|
+
modified: parsed.modified,
|
|
33
|
+
untracked: parsed.untracked,
|
|
34
|
+
deleted: parsed.deleted,
|
|
35
|
+
renamed: parsed.renamed,
|
|
36
|
+
hasConflicts: parsed.conflictFiles.length > 0,
|
|
37
|
+
conflictFiles: parsed.conflictFiles,
|
|
38
|
+
stashCount,
|
|
39
|
+
lastCheckedAt,
|
|
40
|
+
};
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error instanceof GitCommandError) {
|
|
43
|
+
return emptyStatus(workspace, lastCheckedAt, error);
|
|
44
|
+
}
|
|
45
|
+
return emptyStatus(
|
|
46
|
+
workspace,
|
|
47
|
+
lastCheckedAt,
|
|
48
|
+
new GitCommandError('git_command_failed', 'Failed to read Git status', { cause: error }),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface ParsedPorcelainStatus {
|
|
54
|
+
branch: string | null;
|
|
55
|
+
upstream: string | null;
|
|
56
|
+
ahead: number;
|
|
57
|
+
behind: number;
|
|
58
|
+
staged: number;
|
|
59
|
+
modified: number;
|
|
60
|
+
untracked: number;
|
|
61
|
+
deleted: number;
|
|
62
|
+
renamed: number;
|
|
63
|
+
conflictFiles: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function parsePorcelainV2Status(output: string): ParsedPorcelainStatus {
|
|
67
|
+
const parsed: ParsedPorcelainStatus = {
|
|
68
|
+
branch: null,
|
|
69
|
+
upstream: null,
|
|
70
|
+
ahead: 0,
|
|
71
|
+
behind: 0,
|
|
72
|
+
staged: 0,
|
|
73
|
+
modified: 0,
|
|
74
|
+
untracked: 0,
|
|
75
|
+
deleted: 0,
|
|
76
|
+
renamed: 0,
|
|
77
|
+
conflictFiles: [],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
for (const line of output.split('\n')) {
|
|
81
|
+
if (!line) continue;
|
|
82
|
+
|
|
83
|
+
if (line.startsWith('# branch.head ')) {
|
|
84
|
+
const branch = line.slice('# branch.head '.length).trim();
|
|
85
|
+
parsed.branch = branch && branch !== '(detached)' ? branch : null;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (line.startsWith('# branch.upstream ')) {
|
|
90
|
+
parsed.upstream = line.slice('# branch.upstream '.length).trim() || null;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (line.startsWith('# branch.ab ')) {
|
|
95
|
+
const match = line.match(/\+(-?\d+)\s+-(-?\d+)/);
|
|
96
|
+
if (match) {
|
|
97
|
+
parsed.ahead = Number.parseInt(match[1] ?? '0', 10) || 0;
|
|
98
|
+
parsed.behind = Number.parseInt(match[2] ?? '0', 10) || 0;
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (line.startsWith('? ')) {
|
|
104
|
+
parsed.untracked += 1;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (line.startsWith('u ')) {
|
|
109
|
+
const fields = line.split(' ');
|
|
110
|
+
const filePath = fields.slice(10).join(' ');
|
|
111
|
+
if (filePath) parsed.conflictFiles.push(filePath);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (line.startsWith('1 ') || line.startsWith('2 ')) {
|
|
116
|
+
const fields = line.split(' ');
|
|
117
|
+
const xy = fields[1] ?? '..';
|
|
118
|
+
const indexStatus = xy[0] ?? '.';
|
|
119
|
+
const worktreeStatus = xy[1] ?? '.';
|
|
120
|
+
|
|
121
|
+
if (isStagedStatus(indexStatus)) parsed.staged += 1;
|
|
122
|
+
if (worktreeStatus === 'M' || worktreeStatus === 'T') parsed.modified += 1;
|
|
123
|
+
if (indexStatus === 'D' || worktreeStatus === 'D') parsed.deleted += 1;
|
|
124
|
+
if (indexStatus === 'R' || worktreeStatus === 'R') parsed.renamed += 1;
|
|
125
|
+
if (xy.includes('U')) {
|
|
126
|
+
const filePath = fields.slice(line.startsWith('2 ') ? 9 : 8).join(' ').split('\t')[0] ?? '';
|
|
127
|
+
if (filePath) parsed.conflictFiles.push(filePath);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
parsed.conflictFiles = Array.from(new Set(parsed.conflictFiles));
|
|
133
|
+
return parsed;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function readHead(
|
|
137
|
+
repo: { workspace: string; repoRoot: string | null; isGitRepo: boolean },
|
|
138
|
+
options: GitStatusOptions,
|
|
139
|
+
): Promise<{ commit: string | null; message: string | null }> {
|
|
140
|
+
try {
|
|
141
|
+
const result = await runGit(repo, ['log', '-1', '--pretty=%h%x00%s'], options);
|
|
142
|
+
const text = result.stdout.trimEnd();
|
|
143
|
+
if (!text) return { commit: null, message: null };
|
|
144
|
+
const [commit, ...messageParts] = text.split('\0');
|
|
145
|
+
return {
|
|
146
|
+
commit: commit || null,
|
|
147
|
+
message: messageParts.join('\0') || null,
|
|
148
|
+
};
|
|
149
|
+
} catch {
|
|
150
|
+
return { commit: null, message: null };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function readStashCount(
|
|
155
|
+
repo: { workspace: string; repoRoot: string | null; isGitRepo: boolean },
|
|
156
|
+
options: GitStatusOptions,
|
|
157
|
+
): Promise<number> {
|
|
158
|
+
try {
|
|
159
|
+
const result = await runGit(repo, ['stash', 'list', '--format=%gd'], options);
|
|
160
|
+
return result.stdout.split('\n').filter((line) => line.trim().length > 0).length;
|
|
161
|
+
} catch {
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isStagedStatus(status: string): boolean {
|
|
167
|
+
return status !== '.' && status !== '?' && status !== 'U';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function emptyStatus(workspace: string, lastCheckedAt: number, error: GitCommandError): GitRepoStatus {
|
|
171
|
+
return {
|
|
172
|
+
workspace,
|
|
173
|
+
repoRoot: null,
|
|
174
|
+
isGitRepo: false,
|
|
175
|
+
branch: null,
|
|
176
|
+
headCommit: null,
|
|
177
|
+
headMessage: null,
|
|
178
|
+
upstream: null,
|
|
179
|
+
ahead: 0,
|
|
180
|
+
behind: 0,
|
|
181
|
+
staged: 0,
|
|
182
|
+
modified: 0,
|
|
183
|
+
untracked: 0,
|
|
184
|
+
deleted: 0,
|
|
185
|
+
renamed: 0,
|
|
186
|
+
hasConflicts: false,
|
|
187
|
+
conflictFiles: [],
|
|
188
|
+
stashCount: 0,
|
|
189
|
+
lastCheckedAt,
|
|
190
|
+
error: error.stderr || error.message,
|
|
191
|
+
reason: error.reason,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { GitCompactSummary, GitDiffSummary, GitRepoStatus } from './git-types.js';
|
|
2
|
+
|
|
3
|
+
function countStatusChangedFiles(status: GitRepoStatus): number {
|
|
4
|
+
const conflictCount = status.conflictFiles.length > 0 ? status.conflictFiles.length : status.hasConflicts ? 1 : 0;
|
|
5
|
+
return status.staged + status.modified + status.untracked + status.deleted + status.renamed + conflictCount;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Build the small Git shape suitable for session lists and topic summaries.
|
|
10
|
+
*
|
|
11
|
+
* This helper intentionally preserves non-git/error information as plain fields
|
|
12
|
+
* instead of turning expected states (for example `not_git_repo`) into alarming
|
|
13
|
+
* derived messages.
|
|
14
|
+
*/
|
|
15
|
+
export function createGitCompactSummary(status: GitRepoStatus, diffSummary?: GitDiffSummary): GitCompactSummary {
|
|
16
|
+
const statusChangedFiles = countStatusChangedFiles(status);
|
|
17
|
+
const diffChangedFiles = diffSummary?.files.length ?? 0;
|
|
18
|
+
const changedFiles = Math.max(statusChangedFiles, diffChangedFiles);
|
|
19
|
+
const conflictCount = status.conflictFiles.length > 0 ? status.conflictFiles.length : status.hasConflicts ? 1 : 0;
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
isGitRepo: status.isGitRepo,
|
|
23
|
+
repoRoot: status.repoRoot,
|
|
24
|
+
branch: status.branch,
|
|
25
|
+
dirty:
|
|
26
|
+
status.staged > 0 ||
|
|
27
|
+
status.modified > 0 ||
|
|
28
|
+
status.untracked > 0 ||
|
|
29
|
+
status.deleted > 0 ||
|
|
30
|
+
status.renamed > 0 ||
|
|
31
|
+
conflictCount > 0 ||
|
|
32
|
+
changedFiles > 0,
|
|
33
|
+
changedFiles,
|
|
34
|
+
ahead: status.ahead,
|
|
35
|
+
behind: status.behind,
|
|
36
|
+
hasConflicts: status.hasConflicts || conflictCount > 0,
|
|
37
|
+
lastCheckedAt: Math.max(status.lastCheckedAt, diffSummary?.lastCheckedAt ?? status.lastCheckedAt),
|
|
38
|
+
error: status.error ?? diffSummary?.error,
|
|
39
|
+
reason: status.reason ?? diffSummary?.reason,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const summarizeGitStatus = createGitCompactSummary;
|