@zoebuildsai/trace 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitignore +115 -0
- package/.trace/progress.json +22 -0
- package/README.md +466 -0
- package/RELEASE-NOTES-1.5.0.md +410 -0
- package/STATUS.md +245 -0
- package/dist/auto-commit.d.ts +66 -0
- package/dist/auto-commit.d.ts.map +1 -0
- package/dist/auto-commit.js +180 -0
- package/dist/auto-commit.js.map +1 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +246 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands.d.ts +46 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +256 -0
- package/dist/commands.js.map +1 -0
- package/dist/diff.d.ts +23 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +106 -0
- package/dist/diff.js.map +1 -0
- package/dist/github.d.ts.map +1 -0
- package/dist/github.js.map +1 -0
- package/dist/index-cache.d.ts +35 -0
- package/dist/index-cache.d.ts.map +1 -0
- package/dist/index-cache.js +114 -0
- package/dist/index-cache.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/storage.d.ts +45 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +151 -0
- package/dist/storage.js.map +1 -0
- package/dist/sync.d.ts +60 -0
- package/dist/sync.js +184 -0
- package/dist/tags.d.ts +85 -0
- package/dist/tags.d.ts.map +1 -0
- package/dist/tags.js +219 -0
- package/dist/tags.js.map +1 -0
- package/dist/types.d.ts +102 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/docs/.nojekyll +0 -0
- package/docs/README.md +73 -0
- package/docs/_config.yml +2 -0
- package/docs/index.html +960 -0
- package/docs-website/package.json +20 -0
- package/jest.config.js +21 -0
- package/package.json +50 -0
- package/scripts/init.ts +290 -0
- package/src/agent-audit.ts +270 -0
- package/src/agent-checkout.ts +227 -0
- package/src/agent-coordination.ts +318 -0
- package/src/async-queue.ts +203 -0
- package/src/auto-branching.ts +279 -0
- package/src/auto-commit.ts +166 -0
- package/src/cherry-pick.ts +252 -0
- package/src/chunked-upload.ts +224 -0
- package/src/cli-v2.ts +335 -0
- package/src/cli.ts +318 -0
- package/src/cliff-detection.ts +232 -0
- package/src/commands.ts +267 -0
- package/src/commit-hash-system.ts +351 -0
- package/src/compression.ts +176 -0
- package/src/conflict-resolution-ui.ts +277 -0
- package/src/conflict-visualization.ts +238 -0
- package/src/diff-formatter.ts +184 -0
- package/src/diff.ts +124 -0
- package/src/distributed-coordination.ts +273 -0
- package/src/git-interop.ts +316 -0
- package/src/index-cache.ts +88 -0
- package/src/index.ts +38 -0
- package/src/merge-engine.ts +143 -0
- package/src/message-search.ts +370 -0
- package/src/performance-monitoring.ts +236 -0
- package/src/rebase.ts +327 -0
- package/src/rollback.ts +215 -0
- package/src/semantic-grouping.ts +245 -0
- package/src/stage-area.ts +324 -0
- package/src/stash.ts +278 -0
- package/src/storage.ts +131 -0
- package/src/sync.ts +205 -0
- package/src/tags.ts +244 -0
- package/src/types.ts +119 -0
- package/src/webhooks.ts +119 -0
- package/src/workspace-isolation.ts +298 -0
- package/tests/auto-commit.test.ts +308 -0
- package/tests/checkout.test.ts +136 -0
- package/tests/commit.test.ts +118 -0
- package/tests/diff.test.ts +191 -0
- package/tests/github.test.ts +94 -0
- package/tests/integration.test.ts +267 -0
- package/tests/log.test.ts +125 -0
- package/tests/phase2-integration.test.ts +370 -0
- package/tests/storage.test.ts +167 -0
- package/tests/tags.test.ts +477 -0
- package/tests/types.test.ts +75 -0
- package/tests/v1.1/agent-audit.test.ts +472 -0
- package/tests/v1.1/agent-coordination.test.ts +308 -0
- package/tests/v1.1/async-queue.test.ts +253 -0
- package/tests/v1.1/comprehensive.test.ts +521 -0
- package/tests/v1.1/diff-formatter.test.ts +238 -0
- package/tests/v1.1/integration.test.ts +389 -0
- package/tests/v1.1/onboarding.test.ts +365 -0
- package/tests/v1.1/rollback.test.ts +370 -0
- package/tests/v1.1/semantic-grouping.test.ts +230 -0
- package/tests/v1.2/chunked-upload.test.ts +301 -0
- package/tests/v1.2/cliff-detection.test.ts +272 -0
- package/tests/v1.2/commit-hash-system.test.ts +288 -0
- package/tests/v1.2/compression.test.ts +220 -0
- package/tests/v1.2/conflict-visualization.test.ts +263 -0
- package/tests/v1.2/distributed.test.ts +261 -0
- package/tests/v1.2/performance-monitoring.test.ts +328 -0
- package/tests/v1.3/auto-branching.test.ts +270 -0
- package/tests/v1.3/message-search.test.ts +264 -0
- package/tests/v1.3/stage-area.test.ts +330 -0
- package/tests/v1.3/stash-rebase-cherry-pick.test.ts +361 -0
- package/tests/v1.4/cli.test.ts +171 -0
- package/tests/v1.4/conflict-resolution-advanced.test.ts +429 -0
- package/tests/v1.4/conflict-resolution-ui.test.ts +286 -0
- package/tests/v1.4/workspace-isolation-advanced.test.ts +382 -0
- package/tests/v1.4/workspace-isolation.test.ts +268 -0
- package/tests/v1.5/agent-coordination.real.test.ts +401 -0
- package/tests/v1.5/cli-v2.test.ts +354 -0
- package/tests/v1.5/git-interop.real.test.ts +358 -0
- package/tests/v1.5/integration-testing.real.test.ts +440 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage Area for Trace
|
|
3
|
+
* Smart partial commits (like git add, but agent-native)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface StagedChange {
|
|
7
|
+
path: string;
|
|
8
|
+
type: 'add' | 'modify' | 'delete';
|
|
9
|
+
oldContent?: string;
|
|
10
|
+
newContent?: string;
|
|
11
|
+
timestamp: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CommitProposal {
|
|
15
|
+
stagedChanges: StagedChange[];
|
|
16
|
+
message: string;
|
|
17
|
+
type: 'feature' | 'fix' | 'docs' | 'test' | 'refactor' | 'chore';
|
|
18
|
+
timestamp: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class StageArea {
|
|
22
|
+
private staged: Map<string, StagedChange> = new Map();
|
|
23
|
+
private workingDirectory: Map<string, string> = new Map();
|
|
24
|
+
private lastCommit: Map<string, string> = new Map();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Stage a change
|
|
28
|
+
*/
|
|
29
|
+
stageChange(
|
|
30
|
+
path: string,
|
|
31
|
+
type: 'add' | 'modify' | 'delete',
|
|
32
|
+
oldContent?: string,
|
|
33
|
+
newContent?: string
|
|
34
|
+
): StagedChange {
|
|
35
|
+
const change: StagedChange = {
|
|
36
|
+
path,
|
|
37
|
+
type,
|
|
38
|
+
oldContent,
|
|
39
|
+
newContent,
|
|
40
|
+
timestamp: Date.now(),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
this.staged.set(path, change);
|
|
44
|
+
return change;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Stage multiple changes at once
|
|
49
|
+
*/
|
|
50
|
+
stageMultiple(changes: StagedChange[]): void {
|
|
51
|
+
for (const change of changes) {
|
|
52
|
+
this.staged.set(change.path, change);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Auto-stage all changes (git add .)
|
|
58
|
+
*/
|
|
59
|
+
stageAll(workingDir: Map<string, string>): StagedChange[] {
|
|
60
|
+
const changes: StagedChange[] = [];
|
|
61
|
+
|
|
62
|
+
// Find modified/added files
|
|
63
|
+
for (const [path, content] of workingDir) {
|
|
64
|
+
const oldContent = this.lastCommit.get(path);
|
|
65
|
+
if (oldContent === undefined) {
|
|
66
|
+
// New file
|
|
67
|
+
changes.push({
|
|
68
|
+
path,
|
|
69
|
+
type: 'add',
|
|
70
|
+
newContent: content,
|
|
71
|
+
timestamp: Date.now(),
|
|
72
|
+
});
|
|
73
|
+
} else if (oldContent !== content) {
|
|
74
|
+
// Modified file
|
|
75
|
+
changes.push({
|
|
76
|
+
path,
|
|
77
|
+
type: 'modify',
|
|
78
|
+
oldContent,
|
|
79
|
+
newContent: content,
|
|
80
|
+
timestamp: Date.now(),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Find deleted files
|
|
86
|
+
for (const [path] of this.lastCommit) {
|
|
87
|
+
if (!workingDir.has(path)) {
|
|
88
|
+
changes.push({
|
|
89
|
+
path,
|
|
90
|
+
type: 'delete',
|
|
91
|
+
oldContent: this.lastCommit.get(path),
|
|
92
|
+
timestamp: Date.now(),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
this.stageMultiple(changes);
|
|
98
|
+
return changes;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Unstage a file
|
|
103
|
+
*/
|
|
104
|
+
unstageFile(path: string): boolean {
|
|
105
|
+
return this.staged.delete(path);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Unstage all
|
|
110
|
+
*/
|
|
111
|
+
unstageAll(): void {
|
|
112
|
+
this.staged.clear();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get staged changes
|
|
117
|
+
*/
|
|
118
|
+
getStagedChanges(): StagedChange[] {
|
|
119
|
+
return Array.from(this.staged.values());
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Check if file is staged
|
|
124
|
+
*/
|
|
125
|
+
isStaged(path: string): boolean {
|
|
126
|
+
return this.staged.has(path);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get staging status
|
|
131
|
+
*/
|
|
132
|
+
getStatus(): {
|
|
133
|
+
staged: number;
|
|
134
|
+
unstaged: number;
|
|
135
|
+
untracked: number;
|
|
136
|
+
} {
|
|
137
|
+
let unstaged = 0;
|
|
138
|
+
let untracked = 0;
|
|
139
|
+
|
|
140
|
+
for (const [path, content] of this.workingDirectory) {
|
|
141
|
+
if (this.staged.has(path)) continue; // Already staged
|
|
142
|
+
|
|
143
|
+
const lastContent = this.lastCommit.get(path);
|
|
144
|
+
if (lastContent === undefined) {
|
|
145
|
+
untracked++;
|
|
146
|
+
} else if (lastContent !== content) {
|
|
147
|
+
unstaged++;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
staged: this.staged.size,
|
|
153
|
+
unstaged,
|
|
154
|
+
untracked,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Create commit from staged changes
|
|
160
|
+
*/
|
|
161
|
+
createCommit(message: string, type: 'feature' | 'fix' | 'docs' | 'test' | 'refactor' | 'chore' = 'chore'): CommitProposal {
|
|
162
|
+
if (this.staged.size === 0) {
|
|
163
|
+
throw new Error('No staged changes to commit');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
stagedChanges: Array.from(this.staged.values()),
|
|
168
|
+
message,
|
|
169
|
+
type,
|
|
170
|
+
timestamp: Date.now(),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Smart stage by semantic type
|
|
176
|
+
* Stage only 'feat:' changes for feature commit, etc.
|
|
177
|
+
*/
|
|
178
|
+
stageByType(commitType: 'feature' | 'fix' | 'docs' | 'test' | 'refactor' | 'chore'): StagedChange[] {
|
|
179
|
+
const patterns: Record<string, RegExp[]> = {
|
|
180
|
+
feature: [/^(src|lib)\/.+\.(ts|js)$/, /feat:/i],
|
|
181
|
+
fix: [/^(src|lib)\/.+\.(ts|js)$/, /fix:/i],
|
|
182
|
+
docs: [/\.md$/, /^docs\//],
|
|
183
|
+
test: [/\.test\.(ts|js)$/, /test:/i],
|
|
184
|
+
refactor: [/^(src|lib)\/.+\.(ts|js)$/],
|
|
185
|
+
chore: [/^(\.|\w+\.json|\.yml)/, /chore:/i],
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const relevantPatterns = patterns[commitType] || [];
|
|
189
|
+
const staged: StagedChange[] = [];
|
|
190
|
+
|
|
191
|
+
for (const change of this.staged.values()) {
|
|
192
|
+
if (relevantPatterns.some(p => p.test(change.path))) {
|
|
193
|
+
staged.push(change);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return staged;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Discard staged changes (reset before commit)
|
|
202
|
+
*/
|
|
203
|
+
discardStaged(): void {
|
|
204
|
+
this.staged.clear();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Update working directory reference
|
|
209
|
+
*/
|
|
210
|
+
updateWorkingDirectory(files: Map<string, string>): void {
|
|
211
|
+
this.workingDirectory = new Map(files);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Record commit (update lastCommit reference)
|
|
216
|
+
*/
|
|
217
|
+
recordCommit(files: Map<string, string>): void {
|
|
218
|
+
this.lastCommit = new Map(files);
|
|
219
|
+
this.staged.clear();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Diff staged vs last commit
|
|
224
|
+
*/
|
|
225
|
+
getDiff(): Map<string, { old?: string; new?: string }> {
|
|
226
|
+
const diff = new Map<string, { old?: string; new?: string }>();
|
|
227
|
+
|
|
228
|
+
for (const change of this.staged.values()) {
|
|
229
|
+
diff.set(change.path, {
|
|
230
|
+
old: change.oldContent,
|
|
231
|
+
new: change.newContent,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return diff;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Get staging stats
|
|
240
|
+
*/
|
|
241
|
+
getStats(): {
|
|
242
|
+
stagedFiles: number;
|
|
243
|
+
addedFiles: number;
|
|
244
|
+
modifiedFiles: number;
|
|
245
|
+
deletedFiles: number;
|
|
246
|
+
estimatedSize: number;
|
|
247
|
+
} {
|
|
248
|
+
const changes = Array.from(this.staged.values());
|
|
249
|
+
let estimatedSize = 0;
|
|
250
|
+
|
|
251
|
+
const addedFiles = changes.filter(c => c.type === 'add').length;
|
|
252
|
+
const modifiedFiles = changes.filter(c => c.type === 'modify').length;
|
|
253
|
+
const deletedFiles = changes.filter(c => c.type === 'delete').length;
|
|
254
|
+
|
|
255
|
+
for (const change of changes) {
|
|
256
|
+
if (change.newContent) {
|
|
257
|
+
estimatedSize += change.newContent.length;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
stagedFiles: changes.length,
|
|
263
|
+
addedFiles,
|
|
264
|
+
modifiedFiles,
|
|
265
|
+
deletedFiles,
|
|
266
|
+
estimatedSize,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Detect type from staged changes
|
|
272
|
+
*/
|
|
273
|
+
detectCommitType(): 'feature' | 'fix' | 'docs' | 'test' | 'refactor' | 'chore' {
|
|
274
|
+
const changes = Array.from(this.staged.values());
|
|
275
|
+
|
|
276
|
+
// Check file patterns
|
|
277
|
+
const testFiles = changes.filter(c => /\.test\.(ts|js)$/.test(c.path)).length;
|
|
278
|
+
const docFiles = changes.filter(c => /\.md$/.test(c.path)).length;
|
|
279
|
+
const srcFiles = changes.filter(c => /^(src|lib)\/.+\.(ts|js)$/.test(c.path)).length;
|
|
280
|
+
|
|
281
|
+
if (testFiles > srcFiles) return 'test';
|
|
282
|
+
if (docFiles > 0 && srcFiles === 0) return 'docs';
|
|
283
|
+
if (srcFiles > 0) return 'feature'; // Default to feature for code changes
|
|
284
|
+
|
|
285
|
+
return 'chore';
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Validate commit (all files present, no conflicts)
|
|
290
|
+
*/
|
|
291
|
+
validateCommit(): { valid: boolean; errors: string[] } {
|
|
292
|
+
const errors: string[] = [];
|
|
293
|
+
|
|
294
|
+
if (this.staged.size === 0) {
|
|
295
|
+
errors.push('No staged changes');
|
|
296
|
+
return { valid: false, errors };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Check for conflicting changes (e.g., add + delete same file)
|
|
300
|
+
const pathCounts = new Map<string, number>();
|
|
301
|
+
for (const change of this.staged.values()) {
|
|
302
|
+
pathCounts.set(change.path, (pathCounts.get(change.path) || 0) + 1);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
for (const [path, count] of pathCounts) {
|
|
306
|
+
if (count > 1) {
|
|
307
|
+
errors.push(`File ${path} has multiple staged changes`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return { valid: errors.length === 0, errors };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Clear stage (for testing)
|
|
316
|
+
*/
|
|
317
|
+
clear(): void {
|
|
318
|
+
this.staged.clear();
|
|
319
|
+
this.workingDirectory.clear();
|
|
320
|
+
this.lastCommit.clear();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export default StageArea;
|
package/src/stash.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stash for Trace
|
|
3
|
+
* Temporary work storage (save WIP without committing)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface StashedWork {
|
|
7
|
+
id: string;
|
|
8
|
+
description: string;
|
|
9
|
+
timestamp: number;
|
|
10
|
+
files: Map<string, string>;
|
|
11
|
+
branch: string;
|
|
12
|
+
author: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface StashEntry {
|
|
16
|
+
id: string;
|
|
17
|
+
description: string;
|
|
18
|
+
timestamp: number;
|
|
19
|
+
branch: string;
|
|
20
|
+
author: string;
|
|
21
|
+
fileCount: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class Stash {
|
|
25
|
+
private stashes: Map<string, StashedWork> = new Map();
|
|
26
|
+
private stashStack: string[] = []; // LIFO stack for pop
|
|
27
|
+
private authorStashes: Map<string, string[]> = new Map(); // author -> [stash IDs]
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Stash current work
|
|
31
|
+
*/
|
|
32
|
+
stash(
|
|
33
|
+
files: Map<string, string>,
|
|
34
|
+
description: string,
|
|
35
|
+
branch: string,
|
|
36
|
+
author: string
|
|
37
|
+
): StashedWork {
|
|
38
|
+
const id = this.generateStashId();
|
|
39
|
+
const stashed: StashedWork = {
|
|
40
|
+
id,
|
|
41
|
+
description,
|
|
42
|
+
timestamp: Date.now(),
|
|
43
|
+
files: new Map(files),
|
|
44
|
+
branch,
|
|
45
|
+
author,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
this.stashes.set(id, stashed);
|
|
49
|
+
this.stashStack.push(id);
|
|
50
|
+
|
|
51
|
+
if (!this.authorStashes.has(author)) {
|
|
52
|
+
this.authorStashes.set(author, []);
|
|
53
|
+
}
|
|
54
|
+
this.authorStashes.get(author)!.push(id);
|
|
55
|
+
|
|
56
|
+
return stashed;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Pop stash (apply and remove most recent)
|
|
61
|
+
*/
|
|
62
|
+
pop(): StashedWork | undefined {
|
|
63
|
+
if (this.stashStack.length === 0) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const id = this.stashStack.pop()!;
|
|
68
|
+
const stashed = this.stashes.get(id);
|
|
69
|
+
|
|
70
|
+
if (stashed) {
|
|
71
|
+
// Remove from author tracking
|
|
72
|
+
const authorStashes = this.authorStashes.get(stashed.author) || [];
|
|
73
|
+
this.authorStashes.set(
|
|
74
|
+
stashed.author,
|
|
75
|
+
authorStashes.filter(s => s !== id)
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
this.stashes.delete(id);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return stashed;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Apply stash without removing (like git stash apply)
|
|
86
|
+
*/
|
|
87
|
+
apply(stashId: string): StashedWork | undefined {
|
|
88
|
+
return this.stashes.get(stashId);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* List all stashes
|
|
93
|
+
*/
|
|
94
|
+
list(): StashEntry[] {
|
|
95
|
+
return Array.from(this.stashes.values()).map(s => ({
|
|
96
|
+
id: s.id,
|
|
97
|
+
description: s.description,
|
|
98
|
+
timestamp: s.timestamp,
|
|
99
|
+
branch: s.branch,
|
|
100
|
+
author: s.author,
|
|
101
|
+
fileCount: s.files.size,
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get stashes for author
|
|
107
|
+
*/
|
|
108
|
+
getByAuthor(author: string): StashEntry[] {
|
|
109
|
+
const ids = this.authorStashes.get(author) || [];
|
|
110
|
+
return ids
|
|
111
|
+
.map(id => this.stashes.get(id))
|
|
112
|
+
.filter((s): s is StashedWork => s !== undefined)
|
|
113
|
+
.map(s => ({
|
|
114
|
+
id: s.id,
|
|
115
|
+
description: s.description,
|
|
116
|
+
timestamp: s.timestamp,
|
|
117
|
+
branch: s.branch,
|
|
118
|
+
author: s.author,
|
|
119
|
+
fileCount: s.files.size,
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get stashes for branch
|
|
125
|
+
*/
|
|
126
|
+
getByBranch(branch: string): StashEntry[] {
|
|
127
|
+
return Array.from(this.stashes.values())
|
|
128
|
+
.filter(s => s.branch === branch)
|
|
129
|
+
.map(s => ({
|
|
130
|
+
id: s.id,
|
|
131
|
+
description: s.description,
|
|
132
|
+
timestamp: s.timestamp,
|
|
133
|
+
branch: s.branch,
|
|
134
|
+
author: s.author,
|
|
135
|
+
fileCount: s.files.size,
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Drop stash (delete without applying)
|
|
141
|
+
*/
|
|
142
|
+
drop(stashId: string): boolean {
|
|
143
|
+
const stashed = this.stashes.get(stashId);
|
|
144
|
+
if (!stashed) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this.stashes.delete(stashId);
|
|
149
|
+
this.stashStack = this.stashStack.filter(id => id !== stashId);
|
|
150
|
+
|
|
151
|
+
const authorStashes = this.authorStashes.get(stashed.author) || [];
|
|
152
|
+
this.authorStashes.set(
|
|
153
|
+
stashed.author,
|
|
154
|
+
authorStashes.filter(s => s !== stashId)
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Clear all stashes
|
|
162
|
+
*/
|
|
163
|
+
clearAll(): void {
|
|
164
|
+
this.stashes.clear();
|
|
165
|
+
this.stashStack = [];
|
|
166
|
+
this.authorStashes.clear();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Get stash details
|
|
171
|
+
*/
|
|
172
|
+
getStash(stashId: string): StashedWork | undefined {
|
|
173
|
+
return this.stashes.get(stashId);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Check if stash exists
|
|
178
|
+
*/
|
|
179
|
+
hasStash(stashId: string): boolean {
|
|
180
|
+
return this.stashes.has(stashId);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Get statistics
|
|
185
|
+
*/
|
|
186
|
+
getStats(): {
|
|
187
|
+
totalStashes: number;
|
|
188
|
+
oldestStash?: StashEntry;
|
|
189
|
+
newestStash?: StashEntry;
|
|
190
|
+
totalFiles: number;
|
|
191
|
+
} {
|
|
192
|
+
const entries = this.list();
|
|
193
|
+
if (entries.length === 0) {
|
|
194
|
+
return { totalStashes: 0, totalFiles: 0 };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const oldest = entries.reduce((a, b) => (a.timestamp < b.timestamp ? a : b));
|
|
198
|
+
const newest = entries.reduce((a, b) => (a.timestamp > b.timestamp ? a : b));
|
|
199
|
+
|
|
200
|
+
const totalFiles = Array.from(this.stashes.values()).reduce(
|
|
201
|
+
(sum, s) => sum + s.files.size,
|
|
202
|
+
0
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
totalStashes: entries.length,
|
|
207
|
+
oldestStash: oldest,
|
|
208
|
+
newestStash: newest,
|
|
209
|
+
totalFiles,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Auto-stash before checkout (safety)
|
|
215
|
+
*/
|
|
216
|
+
autoStashIfDirty(
|
|
217
|
+
files: Map<string, string>,
|
|
218
|
+
lastCommit: Map<string, string>,
|
|
219
|
+
branch: string,
|
|
220
|
+
author: string
|
|
221
|
+
): StashedWork | null {
|
|
222
|
+
// Check if working directory is dirty
|
|
223
|
+
let isDirty = false;
|
|
224
|
+
|
|
225
|
+
for (const [path, content] of files) {
|
|
226
|
+
if (lastCommit.get(path) !== content) {
|
|
227
|
+
isDirty = true;
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Check for new files
|
|
233
|
+
if (!isDirty) {
|
|
234
|
+
for (const path of files.keys()) {
|
|
235
|
+
if (!lastCommit.has(path)) {
|
|
236
|
+
isDirty = true;
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (isDirty) {
|
|
243
|
+
return this.stash(files, `Auto-stash before checkout (${branch})`, branch, author);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Format stash list for display
|
|
251
|
+
*/
|
|
252
|
+
static formatList(entries: StashEntry[]): string {
|
|
253
|
+
if (entries.length === 0) {
|
|
254
|
+
return 'No stashes found.';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let output = `\n📦 STASHES (${entries.length} total):\n`;
|
|
258
|
+
|
|
259
|
+
for (let i = 0; i < entries.length; i++) {
|
|
260
|
+
const entry = entries[i];
|
|
261
|
+
output += `${i}. stash@{${i}} (${entry.id.substring(0, 7)})\n`;
|
|
262
|
+
output += ` Description: ${entry.description}\n`;
|
|
263
|
+
output += ` Author: ${entry.author} | Branch: ${entry.branch}\n`;
|
|
264
|
+
output += ` Files: ${entry.fileCount} | Created: ${new Date(entry.timestamp).toISOString()}\n`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return output;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Generate stash ID
|
|
272
|
+
*/
|
|
273
|
+
private generateStashId(): string {
|
|
274
|
+
return `stash-${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export default Stash;
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as crypto from 'crypto';
|
|
4
|
+
import { CommitObject, TreeObject, FileEntry } from './types';
|
|
5
|
+
|
|
6
|
+
export class Storage {
|
|
7
|
+
private basePath: string;
|
|
8
|
+
private objectsDir: string;
|
|
9
|
+
private refsDir: string;
|
|
10
|
+
|
|
11
|
+
constructor(basePath: string = path.join(process.env.HOME || '', '.openclaw/memory-git')) {
|
|
12
|
+
this.basePath = basePath;
|
|
13
|
+
this.objectsDir = path.join(basePath, 'objects');
|
|
14
|
+
this.refsDir = path.join(basePath, 'refs');
|
|
15
|
+
this.ensureDirectories();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
private ensureDirectories(): void {
|
|
19
|
+
[this.basePath, this.objectsDir, this.refsDir].forEach(dir => {
|
|
20
|
+
if (!fs.existsSync(dir)) {
|
|
21
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Compute SHA-256 hash of content
|
|
28
|
+
*/
|
|
29
|
+
hash(content: string | Buffer): string {
|
|
30
|
+
const buffer = typeof content === 'string' ? Buffer.from(content, 'utf-8') : content;
|
|
31
|
+
return crypto.createHash('sha256').update(buffer).digest('hex');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Save commit object to storage
|
|
36
|
+
*/
|
|
37
|
+
saveCommit(commit: CommitObject): void {
|
|
38
|
+
const commitPath = path.join(this.objectsDir, `${commit.hash}.json`);
|
|
39
|
+
fs.writeFileSync(commitPath, JSON.stringify(commit, this.replaceMapValues, 2));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Load commit object from storage
|
|
44
|
+
*/
|
|
45
|
+
loadCommit(hash: string): CommitObject | null {
|
|
46
|
+
const commitPath = path.join(this.objectsDir, `${hash}.json`);
|
|
47
|
+
if (!fs.existsSync(commitPath)) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const content = fs.readFileSync(commitPath, 'utf-8');
|
|
51
|
+
const commit = JSON.parse(content) as CommitObject;
|
|
52
|
+
const filesRecord = commit.tree.files as unknown as Record<string, FileEntry>;
|
|
53
|
+
commit.tree.files = new Map(Object.entries(filesRecord));
|
|
54
|
+
return commit;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Save reference (branch/tag)
|
|
59
|
+
*/
|
|
60
|
+
saveRef(name: string, hash: string): void {
|
|
61
|
+
const refPath = path.join(this.refsDir, name);
|
|
62
|
+
fs.mkdirSync(path.dirname(refPath), { recursive: true });
|
|
63
|
+
fs.writeFileSync(refPath, hash);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Load reference
|
|
68
|
+
*/
|
|
69
|
+
loadRef(name: string): string | null {
|
|
70
|
+
const refPath = path.join(this.refsDir, name);
|
|
71
|
+
if (!fs.existsSync(refPath)) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return fs.readFileSync(refPath, 'utf-8').trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* List all commits
|
|
79
|
+
*/
|
|
80
|
+
listCommits(): string[] {
|
|
81
|
+
if (!fs.existsSync(this.objectsDir)) {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
return fs
|
|
85
|
+
.readdirSync(this.objectsDir)
|
|
86
|
+
.filter(f => f.endsWith('.json'))
|
|
87
|
+
.map(f => f.replace('.json', ''));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Delete commit
|
|
92
|
+
*/
|
|
93
|
+
deleteCommit(hash: string): void {
|
|
94
|
+
const commitPath = path.join(this.objectsDir, `${hash}.json`);
|
|
95
|
+
if (fs.existsSync(commitPath)) {
|
|
96
|
+
fs.unlinkSync(commitPath);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get commit history chain
|
|
102
|
+
*/
|
|
103
|
+
getHistory(startHash: string): CommitObject[] {
|
|
104
|
+
const history: CommitObject[] = [];
|
|
105
|
+
let current: CommitObject | null = this.loadCommit(startHash);
|
|
106
|
+
|
|
107
|
+
while (current !== null) {
|
|
108
|
+
history.push(current);
|
|
109
|
+
if (current.parent === null) {
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
current = this.loadCommit(current.parent);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return history;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Helper to serialize Maps as objects for JSON
|
|
120
|
+
*/
|
|
121
|
+
private replaceMapValues(key: string, value: unknown): unknown {
|
|
122
|
+
if (value instanceof Map) {
|
|
123
|
+
const obj: Record<string, unknown> = {};
|
|
124
|
+
value.forEach((v, k) => {
|
|
125
|
+
obj[k] = v;
|
|
126
|
+
});
|
|
127
|
+
return obj;
|
|
128
|
+
}
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
}
|