vibeteam 0.6.0 → 0.6.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/dist/server/server/GitStatusManager.js +342 -47
- package/dist/server/server/index.js +1834 -266
- package/dist/server/server/openapi.json +220 -0
- package/mcp/server.js +133 -1
- package/package.json +2 -1
- package/public/assets/{ActivityFeedPanel-B-RPEJq-.js → ActivityFeedPanel-DurVWYkA.js} +1 -1
- package/public/assets/GitPanel-APgTZzN7.css +1 -0
- package/public/assets/GitPanel-B44UP-is.js +1 -0
- package/public/assets/{IdeationPanel-D2zwkzNM.js → IdeationPanel-C2PE8iDm.js} +1 -1
- package/public/assets/IdeationPanel-wtk14HsU.css +1 -0
- package/public/assets/{KanbanPanel-CWMEBU0G.js → KanbanPanel-BFZE5jJP.js} +1 -1
- package/public/assets/{ProjectStatePanel-CLa4i0kf.js → ProjectStatePanel-DbnEku2A.js} +1 -1
- package/public/assets/index-CM1T9m8S.js +41 -0
- package/public/assets/index-CkRgyaZ9.css +1 -0
- package/public/index.html +2 -2
- package/public/assets/IdeationPanel-BD3UuuBF.css +0 -1
- package/public/assets/index--QskQwUD.js +0 -26
- package/public/assets/index-B6oqkH-M.css +0 -1
|
@@ -15,6 +15,10 @@ export class GitStatusManager {
|
|
|
15
15
|
directories = new Map(); // sessionId -> { directory, isWorktree, sourceRepo }
|
|
16
16
|
pollInterval = null;
|
|
17
17
|
onUpdate = null;
|
|
18
|
+
// Per-session timestamp (ms) of the most recent successful `git fetch`
|
|
19
|
+
// — surfaced via gitStatus.lastFetched so the Git panel can show
|
|
20
|
+
// "fetched Xm ago".
|
|
21
|
+
lastFetchPerSession = new Map();
|
|
18
22
|
// PERFORMANCE: Activity-based state for adaptive polling
|
|
19
23
|
isActivityChecker = null; // Function to check if any agent is working
|
|
20
24
|
sessionStatusChecker = null; // Function to check session status by ID
|
|
@@ -22,6 +26,7 @@ export class GitStatusManager {
|
|
|
22
26
|
POLL_INTERVAL_ACTIVE_MS = 15000; // 15s when agent is working (was 5s)
|
|
23
27
|
POLL_INTERVAL_IDLE_MS = 60000; // 60s when idle (was 30s)
|
|
24
28
|
EXEC_TIMEOUT_MS = 5000; // Timeout for git commands
|
|
29
|
+
MANUAL_FETCH_TIMEOUT_MS = 30000; // Longer timeout for user-triggered fetches
|
|
25
30
|
FETCH_INTERVAL_MS = 120000; // Fetch from remote every 120s (was 60s)
|
|
26
31
|
lastFetchTime = 0;
|
|
27
32
|
currentPollInterval = 60000; // Start with idle interval
|
|
@@ -81,6 +86,7 @@ export class GitStatusManager {
|
|
|
81
86
|
untrack(sessionId) {
|
|
82
87
|
this.directories.delete(sessionId);
|
|
83
88
|
this.statusCache.delete(sessionId);
|
|
89
|
+
this.lastFetchPerSession.delete(sessionId);
|
|
84
90
|
}
|
|
85
91
|
/**
|
|
86
92
|
* Get cached status for a session
|
|
@@ -151,18 +157,26 @@ export class GitStatusManager {
|
|
|
151
157
|
const now = Date.now();
|
|
152
158
|
if (now - this.lastFetchTime > this.FETCH_INTERVAL_MS) {
|
|
153
159
|
this.lastFetchTime = now;
|
|
154
|
-
// Fetch in background
|
|
155
|
-
|
|
160
|
+
// Fetch in background per unique directory; record per-session
|
|
161
|
+
// timestamps for the sessions that share each dir.
|
|
162
|
+
const dirToSessions = new Map();
|
|
156
163
|
for (const [sessionId, info] of this.directories) {
|
|
157
164
|
// PERFORMANCE: Skip stopped/offline sessions for git fetch
|
|
158
165
|
if (this.sessionStatusChecker) {
|
|
159
166
|
const status = this.sessionStatusChecker(sessionId);
|
|
160
167
|
if (status === 'offline' || status === 'stopped') continue;
|
|
161
168
|
}
|
|
162
|
-
|
|
169
|
+
const dir = info.sourceRepo || info.directory;
|
|
170
|
+
if (!dirToSessions.has(dir)) dirToSessions.set(dir, []);
|
|
171
|
+
dirToSessions.get(dir).push(sessionId);
|
|
163
172
|
}
|
|
164
|
-
for (const dir of
|
|
165
|
-
this.execGit(['fetch', '--quiet'], dir)
|
|
173
|
+
for (const [dir, sids] of dirToSessions) {
|
|
174
|
+
this.execGit(['fetch', '--quiet'], dir)
|
|
175
|
+
.then(() => {
|
|
176
|
+
const fetchedAt = Date.now();
|
|
177
|
+
for (const sid of sids) this.lastFetchPerSession.set(sid, fetchedAt);
|
|
178
|
+
})
|
|
179
|
+
.catch(() => { /* silent */ });
|
|
166
180
|
}
|
|
167
181
|
}
|
|
168
182
|
// PERFORMANCE: Skip stopped/offline sessions for status polling
|
|
@@ -185,6 +199,8 @@ export class GitStatusManager {
|
|
|
185
199
|
if (!info)
|
|
186
200
|
return null;
|
|
187
201
|
const status = await this.getGitStatus(info.directory, info.isWorktree);
|
|
202
|
+
// Decorate with per-session metadata (last successful `git fetch` ms).
|
|
203
|
+
status.lastFetched = this.lastFetchPerSession.get(sessionId) ?? null;
|
|
188
204
|
// Check if status changed
|
|
189
205
|
const oldStatus = this.statusCache.get(sessionId);
|
|
190
206
|
const changed = !oldStatus || this.hasStatusChanged(oldStatus, status);
|
|
@@ -206,7 +222,8 @@ export class GitStatusManager {
|
|
|
206
222
|
old.totalFiles !== current.totalFiles ||
|
|
207
223
|
old.linesAdded !== current.linesAdded ||
|
|
208
224
|
old.linesRemoved !== current.linesRemoved ||
|
|
209
|
-
old.lastCommitTime !== current.lastCommitTime
|
|
225
|
+
old.lastCommitTime !== current.lastCommitTime ||
|
|
226
|
+
old.lastFetched !== current.lastFetched);
|
|
210
227
|
}
|
|
211
228
|
/**
|
|
212
229
|
* Get git status for a directory
|
|
@@ -254,45 +271,7 @@ export class GitStatusManager {
|
|
|
254
271
|
status.branch = branchResult.trim();
|
|
255
272
|
// Parse ahead/behind - for worktrees, compare against origin/main
|
|
256
273
|
try {
|
|
257
|
-
|
|
258
|
-
if (isWorktree) {
|
|
259
|
-
// For worktrees, compare against origin/main (or origin/master as fallback)
|
|
260
|
-
try {
|
|
261
|
-
await this.execGit(['rev-parse', 'origin/main'], directory);
|
|
262
|
-
compareRef = 'origin/main';
|
|
263
|
-
}
|
|
264
|
-
catch {
|
|
265
|
-
try {
|
|
266
|
-
await this.execGit(['rev-parse', 'origin/master'], directory);
|
|
267
|
-
compareRef = 'origin/master';
|
|
268
|
-
}
|
|
269
|
-
catch {
|
|
270
|
-
compareRef = null;
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
else {
|
|
275
|
-
// For regular repos, try upstream first, then origin/main
|
|
276
|
-
try {
|
|
277
|
-
await this.execGit(['rev-parse', '@{upstream}'], directory);
|
|
278
|
-
compareRef = '@{upstream}';
|
|
279
|
-
}
|
|
280
|
-
catch {
|
|
281
|
-
try {
|
|
282
|
-
await this.execGit(['rev-parse', 'origin/main'], directory);
|
|
283
|
-
compareRef = 'origin/main';
|
|
284
|
-
}
|
|
285
|
-
catch {
|
|
286
|
-
try {
|
|
287
|
-
await this.execGit(['rev-parse', 'origin/master'], directory);
|
|
288
|
-
compareRef = 'origin/master';
|
|
289
|
-
}
|
|
290
|
-
catch {
|
|
291
|
-
compareRef = null;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
}
|
|
274
|
+
const compareRef = await this.resolveCompareRef(directory, isWorktree);
|
|
296
275
|
if (compareRef) {
|
|
297
276
|
const abResult = await this.execGit(['rev-list', '--left-right', '--count', `${compareRef}...HEAD`], directory);
|
|
298
277
|
const [behind, ahead] = abResult.trim().split(/\s+/).map(Number);
|
|
@@ -372,11 +351,327 @@ export class GitStatusManager {
|
|
|
372
351
|
* Execute a git command in a directory using execFile (no shell).
|
|
373
352
|
* Args should be passed as an array, not a string, to prevent command injection.
|
|
374
353
|
*/
|
|
375
|
-
async execGit(args, cwd) {
|
|
354
|
+
async execGit(args, cwd, timeoutMs) {
|
|
376
355
|
const { stdout } = await execFileAsync('git', args, {
|
|
377
356
|
cwd,
|
|
378
|
-
timeout: this.EXEC_TIMEOUT_MS,
|
|
357
|
+
timeout: typeof timeoutMs === 'number' ? timeoutMs : this.EXEC_TIMEOUT_MS,
|
|
379
358
|
});
|
|
380
359
|
return stdout;
|
|
381
360
|
}
|
|
361
|
+
/**
|
|
362
|
+
* Resolve a session's working directory or throw a typed error.
|
|
363
|
+
*/
|
|
364
|
+
resolveDirectory(sessionId) {
|
|
365
|
+
const info = this.directories.get(sessionId);
|
|
366
|
+
if (!info) {
|
|
367
|
+
const err = new Error('not_tracked');
|
|
368
|
+
err.code = 'not_tracked';
|
|
369
|
+
throw err;
|
|
370
|
+
}
|
|
371
|
+
return info.directory;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Translate exec errors into a stable code surface for callers.
|
|
375
|
+
*/
|
|
376
|
+
classifyExecError(err) {
|
|
377
|
+
if (!err) return 'unknown';
|
|
378
|
+
if (err.code === 'ENOENT') return 'git_unavailable';
|
|
379
|
+
const msg = String(err.stderr || err.message || '').toLowerCase();
|
|
380
|
+
if (msg.includes('not a git repository')) return 'not_a_repo';
|
|
381
|
+
if (msg.includes('no such file or directory')) return 'path_missing';
|
|
382
|
+
if (msg.includes('does not exist') && msg.includes('path')) return 'path_missing';
|
|
383
|
+
if (msg.includes('could not resolve host') || msg.includes('connection') || msg.includes('network is unreachable')) return 'network_error';
|
|
384
|
+
if (msg.includes('authentication') || msg.includes('permission denied') || msg.includes('could not read username')) return 'auth_error';
|
|
385
|
+
return 'git_error';
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Resolve the upstream comparison ref for a directory.
|
|
389
|
+
* - Worktrees skip the local upstream and compare directly against origin/main(/master)
|
|
390
|
+
* - Regular repos prefer @{upstream}, falling back to origin/main(/master)
|
|
391
|
+
* Returns null if no usable ref exists.
|
|
392
|
+
*/
|
|
393
|
+
async resolveCompareRef(directory, isWorktree) {
|
|
394
|
+
const tryRef = async (ref) => {
|
|
395
|
+
try {
|
|
396
|
+
await this.execGit(['rev-parse', ref], directory);
|
|
397
|
+
return ref;
|
|
398
|
+
} catch {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
if (!isWorktree) {
|
|
403
|
+
const upstream = await tryRef('@{upstream}');
|
|
404
|
+
if (upstream) return upstream;
|
|
405
|
+
}
|
|
406
|
+
return (await tryRef('origin/main')) || (await tryRef('origin/master'));
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Manually fetch from the remote for this session (user-triggered).
|
|
410
|
+
* Records the timestamp on success and re-polls status so ahead/behind
|
|
411
|
+
* counts reflect the new remote state.
|
|
412
|
+
*/
|
|
413
|
+
async fetchRemote(sessionId) {
|
|
414
|
+
const directory = this.resolveDirectory(sessionId);
|
|
415
|
+
const info = this.directories.get(sessionId);
|
|
416
|
+
const fetchTarget = info.sourceRepo || directory;
|
|
417
|
+
try {
|
|
418
|
+
await this.execGit(['fetch', '--prune', '--quiet'], fetchTarget, this.MANUAL_FETCH_TIMEOUT_MS);
|
|
419
|
+
} catch (err) {
|
|
420
|
+
const code = this.classifyExecError(err);
|
|
421
|
+
const e = new Error(code);
|
|
422
|
+
e.code = code;
|
|
423
|
+
throw e;
|
|
424
|
+
}
|
|
425
|
+
const fetchedAt = Date.now();
|
|
426
|
+
// Record for every session that shares this fetch target dir.
|
|
427
|
+
for (const [sid, sInfo] of this.directories) {
|
|
428
|
+
if ((sInfo.sourceRepo || sInfo.directory) === fetchTarget) {
|
|
429
|
+
this.lastFetchPerSession.set(sid, fetchedAt);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
// Refresh status so ahead/behind reflects the new remote refs.
|
|
433
|
+
const status = await this.fetchStatus(sessionId);
|
|
434
|
+
return { lastFetched: fetchedAt, status };
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* List incoming commits — i.e. commits reachable from the configured
|
|
438
|
+
* upstream/origin ref but not from HEAD. Useful for "what would I get if
|
|
439
|
+
* I pulled?". Returns empty array when there is no upstream or the
|
|
440
|
+
* branch is up-to-date.
|
|
441
|
+
*/
|
|
442
|
+
async getIncoming(sessionId, limit = 20) {
|
|
443
|
+
const directory = this.resolveDirectory(sessionId);
|
|
444
|
+
const info = this.directories.get(sessionId);
|
|
445
|
+
const compareRef = await this.resolveCompareRef(directory, info.isWorktree);
|
|
446
|
+
if (!compareRef) return { commits: [], hasMore: false, compareRef: null };
|
|
447
|
+
const safeLimit = Math.max(1, Math.min(100, Math.floor(Number(limit) || 20)));
|
|
448
|
+
const fmt = ['%H', '%h', '%s', '%an', '%ae', '%ct', '%P'].join('%x1f');
|
|
449
|
+
try {
|
|
450
|
+
const out = await this.execGit(
|
|
451
|
+
['log', `HEAD..${compareRef}`, `-n${safeLimit + 1}`, `--format=${fmt}`],
|
|
452
|
+
directory
|
|
453
|
+
);
|
|
454
|
+
const lines = out.split('\n').filter((l) => l.length > 0);
|
|
455
|
+
const trimmed = lines.slice(0, safeLimit);
|
|
456
|
+
const hasMore = lines.length > safeLimit;
|
|
457
|
+
const commits = trimmed.map(parseCommitLine);
|
|
458
|
+
return { commits, hasMore, compareRef };
|
|
459
|
+
} catch (err) {
|
|
460
|
+
const msg = String(err.stderr || err.message || '').toLowerCase();
|
|
461
|
+
if (msg.includes('unknown revision') || msg.includes('bad revision')) {
|
|
462
|
+
return { commits: [], hasMore: false, compareRef };
|
|
463
|
+
}
|
|
464
|
+
const code = this.classifyExecError(err);
|
|
465
|
+
const e = new Error(code);
|
|
466
|
+
e.code = code;
|
|
467
|
+
throw e;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* List changed files (staged, unstaged, untracked) for a session.
|
|
472
|
+
* Uses porcelain v1 with -z for NUL-safe parsing of paths and renames.
|
|
473
|
+
*/
|
|
474
|
+
async getFiles(sessionId) {
|
|
475
|
+
const directory = this.resolveDirectory(sessionId);
|
|
476
|
+
try {
|
|
477
|
+
const [statusOut, diffNumstatOut, diffCachedNumstatOut] = await Promise.all([
|
|
478
|
+
this.execGit(['status', '--porcelain=v1', '-z'], directory),
|
|
479
|
+
this.execGit(['diff', '--numstat', '-z'], directory).catch(() => ''),
|
|
480
|
+
this.execGit(['diff', '--cached', '--numstat', '-z'], directory).catch(() => ''),
|
|
481
|
+
]);
|
|
482
|
+
const files = parsePorcelainZ(statusOut);
|
|
483
|
+
const unstagedStats = parseNumstatZ(diffNumstatOut);
|
|
484
|
+
const stagedStats = parseNumstatZ(diffCachedNumstatOut);
|
|
485
|
+
for (const file of files) {
|
|
486
|
+
const u = unstagedStats.get(file.path);
|
|
487
|
+
const s = stagedStats.get(file.path);
|
|
488
|
+
let added = 0;
|
|
489
|
+
let removed = 0;
|
|
490
|
+
let hasStat = false;
|
|
491
|
+
if (u) { added += u.added; removed += u.removed; hasStat = true; }
|
|
492
|
+
if (s) { added += s.added; removed += s.removed; hasStat = true; }
|
|
493
|
+
if (hasStat) {
|
|
494
|
+
file.linesAdded = added;
|
|
495
|
+
file.linesRemoved = removed;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return files;
|
|
499
|
+
}
|
|
500
|
+
catch (err) {
|
|
501
|
+
const code = this.classifyExecError(err);
|
|
502
|
+
const e = new Error(code);
|
|
503
|
+
e.code = code;
|
|
504
|
+
throw e;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* List recent commits, paginated via `before` (exclusive cursor).
|
|
509
|
+
*/
|
|
510
|
+
async getCommits(sessionId, limit = 20, beforeSha) {
|
|
511
|
+
const directory = this.resolveDirectory(sessionId);
|
|
512
|
+
const safeLimit = Math.max(1, Math.min(100, Math.floor(Number(limit) || 20)));
|
|
513
|
+
const ref = beforeSha ? `${beforeSha}~1` : 'HEAD';
|
|
514
|
+
const fmt = ['%H', '%h', '%s', '%an', '%ae', '%ct', '%P'].join('%x1f');
|
|
515
|
+
try {
|
|
516
|
+
const out = await this.execGit(
|
|
517
|
+
['log', ref, `-n${safeLimit + 1}`, `--format=${fmt}`],
|
|
518
|
+
directory
|
|
519
|
+
);
|
|
520
|
+
const lines = out.split('\n').filter((l) => l.length > 0);
|
|
521
|
+
const trimmed = lines.slice(0, safeLimit);
|
|
522
|
+
const hasMore = lines.length > safeLimit;
|
|
523
|
+
const commits = trimmed.map(parseCommitLine);
|
|
524
|
+
return { commits, hasMore };
|
|
525
|
+
}
|
|
526
|
+
catch (err) {
|
|
527
|
+
// Empty repo with no HEAD → return an empty list, not an error.
|
|
528
|
+
const msg = String(err.stderr || err.message || '').toLowerCase();
|
|
529
|
+
if (msg.includes('does not have any commits') || msg.includes('unknown revision') || msg.includes('bad revision')) {
|
|
530
|
+
return { commits: [], hasMore: false };
|
|
531
|
+
}
|
|
532
|
+
const code = this.classifyExecError(err);
|
|
533
|
+
const e = new Error(code);
|
|
534
|
+
e.code = code;
|
|
535
|
+
throw e;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Get detailed info for a single commit, including per-file numstat.
|
|
540
|
+
*/
|
|
541
|
+
async getCommitDetail(sessionId, sha) {
|
|
542
|
+
const directory = this.resolveDirectory(sessionId);
|
|
543
|
+
if (!/^[a-f0-9]{4,40}$/i.test(sha)) {
|
|
544
|
+
const e = new Error('invalid_sha');
|
|
545
|
+
e.code = 'invalid_sha';
|
|
546
|
+
throw e;
|
|
547
|
+
}
|
|
548
|
+
try {
|
|
549
|
+
const fmt = ['%H', '%an', '%ae', '%ct', '%P', '%s', '%b'].join('%x1f');
|
|
550
|
+
const headerOut = await this.execGit(
|
|
551
|
+
['show', '-s', `--format=${fmt}`, sha],
|
|
552
|
+
directory
|
|
553
|
+
);
|
|
554
|
+
const numstatOut = await this.execGit(
|
|
555
|
+
['show', '--numstat', '--format=', sha],
|
|
556
|
+
directory
|
|
557
|
+
);
|
|
558
|
+
const parts = headerOut.replace(/\n+$/, '').split('\x1f');
|
|
559
|
+
const [hash, authorName, authorEmail, ts, parents, subject, body] = parts;
|
|
560
|
+
const files = numstatOut
|
|
561
|
+
.split('\n')
|
|
562
|
+
.map((l) => l.trim())
|
|
563
|
+
.filter(Boolean)
|
|
564
|
+
.map((line) => {
|
|
565
|
+
const cols = line.split('\t');
|
|
566
|
+
if (cols.length < 3) return null;
|
|
567
|
+
const added = cols[0] === '-' ? 0 : parseInt(cols[0], 10) || 0;
|
|
568
|
+
const removed = cols[1] === '-' ? 0 : parseInt(cols[1], 10) || 0;
|
|
569
|
+
return { path: cols[2], linesAdded: added, linesRemoved: removed };
|
|
570
|
+
})
|
|
571
|
+
.filter(Boolean);
|
|
572
|
+
return {
|
|
573
|
+
hash,
|
|
574
|
+
shortHash: hash.slice(0, 7),
|
|
575
|
+
subject: subject || '',
|
|
576
|
+
authorName,
|
|
577
|
+
authorEmail,
|
|
578
|
+
timestamp: parseInt(ts, 10) || 0,
|
|
579
|
+
parents: parents ? parents.split(' ').filter(Boolean) : [],
|
|
580
|
+
body: (body || '').replace(/\n+$/, ''),
|
|
581
|
+
files,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
catch (err) {
|
|
585
|
+
const msg = String(err.stderr || err.message || '').toLowerCase();
|
|
586
|
+
if (msg.includes('unknown revision') || msg.includes('bad revision') || msg.includes('ambiguous argument')) {
|
|
587
|
+
const e = new Error('not_found');
|
|
588
|
+
e.code = 'not_found';
|
|
589
|
+
throw e;
|
|
590
|
+
}
|
|
591
|
+
const code = this.classifyExecError(err);
|
|
592
|
+
const e = new Error(code);
|
|
593
|
+
e.code = code;
|
|
594
|
+
throw e;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// ============================================================================
|
|
600
|
+
// Helpers (porcelain -z and numstat -z parsing)
|
|
601
|
+
// ============================================================================
|
|
602
|
+
|
|
603
|
+
function parsePorcelainZ(input) {
|
|
604
|
+
if (!input) return [];
|
|
605
|
+
const tokens = input.split('\0');
|
|
606
|
+
const files = [];
|
|
607
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
608
|
+
const entry = tokens[i];
|
|
609
|
+
if (!entry) continue;
|
|
610
|
+
if (entry.length < 3) continue;
|
|
611
|
+
const code = entry.slice(0, 2);
|
|
612
|
+
const path = entry.slice(3);
|
|
613
|
+
const staged = code[0];
|
|
614
|
+
const unstaged = code[1];
|
|
615
|
+
const isUntracked = staged === '?' && unstaged === '?';
|
|
616
|
+
const isRename = staged === 'R' || staged === 'C';
|
|
617
|
+
let oldPath;
|
|
618
|
+
if (isRename) {
|
|
619
|
+
// -z renames: next token is the original path
|
|
620
|
+
const next = tokens[i + 1];
|
|
621
|
+
if (next != null) {
|
|
622
|
+
oldPath = next;
|
|
623
|
+
i += 1;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
files.push({
|
|
627
|
+
path,
|
|
628
|
+
...(oldPath ? { oldPath } : {}),
|
|
629
|
+
statusStaged: staged,
|
|
630
|
+
statusUnstaged: unstaged,
|
|
631
|
+
isUntracked,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
return files;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function parseNumstatZ(input) {
|
|
638
|
+
const map = new Map();
|
|
639
|
+
if (!input) return map;
|
|
640
|
+
// `git diff --numstat -z` emits records separated by NUL.
|
|
641
|
+
// Each record: "<added>\t<removed>\t<path>" — for renames it becomes:
|
|
642
|
+
// "<added>\t<removed>\t\0<oldpath>\0<newpath>"
|
|
643
|
+
const tokens = input.split('\0');
|
|
644
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
645
|
+
const t = tokens[i];
|
|
646
|
+
if (!t) continue;
|
|
647
|
+
const cols = t.split('\t');
|
|
648
|
+
if (cols.length < 3) continue;
|
|
649
|
+
const added = cols[0] === '-' ? 0 : parseInt(cols[0], 10) || 0;
|
|
650
|
+
const removed = cols[1] === '-' ? 0 : parseInt(cols[1], 10) || 0;
|
|
651
|
+
let pathField = cols[2];
|
|
652
|
+
if (pathField === '') {
|
|
653
|
+
// rename: next two tokens are oldpath, newpath
|
|
654
|
+
const oldPath = tokens[i + 1];
|
|
655
|
+
const newPath = tokens[i + 2];
|
|
656
|
+
i += 2;
|
|
657
|
+
if (newPath) map.set(newPath, { added, removed });
|
|
658
|
+
if (oldPath && oldPath !== newPath) map.set(oldPath, { added, removed });
|
|
659
|
+
} else {
|
|
660
|
+
map.set(pathField, { added, removed });
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return map;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function parseCommitLine(line) {
|
|
667
|
+
const [hash, shortHash, subject, authorName, authorEmail, ts, parents] = line.split('\x1f');
|
|
668
|
+
return {
|
|
669
|
+
hash: hash || '',
|
|
670
|
+
shortHash: shortHash || (hash ? hash.slice(0, 7) : ''),
|
|
671
|
+
subject: subject || '',
|
|
672
|
+
authorName: authorName || '',
|
|
673
|
+
authorEmail: authorEmail || '',
|
|
674
|
+
timestamp: parseInt(ts, 10) || 0,
|
|
675
|
+
parents: parents ? parents.split(' ').filter(Boolean) : [],
|
|
676
|
+
};
|
|
382
677
|
}
|