worktree-bay 4.0.2 → 4.0.3

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/dist/engine.js CHANGED
@@ -8,7 +8,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
10
  import { color as cc } from './util/color.js';
11
- import { startDetached, recordedFor, pidAlive, setPid, pidOnPort, readLogTail, stopManaged } from './proc.js';
11
+ import { startDetached, recordedFor, pidAlive, setPid, pidOnPort, readLogTail, stopManaged, stopUnrecordedOnPort } from './proc.js';
12
12
  import { t } from './i18n.js';
13
13
  export function mergeEnvText(text, kv) {
14
14
  const lines = text.split('\n');
@@ -82,7 +82,7 @@ export async function ensureStarted(ctx) {
82
82
  log(cc.dim(t(` • ${service} dev server 已在跑(pid ${rec.pid},端口 ${port})`, ` • ${service} dev server already running (pid ${rec.pid}, port ${port})`)));
83
83
  return;
84
84
  }
85
- if (await portInUse(port)) {
85
+ if (pidOnPort(port)) {
86
86
  log(cc.dim(t(` • 端口 ${port} 已在监听,视为 ${service} dev server 在跑,跳过启动`, ` • port ${port} already listening; treating ${service} dev server as up, skip`)));
87
87
  return;
88
88
  }
@@ -115,38 +115,53 @@ async function waitForListen(port, ms) {
115
115
  }
116
116
  }
117
117
  // 「运行体」= docker 容器(infra) + node dev server。up 重入 / start / restart 共用,统一边界。
118
- // 恢复运行体:有 stop 钩子的 infra 服务重跑 setup(docker compose up -d 幂等恢复)+ 起 managed dev server。
118
+ // 恢复运行体:有 stop 钩子的 infra 服务在端口没监听时才重跑 setup(docker compose up -d)+ 起 managed dev server。
119
+ // 「是否在跑」一律按端口实判,与 ls 同源(pidOnPort/netstat,而非 connect 探测——docker 发布端口两者会不一致,
120
+ // 导致 ls 显示在跑、start 却误判没跑去「恢复」)。端口已在监听就视为在跑、跳过,不再无脑重跑 setup。
119
121
  export async function ensureRuntime(ctx) {
120
122
  const { sp, dir, service, vars } = ctx;
121
123
  if (sp.stop && sp.setup) {
122
- const cmd = renderTemplate(sp.setup, vars);
123
- await runShellLive(cmd, { cwd: dir }, t(`恢复 ${service}:${cmd}`, `resume ${service}: ${cmd}`));
124
+ const port = Number(vars.port);
125
+ if (pidOnPort(port))
126
+ log(cc.dim(t(` • ${service} 已在跑(端口 ${port} 在监听),跳过恢复`, ` • ${service} already up (port ${port} listening), skip resume`)));
127
+ else {
128
+ const cmd = renderTemplate(sp.setup, vars);
129
+ await runShellLive(cmd, { cwd: dir }, t(`恢复 ${service}:${cmd}`, `resume ${service}: ${cmd}`));
130
+ }
124
131
  }
125
132
  await ensureStarted(ctx);
126
133
  }
127
134
  // 停止运行体:杀 managed dev server + 跑 stop 钩子(docker compose stop)。不动 worktree。
128
- // 始终给每个服务输出一行状态——哪怕本就没在跑(否则只剩一个裸服务名,看不出结果)。
129
- // 用端口实判:docker 容器停了端口即释放、dev server 同理,比「有没有 managed 记录」更准,
130
- // 据此给出诚实状态(本就空闲 / 外部未托管 / 未在运行),不让一个绿勾掩盖「其实没东西在跑」。
135
+ // 始终给每个服务输出一行状态。「是否在跑」用 pidOnPort(与 ls 同源),不用 connect 探测。
136
+ // 严格判定:只停「本目录 + 本端口 + 本进程」——dev server 凭账本(dir 已规范化匹配)认本进程,
137
+ // 不去按端口盲杀(端口可能被无关进程占);没有账本记录就如实报告、不动它。
131
138
  export async function stopRuntime(ctx) {
132
139
  const { cfg, sp, dir, service, vars } = ctx;
133
140
  const port = Number(vars.port);
134
- const wasUp = await portInUse(port);
141
+ const onPort = pidOnPort(port); // 停之前先记下端口真相(stopManaged 可能把它杀掉)
135
142
  const stopped = stopManaged(cfg.workspaceRoot, dir);
136
143
  if (stopped)
137
- log(` ${cc.green('✓')} ` + t(`已停止 dev server(pid ${stopped.pid})`, `stopped dev server (pid ${stopped.pid})`));
144
+ log(` ${cc.green('✓')} ` + t(`已停止 dev server(pid ${stopped.pid},端口 ${stopped.port})`, `stopped dev server (pid ${stopped.pid}, port ${stopped.port})`));
138
145
  if (sp.stop) {
139
146
  // stop 钩子始终跑(docker compose stop 幂等,且能收掉 app 端口没监听、但 mysql/redis 等边车还在的情况)。
140
147
  const cmd = renderTemplate(sp.stop, vars);
141
148
  await runShellLive(cmd, { cwd: dir }, t(`停 ${service}:${cmd}`, `stop ${service}: ${cmd}`));
142
- if (!wasUp)
149
+ if (!onPort)
143
150
  log(` ${cc.dim('•')} ` + t(`(端口 ${port} 此前空闲,${service} 实际并未在对外服务)`, `(port ${port} was idle; ${service} wasn't actually serving)`));
144
151
  }
145
152
  if (!stopped && !sp.stop) {
146
- if (wasUp)
147
- log(` ${cc.yellow('•')} ` + t(`端口 ${port} 仍被占用(外部启动、未托管),请手动停止`, `port ${port} in use (external, unmanaged); stop manually`));
148
- else
153
+ if (!onPort) {
149
154
  log(` ${cc.dim('•')} ` + t('未在运行', 'not running'));
155
+ return;
156
+ }
157
+ // 无账本记录:校验后才杀(cwd==本目录 或 命令行含本端口),确证不了就不动
158
+ const r = stopUnrecordedOnPort(cfg.workspaceRoot, dir, port);
159
+ if (r.confirmed)
160
+ log(` ${cc.green('✓')} ` + t(`已停止(端口 ${port} 上 pid ${r.pid},经${r.how === 'cwd' ? '工作目录' : '命令行'}确认属本 worktree)`, `stopped (pid ${r.pid} on port ${port}, confirmed as this worktree by ${r.how})`));
161
+ else if (r.reason === 'cwd-mismatch')
162
+ log(` ${cc.yellow('•')} ` + t(`端口 ${port} 被 pid ${r.pid} 占用,但其工作目录非本 worktree(${r.cwd}),未停`, `port ${port} held by pid ${r.pid}, but its cwd isn't this worktree (${r.cwd}); left running`));
163
+ else
164
+ log(` ${cc.yellow('•')} ` + t(`端口 ${port} 被 pid ${r.pid} 占用,无账本记录且无法确认是本服务本进程,未自动停止`, `port ${port} held by pid ${r.pid}, no record and can't confirm it's this service; left running`));
150
165
  }
151
166
  }
152
167
  export function execArgv(ctx, cmd) {
package/dist/proc.js CHANGED
@@ -17,8 +17,12 @@ export function pidAlive(pid) { if (!pid || pid < 1)
17
17
  catch {
18
18
  return false;
19
19
  } }
20
- export function recordedFor(ws, dir) { return readProcs(ws).find((r) => r.dir === dir); }
21
- export function setPid(ws, dir, pid) { const recs = readProcs(ws); const r = recs.find((x) => x.dir === dir); if (r) {
20
+ // 账本 dir 可能存成相对/绝对、斜杠或大小写不一(写入与查时来源不同)。统一相对 ws 解析成绝对、
21
+ // Windows 下再大小写折叠后比较,保证「同一个 worktree 目录」一定能匹配上(严格判定的「本目录」凭据)。
22
+ function normDir(ws, p) { const r = path.resolve(ws, p); return process.platform === 'win32' ? r.toLowerCase() : r; }
23
+ function sameDir(ws, a, b) { return normDir(ws, a) === normDir(ws, b); }
24
+ export function recordedFor(ws, dir) { return readProcs(ws).find((r) => sameDir(ws, r.dir, dir)); }
25
+ export function setPid(ws, dir, pid) { const recs = readProcs(ws); const r = recs.find((x) => sameDir(ws, x.dir, dir)); if (r) {
22
26
  r.pid = pid;
23
27
  writeProcs(ws, recs);
24
28
  } }
@@ -55,6 +59,70 @@ export function pidOnPort(port) {
55
59
  }
56
60
  return undefined;
57
61
  }
62
+ // 取进程工作目录(cwd)。Linux/macOS 原生可取(最强的「本目录」证据);
63
+ // Windows 普通命令拿不到他进程 cwd(存在 PEB、需 ReadProcessMemory),返回 undefined,由调用方降级到命令行核对。
64
+ export function processCwd(pid) {
65
+ if (!pid || pid < 1)
66
+ return undefined;
67
+ try {
68
+ if (process.platform === 'linux')
69
+ return fs.readlinkSync(`/proc/${pid}/cwd`);
70
+ if (process.platform === 'darwin') {
71
+ const r = spawnSync('lsof', ['-a', '-d', 'cwd', '-p', String(pid), '-Fn'], { encoding: 'utf8' });
72
+ if (r.status === 0) {
73
+ const m = /^n(.+)$/m.exec(r.stdout || '');
74
+ if (m)
75
+ return m[1].trim();
76
+ }
77
+ }
78
+ }
79
+ catch { /* 取不到就降级 */ }
80
+ return undefined;
81
+ }
82
+ // 取进程命令行(cwd 取不到时的跨平台兜底,尤其 Windows)。
83
+ export function processCmdline(pid) {
84
+ if (!pid || pid < 1)
85
+ return undefined;
86
+ try {
87
+ if (process.platform === 'win32') {
88
+ const r = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`], { encoding: 'utf8' });
89
+ if (r.status === 0)
90
+ return (r.stdout || '').trim() || undefined;
91
+ }
92
+ else {
93
+ const r = spawnSync('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8' });
94
+ if (r.status === 0)
95
+ return (r.stdout || '').trim() || undefined;
96
+ }
97
+ }
98
+ catch { /* 取不到就放弃确证 */ }
99
+ return undefined;
100
+ }
101
+ // 无账本记录时的严格兜底:端口上的进程,仅当能确证属于本 worktree 才杀(先校验后杀,绝不凭端口盲杀)。
102
+ // 证据:① cwd == 本 worktree 目录(Linux/macOS,最强,证明「本目录」);
103
+ // ② cwd 取不到(Windows)→ 命令行含 `--port <本端口>`(本端口按槽唯一分配,等价确证本服务本进程)。
104
+ // 杀用 killTree(含进程树);都无法确证则不动、返回 confirmed:false 供上层如实报告。
105
+ export function stopUnrecordedOnPort(ws, dir, port) {
106
+ const pid = pidOnPort(port);
107
+ if (!pid)
108
+ return { confirmed: false, reason: 'idle' };
109
+ const cwd = processCwd(pid);
110
+ if (cwd !== undefined) {
111
+ if (sameDir(ws, cwd, dir)) {
112
+ if (pidAlive(pid))
113
+ killTree(pid);
114
+ return { pid, confirmed: true, how: 'cwd', cwd };
115
+ }
116
+ return { pid, confirmed: false, cwd, reason: 'cwd-mismatch' };
117
+ }
118
+ const cmd = processCmdline(pid);
119
+ if (cmd && (cmd.includes(`--port ${port}`) || cmd.includes(`--port=${port}`))) {
120
+ if (pidAlive(pid))
121
+ killTree(pid);
122
+ return { pid, confirmed: true, how: 'cmdline', cmd };
123
+ }
124
+ return { pid, confirmed: false, cmd, reason: 'unverified' };
125
+ }
58
126
  export function startDetached(ws, dir, service, slug, port, cmd) {
59
127
  const logDir = path.join(ws, '.worktree-bay', 'logs');
60
128
  fs.mkdirSync(logDir, { recursive: true });
@@ -69,8 +137,9 @@ export function startDetached(ws, dir, service, slug, port, cmd) {
69
137
  const child = spawn(cmd, { cwd: dir, shell: true, detached, stdio: ['ignore', fd, fd], windowsHide: true });
70
138
  const pid = child.pid ?? -1;
71
139
  child.unref();
72
- const rec = { dir, service, port, pid, cmd, log, startedAt: Date.now() };
73
- writeProcs(ws, [...readProcs(ws).filter((r) => r.dir !== dir), rec]);
140
+ // 一律存绝对 dir,避免相对/绝对混存导致后续按目录匹配漂移
141
+ const rec = { dir: path.resolve(ws, dir), service, port, pid, cmd, log, startedAt: Date.now() };
142
+ writeProcs(ws, [...readProcs(ws).filter((r) => !sameDir(ws, r.dir, dir)), rec]);
74
143
  return rec;
75
144
  }
76
145
  function killTree(pid) {
@@ -92,7 +161,7 @@ function killTree(pid) {
92
161
  // 同时按「记录 pid」和「当前端口占用 pid」双杀——shell/pnpm 中间层会让记录 pid 漂移,按端口兜底最稳。
93
162
  export function stopManaged(ws, dir) {
94
163
  const recs = readProcs(ws);
95
- const rec = recs.find((r) => r.dir === dir);
164
+ const rec = recs.find((r) => sameDir(ws, r.dir, dir));
96
165
  if (!rec)
97
166
  return undefined;
98
167
  const targets = new Set();
@@ -104,6 +173,6 @@ export function stopManaged(ws, dir) {
104
173
  for (const pid of targets)
105
174
  if (pidAlive(pid))
106
175
  killTree(pid);
107
- writeProcs(ws, recs.filter((r) => r.dir !== dir));
176
+ writeProcs(ws, recs.filter((r) => !sameDir(ws, r.dir, dir)));
108
177
  return rec;
109
178
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktree-bay",
3
- "version": "4.0.2",
3
+ "version": "4.0.3",
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",
package/skill.md CHANGED
@@ -163,7 +163,8 @@ worktree-bay gc # 回收已合并的
163
163
 
164
164
  - **占用真相 = 文件系统**:扫各服务 `.worktrees/s<N>-*`;`.worktree-bay-slots.json` 只是「功能名→槽号」标签账本(预约)。
165
165
  - **dev server 托管**:`start` 进程后台 detach 启动、日志落 `.worktree-bay/logs/`、按端口追踪真实 pid;`ls` 行首 `●` 标在跑(绿)/未跑(灰);`stop`/`down` 按端口可靠停。
166
- - **运行状态判断 = 端口**:`ls` `running`、`stop` 的「是否真在跑」一律按约定端口是否被监听判(覆盖 docker / 托管 dev server / 外部手起三类),不依赖 pid 账本(dir 形态会漂移、docker 无账本记录)。`stop` 对每个服务都给状态:已停 / 端口空闲(docker 钩子仍幂等跑一遍)/ 外部未托管需手动停。
166
+ - **运行状态判断 = 端口**:`ls`/`start`/`stop` 判「在不在跑」全用 `pidOnPort`(netstat/lsof,与 ls 同源),不用 connect 探测(docker 发布端口两者会不一致)、也不只看 pid 账本(dir 形态会漂移、docker 无账本记录)。`start` 端口已在监听就跳过、不再误报「恢复」。
167
+ - **stop 严格停「本目录+本端口+本进程」**:① 优先用启动账本(dir 已规范化匹配:相对/绝对/大小写都认);② 账本缺失时**校验后才杀**——Linux/macOS 比对进程 `cwd` 是否为本 worktree,Windows 取不到 cwd 则核对命令行含 `--port <本端口>`(端口按槽唯一,等价确证);都确证不了就**不动它**并如实报告(绝不凭端口盲杀)。每个服务都有状态行:已停 / 端口空闲 / 无法确认未停。
167
168
  - **并发安全**:`claim/add/up/rm/down/gc` 全程持工作区原子锁。
168
169
  - **前端自接后端**:前端有 `upstream` 且同槽该上游服务的 worktree 已建,则 `{upstreamBase}` = 本槽上游端口;否则用 `fallback`。所以**联调时先 `up`/`add` 后端,再起前端**。
169
170
  - **合并感知回收**:`gc` 先 `git fetch`,用 `merge-base --is-ancestor` 判断是否并入主分支;**只在「已合并 + 工作区干净 + 无未推」时才自动删**,判不准一律保守不删、只标记。