@cardstack/boxel-cli 0.0.1
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/LICENSE +21 -0
- package/README.md +111 -0
- package/api.ts +24 -0
- package/dist/index.js +75 -0
- package/package.json +78 -0
- package/src/commands/profile.ts +457 -0
- package/src/commands/realm/create.ts +245 -0
- package/src/commands/realm/index.ts +16 -0
- package/src/commands/realm/pull.ts +245 -0
- package/src/commands/realm/push.ts +379 -0
- package/src/commands/realm/sync.ts +587 -0
- package/src/commands/run-command.ts +186 -0
- package/src/index.ts +47 -0
- package/src/lib/auth.ts +169 -0
- package/src/lib/boxel-cli-client.ts +631 -0
- package/src/lib/checkpoint-manager.ts +609 -0
- package/src/lib/colors.ts +9 -0
- package/src/lib/profile-manager.ts +583 -0
- package/src/lib/realm-sync-base.ts +647 -0
- package/src/lib/sync-logic.ts +169 -0
- package/src/lib/sync-manifest.ts +81 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { isProtectedFile } from './realm-sync-base';
|
|
5
|
+
|
|
6
|
+
export interface Checkpoint {
|
|
7
|
+
hash: string;
|
|
8
|
+
shortHash: string;
|
|
9
|
+
message: string;
|
|
10
|
+
description: string;
|
|
11
|
+
date: Date;
|
|
12
|
+
isMajor: boolean;
|
|
13
|
+
filesChanged: number;
|
|
14
|
+
insertions: number;
|
|
15
|
+
deletions: number;
|
|
16
|
+
source: 'local' | 'remote' | 'manual';
|
|
17
|
+
isMilestone: boolean;
|
|
18
|
+
milestoneName?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CheckpointChange {
|
|
22
|
+
file: string;
|
|
23
|
+
status: 'added' | 'modified' | 'deleted';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function pathExists(p: string): Promise<boolean> {
|
|
27
|
+
try {
|
|
28
|
+
await fs.access(p);
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class CheckpointManager {
|
|
36
|
+
private workspaceDir: string;
|
|
37
|
+
private gitDir: string;
|
|
38
|
+
|
|
39
|
+
constructor(workspaceDir: string) {
|
|
40
|
+
this.workspaceDir = path.resolve(workspaceDir);
|
|
41
|
+
this.gitDir = path.join(this.workspaceDir, '.boxel-history');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async init(): Promise<void> {
|
|
45
|
+
if (!(await pathExists(this.gitDir))) {
|
|
46
|
+
await fs.mkdir(this.gitDir, { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const gitPath = path.join(this.gitDir, '.git');
|
|
50
|
+
if (!(await pathExists(gitPath))) {
|
|
51
|
+
await this.git('init');
|
|
52
|
+
await this.git('config', 'user.email', 'boxel-cli@local');
|
|
53
|
+
await this.git('config', 'user.name', 'Boxel CLI');
|
|
54
|
+
await this.git(
|
|
55
|
+
'commit',
|
|
56
|
+
'--allow-empty',
|
|
57
|
+
'-m',
|
|
58
|
+
'[init] Initialize checkpoint history',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async isInitialized(): Promise<boolean> {
|
|
64
|
+
return pathExists(path.join(this.gitDir, '.git'));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private async syncFilesToHistory(): Promise<void> {
|
|
68
|
+
const files = await this.getWorkspaceFiles();
|
|
69
|
+
const fileSet = new Set(files);
|
|
70
|
+
|
|
71
|
+
const historyFiles = await this.getHistoryFiles();
|
|
72
|
+
await Promise.all(
|
|
73
|
+
historyFiles.map(async (file) => {
|
|
74
|
+
if (!fileSet.has(file)) {
|
|
75
|
+
const historyPath = path.join(this.gitDir, file);
|
|
76
|
+
try {
|
|
77
|
+
await fs.unlink(historyPath);
|
|
78
|
+
} catch (err: any) {
|
|
79
|
+
if (err.code !== 'ENOENT') throw err;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
await Promise.all(
|
|
86
|
+
files.map(async (file) => {
|
|
87
|
+
const srcPath = path.join(this.workspaceDir, file);
|
|
88
|
+
const destPath = path.join(this.gitDir, file);
|
|
89
|
+
|
|
90
|
+
const destDir = path.dirname(destPath);
|
|
91
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
92
|
+
|
|
93
|
+
await fs.copyFile(srcPath, destPath);
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private async getWorkspaceFiles(): Promise<string[]> {
|
|
99
|
+
const files: string[] = [];
|
|
100
|
+
|
|
101
|
+
const scan = async (dir: string, prefix = ''): Promise<void> => {
|
|
102
|
+
let entries;
|
|
103
|
+
try {
|
|
104
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
105
|
+
} catch (err: any) {
|
|
106
|
+
if (err.code === 'ENOENT') return;
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
await Promise.all(
|
|
111
|
+
entries.map(async (entry) => {
|
|
112
|
+
if (
|
|
113
|
+
entry.name === '.boxel-history' ||
|
|
114
|
+
entry.name === '.boxel-sync.json' ||
|
|
115
|
+
entry.name === 'node_modules'
|
|
116
|
+
) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (entry.name.startsWith('.')) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
124
|
+
|
|
125
|
+
if (entry.isDirectory()) {
|
|
126
|
+
await scan(path.join(dir, entry.name), relPath);
|
|
127
|
+
} else {
|
|
128
|
+
files.push(relPath);
|
|
129
|
+
}
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
await scan(this.workspaceDir);
|
|
135
|
+
return files;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private async getHistoryFiles(): Promise<string[]> {
|
|
139
|
+
const files: string[] = [];
|
|
140
|
+
|
|
141
|
+
const scan = async (dir: string, prefix = ''): Promise<void> => {
|
|
142
|
+
let entries;
|
|
143
|
+
try {
|
|
144
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
145
|
+
} catch (err: any) {
|
|
146
|
+
if (err.code === 'ENOENT') return;
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await Promise.all(
|
|
151
|
+
entries.map(async (entry) => {
|
|
152
|
+
if (entry.name === '.git') return;
|
|
153
|
+
|
|
154
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
155
|
+
|
|
156
|
+
if (entry.isDirectory()) {
|
|
157
|
+
await scan(path.join(dir, entry.name), relPath);
|
|
158
|
+
} else {
|
|
159
|
+
files.push(relPath);
|
|
160
|
+
}
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
await scan(this.gitDir);
|
|
166
|
+
return files;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async detectCurrentChanges(): Promise<CheckpointChange[]> {
|
|
170
|
+
if (!(await this.isInitialized())) {
|
|
171
|
+
const files = await this.getWorkspaceFiles();
|
|
172
|
+
return files.map((file) => ({ file, status: 'added' as const }));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
await this.syncFilesToHistory();
|
|
176
|
+
|
|
177
|
+
// Do not trim leading whitespace: porcelain lines look like " M file"
|
|
178
|
+
// for unstaged modifications, and the leading space is part of the
|
|
179
|
+
// two-char status code.
|
|
180
|
+
const statusOutput = (await this.git('status', '--porcelain')).replace(
|
|
181
|
+
/\n+$/,
|
|
182
|
+
'',
|
|
183
|
+
);
|
|
184
|
+
if (!statusOutput) {
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const changes: CheckpointChange[] = [];
|
|
189
|
+
for (const line of statusOutput.split('\n')) {
|
|
190
|
+
if (!line) continue;
|
|
191
|
+
|
|
192
|
+
const statusCode = line.substring(0, 2);
|
|
193
|
+
const file = line.substring(3);
|
|
194
|
+
|
|
195
|
+
if (statusCode.includes('R')) {
|
|
196
|
+
const arrowIndex = file.indexOf(' -> ');
|
|
197
|
+
if (arrowIndex !== -1) {
|
|
198
|
+
const oldFile = file.substring(0, arrowIndex);
|
|
199
|
+
const newFile = file.substring(arrowIndex + 4);
|
|
200
|
+
changes.push({ file: oldFile, status: 'deleted' });
|
|
201
|
+
changes.push({ file: newFile, status: 'added' });
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (statusCode.includes('D')) {
|
|
207
|
+
changes.push({ file, status: 'deleted' });
|
|
208
|
+
} else if (
|
|
209
|
+
statusCode.includes('A') ||
|
|
210
|
+
statusCode.includes('C') ||
|
|
211
|
+
statusCode === '??'
|
|
212
|
+
) {
|
|
213
|
+
changes.push({ file, status: 'added' });
|
|
214
|
+
} else if (
|
|
215
|
+
statusCode.includes('M') ||
|
|
216
|
+
statusCode.includes('U') ||
|
|
217
|
+
statusCode.includes('T')
|
|
218
|
+
) {
|
|
219
|
+
changes.push({ file, status: 'modified' });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return changes;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async createCheckpoint(
|
|
227
|
+
source: 'local' | 'remote' | 'manual',
|
|
228
|
+
changes: CheckpointChange[],
|
|
229
|
+
customMessage?: string,
|
|
230
|
+
): Promise<Checkpoint | null> {
|
|
231
|
+
if (!(await this.isInitialized())) {
|
|
232
|
+
await this.init();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
await this.syncFilesToHistory();
|
|
236
|
+
|
|
237
|
+
await this.git('add', '-A');
|
|
238
|
+
|
|
239
|
+
const statusOutput = await this.git('status', '--porcelain');
|
|
240
|
+
if (!statusOutput.trim()) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const isMajor = this.classifyChanges(changes);
|
|
245
|
+
|
|
246
|
+
const { message, description } = customMessage
|
|
247
|
+
? { message: customMessage, description: '' }
|
|
248
|
+
: this.generateCommitMessage(source, changes, isMajor);
|
|
249
|
+
|
|
250
|
+
const prefix = isMajor ? '[MAJOR]' : '[minor]';
|
|
251
|
+
const sourceTag = `[${source}]`;
|
|
252
|
+
const fullMessage = `${prefix} ${sourceTag} ${message}${description ? '\n\n' + description : ''}`;
|
|
253
|
+
|
|
254
|
+
await this.git('commit', '-m', fullMessage);
|
|
255
|
+
|
|
256
|
+
const hash = (await this.git('rev-parse', 'HEAD')).trim();
|
|
257
|
+
const shortHash = hash.substring(0, 7);
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
hash,
|
|
261
|
+
shortHash,
|
|
262
|
+
message,
|
|
263
|
+
description,
|
|
264
|
+
date: new Date(),
|
|
265
|
+
isMajor,
|
|
266
|
+
filesChanged: changes.length,
|
|
267
|
+
insertions: 0,
|
|
268
|
+
deletions: 0,
|
|
269
|
+
source,
|
|
270
|
+
isMilestone: false,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private classifyChanges(changes: CheckpointChange[]): boolean {
|
|
275
|
+
if (changes.length > 3) return true;
|
|
276
|
+
|
|
277
|
+
for (const change of changes) {
|
|
278
|
+
if (change.status === 'added' || change.status === 'deleted') return true;
|
|
279
|
+
if (change.file.endsWith('.gts')) return true;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private generateCommitMessage(
|
|
286
|
+
source: 'local' | 'remote' | 'manual',
|
|
287
|
+
changes: CheckpointChange[],
|
|
288
|
+
_isMajor: boolean,
|
|
289
|
+
): { message: string; description: string } {
|
|
290
|
+
const sourceLabel =
|
|
291
|
+
source === 'local' ? 'Push' : source === 'remote' ? 'Pull' : 'Manual';
|
|
292
|
+
|
|
293
|
+
if (changes.length === 0) {
|
|
294
|
+
return {
|
|
295
|
+
message: `${sourceLabel}: No changes detected`,
|
|
296
|
+
description: '',
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (changes.length === 1) {
|
|
301
|
+
const change = changes[0];
|
|
302
|
+
const action =
|
|
303
|
+
change.status === 'added'
|
|
304
|
+
? 'Add'
|
|
305
|
+
: change.status === 'deleted'
|
|
306
|
+
? 'Delete'
|
|
307
|
+
: 'Update';
|
|
308
|
+
return {
|
|
309
|
+
message: `${sourceLabel}: ${action} ${change.file}`,
|
|
310
|
+
description: '',
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const added = changes.filter((c) => c.status === 'added');
|
|
315
|
+
const modified = changes.filter((c) => c.status === 'modified');
|
|
316
|
+
const deleted = changes.filter((c) => c.status === 'deleted');
|
|
317
|
+
|
|
318
|
+
const parts: string[] = [];
|
|
319
|
+
if (added.length > 0) parts.push(`+${added.length}`);
|
|
320
|
+
if (modified.length > 0) parts.push(`~${modified.length}`);
|
|
321
|
+
if (deleted.length > 0) parts.push(`-${deleted.length}`);
|
|
322
|
+
|
|
323
|
+
const message = `${sourceLabel}: ${changes.length} files (${parts.join(', ')})`;
|
|
324
|
+
|
|
325
|
+
const lines: string[] = [];
|
|
326
|
+
if (added.length > 0) {
|
|
327
|
+
lines.push('Added:');
|
|
328
|
+
added.forEach((c) => lines.push(` + ${c.file}`));
|
|
329
|
+
}
|
|
330
|
+
if (modified.length > 0) {
|
|
331
|
+
lines.push('Modified:');
|
|
332
|
+
modified.forEach((c) => lines.push(` ~ ${c.file}`));
|
|
333
|
+
}
|
|
334
|
+
if (deleted.length > 0) {
|
|
335
|
+
lines.push('Deleted:');
|
|
336
|
+
deleted.forEach((c) => lines.push(` - ${c.file}`));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return { message, description: lines.join('\n') };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async getCheckpoints(limit = 50): Promise<Checkpoint[]> {
|
|
343
|
+
if (!(await this.isInitialized())) {
|
|
344
|
+
return [];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const format = '%H|%h|%s|%aI|%an';
|
|
348
|
+
const log = await this.git('log', `--format=${format}`, `-${limit}`);
|
|
349
|
+
|
|
350
|
+
if (!log.trim()) {
|
|
351
|
+
return [];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const milestones = await this.getAllMilestones();
|
|
355
|
+
|
|
356
|
+
const lines = log
|
|
357
|
+
.trim()
|
|
358
|
+
.split('\n')
|
|
359
|
+
// The `[init]` bootstrap commit created by init() is an internal
|
|
360
|
+
// bookkeeping commit, not a user-visible checkpoint.
|
|
361
|
+
.filter((line) => {
|
|
362
|
+
const subject = line.split('|')[2] ?? '';
|
|
363
|
+
return !subject.startsWith('[init]');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
return Promise.all(
|
|
367
|
+
lines.map(async (line) => {
|
|
368
|
+
const [hash, shortHash, subject, dateStr] = line.split('|');
|
|
369
|
+
|
|
370
|
+
const isMajor = subject.includes('[MAJOR]');
|
|
371
|
+
const source = subject.includes('[local]')
|
|
372
|
+
? ('local' as const)
|
|
373
|
+
: subject.includes('[remote]')
|
|
374
|
+
? ('remote' as const)
|
|
375
|
+
: ('manual' as const);
|
|
376
|
+
|
|
377
|
+
const message = subject
|
|
378
|
+
.replace(/\[(MAJOR|minor)\]\s*/i, '')
|
|
379
|
+
.replace(/\[(local|remote|manual)\]\s*/i, '');
|
|
380
|
+
|
|
381
|
+
const stats = await this.getCommitStats(hash);
|
|
382
|
+
|
|
383
|
+
const milestoneName = milestones.get(hash);
|
|
384
|
+
const isMilestone = !!milestoneName;
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
hash,
|
|
388
|
+
shortHash,
|
|
389
|
+
message,
|
|
390
|
+
description: '',
|
|
391
|
+
date: new Date(dateStr),
|
|
392
|
+
isMajor,
|
|
393
|
+
source,
|
|
394
|
+
isMilestone,
|
|
395
|
+
milestoneName,
|
|
396
|
+
...stats,
|
|
397
|
+
};
|
|
398
|
+
}),
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private async getCommitStats(hash: string): Promise<{
|
|
403
|
+
filesChanged: number;
|
|
404
|
+
insertions: number;
|
|
405
|
+
deletions: number;
|
|
406
|
+
}> {
|
|
407
|
+
try {
|
|
408
|
+
const stat = await this.git('show', '--stat', '--format=', hash);
|
|
409
|
+
const lines = stat.trim().split('\n');
|
|
410
|
+
const summaryLine = lines[lines.length - 1] || '';
|
|
411
|
+
|
|
412
|
+
const filesMatch = summaryLine.match(/(\d+) files? changed/);
|
|
413
|
+
const insertMatch = summaryLine.match(/(\d+) insertions?/);
|
|
414
|
+
const deleteMatch = summaryLine.match(/(\d+) deletions?/);
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
filesChanged: filesMatch ? parseInt(filesMatch[1]) : 0,
|
|
418
|
+
insertions: insertMatch ? parseInt(insertMatch[1]) : 0,
|
|
419
|
+
deletions: deleteMatch ? parseInt(deleteMatch[1]) : 0,
|
|
420
|
+
};
|
|
421
|
+
} catch {
|
|
422
|
+
return { filesChanged: 0, insertions: 0, deletions: 0 };
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async getChangedFiles(hash: string): Promise<string[]> {
|
|
427
|
+
const output = await this.git('show', '--name-only', '--format=', hash);
|
|
428
|
+
return output.trim().split('\n').filter(Boolean);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async getDiff(hash: string): Promise<string> {
|
|
432
|
+
return this.git('show', '--format=', hash);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async restore(hash: string): Promise<void> {
|
|
436
|
+
const currentFiles = await this.getHistoryFiles();
|
|
437
|
+
await Promise.all(
|
|
438
|
+
currentFiles.map(async (file) => {
|
|
439
|
+
const filePath = path.join(this.gitDir, file);
|
|
440
|
+
try {
|
|
441
|
+
await fs.unlink(filePath);
|
|
442
|
+
} catch (err: any) {
|
|
443
|
+
if (err.code !== 'ENOENT') throw err;
|
|
444
|
+
}
|
|
445
|
+
}),
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
await this.git('checkout', hash, '--', '.');
|
|
449
|
+
|
|
450
|
+
const historyFiles = await this.getHistoryFiles();
|
|
451
|
+
const historyFileSet = new Set(historyFiles);
|
|
452
|
+
const workspaceFiles = await this.getWorkspaceFiles();
|
|
453
|
+
|
|
454
|
+
await Promise.all(
|
|
455
|
+
workspaceFiles.map(async (file) => {
|
|
456
|
+
if (isProtectedFile(file)) return;
|
|
457
|
+
if (!historyFileSet.has(file)) {
|
|
458
|
+
const filePath = path.join(this.workspaceDir, file);
|
|
459
|
+
try {
|
|
460
|
+
await fs.unlink(filePath);
|
|
461
|
+
} catch (err: any) {
|
|
462
|
+
if (err.code !== 'ENOENT') throw err;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}),
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
await Promise.all(
|
|
469
|
+
historyFiles.map(async (file) => {
|
|
470
|
+
if (isProtectedFile(file)) return;
|
|
471
|
+
const srcPath = path.join(this.gitDir, file);
|
|
472
|
+
const destPath = path.join(this.workspaceDir, file);
|
|
473
|
+
|
|
474
|
+
const destDir = path.dirname(destPath);
|
|
475
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
476
|
+
|
|
477
|
+
await fs.copyFile(srcPath, destPath);
|
|
478
|
+
}),
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
await this.git('checkout', 'HEAD', '--', '.');
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async markMilestone(
|
|
485
|
+
hashOrIndex: string | number,
|
|
486
|
+
name: string,
|
|
487
|
+
): Promise<{ hash: string; name: string } | null> {
|
|
488
|
+
if (!(await this.isInitialized())) {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
let hash: string;
|
|
493
|
+
if (typeof hashOrIndex === 'number') {
|
|
494
|
+
const checkpoints = await this.getCheckpoints(hashOrIndex + 1);
|
|
495
|
+
if (hashOrIndex < 1 || hashOrIndex > checkpoints.length) {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
hash = checkpoints[hashOrIndex - 1].hash;
|
|
499
|
+
} else {
|
|
500
|
+
hash = hashOrIndex;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const tagName = `milestone/${name.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9\-_.]/g, '')}`;
|
|
504
|
+
|
|
505
|
+
try {
|
|
506
|
+
await this.git('tag', '-a', tagName, hash, '-m', `Milestone: ${name}`);
|
|
507
|
+
return { hash, name };
|
|
508
|
+
} catch {
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async unmarkMilestone(hashOrIndex: string | number): Promise<boolean> {
|
|
514
|
+
if (!(await this.isInitialized())) {
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
let hash: string;
|
|
519
|
+
if (typeof hashOrIndex === 'number') {
|
|
520
|
+
const checkpoints = await this.getCheckpoints(hashOrIndex + 1);
|
|
521
|
+
if (hashOrIndex < 1 || hashOrIndex > checkpoints.length) {
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
524
|
+
hash = checkpoints[hashOrIndex - 1].hash;
|
|
525
|
+
} else {
|
|
526
|
+
hash = hashOrIndex;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const tags = await this.getMilestoneTags(hash);
|
|
530
|
+
if (tags.length === 0) {
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
for (const tag of tags) {
|
|
535
|
+
try {
|
|
536
|
+
await this.git('tag', '-d', tag);
|
|
537
|
+
} catch {
|
|
538
|
+
// Ignore errors
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private async getMilestoneTags(hash: string): Promise<string[]> {
|
|
546
|
+
try {
|
|
547
|
+
const output = await this.git('tag', '--points-at', hash);
|
|
548
|
+
return output
|
|
549
|
+
.trim()
|
|
550
|
+
.split('\n')
|
|
551
|
+
.filter((tag) => tag.startsWith('milestone/'))
|
|
552
|
+
.filter(Boolean);
|
|
553
|
+
} catch {
|
|
554
|
+
return [];
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
private async getAllMilestones(): Promise<Map<string, string>> {
|
|
559
|
+
const milestones = new Map<string, string>();
|
|
560
|
+
try {
|
|
561
|
+
const tags = await this.git('tag', '-l', 'milestone/*');
|
|
562
|
+
for (const tag of tags.trim().split('\n').filter(Boolean)) {
|
|
563
|
+
try {
|
|
564
|
+
const hash = (await this.git('rev-list', '-1', tag)).trim();
|
|
565
|
+
const name = tag.replace('milestone/', '').replace(/-/g, ' ');
|
|
566
|
+
milestones.set(hash, name);
|
|
567
|
+
} catch {
|
|
568
|
+
// Ignore invalid tags
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
} catch {
|
|
572
|
+
// No tags
|
|
573
|
+
}
|
|
574
|
+
return milestones;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async getMilestones(): Promise<Checkpoint[]> {
|
|
578
|
+
const all = await this.getCheckpoints(100);
|
|
579
|
+
return all.filter((cp) => cp.isMilestone);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
private git(...args: string[]): Promise<string> {
|
|
583
|
+
return new Promise((resolve, reject) => {
|
|
584
|
+
const child = spawn('git', args, {
|
|
585
|
+
cwd: this.gitDir,
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
let stdout = '';
|
|
589
|
+
let stderr = '';
|
|
590
|
+
|
|
591
|
+
child.stdout.on('data', (chunk) => {
|
|
592
|
+
stdout += chunk.toString('utf-8');
|
|
593
|
+
});
|
|
594
|
+
child.stderr.on('data', (chunk) => {
|
|
595
|
+
stderr += chunk.toString('utf-8');
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
child.on('error', (err) => reject(err));
|
|
599
|
+
|
|
600
|
+
child.on('close', (code) => {
|
|
601
|
+
if (code !== 0 && !args.includes('status')) {
|
|
602
|
+
reject(new Error(`git ${args.join(' ')} failed: ${stderr}`));
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
resolve(stdout);
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// ANSI color codes
|
|
2
|
+
export const FG_GREEN = '\x1b[32m';
|
|
3
|
+
export const FG_YELLOW = '\x1b[33m';
|
|
4
|
+
export const FG_CYAN = '\x1b[36m';
|
|
5
|
+
export const FG_MAGENTA = '\x1b[35m';
|
|
6
|
+
export const FG_RED = '\x1b[31m';
|
|
7
|
+
export const DIM = '\x1b[2m';
|
|
8
|
+
export const BOLD = '\x1b[1m';
|
|
9
|
+
export const RESET = '\x1b[0m';
|