vibeteam 0.5.5 → 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.
@@ -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 for all unique directories (skip stopped/offline)
155
- const uniqueDirs = new Set();
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
- uniqueDirs.add(info.sourceRepo || info.directory);
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 uniqueDirs) {
165
- this.execGit(['fetch', '--quiet'], dir).catch(() => { }); // Silent fail
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
- let compareRef;
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
  }