flowviant 0.78.0 → 0.79.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.
@@ -27,7 +27,7 @@
27
27
 
28
28
  import { existsSync, statSync, readFileSync } from 'node:fs';
29
29
  import { join } from 'node:path';
30
- import { git } from './git.mjs';
30
+ import { git, gitRaw, splitNul } from './git.mjs';
31
31
 
32
32
  /** Rows reported. The rail shows a handful; the totals below cover the rest. */
33
33
  const MAX_FILES = 20;
@@ -70,6 +70,14 @@ export function taskIdsFromMessage(body) {
70
70
  return ids;
71
71
  }
72
72
 
73
+ /**
74
+ * The %x1e/%x1f record delimiters, removed from any field that survives
75
+ * parsing. Splitting alone is not enough: git PRESERVES both bytes in a commit
76
+ * body, so a surviving field can still carry a fragment of a forged record,
77
+ * and a stripped field can never re-assemble one downstream.
78
+ */
79
+ export const stripDelims = (s) => String(s ?? '').replace(/[\x1e\x1f]/g, '');
80
+
73
81
  /**
74
82
  * The commits this branch has that base does not, with the cards they name.
75
83
  *
@@ -81,14 +89,27 @@ export function taskIdsFromMessage(body) {
81
89
  * `--no-merges`, because a merge commit describes a range rather than doing
82
90
  * work, and its trailer (if it has one) would double-book the range's own
83
91
  * commits.
92
+ *
93
+ * THE SHA LIST COMES FROM REV-LIST, NEVER FROM THE FORMATTED LOG. The %x1e and
94
+ * %x1f delimiters are formatting, not a fence: git preserves both bytes inside
95
+ * a commit BODY (verified empirically), so a crafted message can fabricate
96
+ * whole records — a self-named sha carrying task ids, exactly the receipt this
97
+ * product refuses to let an agent write about itself. rev-list prints nothing
98
+ * an author controls, so its output is the set of commits that exist: a parsed
99
+ * record whose sha is not in that set is a forgery and is dropped, a repeated
100
+ * sha is the same forgery wearing a real commit's name, and the delimiter
101
+ * bytes are stripped from every surviving field.
84
102
  */
85
103
  function branchCommits(wt, base) {
86
104
  if (!base) return [];
87
105
  const out = [];
88
106
  try {
89
- // %x1e between records, %x1f between fields — a subject and a body can
90
- // contain anything a person can type, so the delimiters must be bytes they
91
- // cannot.
107
+ const real = new Set(
108
+ git(['rev-list', '--no-merges', '-n', String(MAX_COMMITS), `${base}..HEAD`], wt)
109
+ .split('\n')
110
+ .filter(Boolean)
111
+ );
112
+ if (real.size === 0) return [];
92
113
  const raw = git(
93
114
  [
94
115
  'log',
@@ -103,9 +124,10 @@ function branchCommits(wt, base) {
103
124
  for (const rec of raw.split('\x1e')) {
104
125
  const line = rec.replace(/^\n+/, '');
105
126
  if (!line.trim()) continue;
106
- const [sha, subject, author, at, body] = line.split('\x1f');
107
- if (!sha) continue;
108
- const taskIds = taskIdsFromMessage(body);
127
+ const [sha, subject, author, at, ...bodyParts] = line.split('\x1f');
128
+ if (!real.has(sha)) continue;
129
+ real.delete(sha);
130
+ const taskIds = taskIdsFromMessage(stripDelims(bodyParts.join('\n')));
109
131
  if (taskIds.length === 0) continue;
110
132
  let additions = 0;
111
133
  let deletions = 0;
@@ -125,9 +147,9 @@ function branchCommits(wt, base) {
125
147
  // Clamped to the server's zod caps, same rule as everything else in
126
148
  // this file: one over-cap string 400s the whole batch.
127
149
  sha: sha.slice(0, 64),
128
- subject: (subject ?? '').slice(0, 200),
129
- author: (author ?? '').slice(0, 80),
130
- at: (at ?? '').slice(0, 40),
150
+ subject: stripDelims(subject).slice(0, 200),
151
+ author: stripDelims(author).slice(0, 80),
152
+ at: stripDelims(at).slice(0, 40),
131
153
  additions,
132
154
  deletions,
133
155
  taskIds: taskIds.slice(0, 8),
@@ -202,13 +224,25 @@ export function worktreeDiff(wt, baseRef) {
202
224
  };
203
225
 
204
226
  // Tracked: working tree vs base. `git diff <base>` (no --cached, no second
205
- // ref) is exactly "everything this session did", committed or not.
227
+ // ref) is exactly "everything this session did", committed or not. `-z`, for
228
+ // the reason gitRaw exists: git's LINE-based output C-quotes any path that
229
+ // is not plain ASCII, so an accented filename arrives as "n\303\251w.txt" —
230
+ // a string that is not the path and reads as escaped garbage in the rail.
206
231
  try {
207
- const raw = git(['diff', '--numstat', base || 'HEAD'], wt);
208
- for (const line of raw.split('\n')) {
209
- if (!line.trim()) continue;
210
- const [a, d, ...rest] = line.split('\t');
211
- const path = rest.join('\t');
232
+ // `--numstat -z` frames a normal change as one field, "added\tdeleted\tpath",
233
+ // but a RENAME as three: "added\tdeleted\t" (empty path), then the old path,
234
+ // then the new one. An empty path is therefore the rename marker and the
235
+ // next two fields belong to it — read line-wise, a rename would report a
236
+ // file literally named "old => new".
237
+ const fields = splitNul(gitRaw(['diff', '--numstat', '-z', base || 'HEAD', '--'], wt));
238
+ for (let i = 0; i < fields.length; i++) {
239
+ const [a, d, ...rest] = fields[i].split('\t');
240
+ let path = rest.join('\t');
241
+ if (!path) {
242
+ path = fields[i + 2] ?? fields[i + 1]; // the post-rename name is what exists now
243
+ i += 2;
244
+ if (!path) continue;
245
+ }
212
246
  const binary = a === '-' || d === '-';
213
247
  push(path, binary ? 0 : Number(a) || 0, binary ? 0 : Number(d) || 0, binary);
214
248
  }
@@ -217,11 +251,12 @@ export function worktreeDiff(wt, baseRef) {
217
251
  }
218
252
 
219
253
  // Untracked, minus everything gitignored — new files are the most visible
220
- // work a session does and they would otherwise show as nothing at all.
254
+ // work a session does and they would otherwise show as nothing at all. `-z`
255
+ // here is not cosmetic: a C-quoted untracked name fails the statSync below,
256
+ // and the catch swallowed the row — a session's brand-new `café.txt` simply
257
+ // vanished from the rail.
221
258
  try {
222
- const others = git(['ls-files', '--others', '--exclude-standard'], wt)
223
- .split('\n')
224
- .filter(Boolean);
259
+ const others = splitNul(gitRaw(['ls-files', '--others', '--exclude-standard', '-z'], wt));
225
260
  for (const path of others.slice(0, MAX_UNTRACKED_SCAN)) {
226
261
  try {
227
262
  const full = join(wt, path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.78.0",
3
+ "version": "0.79.0",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant — 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": {