worktree-bay 4.0.2 → 4.0.4
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/commands/lifecycle.js +13 -9
- package/dist/commands/rm.js +16 -9
- package/dist/engine.js +29 -14
- package/dist/proc.js +75 -6
- package/package.json +1 -1
- package/skill.md +3 -1
|
@@ -8,17 +8,17 @@ import { color as c } from '../util/color.js';
|
|
|
8
8
|
import { t } from '../i18n.js';
|
|
9
9
|
// dev server + infra 生命周期:stop/start/restart 同时管 node(managed 进程)与 docker(stop 钩子 + setup 恢复),不动 worktree。
|
|
10
10
|
// services 为空 = 整功能;否则只这些服务
|
|
11
|
+
// 幂等:未占槽 / 指定服务当前未占用 → 返回空,交调用方按 no-op 处理(重复执行不报错)。
|
|
12
|
+
// 仅对「未知服务名」(typo,根本不在 config)报错。
|
|
11
13
|
function occupantsOf(cfg, feature, services = []) {
|
|
14
|
+
for (const s of services)
|
|
15
|
+
if (!cfg.services[s])
|
|
16
|
+
throw new Error(t(`未知服务「${s}」。运行 \`worktree-bay doctor\` 查看配置里有哪些服务。`, `unknown service "${s}". Run \`worktree-bay doctor\` to see configured services.`));
|
|
12
17
|
const slot = slotOfFeature(cfg, feature);
|
|
13
18
|
if (slot === undefined)
|
|
14
|
-
|
|
19
|
+
return [];
|
|
15
20
|
const all = scanOccupancy(cfg).get(slot) ?? [];
|
|
16
|
-
|
|
17
|
-
return all;
|
|
18
|
-
for (const s of services)
|
|
19
|
-
if (!all.some((o) => o.service === s))
|
|
20
|
-
throw new Error(t(`服务「${s}」不在功能「${feature}」里。用 \`worktree-bay ls\` 看已起的服务。`, `service "${s}" is not in feature "${feature}". See \`worktree-bay ls\`.`));
|
|
21
|
-
return all.filter((o) => services.includes(o.service));
|
|
21
|
+
return services.length ? all.filter((o) => services.includes(o.service)) : all;
|
|
22
22
|
}
|
|
23
23
|
function ctxOf(cfg, o) {
|
|
24
24
|
const base = { cfg, service: o.service, sp: cfg.services[o.service], slot: o.slot, slug: o.slug, dir: o.dir, repo: repoPath(cfg, o.service) };
|
|
@@ -37,7 +37,7 @@ export async function stopCommand(cfg, feature, services = []) {
|
|
|
37
37
|
await stopRuntime(ctxOf(cfg, o));
|
|
38
38
|
}
|
|
39
39
|
if (!any)
|
|
40
|
-
log(t('
|
|
40
|
+
log(c.dim(t('没有可停止的运行体(功能未占槽,或相关服务未配置 start/stop)', 'nothing to stop (feature has no slot, or those services have no start/stop)')));
|
|
41
41
|
});
|
|
42
42
|
}
|
|
43
43
|
export async function startCommand(cfg, feature, services = []) {
|
|
@@ -51,14 +51,16 @@ export async function startCommand(cfg, feature, services = []) {
|
|
|
51
51
|
await ensureRuntime(ctxOf(cfg, o));
|
|
52
52
|
}
|
|
53
53
|
if (!any)
|
|
54
|
-
log(t(
|
|
54
|
+
log(c.dim(t(`没有可启动的运行体(功能未占槽——先 \`worktree-bay up ${feature} <服务...>\`,或相关服务未配 start/stop)`, `nothing to start (feature has no slot — run \`worktree-bay up ${feature} <services...>\` first, or those services have no start/stop)`)));
|
|
55
55
|
});
|
|
56
56
|
}
|
|
57
57
|
export async function restartCommand(cfg, feature, services = []) {
|
|
58
58
|
await withLock(cfg.workspaceRoot, async () => {
|
|
59
|
+
let any = false;
|
|
59
60
|
for (const o of occupantsOf(cfg, feature, services)) {
|
|
60
61
|
if (!hasRuntime(cfg, o.service))
|
|
61
62
|
continue;
|
|
63
|
+
any = true;
|
|
62
64
|
const ctx = ctxOf(cfg, o);
|
|
63
65
|
log(c.bold(c.cyan(o.service)) + c.dim(t(' · 重启…', ' · restarting…')));
|
|
64
66
|
await stopRuntime(ctx);
|
|
@@ -69,5 +71,7 @@ export async function restartCommand(cfg, feature, services = []) {
|
|
|
69
71
|
} // 等端口释放
|
|
70
72
|
await ensureRuntime(ctx);
|
|
71
73
|
}
|
|
74
|
+
if (!any)
|
|
75
|
+
log(c.dim(t('没有可重启的运行体(功能未占槽,或相关服务未配置 start/stop)', 'nothing to restart (feature has no slot, or those services have no start/stop)')));
|
|
72
76
|
});
|
|
73
77
|
}
|
package/dist/commands/rm.js
CHANGED
|
@@ -9,24 +9,32 @@ import { stopManaged } from '../proc.js';
|
|
|
9
9
|
import { log, warn } from '../util/log.js';
|
|
10
10
|
import { color as c } from '../util/color.js';
|
|
11
11
|
import { t } from '../i18n.js';
|
|
12
|
-
// services 为空 =
|
|
12
|
+
// services 为空 = 整功能;否则只这些服务。幂等:未占槽 / 指定服务当前未占用 → 返回空(no-op),
|
|
13
|
+
// 仅对「未知服务名」(typo,根本不在 config)报错。
|
|
13
14
|
export function resolveRm(cfg, feature, services = []) {
|
|
15
|
+
for (const s of services)
|
|
16
|
+
if (!cfg.services[s])
|
|
17
|
+
throw new Error(t(`未知服务「${s}」。运行 \`worktree-bay doctor\` 查看配置里有哪些服务。`, `unknown service "${s}". Run \`worktree-bay doctor\` to see configured services.`));
|
|
14
18
|
const slot = slotOfFeature(cfg, feature);
|
|
15
19
|
if (slot === undefined)
|
|
16
|
-
|
|
20
|
+
return [];
|
|
17
21
|
const all = scanOccupancy(cfg).get(slot) ?? [];
|
|
18
|
-
|
|
19
|
-
return all;
|
|
20
|
-
for (const s of services)
|
|
21
|
-
if (!all.some((o) => o.service === s))
|
|
22
|
-
throw new Error(t(`服务「${s}」不在功能「${feature}」里。用 \`worktree-bay ls\` 看已起的服务。`, `service "${s}" is not in feature "${feature}". See \`worktree-bay ls\`.`));
|
|
23
|
-
return all.filter((o) => services.includes(o.service));
|
|
22
|
+
return services.length ? all.filter((o) => services.includes(o.service)) : all;
|
|
24
23
|
}
|
|
25
24
|
export async function rmCommand(cfg, feature, services, force) {
|
|
26
25
|
await withLock(cfg.workspaceRoot, async () => {
|
|
26
|
+
const slot = slotOfFeature(cfg, feature);
|
|
27
|
+
if (slot === undefined) {
|
|
28
|
+
log(c.green('✓') + ' ' + t(`功能「${feature}」未占槽,无需拆除(已是目标状态)`, `feature "${feature}" has no slot — nothing to tear down (already in target state)`));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
27
31
|
let removed = 0;
|
|
28
32
|
const wholeFeature = services.length === 0;
|
|
29
33
|
const occs = resolveRm(cfg, feature, services);
|
|
34
|
+
if (occs.length === 0) {
|
|
35
|
+
log(c.dim(t('指定服务当前未占用,无需拆除(已是目标状态)', 'those services aren\'t occupied — nothing to tear down (already in target state)')));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
30
38
|
for (const o of occs) {
|
|
31
39
|
const repo = repoPath(cfg, o.service);
|
|
32
40
|
const branch = currentBranch(o.dir);
|
|
@@ -47,7 +55,6 @@ export async function rmCommand(cfg, feature, services, force) {
|
|
|
47
55
|
await withProgress(t(`移除 ${o.service} 的 worktree`, `removing ${o.service} worktree`), () => removeWorktree(repo, o.dir, force));
|
|
48
56
|
removed++;
|
|
49
57
|
}
|
|
50
|
-
const slot = slotOfFeature(cfg, feature);
|
|
51
58
|
if (wholeFeature && (scanOccupancy(cfg).get(slot) ?? []).length === 0) {
|
|
52
59
|
removeLabel(cfg, slot);
|
|
53
60
|
if (removed === 0)
|
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 (
|
|
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
|
|
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
|
|
123
|
-
|
|
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
|
-
//
|
|
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
|
|
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 (!
|
|
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 (
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
|
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
|
|
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.
|
|
3
|
+
"version": "4.0.4",
|
|
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
|
@@ -161,9 +161,11 @@ worktree-bay gc # 回收已合并的
|
|
|
161
161
|
|
|
162
162
|
## 工作原理要点
|
|
163
163
|
|
|
164
|
+
- **全命令幂等**:`up / down / start / stop / restart` 重复执行都收敛到同一目标态、不报错——`up` 重入=复用 worktree + 恢复运行体;`start`/`stop` 已在目标态则跳过/仍逐服务给状态;`down` 对未占槽或已拆的服务是友好 no-op。仅「未知服务名」(typo,不在 config)才报错。
|
|
164
165
|
- **占用真相 = 文件系统**:扫各服务 `.worktrees/s<N>-*`;`.worktree-bay-slots.json` 只是「功能名→槽号」标签账本(预约)。
|
|
165
166
|
- **dev server 托管**:`start` 进程后台 detach 启动、日志落 `.worktree-bay/logs/`、按端口追踪真实 pid;`ls` 行首 `●` 标在跑(绿)/未跑(灰);`stop`/`down` 按端口可靠停。
|
|
166
|
-
- **运行状态判断 = 端口**:`ls`
|
|
167
|
+
- **运行状态判断 = 端口**:`ls`/`start`/`stop` 判「在不在跑」全用 `pidOnPort`(netstat/lsof,与 ls 同源),不用 connect 探测(docker 发布端口两者会不一致)、也不只看 pid 账本(dir 形态会漂移、docker 无账本记录)。`start` 端口已在监听就跳过、不再误报「恢复」。
|
|
168
|
+
- **stop 严格停「本目录+本端口+本进程」**:① 优先用启动账本(dir 已规范化匹配:相对/绝对/大小写都认);② 账本缺失时**校验后才杀**——Linux/macOS 比对进程 `cwd` 是否为本 worktree,Windows 取不到 cwd 则核对命令行含 `--port <本端口>`(端口按槽唯一,等价确证);都确证不了就**不动它**并如实报告(绝不凭端口盲杀)。每个服务都有状态行:已停 / 端口空闲 / 无法确认未停。
|
|
167
169
|
- **并发安全**:`claim/add/up/rm/down/gc` 全程持工作区原子锁。
|
|
168
170
|
- **前端自接后端**:前端有 `upstream` 且同槽该上游服务的 worktree 已建,则 `{upstreamBase}` = 本槽上游端口;否则用 `fallback`。所以**联调时先 `up`/`add` 后端,再起前端**。
|
|
169
171
|
- **合并感知回收**:`gc` 先 `git fetch`,用 `merge-base --is-ancestor` 判断是否并入主分支;**只在「已合并 + 工作区干净 + 无未推」时才自动删**,判不准一律保守不删、只标记。
|