@goodtek/vibeops 3.0.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 +30 -0
- package/README.md +41 -3
- package/dist/commands/pull.js +16 -0
- package/dist/commands/task-add.js +175 -97
- package/dist/commands/task-del.js +7 -0
- package/dist/commands/task-ship.js +7 -0
- package/dist/commands/task-sync.js +60 -28
- package/dist/lib/git-integration-sync.js +77 -71
- package/dist/lib/git-worktree.js +214 -0
- package/dist/lib/git.js +128 -105
- package/dist/lib/task-del.js +7 -5
- package/dist/lib/task-git-commit.js +9 -3
- package/dist/lib/task-id-allocation.js +192 -0
- package/dist/lib/task-lock.js +356 -0
- package/dist/lib/task-new-cycle.js +12 -1
- package/dist/lib/task-push-pr.js +7 -0
- package/dist/lib/task-reship.js +27 -12
- package/dist/lib/task-start.js +35 -26
- package/package.json +1 -1
package/dist/lib/git.js
CHANGED
|
@@ -4,9 +4,16 @@ import { dim, log } from "./logger.js";
|
|
|
4
4
|
const exec = promisify(execFile);
|
|
5
5
|
/** Avoid `maxBuffer` errors on large `git commit` / `git add` output. */
|
|
6
6
|
const DEFAULT_GIT_MAX_BUFFER = 64 * 1024 * 1024;
|
|
7
|
+
/**
|
|
8
|
+
* Environment for every git call: output that VibeOps parses (porcelain, refusal
|
|
9
|
+
* messages) must not be localized.
|
|
10
|
+
*/
|
|
11
|
+
export function gitEnv() {
|
|
12
|
+
return { ...process.env, LC_ALL: "C", LANG: "C", LANGUAGE: "C" };
|
|
13
|
+
}
|
|
7
14
|
async function tryGit(cwd, args) {
|
|
8
15
|
try {
|
|
9
|
-
const { stdout } = await exec("git", args, { cwd });
|
|
16
|
+
const { stdout } = await exec("git", args, { cwd, env: gitEnv() });
|
|
10
17
|
return { stdout };
|
|
11
18
|
}
|
|
12
19
|
catch {
|
|
@@ -16,6 +23,7 @@ async function tryGit(cwd, args) {
|
|
|
16
23
|
export async function runGit(cwd, args, options = {}) {
|
|
17
24
|
const { stdout, stderr } = await exec("git", args, {
|
|
18
25
|
cwd,
|
|
26
|
+
env: gitEnv(),
|
|
19
27
|
maxBuffer: options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER,
|
|
20
28
|
});
|
|
21
29
|
return { stdout, stderr };
|
|
@@ -134,69 +142,68 @@ export async function gitCreateBranch(cwd, name, startPoint) {
|
|
|
134
142
|
await runGit(cwd, args);
|
|
135
143
|
}
|
|
136
144
|
/**
|
|
137
|
-
* When only governance / VibeOps paths are dirty,
|
|
138
|
-
*
|
|
145
|
+
* When only governance / VibeOps paths are dirty, set aside the TRACKED
|
|
146
|
+
* governance modifications so `git switch` can proceed. Untracked files are not
|
|
147
|
+
* stashed — they move with the switch.
|
|
148
|
+
*
|
|
149
|
+
* `refs/stash` is shared by every worktree of a repository, so this never uses
|
|
150
|
+
* `stash push` / `stash pop` (which act on the top entry, possibly another
|
|
151
|
+
* worktree's). It creates a stash commit (`git stash create`), records it in the
|
|
152
|
+
* stash list for safety (`git stash store`), and returns its SHA; restore with
|
|
153
|
+
* {@link restoreGovernanceStashAfterSwitch}, which applies and drops exactly that
|
|
154
|
+
* entry. Returns null when nothing was set aside.
|
|
139
155
|
*/
|
|
140
156
|
export async function stashGovernanceIfBlocking(cwd) {
|
|
141
157
|
const git = await readGitInfo(cwd);
|
|
142
158
|
if (git.dirty !== true)
|
|
143
|
-
return
|
|
159
|
+
return null;
|
|
144
160
|
const gov = await gitGovernanceOnlyDirty(cwd);
|
|
145
161
|
if (!gov.onlyGovernance || gov.allPaths.length === 0)
|
|
146
|
-
return
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
await runGit(cwd, [
|
|
161
|
-
"stash",
|
|
162
|
-
"push",
|
|
163
|
-
"-m",
|
|
164
|
-
"vibeops: governance before branch switch",
|
|
165
|
-
"--",
|
|
166
|
-
...tracked,
|
|
167
|
-
]);
|
|
168
|
-
didStash = true;
|
|
169
|
-
}
|
|
170
|
-
if (untracked.length > 0) {
|
|
171
|
-
try {
|
|
172
|
-
await runGit(cwd, [
|
|
173
|
-
"stash",
|
|
174
|
-
"push",
|
|
175
|
-
"-u",
|
|
176
|
-
"-m",
|
|
177
|
-
"vibeops: governance untracked before branch switch",
|
|
178
|
-
"--",
|
|
179
|
-
...untracked,
|
|
180
|
-
]);
|
|
181
|
-
didStash = true;
|
|
182
|
-
}
|
|
183
|
-
catch {
|
|
184
|
-
// Untracked generated files (e.g. cursor-implement-*.md) may not stash; switch can still proceed.
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
return didStash;
|
|
162
|
+
return null;
|
|
163
|
+
const tracked = (await gitStatusEntries(cwd))
|
|
164
|
+
.filter((e) => !e.untracked && isGovernanceDocumentationPath(e.path))
|
|
165
|
+
.map((e) => e.path);
|
|
166
|
+
if (tracked.length === 0)
|
|
167
|
+
return null;
|
|
168
|
+
const message = "vibeops: governance before branch switch";
|
|
169
|
+
const { stdout } = await runGit(cwd, ["stash", "create", message]);
|
|
170
|
+
const sha = stdout.trim();
|
|
171
|
+
if (sha.length === 0)
|
|
172
|
+
return null;
|
|
173
|
+
await runGit(cwd, ["stash", "store", "-q", "-m", message, sha]);
|
|
174
|
+
await runGit(cwd, ["restore", "--source=HEAD", "--staged", "--worktree", "--", ...tracked]);
|
|
175
|
+
return sha;
|
|
188
176
|
}
|
|
189
|
-
/**
|
|
190
|
-
|
|
191
|
-
|
|
177
|
+
/**
|
|
178
|
+
* Re-apply the governance changes set aside by {@link stashGovernanceIfBlocking}
|
|
179
|
+
* (by SHA) and drop exactly that stash entry — never another worktree's.
|
|
180
|
+
*/
|
|
181
|
+
export async function restoreGovernanceStashAfterSwitch(cwd, stashSha) {
|
|
182
|
+
if (stashSha === null)
|
|
183
|
+
return;
|
|
184
|
+
try {
|
|
185
|
+
await runGit(cwd, ["stash", "apply", "-q", stashSha]);
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
const msg = e instanceof Error ? e.message.split("\n")[0] : String(e);
|
|
189
|
+
log.warn(`Could not restore governance changes (${msg}). They are kept in the stash list: ${dim(`git stash apply ${stashSha}`)}`);
|
|
192
190
|
return;
|
|
191
|
+
}
|
|
193
192
|
try {
|
|
194
|
-
await runGit(cwd, ["stash", "
|
|
195
|
-
|
|
193
|
+
const { stdout } = await runGit(cwd, ["stash", "list", "--format=%H"]);
|
|
194
|
+
const index = stdout.split("\n").indexOf(stashSha);
|
|
195
|
+
if (index >= 0) {
|
|
196
|
+
const ref = `stash@{${index}}`;
|
|
197
|
+
// Re-check right before dropping: the list is shared with other worktrees.
|
|
198
|
+
if ((await gitRevParse(cwd, ref)) === stashSha) {
|
|
199
|
+
await runGit(cwd, ["stash", "drop", "-q", ref]);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
log.info(dim("Restored governance changes after the branch switch."));
|
|
196
203
|
}
|
|
197
204
|
catch (e) {
|
|
198
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
199
|
-
log.warn(`
|
|
205
|
+
const msg = e instanceof Error ? e.message.split("\n")[0] : String(e);
|
|
206
|
+
log.warn(`Restored governance changes; could not drop stash entry ${stashSha} (${msg}).`);
|
|
200
207
|
}
|
|
201
208
|
}
|
|
202
209
|
export async function gitCheckout(cwd, ref) {
|
|
@@ -207,12 +214,14 @@ export async function gitCheckout(cwd, ref) {
|
|
|
207
214
|
await runGit(cwd, ["switch", ref]);
|
|
208
215
|
await restoreGovernanceStashAfterSwitch(cwd, stashed);
|
|
209
216
|
}
|
|
210
|
-
export async function gitCheckoutNewBranch(cwd, name, startPoint) {
|
|
217
|
+
export async function gitCheckoutNewBranch(cwd, name, startPoint, opts = {}) {
|
|
211
218
|
const stashed = await stashGovernanceIfBlocking(cwd);
|
|
212
219
|
if (stashed) {
|
|
213
220
|
log.info(dim("Stashed governance-only changes (.vibeops/, docs/) before branch switch."));
|
|
214
221
|
}
|
|
215
222
|
const args = ["switch", "-c", name];
|
|
223
|
+
if (opts.noTrack === true)
|
|
224
|
+
args.push("--no-track");
|
|
216
225
|
if (typeof startPoint === "string" && startPoint.length > 0)
|
|
217
226
|
args.push(startPoint);
|
|
218
227
|
await runGit(cwd, args);
|
|
@@ -247,52 +256,51 @@ export async function gitStatusPorcelain(cwd) {
|
|
|
247
256
|
return [];
|
|
248
257
|
return res.stdout.split("\n").filter((line) => line.length > 0);
|
|
249
258
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
258
|
-
if (code === "!!") {
|
|
259
|
-
return null;
|
|
260
|
-
}
|
|
261
|
-
const x = code.charAt(0);
|
|
262
|
-
const y = code.charAt(1);
|
|
263
|
-
// Rename / copy: `R old -> new` or `C old -> new`
|
|
264
|
-
if (x === "R" || x === "C") {
|
|
265
|
-
const arrow = rest.indexOf(" -> ");
|
|
266
|
-
if (arrow >= 0) {
|
|
267
|
-
const origPath = rest.slice(0, arrow);
|
|
268
|
-
const path = rest.slice(arrow + 4);
|
|
269
|
-
return {
|
|
270
|
-
code,
|
|
271
|
-
staged: true,
|
|
272
|
-
unstaged: y !== " " && y !== "?",
|
|
273
|
-
untracked: false,
|
|
274
|
-
path,
|
|
275
|
-
origPath,
|
|
276
|
-
};
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
return {
|
|
280
|
-
code,
|
|
281
|
-
staged: x !== " " && x !== "?",
|
|
282
|
-
unstaged: y !== " " && y !== "?",
|
|
283
|
-
untracked: false,
|
|
284
|
-
path: rest,
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
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");
|
|
288
266
|
const out = [];
|
|
289
|
-
for (
|
|
290
|
-
const
|
|
291
|
-
if (
|
|
292
|
-
|
|
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
|
+
});
|
|
293
294
|
}
|
|
294
295
|
return out;
|
|
295
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
|
+
}
|
|
296
304
|
/** Paths under these prefixes may stay uncommitted across `start` / `done` / doc updates. */
|
|
297
305
|
const GOVERNANCE_DOC_REL_PREFIXES = [
|
|
298
306
|
"docs/tasks/",
|
|
@@ -380,7 +388,7 @@ export async function tryResolveGovernanceUnmerged(cwd) {
|
|
|
380
388
|
return resolved;
|
|
381
389
|
}
|
|
382
390
|
export async function listWorkingTreeRelPaths(cwd) {
|
|
383
|
-
const entries =
|
|
391
|
+
const entries = await gitStatusEntries(cwd);
|
|
384
392
|
const all = new Set();
|
|
385
393
|
for (const e of entries) {
|
|
386
394
|
all.add(e.path);
|
|
@@ -407,13 +415,29 @@ export function partitionPathsForAutoCommit(paths, opts = {}) {
|
|
|
407
415
|
}
|
|
408
416
|
return { committable, excluded, unmerged };
|
|
409
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
|
+
*/
|
|
410
434
|
export async function gitAddPaths(cwd, paths) {
|
|
411
435
|
if (paths.length === 0)
|
|
412
436
|
return;
|
|
413
437
|
const chunkSize = 80;
|
|
414
438
|
for (let i = 0; i < paths.length; i += chunkSize) {
|
|
415
|
-
const slice = paths.slice(i, i + chunkSize);
|
|
416
|
-
await runGit(cwd, ["add", "--", ...slice]);
|
|
439
|
+
const slice = paths.slice(i, i + chunkSize).map((p) => `:(literal)${p}`);
|
|
440
|
+
await runGit(cwd, ["add", "-A", "--", ...slice]);
|
|
417
441
|
}
|
|
418
442
|
}
|
|
419
443
|
/**
|
|
@@ -423,8 +447,7 @@ export async function gitAddPaths(cwd, paths) {
|
|
|
423
447
|
* block the next TASK unless application code is also dirty.
|
|
424
448
|
*/
|
|
425
449
|
export async function gitGovernanceOnlyDirty(cwd) {
|
|
426
|
-
const
|
|
427
|
-
const entries = parsePorcelain(lines);
|
|
450
|
+
const entries = await gitStatusEntries(cwd);
|
|
428
451
|
const all = new Set();
|
|
429
452
|
for (const e of entries) {
|
|
430
453
|
all.add(e.path);
|
|
@@ -441,15 +464,15 @@ export async function gitGovernanceOnlyDirty(cwd) {
|
|
|
441
464
|
};
|
|
442
465
|
}
|
|
443
466
|
export async function gitWorkingTreeChangedFiles(cwd) {
|
|
444
|
-
const entries =
|
|
467
|
+
const entries = await gitStatusEntries(cwd);
|
|
445
468
|
return entries.filter((e) => e.unstaged).map((e) => e.path);
|
|
446
469
|
}
|
|
447
470
|
export async function gitStagedChangedFiles(cwd) {
|
|
448
|
-
const entries =
|
|
471
|
+
const entries = await gitStatusEntries(cwd);
|
|
449
472
|
return entries.filter((e) => e.staged).map((e) => e.path);
|
|
450
473
|
}
|
|
451
474
|
export async function gitUntrackedFiles(cwd) {
|
|
452
|
-
const entries =
|
|
475
|
+
const entries = await gitStatusEntries(cwd);
|
|
453
476
|
return entries.filter((e) => e.untracked).map((e) => e.path);
|
|
454
477
|
}
|
|
455
478
|
export async function gitCommittedChangedFilesSince(baseCommit, cwd) {
|
|
@@ -458,7 +481,7 @@ export async function gitCommittedChangedFilesSince(baseCommit, cwd) {
|
|
|
458
481
|
return gitDiffNameOnly(cwd, `${baseCommit}..HEAD`);
|
|
459
482
|
}
|
|
460
483
|
export async function gitAllChangedFilesSinceTaskStart(baseCommit, cwd) {
|
|
461
|
-
const entries =
|
|
484
|
+
const entries = await gitStatusEntries(cwd);
|
|
462
485
|
const working = entries.filter((e) => e.unstaged).map((e) => e.path);
|
|
463
486
|
const staged = entries.filter((e) => e.staged).map((e) => e.path);
|
|
464
487
|
const untracked = entries.filter((e) => e.untracked).map((e) => e.path);
|
package/dist/lib/task-del.js
CHANGED
|
@@ -2,7 +2,8 @@ import { resolve } from "node:path";
|
|
|
2
2
|
import { deleteFile } from "./filesystem.js";
|
|
3
3
|
import { GitConfigError, requireGitConfig } from "./git-config.js";
|
|
4
4
|
import { mergeRequestLabel } from "./git-host.js";
|
|
5
|
-
import { gitBranchExists, gitDeleteBranch, gitDeleteRemoteBranch, gitGovernanceOnlyDirty, gitRemoteBranchExists,
|
|
5
|
+
import { gitBranchExists, gitDeleteBranch, gitDeleteRemoteBranch, gitGovernanceOnlyDirty, gitRemoteBranchExists, readGitInfo, } from "./git.js";
|
|
6
|
+
import { leaveTaskBranch } from "./git-worktree.js";
|
|
6
7
|
import { dim, log } from "./logger.js";
|
|
7
8
|
import { closeMergeRequest, findMergeRequestByBranches, probeMergeRequestCli, } from "./pr-create.js";
|
|
8
9
|
import { normalizeTaskRef } from "./resolve-task.js";
|
|
@@ -83,12 +84,13 @@ export async function deleteTaskBranches(opts) {
|
|
|
83
84
|
}
|
|
84
85
|
const git = await readGitInfo(cwd);
|
|
85
86
|
if (git.branch === opts.taskBranch) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
// Worktree-safe (D-006): detach when another worktree owns the integration branch.
|
|
88
|
+
const left = await leaveTaskBranch(cwd, opts.remote, opts.integrationBranch);
|
|
89
|
+
if (!left.ok) {
|
|
90
|
+
log.error(left.message);
|
|
89
91
|
return false;
|
|
90
92
|
}
|
|
91
|
-
log.ok(`On ${opts.integrationBranch}`);
|
|
93
|
+
log.ok(left.on === "integration" ? `On ${opts.integrationBranch}` : `Detached at ${left.ref}`);
|
|
92
94
|
}
|
|
93
95
|
if (await gitBranchExists(cwd, opts.taskBranch)) {
|
|
94
96
|
try {
|
|
@@ -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;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { readdir, realpath } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
3
|
+
import { gitRemoteUrl, runGit } from "./git.js";
|
|
4
|
+
import { currentWorktreeRoot, listWorktrees } from "./git-worktree.js";
|
|
5
|
+
/**
|
|
6
|
+
* Collision-free TASK ids (D-006).
|
|
7
|
+
*
|
|
8
|
+
* next id = 1 + max TASK number found in ANY of:
|
|
9
|
+
* 1. local `docs/tasks` (this worktree, including uncommitted files);
|
|
10
|
+
* 2. `docs/tasks` on `<remote>/<integration>` (`git ls-tree`), else local `<integration>`;
|
|
11
|
+
* 3. VibeOps task branches — local heads and `<remote>/task/*` (refreshed with
|
|
12
|
+
* `git fetch --prune <remote> +refs/heads/task/*:refs/remotes/<remote>/task/*`) —
|
|
13
|
+
* counted only when the branch name has the generated form AND the branch's
|
|
14
|
+
* tree contains that TASK file (so `task/2026-q4-plan` does not inflate ids);
|
|
15
|
+
* 4. `docs/tasks` in every worktree from `git worktree list` (covers task branches
|
|
16
|
+
* whose TASK file is not committed yet).
|
|
17
|
+
*
|
|
18
|
+
* A configured remote whose task branches cannot be fetched is an error (fail
|
|
19
|
+
* closed) — an id could otherwise collide with an unmerged branch.
|
|
20
|
+
*/
|
|
21
|
+
const TASK_FILE_RE = /^TASK-(\d+)(?:-|\.md$)/i;
|
|
22
|
+
/**
|
|
23
|
+
* Generated task branch form: `task/<NNN>` or `task/<NNN>-<slug>`, where NNN is the
|
|
24
|
+
* zero-padded TASK number (≥ 3 digits, `formatTaskId`) and slug is `slugify` output
|
|
25
|
+
* (`[a-z0-9]+` joined by single hyphens) — i.e. `branchNameForTaskFile` of
|
|
26
|
+
* `TASK-<NNN>-<slug>.md`.
|
|
27
|
+
*/
|
|
28
|
+
export const GENERATED_TASK_BRANCH_RE = /^task\/(\d{3,})(?:-([a-z0-9]+(?:-[a-z0-9]+)*))?$/;
|
|
29
|
+
export function taskNumberFromFilename(name) {
|
|
30
|
+
const m = TASK_FILE_RE.exec(name.trim());
|
|
31
|
+
return m ? Number.parseInt(m[1], 10) : null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* TASK number of a branch in the generated form (accepts `refs/heads/` and
|
|
35
|
+
* `refs/remotes/<remote>/` prefixes), else null.
|
|
36
|
+
*/
|
|
37
|
+
export function taskNumberFromBranch(ref) {
|
|
38
|
+
const name = ref
|
|
39
|
+
.trim()
|
|
40
|
+
.replace(/^refs\/heads\//, "")
|
|
41
|
+
.replace(/^refs\/remotes\/[^/]+\//, "");
|
|
42
|
+
const m = GENERATED_TASK_BRANCH_RE.exec(name);
|
|
43
|
+
return m ? Number.parseInt(m[1], 10) : null;
|
|
44
|
+
}
|
|
45
|
+
export class TaskIdAllocationError extends Error {
|
|
46
|
+
constructor(message) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = "TaskIdAllocationError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function maxInDir(dir) {
|
|
52
|
+
let max = 0;
|
|
53
|
+
let names;
|
|
54
|
+
try {
|
|
55
|
+
names = await readdir(dir);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
for (const n of names) {
|
|
61
|
+
const v = taskNumberFromFilename(n);
|
|
62
|
+
if (v !== null)
|
|
63
|
+
max = Math.max(max, v);
|
|
64
|
+
}
|
|
65
|
+
return max;
|
|
66
|
+
}
|
|
67
|
+
function maxOf(values) {
|
|
68
|
+
let max = 0;
|
|
69
|
+
for (const v of values)
|
|
70
|
+
if (v !== null)
|
|
71
|
+
max = Math.max(max, v);
|
|
72
|
+
return max;
|
|
73
|
+
}
|
|
74
|
+
async function refExists(cwd, ref) {
|
|
75
|
+
try {
|
|
76
|
+
await runGit(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function branchHasTaskFile(cwd, ref, tasksRel, n) {
|
|
84
|
+
try {
|
|
85
|
+
const { stdout } = await runGit(cwd, ["ls-tree", "--name-only", `${ref}:${tasksRel}`]);
|
|
86
|
+
return stdout.split("\n").some((f) => taskNumberFromFilename(f) === n);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return false; // no docs/tasks in that tree
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Cross-machine guard for `task ship`: refuse to push `taskBranch` when the remote
|
|
94
|
+
* already has a different generated task branch with the same TASK number (another
|
|
95
|
+
* machine allocated the same id). Fails closed when the remote cannot be listed.
|
|
96
|
+
*/
|
|
97
|
+
export async function assertTaskIdFreeOnRemote(cwd, remote, taskBranch) {
|
|
98
|
+
const n = taskNumberFromBranch(taskBranch);
|
|
99
|
+
if (n === null)
|
|
100
|
+
return { ok: true };
|
|
101
|
+
if ((await gitRemoteUrl(cwd, remote)) === null)
|
|
102
|
+
return { ok: true };
|
|
103
|
+
let stdout;
|
|
104
|
+
try {
|
|
105
|
+
({ stdout } = await runGit(cwd, ["ls-remote", "--heads", remote, "task/*"]));
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
const msg = e instanceof Error ? e.message.split("\n")[0] : String(e);
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
message: `Cannot list task branches on ${remote} (${msg}) — refusing to push without checking for a TASK id collision.`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const clashes = stdout
|
|
115
|
+
.split("\n")
|
|
116
|
+
.map((l) => (l.split("\t")[1] ?? "").replace(/^refs\/heads\//, ""))
|
|
117
|
+
.filter((name) => name.length > 0 && name !== taskBranch && taskNumberFromBranch(name) === n);
|
|
118
|
+
if (clashes.length > 0) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
message: `TASK id collision: ${remote} already has ${clashes.join(", ")} for TASK-${String(n).padStart(3, "0")}. Not pushing ${taskBranch}. Renumber this TASK (file + branch) and rerun.`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return { ok: true };
|
|
125
|
+
}
|
|
126
|
+
export async function allocateTaskId(input) {
|
|
127
|
+
const { cwd, tasksDir, remote, integrationBranch } = input;
|
|
128
|
+
const root = await currentWorktreeRoot(cwd);
|
|
129
|
+
// Compare canonical paths (macOS /var → /private/var) to get the repo-relative dir.
|
|
130
|
+
const tasksAbs = await realpath(tasksDir).catch(async () => join(await realpath(dirname(tasksDir)), basename(tasksDir)));
|
|
131
|
+
const tasksRel = relative(root, tasksAbs).split("\\").join("/");
|
|
132
|
+
if (tasksRel.startsWith("..") || tasksRel.length === 0) {
|
|
133
|
+
throw new Error(`TASK directory ${tasksDir} is not inside the worktree ${root}.`);
|
|
134
|
+
}
|
|
135
|
+
// 1. local docs/tasks
|
|
136
|
+
const localFiles = await maxInDir(tasksDir);
|
|
137
|
+
// 2. integration tree
|
|
138
|
+
let integrationTree = 0;
|
|
139
|
+
const treeRef = (await refExists(cwd, `refs/remotes/${remote}/${integrationBranch}`))
|
|
140
|
+
? `${remote}/${integrationBranch}`
|
|
141
|
+
: (await refExists(cwd, `refs/heads/${integrationBranch}`))
|
|
142
|
+
? integrationBranch
|
|
143
|
+
: null;
|
|
144
|
+
if (treeRef !== null) {
|
|
145
|
+
const { stdout } = await runGit(cwd, ["ls-tree", "--name-only", `${treeRef}:${tasksRel}`]).catch(() => ({ stdout: "" }));
|
|
146
|
+
integrationTree = maxOf(stdout.split("\n").map(taskNumberFromFilename));
|
|
147
|
+
}
|
|
148
|
+
// 3. VibeOps task branches (local + freshly fetched remote), with TASK-file evidence
|
|
149
|
+
if (input.fetchRemote !== false && (await gitRemoteUrl(cwd, remote)) !== null) {
|
|
150
|
+
try {
|
|
151
|
+
await runGit(cwd, [
|
|
152
|
+
"fetch",
|
|
153
|
+
"--prune",
|
|
154
|
+
remote,
|
|
155
|
+
`+refs/heads/task/*:refs/remotes/${remote}/task/*`,
|
|
156
|
+
]);
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
const msg = e instanceof Error ? e.message.split("\n")[0] : String(e);
|
|
160
|
+
throw new TaskIdAllocationError(`Cannot fetch task branches from ${remote} (${msg}) — refusing to pick a TASK id that may collide. Check network / auth and rerun.`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const { stdout: refs } = await runGit(cwd, [
|
|
164
|
+
"for-each-ref",
|
|
165
|
+
"--format=%(refname)",
|
|
166
|
+
"refs/heads/task/",
|
|
167
|
+
`refs/remotes/${remote}/task/`,
|
|
168
|
+
]);
|
|
169
|
+
let branches = 0;
|
|
170
|
+
for (const ref of refs.split("\n").filter((r) => r.length > 0)) {
|
|
171
|
+
const n = taskNumberFromBranch(ref);
|
|
172
|
+
if (n === null || n <= branches)
|
|
173
|
+
continue;
|
|
174
|
+
if (await branchHasTaskFile(cwd, ref, tasksRel, n))
|
|
175
|
+
branches = n;
|
|
176
|
+
}
|
|
177
|
+
// 4. every worktree's docs/tasks
|
|
178
|
+
let worktrees = 0;
|
|
179
|
+
for (const wt of await listWorktrees(cwd)) {
|
|
180
|
+
if (wt.bare)
|
|
181
|
+
continue;
|
|
182
|
+
worktrees = Math.max(worktrees, await maxInDir(join(wt.path, tasksRel)));
|
|
183
|
+
}
|
|
184
|
+
const sources = {
|
|
185
|
+
localFiles,
|
|
186
|
+
integrationTree,
|
|
187
|
+
branches,
|
|
188
|
+
worktrees,
|
|
189
|
+
};
|
|
190
|
+
const max = Math.max(...Object.values(sources));
|
|
191
|
+
return { next: max + 1, max, sources };
|
|
192
|
+
}
|