@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 +6 -0
- package/dist/lib/git.js +66 -52
- package/dist/lib/task-git-commit.js +9 -3
- package/package.json +1 -1
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 =
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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 (
|
|
299
|
-
const
|
|
300
|
-
if (
|
|
301
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
-
|
|
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;
|