@goodtek/vibeops 3.1.0 → 3.1.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/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@ All notable changes to VibeOps are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 3.1.1 - 2026-10-15
8
+
9
+ ### Fixed
10
+
11
+ - **`task ship` with deletions / renames:** a staged deletion (`D `, e.g. `git rm --cached <path>` + file removed) or a staged rename made ship fail with `fatal: pathspec '<path>' did not match any files`, because every dirty path was passed to `git add`. Ship now parses `git status --porcelain=v1 -z` and runs `git add -A -- :(literal)<path>` only for untracked / unstaged paths; staged changes are committed as they are. Unstaged deletions of tracked files are staged as deletions. Names with spaces or non-ASCII characters (previously quoted by porcelain output) are staged correctly (TASK-022).
12
+
7
13
  ## 3.1.0 - 2026-10-03
8
14
 
9
15
  ### Added
package/dist/lib/git.js CHANGED
@@ -160,7 +160,7 @@ export async function stashGovernanceIfBlocking(cwd) {
160
160
  const gov = await gitGovernanceOnlyDirty(cwd);
161
161
  if (!gov.onlyGovernance || gov.allPaths.length === 0)
162
162
  return null;
163
- const tracked = parsePorcelain(await gitStatusPorcelain(cwd))
163
+ const tracked = (await gitStatusEntries(cwd))
164
164
  .filter((e) => !e.untracked && isGovernanceDocumentationPath(e.path))
165
165
  .map((e) => e.path);
166
166
  if (tracked.length === 0)
@@ -256,52 +256,51 @@ export async function gitStatusPorcelain(cwd) {
256
256
  return [];
257
257
  return res.stdout.split("\n").filter((line) => line.length > 0);
258
258
  }
259
- function parsePorcelainLine(line) {
260
- if (line.length < 3)
261
- return null;
262
- const code = line.slice(0, 2);
263
- const rest = line.slice(3);
264
- if (code === "??") {
265
- return { code, staged: false, unstaged: false, untracked: true, path: rest };
266
- }
267
- if (code === "!!") {
268
- return null;
269
- }
270
- const x = code.charAt(0);
271
- const y = code.charAt(1);
272
- // Rename / copy: `R old -> new` or `C old -> new`
273
- if (x === "R" || x === "C") {
274
- const arrow = rest.indexOf(" -> ");
275
- if (arrow >= 0) {
276
- const origPath = rest.slice(0, arrow);
277
- const path = rest.slice(arrow + 4);
278
- return {
279
- code,
280
- staged: true,
281
- unstaged: y !== " " && y !== "?",
282
- untracked: false,
283
- path,
284
- origPath,
285
- };
286
- }
287
- }
288
- return {
289
- code,
290
- staged: x !== " " && x !== "?",
291
- unstaged: y !== " " && y !== "?",
292
- untracked: false,
293
- path: rest,
294
- };
295
- }
296
- function parsePorcelain(lines) {
259
+ /**
260
+ * Parse `git status --porcelain=v1 -z`: records are NUL-terminated, paths are
261
+ * never quoted, and a rename / copy record is followed by its source path as a
262
+ * separate NUL-terminated field (`XY new\0old\0`).
263
+ */
264
+ export function parsePorcelainZ(stdout) {
265
+ const fields = stdout.split("\0");
297
266
  const out = [];
298
- for (const line of lines) {
299
- const entry = parsePorcelainLine(line);
300
- if (entry !== null)
301
- out.push(entry);
267
+ for (let i = 0; i < fields.length; i++) {
268
+ const record = fields[i];
269
+ if (record.length < 4)
270
+ continue;
271
+ const code = record.slice(0, 2);
272
+ const path = record.slice(3);
273
+ if (code === "!!")
274
+ continue;
275
+ if (code === "??") {
276
+ out.push({ code, staged: false, unstaged: false, untracked: true, path });
277
+ continue;
278
+ }
279
+ const x = code.charAt(0);
280
+ const y = code.charAt(1);
281
+ let origPath;
282
+ if (x === "R" || x === "C" || y === "R" || y === "C") {
283
+ origPath = fields[i + 1];
284
+ i += 1;
285
+ }
286
+ out.push({
287
+ code,
288
+ staged: x !== " ",
289
+ unstaged: y !== " ",
290
+ untracked: false,
291
+ path,
292
+ ...(typeof origPath === "string" && origPath.length > 0 ? { origPath } : {}),
293
+ });
302
294
  }
303
295
  return out;
304
296
  }
297
+ /** Parsed `git status --porcelain=v1 -z`; empty when the repo is unreadable. */
298
+ async function gitStatusEntries(cwd) {
299
+ const res = await tryGit(cwd, ["status", "--porcelain=v1", "-z"]);
300
+ if (!res)
301
+ return [];
302
+ return parsePorcelainZ(res.stdout);
303
+ }
305
304
  /** Paths under these prefixes may stay uncommitted across `start` / `done` / doc updates. */
306
305
  const GOVERNANCE_DOC_REL_PREFIXES = [
307
306
  "docs/tasks/",
@@ -389,7 +388,7 @@ export async function tryResolveGovernanceUnmerged(cwd) {
389
388
  return resolved;
390
389
  }
391
390
  export async function listWorkingTreeRelPaths(cwd) {
392
- const entries = parsePorcelain(await gitStatusPorcelain(cwd));
391
+ const entries = await gitStatusEntries(cwd);
393
392
  const all = new Set();
394
393
  for (const e of entries) {
395
394
  all.add(e.path);
@@ -416,13 +415,29 @@ export function partitionPathsForAutoCommit(paths, opts = {}) {
416
415
  }
417
416
  return { committable, excluded, unmerged };
418
417
  }
418
+ /**
419
+ * Paths whose working-tree state is not yet in the index: untracked files and
420
+ * entries with a Y-column change (modified, deleted, type-changed, unmerged).
421
+ * Index-only changes — a staged deletion (`D `), the source of a staged rename
422
+ * (`R old -> new`) — are already recorded and exist in neither the index nor
423
+ * the working tree, so `git add` must not be given them (it fails with
424
+ * `pathspec … did not match any files`).
425
+ */
426
+ export async function listUnstagedRelPaths(cwd) {
427
+ const entries = await gitStatusEntries(cwd);
428
+ return entries.filter((e) => e.untracked || e.unstaged).map((e) => e.path);
429
+ }
430
+ /**
431
+ * Stage `paths` with git's own semantics: `-A` records modifications, new files
432
+ * and deletions alike; `:(literal)` keeps names with glob characters exact.
433
+ */
419
434
  export async function gitAddPaths(cwd, paths) {
420
435
  if (paths.length === 0)
421
436
  return;
422
437
  const chunkSize = 80;
423
438
  for (let i = 0; i < paths.length; i += chunkSize) {
424
- const slice = paths.slice(i, i + chunkSize);
425
- await runGit(cwd, ["add", "--", ...slice]);
439
+ const slice = paths.slice(i, i + chunkSize).map((p) => `:(literal)${p}`);
440
+ await runGit(cwd, ["add", "-A", "--", ...slice]);
426
441
  }
427
442
  }
428
443
  /**
@@ -432,8 +447,7 @@ export async function gitAddPaths(cwd, paths) {
432
447
  * block the next TASK unless application code is also dirty.
433
448
  */
434
449
  export async function gitGovernanceOnlyDirty(cwd) {
435
- const lines = await gitStatusPorcelain(cwd);
436
- const entries = parsePorcelain(lines);
450
+ const entries = await gitStatusEntries(cwd);
437
451
  const all = new Set();
438
452
  for (const e of entries) {
439
453
  all.add(e.path);
@@ -450,15 +464,15 @@ export async function gitGovernanceOnlyDirty(cwd) {
450
464
  };
451
465
  }
452
466
  export async function gitWorkingTreeChangedFiles(cwd) {
453
- const entries = parsePorcelain(await gitStatusPorcelain(cwd));
467
+ const entries = await gitStatusEntries(cwd);
454
468
  return entries.filter((e) => e.unstaged).map((e) => e.path);
455
469
  }
456
470
  export async function gitStagedChangedFiles(cwd) {
457
- const entries = parsePorcelain(await gitStatusPorcelain(cwd));
471
+ const entries = await gitStatusEntries(cwd);
458
472
  return entries.filter((e) => e.staged).map((e) => e.path);
459
473
  }
460
474
  export async function gitUntrackedFiles(cwd) {
461
- const entries = parsePorcelain(await gitStatusPorcelain(cwd));
475
+ const entries = await gitStatusEntries(cwd);
462
476
  return entries.filter((e) => e.untracked).map((e) => e.path);
463
477
  }
464
478
  export async function gitCommittedChangedFilesSince(baseCommit, cwd) {
@@ -467,7 +481,7 @@ export async function gitCommittedChangedFilesSince(baseCommit, cwd) {
467
481
  return gitDiffNameOnly(cwd, `${baseCommit}..HEAD`);
468
482
  }
469
483
  export async function gitAllChangedFilesSinceTaskStart(baseCommit, cwd) {
470
- const entries = parsePorcelain(await gitStatusPorcelain(cwd));
484
+ const entries = await gitStatusEntries(cwd);
471
485
  const working = entries.filter((e) => e.unstaged).map((e) => e.path);
472
486
  const staged = entries.filter((e) => e.staged).map((e) => e.path);
473
487
  const untracked = entries.filter((e) => e.untracked).map((e) => e.path);
@@ -1,5 +1,5 @@
1
1
  import { dim, log } from "./logger.js";
2
- import { gitAddPaths, gitPush, listWorkingTreeRelPaths, partitionPathsForAutoCommit, readGitInfo, runGit, } from "./git.js";
2
+ import { gitAddPaths, gitPush, listUnstagedRelPaths, listWorkingTreeRelPaths, partitionPathsForAutoCommit, readGitInfo, runGit, } from "./git.js";
3
3
  export function featCommitMessageFor(taskId, title) {
4
4
  const slug = title.replace(/^TASK-\d+\s*[:\-]\s*/i, "").trim() || taskId;
5
5
  return `feat(${taskId.toLowerCase()}): ${slug}`;
@@ -31,7 +31,12 @@ export function taskScopedCommitMessage(taskId, rawMessage, defaultType = "feat"
31
31
  }
32
32
  return `${defaultType}(${scope}): ${msg}`;
33
33
  }
34
- /** Commit all committable dirty paths; returns whether a commit was created. */
34
+ /**
35
+ * Commit all committable dirty paths (staged and unstaged, including deletions
36
+ * and renames); returns whether a commit was created. Only paths whose
37
+ * working-tree state is not yet in the index are passed to `git add`; staged
38
+ * changes are already part of the commit.
39
+ */
35
40
  export async function commitDirtyWorkingTree(cwd, message, dryRun) {
36
41
  const git = await readGitInfo(cwd);
37
42
  if (!git.isRepo || git.dirty !== true)
@@ -46,7 +51,8 @@ export async function commitDirtyWorkingTree(cwd, message, dryRun) {
46
51
  log.info(dim(` would commit ${committable.length} path(s): ${message}`));
47
52
  return true;
48
53
  }
49
- await gitAddPaths(cwd, committable);
54
+ const committableSet = new Set(committable);
55
+ await gitAddPaths(cwd, (await listUnstagedRelPaths(cwd)).filter((p) => committableSet.has(p)));
50
56
  await runGit(cwd, ["commit", "-q", "-m", message]);
51
57
  log.ok(`Committed: ${message}`);
52
58
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodtek/vibeops",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
4
4
  "description": "Minimal CLI rails for Cursor-based TASK-driven vibe coding.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@9.15.9",