flowviant 0.125.22 → 0.125.23

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.
@@ -13,6 +13,17 @@
13
13
  * uncommitted work in the same total. Untracked files count too: git calls them
14
14
  * nothing until they are added, and a human calls them new work.
15
15
  *
16
+ * …AND THAT WHOLE SPLIT IN TWO (2026-10-25). The agent page's Changes counts
17
+ * the branch's COMMITS, so beside the whole it read as a disagreement: "Native
18
+ * 3D viewer" said +78 −12 in Changes and +114 −15 on the branch row (a running
19
+ * card's commit on top), and "New task submission" said "Nothing committed
20
+ * yet" beside +613 −294 of work nobody had committed. `committed` is
21
+ * `<merge-base>..HEAD` — the figure Changes agrees with — and `uncommitted` is
22
+ * HEAD against the working tree, untracked files again counted as additions,
23
+ * with its own rows so the page can list it apart. The halves need not sum to
24
+ * the whole: a line a commit changed and the working tree changed back counts
25
+ * in both and in neither, which is git's arithmetic, relayed.
26
+ *
16
27
  * It also carries THIS BRANCH'S OWN COMMITS and the cards they name — see
17
28
  * `branchCommits` below. That rides here rather than on a route of its own
18
29
  * because this sweep is already standing in the right directory on the right
@@ -252,16 +263,104 @@ function countLines(buf) {
252
263
  return s.endsWith('\n') ? n - 1 : n;
253
264
  }
254
265
 
266
+ /** One diffstat row. The path is clamped to the server's zod cap (file path ≤
267
+ * 300 — see the caps at worktreeDiff's return): one over-cap string would 400
268
+ * the whole report batch, and a readout must degrade to a shorter label, never
269
+ * to silence. Null for an empty path, which names nothing. */
270
+ function fileRow(path, added, deleted, binary = false) {
271
+ if (!path) return null;
272
+ path = path.slice(0, 300);
273
+ return binary ? { path, added, deleted, binary } : { path, added, deleted };
274
+ }
275
+
276
+ /**
277
+ * `git diff --numstat -z <revs…> --`, read as rows — the one reader of that
278
+ * output, for the whole and both halves alike. Throws when git does.
279
+ *
280
+ * `-z`, for the reason gitRaw exists: git's LINE-based output C-quotes any path
281
+ * that is not plain ASCII, so an accented filename arrives as "n\303\251w.txt"
282
+ * — a string that is not the path and reads as escaped garbage in the rail.
283
+ * And `--numstat -z` frames a normal change as one field, "added\tdeleted\tpath",
284
+ * but a RENAME as three: "added\tdeleted\t" (empty path), then the old path,
285
+ * then the new one. An empty path is therefore the rename marker and the next
286
+ * two fields belong to it — read line-wise, a rename would report a file
287
+ * literally named "old => new".
288
+ */
289
+ function numstatRows(revs, wt) {
290
+ const rows = [];
291
+ const fields = splitNul(gitRaw(['diff', '--numstat', '-z', ...revs, '--'], wt));
292
+ for (let i = 0; i < fields.length; i++) {
293
+ const [a, d, ...rest] = fields[i].split('\t');
294
+ let path = rest.join('\t');
295
+ if (!path) {
296
+ path = fields[i + 2] ?? fields[i + 1]; // the post-rename name is what exists now
297
+ i += 2;
298
+ if (!path) continue;
299
+ }
300
+ const binary = a === '-' || d === '-';
301
+ const row = fileRow(path, binary ? 0 : Number(a) || 0, binary ? 0 : Number(d) || 0, binary);
302
+ if (row) rows.push(row);
303
+ }
304
+ return rows;
305
+ }
306
+
307
+ /** The same rows, or NULL when git would not answer — kept apart from an
308
+ * empty diff, so a failed read omits its half rather than reporting zeros. */
309
+ function numstatOrNull(revs, wt) {
310
+ try {
311
+ return numstatRows(revs, wt);
312
+ } catch {
313
+ return null;
314
+ }
315
+ }
316
+
317
+ /** A diffstat summed from its rows: totals and `fileCount` over ALL of them,
318
+ * and the biggest MAX_FILES as the rows reported, `truncated` saying how many
319
+ * were left out. */
320
+ function diffstat(rows) {
321
+ // Biggest first: with a 20-row cap, the rows that survive should be the ones
322
+ // worth looking at. Ties break by path so the list doesn't shuffle per sweep.
323
+ const files = [...rows].sort(
324
+ (x, y) => y.added + y.deleted - (x.added + x.deleted) || (x.path < y.path ? -1 : 1)
325
+ );
326
+ let additions = 0;
327
+ let deletions = 0;
328
+ for (const f of files) {
329
+ additions += f.added;
330
+ deletions += f.deleted;
331
+ }
332
+ return {
333
+ additions,
334
+ deletions,
335
+ fileCount: files.length,
336
+ truncated: Math.max(0, files.length - MAX_FILES),
337
+ files: files.slice(0, MAX_FILES),
338
+ };
339
+ }
340
+
255
341
  /**
256
342
  * @param {string} wt the worktree directory
257
343
  * @param {string} baseRef the project's base ref (e.g. `origin/main`)
258
- * @returns {null | {branch:string, path:string, ahead:number, behind:number,
259
- * baseLabel:string, baseCommits:{sha:string, subject:string, author:string}[],
344
+ * @returns {null | {branch:string, headSha?:string, path:string, ahead:number,
345
+ * behind:number, baseLabel:string,
346
+ * baseCommits:{sha:string, subject:string, author:string}[],
260
347
  * dirty:boolean, additions:number, deletions:number, fileCount:number,
261
348
  * truncated:number,
262
349
  * files:{path:string, added:number, deleted:number, binary?:boolean}[],
350
+ * committed?:{additions:number, deletions:number, fileCount:number},
351
+ * uncommitted?:{additions:number, deletions:number, fileCount:number,
352
+ * truncated:number,
353
+ * files:{path:string, added:number, deleted:number, binary?:boolean}[]},
263
354
  * commits:{sha:string, subject:string, author:string, at:string,
264
355
  * additions:number, deletions:number, taskIds:string[]}[]}}
356
+ *
357
+ * The top-level additions/deletions/fileCount/truncated/files are the WHOLE
358
+ * (merge-base against the working tree, untracked included) — what the tab
359
+ * rails read. `committed` (`<merge-base>..HEAD`) and `uncommitted` (HEAD
360
+ * against the working tree, untracked included, with its own rows) split it
361
+ * for the agent page, which agrees with the first and lists the second apart.
362
+ * `committed` is absent without a merge-base; either half is absent when its
363
+ * git read failed — unknown, never a fabricated zero.
265
364
  */
266
365
  export function worktreeDiff(wt, baseRef) {
267
366
  if (!wt || !existsSync(wt)) return null;
@@ -289,52 +388,24 @@ export function worktreeDiff(wt, baseRef) {
289
388
  HEAD below, which still reports the uncommitted half honestly */
290
389
  }
291
390
 
292
- const files = [];
293
- let additions = 0;
294
- let deletions = 0;
295
- const push = (path, added, deleted, binary = false) => {
296
- if (!path) return;
297
- // Clamped to the server's zod cap (file path ≤ 300 — see the caps at the
298
- // return, below): one over-cap string would 400 the whole report batch,
299
- // and a readout must degrade to a shorter label, never to silence.
300
- path = path.slice(0, 300);
301
- files.push(binary ? { path, added, deleted, binary } : { path, added, deleted });
302
- additions += added;
303
- deletions += deleted;
304
- };
305
-
306
391
  // Tracked: working tree vs base. `git diff <base>` (no --cached, no second
307
- // ref) is exactly "everything this session did", committed or not. `-z`, for
308
- // the reason gitRaw exists: git's LINE-based output C-quotes any path that
309
- // is not plain ASCII, so an accented filename arrives as "n\303\251w.txt" —
310
- // a string that is not the path and reads as escaped garbage in the rail.
311
- try {
312
- // `--numstat -z` frames a normal change as one field, "added\tdeleted\tpath",
313
- // but a RENAME as three: "added\tdeleted\t" (empty path), then the old path,
314
- // then the new one. An empty path is therefore the rename marker and the
315
- // next two fields belong to it — read line-wise, a rename would report a
316
- // file literally named "old => new".
317
- const fields = splitNul(gitRaw(['diff', '--numstat', '-z', base || 'HEAD', '--'], wt));
318
- for (let i = 0; i < fields.length; i++) {
319
- const [a, d, ...rest] = fields[i].split('\t');
320
- let path = rest.join('\t');
321
- if (!path) {
322
- path = fields[i + 2] ?? fields[i + 1]; // the post-rename name is what exists now
323
- i += 2;
324
- if (!path) continue;
325
- }
326
- const binary = a === '-' || d === '-';
327
- push(path, binary ? 0 : Number(a) || 0, binary ? 0 : Number(d) || 0, binary);
328
- }
329
- } catch {
330
- /* report what we have */
331
- }
392
+ // ref) is exactly "everything this session did", committed or not. Without a
393
+ // merge-base it is HEAD, which is the uncommitted half alone — kept as rows
394
+ // OR NULL, so that half can reuse this read below without mistaking a failed
395
+ // one for a clean tree.
396
+ const trackedRows = numstatOrNull([base || 'HEAD'], wt);
332
397
 
333
398
  // Untracked, minus everything gitignored — new files are the most visible
334
399
  // work a session does and they would otherwise show as nothing at all. `-z`
335
400
  // here is not cosmetic: a C-quoted untracked name fails the statSync below,
336
401
  // and the catch swallowed the row — a session's brand-new `café.txt` simply
337
- // vanished from the rail.
402
+ // vanished from the rail. Scanned ONCE: these rows belong to the whole and to
403
+ // the uncommitted half alike, and the scan opens files.
404
+ const untracked = [];
405
+ const pushUntracked = (path, added, binary = false) => {
406
+ const row = fileRow(path, added, 0, binary);
407
+ if (row) untracked.push(row);
408
+ };
338
409
  try {
339
410
  const others = splitNul(gitRaw(['ls-files', '--others', '--exclude-standard', '-z'], wt));
340
411
  for (const path of others.slice(0, MAX_UNTRACKED_SCAN)) {
@@ -343,12 +414,12 @@ export function worktreeDiff(wt, baseRef) {
343
414
  const st = statSync(full);
344
415
  if (!st.isFile()) continue;
345
416
  if (st.size > MAX_COUNT_BYTES) {
346
- push(path, 0, 0, true);
417
+ pushUntracked(path, 0, true);
347
418
  continue;
348
419
  }
349
420
  const lines = countLines(readFileSync(full));
350
- if (lines === null) push(path, 0, 0, true);
351
- else push(path, lines, 0);
421
+ if (lines === null) pushUntracked(path, 0, true);
422
+ else pushUntracked(path, lines);
352
423
  } catch {
353
424
  /* vanished between listing and reading — it wasn't there to report */
354
425
  }
@@ -357,6 +428,37 @@ export function worktreeDiff(wt, baseRef) {
357
428
  /* report what we have */
358
429
  }
359
430
 
431
+ const whole = diffstat([...(trackedRows ?? []), ...untracked]);
432
+
433
+ // THE WHOLE, SPLIT IN TWO — see the header. Each half is its own git read
434
+ // and each degrades alone: a half git would not answer is OMITTED, because
435
+ // a zero there would claim a clean side nobody measured.
436
+ //
437
+ // Committed: `git diff <merge-base> HEAD`, only with a merge-base. Without
438
+ // one there is no "this branch's commits" to count, and the fallback above
439
+ // already diffed HEAD, which is the other half.
440
+ let committed = null;
441
+ if (base) {
442
+ const rows = numstatOrNull([base, 'HEAD'], wt);
443
+ if (rows) {
444
+ const { additions, deletions, fileCount } = diffstat(rows);
445
+ committed = { additions, deletions, fileCount };
446
+ }
447
+ }
448
+ // Uncommitted: `git diff HEAD` plus the untracked rows. An UNBORN HEAD has
449
+ // no commit, so everything there is uncommitted and the half is the whole —
450
+ // `git diff HEAD` fails on it, and that failure must not drop the half
451
+ // (defensive today: the branch read above already returns null on an unborn
452
+ // HEAD). With no merge-base the whole's tracked read already WAS `git diff
453
+ // HEAD`, so it is reused rather than run twice.
454
+ let uncommitted = null;
455
+ if (!headSha) {
456
+ uncommitted = { ...whole };
457
+ } else {
458
+ const rows = base ? numstatOrNull(['HEAD'], wt) : trackedRows;
459
+ if (rows) uncommitted = diffstat([...rows, ...untracked]);
460
+ }
461
+
360
462
  let ahead = 0;
361
463
  try {
362
464
  if (base) ahead = Number(git(['rev-list', '--count', `${base}..HEAD`], wt)) || 0;
@@ -403,11 +505,6 @@ export function worktreeDiff(wt, baseRef) {
403
505
  /* leave at false */
404
506
  }
405
507
 
406
- // Biggest first: with a 20-row cap, the rows that survive should be the ones
407
- // worth looking at. Ties break by path so the list doesn't shuffle per sweep.
408
- files.sort(
409
- (x, y) => y.added + y.deleted - (x.added + x.deleted) || (x.path < y.path ? -1 : 1)
410
- );
411
508
  // Every string here is clamped to the server's own zod caps (branch ≤ 200,
412
509
  // baseLabel ≤ 120, subject ≤ 200, author ≤ 80, file path ≤ 300): the report
413
510
  // rides in a BATCH, so a single over-cap string — a generated branch name, a
@@ -421,11 +518,13 @@ export function worktreeDiff(wt, baseRef) {
421
518
  baseLabel: String(baseRef).replace(/^origin\//, '').slice(0, 120),
422
519
  baseCommits,
423
520
  dirty,
424
- additions,
425
- deletions,
426
- fileCount: files.length,
427
- truncated: Math.max(0, files.length - MAX_FILES),
428
- files: files.slice(0, MAX_FILES),
521
+ additions: whole.additions,
522
+ deletions: whole.deletions,
523
+ fileCount: whole.fileCount,
524
+ truncated: whole.truncated,
525
+ files: whole.files,
526
+ ...(committed ? { committed } : {}),
527
+ ...(uncommitted ? { uncommitted } : {}),
429
528
  commits: branchCommits(wt, base),
430
529
  };
431
530
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.125.22",
3
+ "version": "0.125.23",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {