worktree-bay 2.2.1 → 2.3.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.
@@ -4,9 +4,9 @@ import { repoPath } from '../config.js';
4
4
  import { withLock } from '../lock.js';
5
5
  import { claim } from '../slots.js';
6
6
  import { slugify, worktreeDirName } from '../naming.js';
7
- import { buildVars, bringUp } from '../engine.js';
7
+ import { buildVars, bringUp, ensureStarted } from '../engine.js';
8
8
  import { mainBranch } from '../git.js';
9
- import { log, warn } from '../util/log.js';
9
+ import { log } from '../util/log.js';
10
10
  import { t } from '../i18n.js';
11
11
  export function resolveAdd(cfg, feature, service, branch) {
12
12
  if (!cfg.services[service])
@@ -20,12 +20,13 @@ export async function addCommand(cfg, feature, service, branch, base) {
20
20
  await withLock(cfg.workspaceRoot, async () => {
21
21
  const p = resolveAdd(cfg, feature, service, br);
22
22
  const sp = cfg.services[service];
23
- if (fs.existsSync(p.dir)) { // 幂等:该服务已在本功能下开过 worktree → 跳过(让 up 可安全重跑)
24
- warn(t(`• ${service} 已在功能 "${feature}"(槽 ${p.slot}),跳过。要重建先 \`worktree-bay rm ${feature} ${service}\`。`, `• ${service} already in "${feature}" (slot ${p.slot}), skipping. To recreate: \`worktree-bay rm ${feature} ${service}\`.`));
25
- return;
26
- }
27
23
  const ctxBase = { cfg, service, sp, slot: p.slot, slug: p.slug, dir: p.dir, repo: p.repo };
28
24
  const ctx = { ...ctxBase, vars: buildVars(cfg, ctxBase) };
25
+ if (fs.existsSync(p.dir)) { // 幂等重入:worktree 已在 → 不重建/不重跑 setup,但确保 dev server 在跑
26
+ log(t(` • 已就绪(槽 ${p.slot},端口 ${ctx.vars.port})`, ` • ready (slot ${p.slot}, port ${ctx.vars.port})`));
27
+ await ensureStarted(ctx);
28
+ return;
29
+ }
29
30
  const resolvedBase = base ?? `origin/${mainBranch(p.repo)}`;
30
31
  try {
31
32
  await bringUp(ctx, resolvedBase, br);
@@ -36,6 +37,7 @@ export async function addCommand(cfg, feature, service, branch, base) {
36
37
  throw new Error(t(`基分支「${resolvedBase}」无效(该仓可能没有 origin 或对应主分支)。给 add 显式传 base,例如:worktree-bay add ${feature} ${service} ${br} HEAD`, `invalid base ref "${resolvedBase}" (this repo may have no origin or main branch). Pass an explicit base to add, e.g.: worktree-bay add ${feature} ${service} ${br} HEAD`));
37
38
  throw e;
38
39
  }
40
+ await ensureStarted(ctx);
39
41
  log(t(`✓ ${service} 挂入 "${feature}"(槽 ${p.slot},端口 ${ctx.vars.port},分支 ${br})`, `✓ ${service} added to "${feature}" (slot ${p.slot}, port ${ctx.vars.port}, branch ${br})`));
40
42
  });
41
43
  }
@@ -3,7 +3,9 @@ import { withLock } from '../lock.js';
3
3
  import { scanOccupancy, pruneEmptyLabels, removeLabel } from '../slots.js';
4
4
  import { buildVars } from '../engine.js';
5
5
  import { currentBranch, isDirty, hasUnpushed, isMergedToMain, remoteBranchGone, removeWorktree } from '../git.js';
6
- import { runShell } from '../util/exec.js';
6
+ import { runShellLive } from '../util/exec.js';
7
+ import { withProgress } from '../util/progress.js';
8
+ import { stopManaged } from '../proc.js';
7
9
  import { log, warn } from '../util/log.js';
8
10
  import { t } from '../i18n.js';
9
11
  export function classifyForGc(s) {
@@ -23,12 +25,14 @@ export async function gcCommand(cfg, apply) {
23
25
  if (v === 'auto-remove') {
24
26
  log(t(`[gc] ${o.service} (槽 ${slot}) 已合并且干净 → 移除`, `[gc] ${o.service} (slot ${slot}) merged & clean → remove`));
25
27
  if (apply) {
28
+ stopManaged(cfg.workspaceRoot, o.dir);
26
29
  const sp = cfg.services[o.service];
27
30
  if (sp.teardown) {
28
31
  const vars = buildVars(cfg, { cfg, service: o.service, sp, slot, slug: o.slug, dir: o.dir, repo });
29
- runShell(renderTemplate(sp.teardown, vars), { cwd: repo });
32
+ const cmd = renderTemplate(sp.teardown, vars);
33
+ await runShellLive(cmd, { cwd: repo }, t(`teardown ${o.service}:${cmd}`, `teardown ${o.service}: ${cmd}`));
30
34
  }
31
- removeWorktree(repo, o.dir, false);
35
+ await withProgress(t(`移除 ${o.service} 的 worktree`, `removing ${o.service} worktree`), () => removeWorktree(repo, o.dir, false));
32
36
  }
33
37
  }
34
38
  else if (v === 'flag')
@@ -1,14 +1,17 @@
1
1
  import { scanOccupancy, readLabels } from '../slots.js';
2
2
  import { portOf } from '../ports.js';
3
3
  import { log } from '../util/log.js';
4
+ import { recordedFor, pidAlive } from '../proc.js';
4
5
  import { t } from '../i18n.js';
6
+ // 该 worktree 是否有存活的托管 dev server(用于 ls 标注 ▸run)
7
+ function running(cfg, dir) { const r = recordedFor(cfg.workspaceRoot, dir); return !!r && pidAlive(r.pid); }
5
8
  export function renderSlots(cfg) {
6
9
  const occ = scanOccupancy(cfg);
7
10
  const labels = readLabels(cfg);
8
11
  const slots = new Set([...occ.keys(), ...Object.keys(labels).map(Number)]);
9
12
  const lines = [];
10
13
  for (const n of [...slots].sort((a, b) => a - b)) {
11
- const svc = (occ.get(n) ?? []).map((o) => `${o.service}@${portOf(cfg.services[o.service].port, n)}`);
14
+ const svc = (occ.get(n) ?? []).map((o) => `${o.service}@${portOf(cfg.services[o.service].port, n)}${running(cfg, o.dir) ? ' ▸run' : ''}`);
12
15
  lines.push(`slot ${n} ${labels[String(n)] ?? t('(未命名)', '(unnamed)')} [${svc.join(', ') || t('无 worktree', 'no worktree')}]`);
13
16
  }
14
17
  return lines.join('\n') || t('(无槽位在用)', '(no slots in use)');
@@ -19,7 +22,7 @@ export function slotsData(cfg) {
19
22
  const slots = new Set([...occ.keys(), ...Object.keys(labels).map(Number)]);
20
23
  return [...slots].sort((a, b) => a - b).map((n) => ({
21
24
  slot: n, feature: labels[String(n)] ?? null,
22
- services: (occ.get(n) ?? []).map((o) => ({ service: o.service, port: portOf(cfg.services[o.service].port, n), dir: o.dir })),
25
+ services: (occ.get(n) ?? []).map((o) => ({ service: o.service, port: portOf(cfg.services[o.service].port, n), dir: o.dir, running: running(cfg, o.dir) })),
23
26
  }));
24
27
  }
25
28
  export function lsCommand(cfg, json = false) { log(json ? JSON.stringify(slotsData(cfg), null, 2) : renderSlots(cfg)); }
@@ -3,7 +3,9 @@ import { withLock } from '../lock.js';
3
3
  import { scanOccupancy, slotOfFeature, removeLabel } from '../slots.js';
4
4
  import { buildVars } from '../engine.js';
5
5
  import { isDirty, hasUnpushed, currentBranch, removeWorktree } from '../git.js';
6
- import { runShell } from '../util/exec.js';
6
+ import { runShellLive } from '../util/exec.js';
7
+ import { withProgress } from '../util/progress.js';
8
+ import { stopManaged } from '../proc.js';
7
9
  import { log, warn } from '../util/log.js';
8
10
  import { t } from '../i18n.js';
9
11
  export function resolveRm(cfg, feature, service) {
@@ -16,20 +18,27 @@ export function resolveRm(cfg, feature, service) {
16
18
  export async function rmCommand(cfg, feature, service, force) {
17
19
  await withLock(cfg.workspaceRoot, async () => {
18
20
  let removed = 0;
19
- for (const o of resolveRm(cfg, feature, service)) {
21
+ const occs = resolveRm(cfg, feature, service);
22
+ for (let i = 0; i < occs.length; i++) {
23
+ const o = occs[i];
20
24
  const repo = repoPath(cfg, o.service);
21
25
  const branch = currentBranch(o.dir);
26
+ const tag = t(`[${i + 1}/${occs.length}]`, `[${i + 1}/${occs.length}]`);
22
27
  if (!force && (isDirty(o.dir) || hasUnpushed(repo, branch))) {
23
- warn(t(`跳过 ${o.service}:有未提交或未推送的改动。先提交/推送,或加 -f 强制删除(会丢这些改动)。`, `skipped ${o.service}: it has uncommitted or unpushed changes. Commit/push first, or pass -f to force-remove (discards them).`));
28
+ warn(t(`▶ ${tag} 跳过 ${o.service}:有未提交或未推送的改动。先提交/推送,或加 -f 强制删除(会丢这些改动)。`, `▶ ${tag} skipped ${o.service}: uncommitted or unpushed changes. Commit/push first, or pass -f to force-remove (discards them).`));
24
29
  continue;
25
30
  }
31
+ log(t(`▶ ${tag} 拆除 ${o.service} …`, `▶ ${tag} removing ${o.service} …`));
32
+ const stopped = stopManaged(cfg.workspaceRoot, o.dir); // 先停 dev server(释放对 worktree 文件的占用)
33
+ if (stopped)
34
+ log(t(` ▸ 已停止 dev server(pid ${stopped.pid})`, ` ▸ stopped dev server (pid ${stopped.pid})`));
26
35
  const sp = cfg.services[o.service];
27
36
  if (sp.teardown) {
28
37
  const vars = buildVars(cfg, { cfg, service: o.service, sp, slot: o.slot, slug: o.slug, dir: o.dir, repo });
29
- runShell(renderTemplate(sp.teardown, vars), { cwd: repo });
38
+ const cmd = renderTemplate(sp.teardown, vars);
39
+ await runShellLive(cmd, { cwd: repo }, t(`teardown ${o.service}:${cmd}`, `teardown ${o.service}: ${cmd}`));
30
40
  }
31
- removeWorktree(repo, o.dir, force);
32
- log(t(`✓ 移除 ${o.service}`, `✓ removed ${o.service}`));
41
+ await withProgress(t(`移除 ${o.service} 的 worktree`, `removing ${o.service} worktree`), () => removeWorktree(repo, o.dir, force));
33
42
  removed++;
34
43
  }
35
44
  const slot = slotOfFeature(cfg, feature);
package/dist/engine.js CHANGED
@@ -4,9 +4,10 @@ import { renderTemplate } from './config.js';
4
4
  import { portOf, isPortFree } from './ports.js';
5
5
  import { scanOccupancy } from './slots.js';
6
6
  import { addWorktree } from './git.js';
7
- import { runShell, run, spliceArgv, isTTY } from './util/exec.js';
7
+ import { runShellLive, run, spliceArgv, isTTY } from './util/exec.js';
8
8
  import { warn, log } from './util/log.js';
9
9
  import { withProgress } from './util/progress.js';
10
+ import { startDetached, recordedFor, pidAlive } from './proc.js';
10
11
  import { t } from './i18n.js';
11
12
  export function mergeEnvText(text, kv) {
12
13
  const lines = text.split('\n');
@@ -47,7 +48,7 @@ export async function bringUp(ctx, base, branch) {
47
48
  for (const rel of sp.copy ?? []) {
48
49
  // dereference: vendor/node_modules 含符号链接,Windows 下原样复制符号链接会失败,跟随并拷目标内容。
49
50
  // 用异步 cp + spinner,让大目录(如 vendor ~238MB)拷贝时不像卡死。
50
- await withProgress(t(`从主 checkout 拷贝 ${rel}(首次较慢)`, `copying ${rel} from main checkout (first time is slow)`), () => fs.promises.cp(path.join(repo, rel), path.join(dir, rel), { recursive: true, dereference: true }));
51
+ await withProgress(t(`拷贝 ${rel}`, `copying ${rel}`), () => fs.promises.cp(path.join(repo, rel), path.join(dir, rel), { recursive: true, dereference: true }));
51
52
  for (const lock of ['composer.lock', 'pnpm-lock.yaml', 'package-lock.json']) {
52
53
  const a = path.join(repo, lock), b = path.join(dir, lock);
53
54
  if (fs.existsSync(a) && fs.existsSync(b) && fs.readFileSync(a, 'utf8') !== fs.readFileSync(b, 'utf8'))
@@ -64,13 +65,29 @@ export async function bringUp(ctx, base, branch) {
64
65
  }
65
66
  if (sp.setup) {
66
67
  const cmd = renderTemplate(sp.setup, vars);
67
- log(t(` → 执行 setup:${cmd}`, ` → running setup: ${cmd}`));
68
- const r = runShell(cmd, { cwd: dir });
68
+ const r = await runShellLive(cmd, { cwd: dir }, t(`setup:${cmd}`, `setup: ${cmd}`));
69
69
  if (r.code !== 0)
70
- throw new Error(t(`setup 命令失败(退出码 ${r.code})。查看上面的输出排查;修好后可重跑 add(已建的 worktree 会被复用,不会重复创建)。`, `setup command failed (exit code ${r.code}). Check the output above; after fixing, re-run add (the existing worktree is reused, not recreated).`));
70
+ throw new Error(t(`setup 命令失败(退出码 ${r.code})。完整输出见上;修好后可重跑 add(已建的 worktree 会被复用,不会重复创建)。`, `setup command failed (exit code ${r.code}). Full output is above; after fixing, re-run add (the existing worktree is reused, not recreated).`));
71
71
  }
72
- if (sp.start)
73
- log(t(` 启动: (cd ${dir} && ${renderTemplate(sp.start, vars)})`, ` start: (cd ${dir} && ${renderTemplate(sp.start, vars)})`));
72
+ }
73
+ // 托管启动 dev server(start):端口已在监听 / 已登记进程存活 跳过;否则后台 detach 启动并登记。
74
+ // 让 up 可重入(再跑一次只补起没在跑的),并由 down 负责停。
75
+ export async function ensureStarted(ctx) {
76
+ const { cfg, sp, dir, service, slug, vars } = ctx;
77
+ if (!sp.start)
78
+ return;
79
+ const ws = cfg.workspaceRoot, port = Number(vars.port);
80
+ const rec = recordedFor(ws, dir);
81
+ if (rec && pidAlive(rec.pid)) {
82
+ log(t(` • ${service} dev server 已在跑(pid ${rec.pid},端口 ${port})`, ` • ${service} dev server already running (pid ${rec.pid}, port ${port})`));
83
+ return;
84
+ }
85
+ if (!(await isPortFree(port))) {
86
+ log(t(` • 端口 ${port} 已在监听,视为 ${service} dev server 在跑,跳过启动`, ` • port ${port} already listening; treating ${service} dev server as up, skip`));
87
+ return;
88
+ }
89
+ const r = startDetached(ws, dir, service, slug, port, renderTemplate(sp.start, vars));
90
+ log(t(` ▸ 已后台启动 ${service} dev server(pid ${r.pid},端口 ${port}) 日志: ${r.log}`, ` ▸ started ${service} dev server in background (pid ${r.pid}, port ${port}) log: ${r.log}`));
74
91
  }
75
92
  export function execArgv(ctx, cmd) {
76
93
  const tpl = (ctx.sp.exec ?? ['sh', '-c', '{cmd...}']).map((el) => el === '{cmd...}' ? el : renderTemplate(el, ctx.vars));
package/dist/git.js CHANGED
@@ -13,15 +13,16 @@ export function addWorktree(repo, dir, branch, base) {
13
13
  else
14
14
  ok(repo, 'worktree', 'add', '-b', branch, dir, base);
15
15
  }
16
- export function removeWorktree(repo, dir, force) {
16
+ export async function removeWorktree(repo, dir, force) {
17
17
  const a = ['worktree', 'remove', dir];
18
18
  if (force)
19
19
  a.push('--force');
20
20
  git(repo, ...a); // 尽力移除;残留交给下面兜底(调用方已先过脏/未推保护,到这里删除是安全的)
21
21
  // git worktree remove 不会删被忽略的文件(前端 node_modules 等)→ 目录残留报 "Directory not empty";
22
22
  // 它还可能先摘了登记再删目录失败,留下「git 不认但磁盘还在」的孤儿。统一兜底:物理删目录 + prune 同步元数据。
23
+ // 用异步 rm,让删 node_modules(可能数秒)时上层 spinner 能转。
23
24
  if (fs.existsSync(dir))
24
- fs.rmSync(dir, { recursive: true, force: true });
25
+ await fs.promises.rm(dir, { recursive: true, force: true });
25
26
  git(repo, 'worktree', 'prune');
26
27
  }
27
28
  export function isDirty(dir) { return ok(dir, 'status', '--porcelain').trim().length > 0; }
package/dist/proc.js ADDED
@@ -0,0 +1,58 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn, spawnSync } from 'node:child_process';
4
+ function regPath(ws) { return path.join(ws, '.worktree-bay', 'processes.json'); }
5
+ export function readProcs(ws) { const p = regPath(ws); try {
6
+ return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : [];
7
+ }
8
+ catch {
9
+ return [];
10
+ } }
11
+ function writeProcs(ws, recs) { fs.mkdirSync(path.join(ws, '.worktree-bay'), { recursive: true }); fs.writeFileSync(regPath(ws), JSON.stringify(recs, null, 2) + '\n'); }
12
+ export function pidAlive(pid) { try {
13
+ process.kill(pid, 0);
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ } }
19
+ export function recordedFor(ws, dir) { return readProcs(ws).find((r) => r.dir === dir); }
20
+ export function startDetached(ws, dir, service, slug, port, cmd) {
21
+ const logDir = path.join(ws, '.worktree-bay', 'logs');
22
+ fs.mkdirSync(logDir, { recursive: true });
23
+ const log = path.join(logDir, `${slug}-${service}.log`);
24
+ const fd = fs.openSync(log, 'a');
25
+ // detached + unref:脱离本进程,CLI 退出后 dev server 继续跑;stdout/stderr 落日志文件。
26
+ const child = spawn(cmd, { cwd: dir, shell: true, detached: true, stdio: ['ignore', fd, fd], windowsHide: true });
27
+ const pid = child.pid ?? -1;
28
+ child.unref();
29
+ const rec = { dir, service, port, pid, cmd, log, startedAt: Date.now() };
30
+ writeProcs(ws, [...readProcs(ws).filter((r) => r.dir !== dir), rec]);
31
+ return rec;
32
+ }
33
+ function killTree(pid) {
34
+ if (process.platform === 'win32')
35
+ spawnSync('taskkill', ['/PID', String(pid), '/T', '/F']);
36
+ else {
37
+ try {
38
+ process.kill(-pid, 'SIGTERM');
39
+ }
40
+ catch {
41
+ try {
42
+ process.kill(pid, 'SIGTERM');
43
+ }
44
+ catch { /* 已退出 */ }
45
+ }
46
+ }
47
+ }
48
+ // 停掉某 worktree 的托管进程(含进程树),并从账本移除。返回被停的记录(无则 undefined)。
49
+ export function stopManaged(ws, dir) {
50
+ const recs = readProcs(ws);
51
+ const rec = recs.find((r) => r.dir === dir);
52
+ if (!rec)
53
+ return undefined;
54
+ if (pidAlive(rec.pid))
55
+ killTree(rec.pid);
56
+ writeProcs(ws, recs.filter((r) => r.dir !== dir));
57
+ return rec;
58
+ }
package/dist/util/exec.js CHANGED
@@ -1,4 +1,5 @@
1
- import { spawnSync } from 'node:child_process';
1
+ import { spawnSync, spawn } from 'node:child_process';
2
+ import { t } from '../i18n.js';
2
3
  export function run(cmd, args, opts = {}) { const r = spawnSync(cmd, args, { cwd: opts.cwd, stdio: 'inherit', shell: false }); return { code: r.status ?? 1 }; }
3
4
  export function runShell(line, opts = {}) { const r = spawnSync(line, { cwd: opts.cwd, stdio: 'inherit', shell: true }); return { code: r.status ?? 1 }; }
4
5
  export function spliceArgv(template, cmd) { const out = []; for (const el of template) {
@@ -8,3 +9,46 @@ export function spliceArgv(template, cmd) { const out = []; for (const el of tem
8
9
  out.push(el);
9
10
  } return out; }
10
11
  export function isTTY() { return Boolean(process.stdout.isTTY && process.stdin.isTTY); }
12
+ const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
13
+ // 折叠式执行 setup/teardown 这类外部命令:TTY 下把输出收进单行临时进度(spinner + 秒数 + 最后一行),
14
+ // 成功收成「✓ label(Ns)」,失败才吐完整日志便于排查;非 TTY(CI/管道/MCP)原样透传保留完整日志。
15
+ export function runShellLive(line, opts, label) {
16
+ if (!process.stderr.isTTY) {
17
+ const r = spawnSync(line, { cwd: opts.cwd, stdio: 'inherit', shell: true });
18
+ return Promise.resolve({ code: r.status ?? 1 });
19
+ }
20
+ return new Promise((resolve) => {
21
+ const t0 = Date.now();
22
+ const child = spawn(line, { cwd: opts.cwd, shell: true });
23
+ const buf = [];
24
+ let last = '';
25
+ let fi = 0;
26
+ const render = () => {
27
+ const secs = Math.floor((Date.now() - t0) / 1000);
28
+ const head = ` ${SPIN[fi++ % SPIN.length]} ${label} ${secs}s `;
29
+ const room = Math.max(0, (process.stderr.columns || 80) - head.length - 1);
30
+ process.stderr.write('\r\x1b[2K' + head + last.slice(0, room));
31
+ };
32
+ const timer = setInterval(render, 120);
33
+ const onData = (d) => {
34
+ const s = d.toString();
35
+ buf.push(s);
36
+ const lines = s.split(/[\r\n]+/).map((l) => l.trim()).filter(Boolean);
37
+ if (lines.length)
38
+ last = lines[lines.length - 1];
39
+ };
40
+ child.stdout?.on('data', onData);
41
+ child.stderr?.on('data', onData);
42
+ child.on('error', () => { clearInterval(timer); process.stderr.write('\r\x1b[2K'); resolve({ code: 1 }); });
43
+ child.on('close', (code) => {
44
+ clearInterval(timer);
45
+ const secs = ((Date.now() - t0) / 1000).toFixed(1);
46
+ process.stderr.write('\r\x1b[2K');
47
+ if (code === 0)
48
+ process.stderr.write(t(` ✓ ${label}(${secs}s)\n`, ` ✓ ${label} (${secs}s)\n`));
49
+ else
50
+ process.stderr.write(t(` ✗ ${label} 失败(退出码 ${code},${secs}s)↓\n`, ` ✗ ${label} failed (exit ${code}, ${secs}s) ↓\n`) + buf.join(''));
51
+ resolve({ code: code ?? 1 });
52
+ });
53
+ });
54
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktree-bay",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "description": "Per-feature git worktree + port slots for parallel multi-service development: auto deps, env wiring, frontend-to-backend, merge-aware reclaim, plus an MCP server for AI agents.",
5
5
  "keywords": [
6
6
  "git",