@goodtek/vibeops 2.5.2 → 3.1.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.
@@ -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
+ }
@@ -0,0 +1,356 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { linkSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { hostname } from "node:os";
4
+ import { isAbsolute, join, resolve } from "node:path";
5
+ import { readTextOrNull } from "./filesystem.js";
6
+ import { isGitRepository, runGit } from "./git.js";
7
+ import { dim, log } from "./logger.js";
8
+ import { VIBEOPS_CONFIG_FILE } from "./paths.js";
9
+ /**
10
+ * Repository-wide lock for VibeOps operations that allocate TASK ids, write TASK
11
+ * files, create / switch / delete task branches, or stash (D-006). All worktrees
12
+ * of a repository share one git common dir, so the lock file lives there:
13
+ * `<git-common-dir>/vibeops-task.lock`, created atomically with its content
14
+ * (temp file + `link`, EEXIST when held).
15
+ *
16
+ * Stale lock: holder on THIS host whose pid is no longer alive, or a lock older
17
+ * than `lock.staleSeconds` on ANY host (covers pid reuse on this host and
18
+ * unverifiable pids on other hosts). A start time in the future (clock skew) is
19
+ * respected, not stale. Removal (release / stale takeover) is guarded by a
20
+ * breaker file — see {@link withBreakerSync}.
21
+ */
22
+ export const LOCK_FILE_NAME = "vibeops-task.lock";
23
+ export const TASK_LOCK_DEFAULTS = { waitSeconds: 60, staleSeconds: 600 };
24
+ export const TASK_LOCK_LIMITS = {
25
+ waitSeconds: { min: 0, max: 600 },
26
+ staleSeconds: { min: 60, max: 86_400 },
27
+ };
28
+ export class TaskLockConfigError extends Error {
29
+ constructor(message) {
30
+ super(`${VIBEOPS_CONFIG_FILE} lock: ${message}`);
31
+ this.name = "TaskLockConfigError";
32
+ }
33
+ }
34
+ export class TaskLockHeldError extends Error {
35
+ constructor(message) {
36
+ super(message);
37
+ this.name = "TaskLockHeldError";
38
+ }
39
+ }
40
+ /** Validate `.vibeops.json` `lock` (fail closed). `undefined` → defaults. */
41
+ export function parseTaskLockConfig(raw) {
42
+ if (raw === undefined)
43
+ return TASK_LOCK_DEFAULTS;
44
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
45
+ throw new TaskLockConfigError("must be an object.");
46
+ }
47
+ const o = raw;
48
+ const allowed = Object.keys(TASK_LOCK_LIMITS);
49
+ const unknown = Object.keys(o).filter((k) => !allowed.includes(k));
50
+ if (unknown.length > 0) {
51
+ throw new TaskLockConfigError(`unknown key ${unknown.map((k) => `\`${k}\``).join(", ")} (allowed: ${allowed.join(", ")}).`);
52
+ }
53
+ const out = { ...TASK_LOCK_DEFAULTS };
54
+ for (const key of allowed) {
55
+ const v = o[key];
56
+ if (v === undefined)
57
+ continue;
58
+ const { min, max } = TASK_LOCK_LIMITS[key];
59
+ if (typeof v !== "number" || !Number.isInteger(v) || v < min || v > max) {
60
+ throw new TaskLockConfigError(`\`${key}\` must be an integer between ${min} and ${max}.`);
61
+ }
62
+ out[key] = v;
63
+ }
64
+ return out;
65
+ }
66
+ export async function readTaskLockConfig(root) {
67
+ const text = await readTextOrNull(join(root, VIBEOPS_CONFIG_FILE));
68
+ if (text === null)
69
+ return TASK_LOCK_DEFAULTS;
70
+ let parsed;
71
+ try {
72
+ parsed = JSON.parse(text);
73
+ }
74
+ catch {
75
+ throw new TaskLockConfigError("file is not valid JSON.");
76
+ }
77
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
78
+ return TASK_LOCK_DEFAULTS;
79
+ }
80
+ return parseTaskLockConfig(parsed.lock);
81
+ }
82
+ export async function taskLockPath(cwd) {
83
+ const { stdout } = await runGit(cwd, ["rev-parse", "--git-common-dir"]);
84
+ const dir = stdout.trim();
85
+ return join(isAbsolute(dir) ? dir : resolve(cwd, dir), LOCK_FILE_NAME);
86
+ }
87
+ function pidAlive(pid) {
88
+ try {
89
+ process.kill(pid, 0);
90
+ return true;
91
+ }
92
+ catch (e) {
93
+ return e.code === "EPERM";
94
+ }
95
+ }
96
+ function readLockSync(path) {
97
+ try {
98
+ const v = JSON.parse(readFileSync(path, "utf8"));
99
+ // pid + host are enough to judge staleness; token / startedAt may be missing
100
+ // in a foreign or older lock file.
101
+ return typeof v.pid === "number" && typeof v.host === "string" ? v : null;
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ function inodeOf(path) {
108
+ try {
109
+ return statSync(path).ino;
110
+ }
111
+ catch {
112
+ return null;
113
+ }
114
+ }
115
+ /** Why the existing lock is stale, or null when it must be respected. */
116
+ export function staleReason(info, cfg, now = Date.now(), mtimeMs = null) {
117
+ if (info === null) {
118
+ // Unparseable (foreign / truncated) file: stale only by age.
119
+ return mtimeMs !== null && (now - mtimeMs) / 1000 > cfg.staleSeconds
120
+ ? `unreadable and older than ${cfg.staleSeconds}s`
121
+ : null;
122
+ }
123
+ if (info.host === hostname() && !pidAlive(info.pid)) {
124
+ return `pid ${info.pid} is not running`;
125
+ }
126
+ const age = (now - Date.parse(info.startedAt)) / 1000;
127
+ return Number.isFinite(age) && age > cfg.staleSeconds
128
+ ? `older than ${cfg.staleSeconds}s (pid ${info.pid} on ${info.host})`
129
+ : null;
130
+ }
131
+ function describe(info, path) {
132
+ if (info === null)
133
+ return `lock file ${path}`;
134
+ return `pid ${info.pid} on ${info.host} since ${info.startedAt} (${info.operation} in ${info.cwd})`;
135
+ }
136
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
137
+ const sleepSync = (ms) => {
138
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
139
+ };
140
+ /**
141
+ * Create `path` atomically WITH its content: write a private temp file, then
142
+ * `link` it into place (fails with EEXIST if `path` exists). Readers never see a
143
+ * half-written lock. Returns false when the lock already exists.
144
+ */
145
+ function createExclusiveSync(path, content) {
146
+ const tmp = `${path}.${randomUUID()}.tmp`;
147
+ writeFileSync(tmp, content, { flag: "wx" });
148
+ try {
149
+ linkSync(tmp, path);
150
+ return true;
151
+ }
152
+ catch (e) {
153
+ if (e.code === "EEXIST")
154
+ return false;
155
+ throw e;
156
+ }
157
+ finally {
158
+ unlinkSync(tmp);
159
+ }
160
+ }
161
+ /**
162
+ * Internal, not configurable: a breaker is held for microseconds (re-check +
163
+ * unlink), so these only matter after a crash inside that window.
164
+ * - BREAKER_STALE_MS: a breaker older than this was left by a crash.
165
+ * - BREAKER_WAIT_MS: upper bound on waiting for the breaker. A stale TAKEOVER
166
+ * waits min(this, lock.waitSeconds) — the user's wait budget. RELEASE always
167
+ * uses this full bound: giving up early would leave the lock behind.
168
+ */
169
+ const BREAKER_STALE_MS = 30_000;
170
+ const BREAKER_WAIT_MS = 60_000;
171
+ /**
172
+ * Every REMOVAL of the lock file (release, stale takeover) happens while holding
173
+ * the breaker `<lock>.break`. Creation needs the lock to be absent (link), so
174
+ * while the breaker is held the lock file cannot change identity: "verify
175
+ * identity, then unlink" is atomic with respect to all other VibeOps processes.
176
+ */
177
+ function withBreakerSync(lockPath, fn, waitMs = BREAKER_WAIT_MS) {
178
+ const breaker = `${lockPath}.break`;
179
+ const token = randomUUID();
180
+ const deadline = Date.now() + Math.min(BREAKER_WAIT_MS, waitMs);
181
+ let loggedWait = false;
182
+ for (;;) {
183
+ if (createExclusiveSync(breaker, `${JSON.stringify({ token, pid: process.pid, host: hostname() })}\n`)) {
184
+ break;
185
+ }
186
+ let st = null;
187
+ try {
188
+ st = statSync(breaker);
189
+ }
190
+ catch {
191
+ continue; // released meanwhile
192
+ }
193
+ if (Date.now() - st.mtimeMs > BREAKER_STALE_MS) {
194
+ // Crash inside a breaker section: move it aside, delete only if it is
195
+ // still the same file (inode) we judged stale.
196
+ const aside = `${breaker}.stale-${randomUUID()}`;
197
+ try {
198
+ renameSync(breaker, aside);
199
+ if (statSync(aside).ino === st.ino)
200
+ unlinkSync(aside);
201
+ else {
202
+ try {
203
+ linkSync(aside, breaker);
204
+ }
205
+ catch {
206
+ // a fresh breaker exists again; ours is not needed
207
+ }
208
+ unlinkSync(aside);
209
+ }
210
+ }
211
+ catch {
212
+ // someone else handled it
213
+ }
214
+ continue;
215
+ }
216
+ if (Date.now() >= deadline) {
217
+ throw new TaskLockHeldError(`VibeOps lock breaker ${breaker} is held; retry, or delete it if no vibeops process is running.`);
218
+ }
219
+ if (!loggedWait) {
220
+ log.info(dim(`Waiting for VibeOps lock breaker ${breaker}…`));
221
+ loggedWait = true;
222
+ }
223
+ sleepSync(2);
224
+ }
225
+ try {
226
+ return fn();
227
+ }
228
+ finally {
229
+ try {
230
+ const cur = JSON.parse(readFileSync(breaker, "utf8"));
231
+ if (cur.token === token)
232
+ unlinkSync(breaker);
233
+ }
234
+ catch {
235
+ // already gone
236
+ }
237
+ }
238
+ }
239
+ /** Unlink the lock only if it is still the file identified by (inode, token). */
240
+ function removeLockIfSame(path, ino, token, breakerWaitMs) {
241
+ return withBreakerSync(path, () => {
242
+ if (inodeOf(path) !== ino)
243
+ return false;
244
+ const cur = readLockSync(path);
245
+ if ((typeof cur?.token === "string" ? cur.token : null) !== token)
246
+ return false;
247
+ unlinkSync(path);
248
+ return true;
249
+ }, breakerWaitMs);
250
+ }
251
+ /** Acquire the repository task lock, waiting up to `waitSeconds` for another holder. */
252
+ export async function acquireTaskLock(cwd, operation, cfg = TASK_LOCK_DEFAULTS) {
253
+ const path = await taskLockPath(cwd);
254
+ const info = {
255
+ token: randomUUID(),
256
+ pid: process.pid,
257
+ host: hostname(),
258
+ startedAt: new Date().toISOString(),
259
+ operation,
260
+ cwd: resolve(cwd),
261
+ };
262
+ const content = `${JSON.stringify(info)}\n`;
263
+ const deadline = Date.now() + cfg.waitSeconds * 1000;
264
+ let loggedWait = false;
265
+ for (;;) {
266
+ if (createExclusiveSync(path, content))
267
+ break;
268
+ let st;
269
+ try {
270
+ st = statSync(path);
271
+ }
272
+ catch {
273
+ continue; // released meanwhile
274
+ }
275
+ const holder = readLockSync(path);
276
+ const stale = staleReason(holder, cfg, Date.now(), st.mtimeMs);
277
+ if (stale !== null) {
278
+ const judgedToken = typeof holder?.token === "string" ? holder.token : null;
279
+ // Takeover honours the user's wait budget; release (below) does not.
280
+ if (removeLockIfSame(path, st.ino, judgedToken, cfg.waitSeconds * 1000)) {
281
+ log.warn(`Removed stale VibeOps lock (${stale}): ${path}`);
282
+ }
283
+ continue;
284
+ }
285
+ if (Date.now() >= deadline) {
286
+ throw new TaskLockHeldError(`Another VibeOps task operation holds the repository lock: ${describe(holder, path)}. ` +
287
+ `Wait for it to finish and rerun. If that process is gone, delete ${path}.`);
288
+ }
289
+ if (!loggedWait) {
290
+ log.info(dim(`Waiting for VibeOps lock held by ${describe(holder, path)}…`));
291
+ loggedWait = true;
292
+ }
293
+ await sleep(Math.min(50, Math.max(0, deadline - Date.now())));
294
+ }
295
+ const ino = inodeOf(path);
296
+ let released = false;
297
+ const releaseSync = () => {
298
+ if (released)
299
+ return;
300
+ released = true;
301
+ if (ino !== null)
302
+ removeLockIfSame(path, ino, info.token);
303
+ };
304
+ process.once("exit", releaseSync);
305
+ return {
306
+ path,
307
+ async release() {
308
+ process.removeListener("exit", releaseSync);
309
+ releaseSync();
310
+ },
311
+ };
312
+ }
313
+ /** Run `fn` while holding the repository task lock. */
314
+ export async function withTaskLock(cwd, operation, fn, cfg) {
315
+ const lock = await acquireTaskLock(cwd, operation, cfg ?? (await readTaskLockConfig(cwd)));
316
+ try {
317
+ return await fn();
318
+ }
319
+ finally {
320
+ await lock.release();
321
+ }
322
+ }
323
+ /**
324
+ * Command wrapper: run `fn` under the repository task lock, reporting lock /
325
+ * config errors with exit code 1. Outside a git repository `fn` runs unlocked
326
+ * (it reports that error itself).
327
+ */
328
+ export async function runUnderTaskLock(cwd, operation, fn) {
329
+ if (!(await isGitRepository(cwd))) {
330
+ await fn();
331
+ return;
332
+ }
333
+ let cfg;
334
+ try {
335
+ cfg = await readTaskLockConfig(cwd);
336
+ }
337
+ catch (e) {
338
+ if (e instanceof TaskLockConfigError) {
339
+ log.error(e.message);
340
+ process.exitCode = 1;
341
+ return;
342
+ }
343
+ throw e;
344
+ }
345
+ try {
346
+ await withTaskLock(cwd, operation, fn, cfg);
347
+ }
348
+ catch (e) {
349
+ if (e instanceof TaskLockHeldError) {
350
+ log.error(e.message);
351
+ process.exitCode = 1;
352
+ return;
353
+ }
354
+ throw e;
355
+ }
356
+ }
@@ -11,6 +11,7 @@ import { applyTaskShipMemory, llmCompleteTaskReship, writeTaskResultSections, }
11
11
  import { assertMergeRequestNotOpen, ensureTaskBranchForReship, integrateIntegrationIntoTaskBranch, loadGitConfigForReship, prepareGitContextForReship, refreshGitContextBaseCommit, resolveReshipTarget, } from "./task-reship.js";
12
12
  import { hasNonEmptySection, parseTaskFilename, readGitContext } from "./task.js";
13
13
  import { prRefLabel } from "./task-ship-state.js";
14
+ import { runUnderTaskLock } from "./task-lock.js";
14
15
  async function commitReshipMetadata(opts) {
15
16
  if (opts.dryRun) {
16
17
  log.info(dim(" would commit new-cycle metadata (Status stays Shipped)"));
@@ -56,7 +57,7 @@ export async function runPostMergeNewCycle(taskRef, options = {}) {
56
57
  process.exitCode = 1;
57
58
  return;
58
59
  }
59
- const branchOk = await ensureTaskBranchForReship({
60
+ const ensureBranch = () => ensureTaskBranchForReship({
60
61
  cwd,
61
62
  taskFile,
62
63
  gitCtx: initialCtx,
@@ -65,6 +66,16 @@ export async function runPostMergeNewCycle(taskRef, options = {}) {
65
66
  recreateBranch: options.recreateBranch === true,
66
67
  dryRun,
67
68
  });
69
+ let branchOk = false;
70
+ if (dryRun) {
71
+ branchOk = await ensureBranch();
72
+ }
73
+ else {
74
+ // D-006: branch switch / recreate / stash are serialized across worktrees.
75
+ await runUnderTaskLock(cwd, "task ship --new-cycle", async () => {
76
+ branchOk = await ensureBranch();
77
+ });
78
+ }
68
79
  if (!branchOk) {
69
80
  process.exitCode = 1;
70
81
  return;
@@ -8,6 +8,7 @@ import { dim, log } from "./logger.js";
8
8
  import { createMergeRequest, findMergeRequestByBranches, probeMergeRequestCli, } from "./pr-create.js";
9
9
  import { fallbackTaskPr, generateTaskPrWithLlm } from "./task-pr-llm.js";
10
10
  import { readGitContext, readTaskFile } from "./task.js";
11
+ import { assertTaskIdFreeOnRemote } from "./task-id-allocation.js";
11
12
  function mergeRequestAlreadyExistsMessage(msg) {
12
13
  const lower = msg.toLowerCase();
13
14
  return (lower.includes("already exists") ||
@@ -66,6 +67,12 @@ export async function finishTaskWithPullRequest(opts) {
66
67
  if (ahead === 0) {
67
68
  log.warn("No commits ahead of base — pushing branch anyway.");
68
69
  }
70
+ // D-006: another machine may have pushed a task branch with the same TASK id.
71
+ const idFree = await assertTaskIdFreeOnRemote(opts.cwd, gitCfg.remote, gitCtx.taskBranch);
72
+ if (!idFree.ok) {
73
+ log.error(idFree.message);
74
+ return { ok: false };
75
+ }
69
76
  try {
70
77
  await gitPush(opts.cwd, gitCfg.remote, gitCtx.taskBranch, true);
71
78
  log.ok(`Pushed ${gitCtx.taskBranch} → ${gitCfg.remote}`);
@@ -1,6 +1,7 @@
1
1
  import { resolve } from "node:path";
2
2
  import { GitConfigError, requireGitConfig } from "./git-config.js";
3
- import { gitBranchExists, gitCheckout, gitCheckoutNewBranch, gitDeleteBranch, gitFetchRemote, gitHeadCommit, gitMergeRemoteBranch, gitMergeInProgress, gitPullFastForwardOnly, gitRemoteBranchExists, gitSwitchToBranch, listUnmergedRelPaths, readGitInfo, } from "./git.js";
3
+ import { gitBranchExists, gitCheckout, gitCheckoutNewBranch, gitDeleteBranch, gitFetchRemote, gitHeadCommit, gitMergeRemoteBranch, gitMergeInProgress, gitFetch, gitRemoteBranchExists, gitRemoteUrl, gitRevParse, gitSwitchToBranch, listUnmergedRelPaths, readGitInfo, } from "./git.js";
4
+ import { fastForwardLocalIntegration, leaveTaskBranch, logLocalIntegrationUpdate, resolveIntegrationBaseRef, } from "./git-worktree.js";
4
5
  import { dim, log } from "./logger.js";
5
6
  import { findMergeRequestByBranches } from "./pr-create.js";
6
7
  import { normalizeTaskRef } from "./resolve-task.js";
@@ -63,9 +64,10 @@ async function removeLocalTaskBranch(cwd, taskBranch, integrationBranch, remote)
63
64
  return true;
64
65
  const git = await readGitInfo(cwd);
65
66
  if (git.branch === taskBranch) {
66
- const ok = await gitSwitchToBranch(cwd, integrationBranch, remote);
67
- if (!ok) {
68
- log.error(`Integration branch "${integrationBranch}" not found.`);
67
+ // Worktree-safe (D-006): detach when another worktree owns the integration branch.
68
+ const left = await leaveTaskBranch(cwd, remote, integrationBranch);
69
+ if (!left.ok) {
70
+ log.error(left.message);
69
71
  return false;
70
72
  }
71
73
  }
@@ -83,14 +85,26 @@ async function removeLocalTaskBranch(cwd, taskBranch, integrationBranch, remote)
83
85
  async function recreateTaskBranchFromIntegration(opts) {
84
86
  const cwd = resolve(opts.cwd);
85
87
  const { taskBranch } = opts.gitCtx;
86
- const ok = await gitSwitchToBranch(cwd, opts.integrationBranch, opts.remote);
87
- if (!ok) {
88
+ // Worktree-safe (D-006): start from <remote>/<integration>; never check out
89
+ // the integration branch (another worktree may own it).
90
+ if ((await gitRemoteUrl(cwd, opts.remote)) !== null) {
91
+ try {
92
+ await gitFetch(cwd, opts.remote, opts.integrationBranch);
93
+ }
94
+ catch (e) {
95
+ const msg = e instanceof Error ? e.message : String(e);
96
+ log.warn(`git fetch failed (${msg}). Continuing with local refs.`);
97
+ }
98
+ }
99
+ const baseRef = await resolveIntegrationBaseRef(cwd, opts.remote, opts.integrationBranch);
100
+ if (baseRef === null) {
88
101
  log.error(`Integration branch "${opts.integrationBranch}" not found.`);
89
102
  return false;
90
103
  }
91
104
  if (await gitRemoteBranchExists(cwd, opts.remote, opts.integrationBranch)) {
92
105
  try {
93
- await gitPullFastForwardOnly(cwd, opts.remote, opts.integrationBranch);
106
+ const update = await fastForwardLocalIntegration(cwd, opts.remote, opts.integrationBranch);
107
+ logLocalIntegrationUpdate(update, opts.remote, opts.integrationBranch);
94
108
  }
95
109
  catch (e) {
96
110
  const msg = e instanceof Error ? e.message : String(e);
@@ -98,17 +112,18 @@ async function recreateTaskBranchFromIntegration(opts) {
98
112
  return false;
99
113
  }
100
114
  }
101
- const head = await gitHeadCommit(cwd);
102
- if (head === null || head.length === 0) {
115
+ // Full SHA: unambiguous base for later diff ranges.
116
+ const head = (await gitRevParse(cwd, `${baseRef}^{commit}`)) ?? "";
117
+ if (head.length === 0) {
103
118
  log.error("Integration branch has no commits.");
104
119
  return false;
105
120
  }
106
121
  try {
107
- await gitCheckoutNewBranch(cwd, taskBranch, opts.integrationBranch);
122
+ await gitCheckoutNewBranch(cwd, taskBranch, baseRef, { noTrack: true });
108
123
  }
109
124
  catch (e) {
110
125
  const msg = e instanceof Error ? e.message : String(e);
111
- log.error(`Could not create ${taskBranch} from ${opts.integrationBranch} with uncommitted changes.`);
126
+ log.error(`Could not create ${taskBranch} from ${baseRef} with uncommitted changes.`);
112
127
  if (msg.length > 0)
113
128
  log.info(dim(msg));
114
129
  return false;
@@ -118,7 +133,7 @@ async function recreateTaskBranchFromIntegration(opts) {
118
133
  baseBranch: opts.integrationBranch,
119
134
  baseCommit: head,
120
135
  });
121
- log.ok(`Created ${taskBranch} from ${opts.integrationBranch}`);
136
+ log.ok(`Created ${taskBranch} from ${baseRef}`);
122
137
  return true;
123
138
  }
124
139
  /** Checkout or recreate the task branch without changing TASK Status. */