worktree-bay 1.5.0 → 2.0.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.
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  在 monorepo / 多仓工作区里并行开发多个功能时,git worktree 能隔离代码,但隔离不了运行时——端口会撞、依赖要重装、前端连不上你本地起在偏移端口的后端。`worktree-bay` 在 worktree 之上补一层「**功能 = 槽位**」的编排:
13
13
 
14
- - **端口不撞**:每个功能占一个槽 `N`,得到端口块 `6000 + N*10`,块内各服务按固定偏移取端口。
14
+ - **端口不撞**:每个服务有自己的端口段,功能占槽 `N` → 各服务用 `自己的基址 + N`,与主 dev(槽 0)和其它槽天然错开。
15
15
  - **免重装**:依赖从主 checkout 拷贝(或按服务自定义安装命令),不必每个 worktree 从头装。
16
16
  - **前端自接后端**:前端按「同槽上游服务」自动把 api base 指向本槽后端端口。
17
17
  - **不泄漏**:槽位占用从文件系统派生;`gc` 合并感知回收(已并入主分支且干净才删,保守不误删)。
@@ -52,12 +52,10 @@ worktree-bay gc
52
52
  ```jsonc
53
53
  {
54
54
  "workspaceRoot": "/path/to/workspace",
55
- "portBase": 6000,
56
- "slotSpan": 10,
57
55
  "maxSlots": 9,
58
56
  "services": {
59
57
  "api": {
60
- "offset": 1,
58
+ "port": 6001, // 端口段基址(= 主 dev/槽0),槽 N 用 6001+N
61
59
  "vars": { "project": "myapi-{slug}" },
62
60
  "copy": [".env", "vendor"], // 从主 checkout 递归拷文件/目录
63
61
  "env": { ".env": { "APP_PORT": "{port}" } }, // 合并键值进 dotenv(保留其它键)
@@ -67,7 +65,7 @@ worktree-bay gc
67
65
  "run": { "test": ["composer", "run", "test"] } // 命名命令
68
66
  },
69
67
  "lms": {
70
- "offset": 2,
68
+ "port": 6011, // 与 api 段不重叠(间距 > maxSlots)
71
69
  "upstream": { "service": "api", "fallback": "http://localhost:6001" }, // → {upstreamBase}
72
70
  "env": { ".env.dev.local": { "VITE_API_BASE_URL": "{upstreamBase}" } },
73
71
  "setup": "pnpm install",
@@ -81,7 +79,7 @@ worktree-bay gc
81
79
 
82
80
  | 原语 | 说明 |
83
81
  |---|---|
84
- | `offset`(必填) | 本服务端口 = `块基址 + offset`,各服务互不相同、`1 offset < slotSpan` |
82
+ | `port`(必填) | 本服务端口段基址(= 主 dev/槽0);槽 N 端口 = `port + N`;各服务的段 `[port, port+maxSlots]` 互不重叠 |
85
83
  | `repo` | 仓库目录名(相对 workspaceRoot),默认 = 服务名 |
86
84
  | `vars` | 自定义模板变量 |
87
85
  | `copy` | 从主 checkout 递归拷贝的文件/目录(含依赖目录) |
@@ -94,11 +92,11 @@ worktree-bay gc
94
92
 
95
93
  ### 模板变量
96
94
 
97
- `{slot}` `{blockBase}` `{port}` `{slug}` `{worktree}` `{repo}` `{upstreamBase}` `{cmd...}`,以及 `vars` 里自定义的。
95
+ `{slot}` `{port}` `{slug}` `{worktree}` `{repo}` `{upstreamBase}` `{cmd...}`,以及 `vars` 里自定义的。
98
96
 
99
97
  ## 工作原理
100
98
 
101
- - **槽位 = 端口块**:功能占槽 `N`(1..`maxSlots`)→ 端口块 `portBase + N*slotSpan`;块内服务按 `offset` 取端口。
99
+ - **按服务分段**:每个服务有自己的端口段(基址 `port` = dev/槽0),功能占槽 `N`(1..`maxSlots`)→ 该服务用 `port + N`。服务数量无上限,各段不重叠即可。
102
100
  - **占用从文件系统派生**:槽是否被占,看各服务 `<repo>/.worktrees/s<N>-*` 目录是否存在;`.worktree-bay-slots.json` 只是「功能名 → 槽号」的标签账本(预约)。删了 worktree,槽自动空出。
103
101
  - **并发安全**:`claim/add/rm/gc` 全程持工作区原子锁。
104
102
  - **前端自接**:前端有 `upstream` 时,若同槽已起该上游服务的 worktree,就把 api base 指向本槽端口;否则用 `fallback`。
@@ -1,11 +1,10 @@
1
1
  import { withLock } from '../lock.js';
2
2
  import { claim } from '../slots.js';
3
- import { blockBase } from '../ports.js';
3
+ import { portOf } from '../ports.js';
4
4
  import { log } from '../util/log.js';
5
5
  export async function claimCommand(cfg, feature) {
6
6
  const slot = await withLock(cfg.workspaceRoot, async () => claim(cfg, feature));
7
- const base = blockBase(cfg.portBase, cfg.slotSpan, slot);
8
- log(`功能 "${feature}" → 槽 ${slot}(块 ${base})`);
7
+ log(`功能 "${feature}" ${slot}`);
9
8
  for (const [n, sp] of Object.entries(cfg.services))
10
- log(` ${n.padEnd(8)} ${base + sp.offset}`);
9
+ log(` ${n.padEnd(8)} ${portOf(sp.port, slot)}`);
11
10
  }
@@ -12,7 +12,7 @@ export function doctor(cfg) {
12
12
  ok('git 可用');
13
13
  else
14
14
  bad('git 不可用(worktree 依赖 git)');
15
- ok(`配置已加载并通过校验(${Object.keys(cfg.services).length} 个服务,槽位 1..${cfg.maxSlots},端口块 ${cfg.portBase}+N*${cfg.slotSpan})`);
15
+ ok(`配置已加载并通过校验(${Object.keys(cfg.services).length} 个服务,槽位 1..${cfg.maxSlots},端口 = 服务基址 + 槽号)`);
16
16
  for (const name of Object.keys(cfg.services)) {
17
17
  const repo = repoPath(cfg, name);
18
18
  if (!fs.existsSync(repo))
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { log, warn } from '../util/log.js';
4
- // 引导生成一份 worktree-bay.config.json:扫描 cwd 子目录里的 git 仓预填为服务
4
+ // 引导生成一份 worktree-bay.config.json:扫描 cwd 子目录里的 git 仓预填为服务(按服务分配端口段)
5
5
  export function initCommand(cwd) {
6
6
  const target = path.join(cwd, 'worktree-bay.config.json');
7
7
  if (fs.existsSync(target)) {
@@ -18,19 +18,19 @@ export function initCommand(cwd) {
18
18
  let services;
19
19
  if (repos.length) {
20
20
  services = {};
21
- repos.slice(0, 9).forEach((r, i) => { services[r] = { offset: i + 1, setup: 'echo TODO: 起本服务的命令', run: { test: ['echo', 'TODO'] } }; });
21
+ repos.forEach((r, i) => { services[r] = { port: 6001 + i * 10, setup: 'echo TODO: 起本服务的命令', run: { test: ['echo', 'TODO'] } }; });
22
22
  }
23
23
  else {
24
24
  services = {
25
- api: { offset: 1, copy: ['.env'], env: { '.env': { APP_PORT: '{port}' } }, setup: 'echo TODO: 起后端', run: { test: ['echo', 'TODO'] } },
26
- web: { offset: 2, upstream: { service: 'api', fallback: 'http://localhost:6001' }, env: { '.env.local': { VITE_API_BASE_URL: '{upstreamBase}' } }, setup: 'pnpm install', start: 'pnpm dev --port {port}' },
25
+ api: { port: 6001, copy: ['.env'], env: { '.env': { APP_PORT: '{port}' } }, setup: 'echo TODO: 起后端', run: { test: ['echo', 'TODO'] } },
26
+ web: { port: 6011, upstream: { service: 'api', fallback: 'http://localhost:6001' }, env: { '.env.local': { VITE_API_BASE_URL: '{upstreamBase}' } }, setup: 'pnpm install', start: 'pnpm dev --port {port}' },
27
27
  };
28
28
  }
29
- const config = { workspaceRoot: cwd, portBase: 6000, slotSpan: 10, maxSlots: 9, services };
29
+ const config = { workspaceRoot: cwd, maxSlots: 9, services };
30
30
  fs.writeFileSync(target, JSON.stringify(config, null, 2) + '\n');
31
31
  log(`✓ 已生成 ${target}`);
32
32
  if (repos.length)
33
- log(` 识别到服务: ${repos.join(', ')}(按目录预填,offset 自增)`);
33
+ log(` 识别到服务: ${repos.join(', ')}(每个分配一段端口,基址间隔 10)`);
34
34
  else
35
35
  log(` 未识别到子 git 仓,已写入 api/web 示例模板`);
36
36
  log(` 下一步:补全各服务的 setup/env/upstream/exec/run。完整配置说明:worktree-bay skill`);
@@ -1,5 +1,5 @@
1
1
  import { scanOccupancy, readLabels } from '../slots.js';
2
- import { blockBase } from '../ports.js';
2
+ import { portOf } from '../ports.js';
3
3
  import { log } from '../util/log.js';
4
4
  export function renderSlots(cfg) {
5
5
  const occ = scanOccupancy(cfg);
@@ -7,9 +7,8 @@ export function renderSlots(cfg) {
7
7
  const slots = new Set([...occ.keys(), ...Object.keys(labels).map(Number)]);
8
8
  const lines = [];
9
9
  for (const n of [...slots].sort((a, b) => a - b)) {
10
- const base = blockBase(cfg.portBase, cfg.slotSpan, n);
11
- const svc = (occ.get(n) ?? []).map((o) => `${o.service}@${base + cfg.services[o.service].offset}`);
12
- lines.push(`slot ${n} ${labels[String(n)] ?? '(unnamed)'} block=${base} [${svc.join(', ') || 'no worktree'}]`);
10
+ const svc = (occ.get(n) ?? []).map((o) => `${o.service}@${portOf(cfg.services[o.service].port, n)}`);
11
+ lines.push(`slot ${n} ${labels[String(n)] ?? '(unnamed)'} [${svc.join(', ') || 'no worktree'}]`);
13
12
  }
14
13
  return lines.join('\n') || '(no slots in use)';
15
14
  }
@@ -17,9 +16,9 @@ export function slotsData(cfg) {
17
16
  const occ = scanOccupancy(cfg);
18
17
  const labels = readLabels(cfg);
19
18
  const slots = new Set([...occ.keys(), ...Object.keys(labels).map(Number)]);
20
- return [...slots].sort((a, b) => a - b).map((n) => {
21
- const base = blockBase(cfg.portBase, cfg.slotSpan, n);
22
- return { slot: n, feature: labels[String(n)] ?? null, block: base, services: (occ.get(n) ?? []).map((o) => ({ service: o.service, port: base + cfg.services[o.service].offset, dir: o.dir })) };
23
- });
19
+ return [...slots].sort((a, b) => a - b).map((n) => ({
20
+ slot: n, feature: labels[String(n)] ?? null,
21
+ services: (occ.get(n) ?? []).map((o) => ({ service: o.service, port: portOf(cfg.services[o.service].port, n), dir: o.dir })),
22
+ }));
24
23
  }
25
24
  export function lsCommand(cfg, json = false) { log(json ? JSON.stringify(slotsData(cfg), null, 2) : renderSlots(cfg)); }
package/dist/config.js CHANGED
@@ -3,33 +3,35 @@ import path from 'node:path';
3
3
  function refs(tpl) { return [...tpl.matchAll(/\{(\w+)\}/g)].map((m) => m[1]); }
4
4
  export function parseConfig(configPath) {
5
5
  const raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
6
- for (const k of ['workspaceRoot', 'portBase', 'slotSpan', 'maxSlots'])
6
+ for (const k of ['workspaceRoot', 'maxSlots'])
7
7
  if (raw[k] === undefined)
8
8
  throw new Error(`config: ${k} required`);
9
+ const maxSlots = raw.maxSlots;
9
10
  const services = raw.services ?? {};
10
- const offsets = new Set();
11
+ // 端口段:每个服务 [port, port+maxSlots](port=主 dev/槽0,槽 1..maxSlots 落在段内)
11
12
  for (const [name, sp] of Object.entries(services)) {
12
- if (typeof sp.offset !== 'number')
13
- throw new Error(`config: ${name}.offset required`);
14
- if (sp.offset < 1 || sp.offset >= raw.slotSpan)
15
- throw new Error(`config: ${name}.offset must be 1..${raw.slotSpan - 1}`); // V2
16
- if (offsets.has(sp.offset))
17
- throw new Error(`config: duplicate offset ${sp.offset} (${name})`); // V1
18
- offsets.add(sp.offset);
13
+ if (typeof sp.port !== 'number' || sp.port < 1)
14
+ throw new Error(`config: ${name}.port must be a positive number`); // V2
19
15
  const repoDir = path.join(raw.workspaceRoot, sp.repo ?? name);
20
16
  if (!fs.existsSync(repoDir))
21
17
  throw new Error(`config: ${name}.repo dir missing: ${repoDir}`); // V5
22
18
  }
19
+ const entries = Object.entries(services);
20
+ for (let i = 0; i < entries.length; i++)
21
+ for (let j = i + 1; j < entries.length; j++) { // V1: 段不重叠
22
+ if (Math.abs(entries[i][1].port - entries[j][1].port) <= maxSlots)
23
+ throw new Error(`config: 服务 ${entries[i][0]} 与 ${entries[j][0]} 端口段重叠(间距需 > maxSlots=${maxSlots})`);
24
+ }
23
25
  for (const [name, sp] of Object.entries(services))
24
26
  if (sp.upstream && !services[sp.upstream.service])
25
27
  throw new Error(`config: ${name}.upstream.service '${sp.upstream.service}' not found`); // V3
26
- const known = new Set(['slot', 'blockBase', 'port', 'slug', 'worktree', 'repo', 'upstreamBase', 'cmd']); // V4
28
+ const known = new Set(['slot', 'port', 'slug', 'worktree', 'repo', 'upstreamBase', 'cmd']); // V4
27
29
  for (const sp of Object.values(services))
28
30
  for (const v of Object.values(sp.vars ?? {}))
29
31
  for (const ref of refs(v))
30
32
  if (!known.has(ref) && !(sp.vars && ref in sp.vars))
31
33
  throw new Error(`config: unknown template var {${ref}}`);
32
- return { workspaceRoot: raw.workspaceRoot, portBase: raw.portBase, slotSpan: raw.slotSpan, maxSlots: raw.maxSlots, services, configDir: path.dirname(configPath) };
34
+ return { workspaceRoot: raw.workspaceRoot, maxSlots, services, configDir: path.dirname(configPath) };
33
35
  }
34
36
  export function loadConfig(startDir) {
35
37
  if (process.env.WORKTREE_BAY_CONFIG)
package/dist/engine.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { renderTemplate } from './config.js';
4
- import { blockBase, portOf, isPortFree } from './ports.js';
4
+ import { portOf, isPortFree } from './ports.js';
5
5
  import { scanOccupancy } from './slots.js';
6
6
  import { addWorktree } from './git.js';
7
7
  import { runShell, run, spliceArgv, isTTY } from './util/exec.js';
@@ -23,13 +23,13 @@ export function mergeEnvText(text, kv) {
23
23
  return out.join('\n');
24
24
  }
25
25
  export function resolveUpstreamBase(cfg, slot, up, materialized) {
26
- return materialized ? `http://localhost:${portOf(cfg.portBase, cfg.slotSpan, slot, cfg.services[up.service].offset)}` : up.fallback;
26
+ return materialized ? `http://localhost:${portOf(cfg.services[up.service].port, slot)}` : up.fallback;
27
27
  }
28
28
  function upstreamMaterialized(cfg, slot, service) {
29
29
  return (scanOccupancy(cfg).get(slot) ?? []).some((o) => o.service === service);
30
30
  }
31
31
  export function buildVars(cfg, ctx) {
32
- const base = { slot: ctx.slot, blockBase: blockBase(cfg.portBase, cfg.slotSpan, ctx.slot), port: portOf(cfg.portBase, cfg.slotSpan, ctx.slot, ctx.sp.offset), slug: ctx.slug, worktree: ctx.dir, repo: ctx.repo };
32
+ const base = { slot: ctx.slot, port: portOf(ctx.sp.port, ctx.slot), slug: ctx.slug, worktree: ctx.dir, repo: ctx.repo };
33
33
  if (ctx.sp.upstream)
34
34
  base.upstreamBase = resolveUpstreamBase(cfg, ctx.slot, ctx.sp.upstream, upstreamMaterialized(cfg, ctx.slot, ctx.sp.upstream.service));
35
35
  for (const [k, v] of Object.entries(ctx.sp.vars ?? {}))
package/dist/ports.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import net from 'node:net';
2
- export function blockBase(portBase, slotSpan, slot) { return portBase + slot * slotSpan; }
3
- export function portOf(portBase, slotSpan, slot, offset) { return blockBase(portBase, slotSpan, slot) + offset; }
2
+ // 按服务分段:某服务在某槽的端口 = 服务基址( dev/槽0) + 槽号
3
+ export function portOf(servicePort, slot) { return servicePort + slot; }
4
4
  export function isPortFree(port) {
5
5
  return new Promise((resolve) => {
6
6
  const srv = net.createServer();
package/package.json CHANGED
@@ -1,16 +1,20 @@
1
1
  {
2
2
  "name": "worktree-bay",
3
- "version": "1.5.0",
4
- "description": "Config-driven git worktree slot + port orchestrator for parallel multi-service development",
3
+ "version": "2.0.0",
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",
7
+ "git-worktree",
7
8
  "worktree",
8
9
  "monorepo",
9
10
  "cli",
10
11
  "ports",
11
12
  "orchestration",
12
13
  "parallel",
13
- "dev"
14
+ "parallel-development",
15
+ "developer-tools",
16
+ "dev-environment",
17
+ "mcp"
14
18
  ],
15
19
  "type": "module",
16
20
  "license": "MIT",
package/skill.md CHANGED
@@ -63,19 +63,17 @@ worktree-bay gc # 回收已合并的
63
63
  | 字段 | 类型 | 说明 |
64
64
  |---|---|---|
65
65
  | `workspaceRoot` | string | 工作区根绝对路径,各服务仓在其下 |
66
- | `portBase` | number | 端口基址,如 `6000` |
67
- | `slotSpan` | number | 每个槽位的端口跨度(块大小),如 `10` |
68
- | `maxSlots` | number | 最大并行功能数,如 `9` |
66
+ | `maxSlots` | number | 最大并行功能数(每个服务预留 `maxSlots` 个端口),如 `9` |
69
67
  | `services` | object | 服务名 → 服务定义(见下) |
70
68
 
71
- **端口计算**:`块基址 = portBase + 槽位N * slotSpan`;`某服务端口 = 块基址 + 该服务 offset`。
72
- 例:portBase=6000, slotSpan=10,槽 1 的块基址 = 6010,offset=1 的服务端口 = 6011。
69
+ **端口模型(按服务分段)**:每个服务有自己的端口段,基址 `port` 就是它的主 dev 端口(= 槽 0);某服务在某槽 N 的端口 = `service.port + N`(槽 1..maxSlots)。
70
+ 例:`api.port=6001` 1 6002、槽 2 用 6003…;`lms.port=6011` → 槽 1 6012…。**服务数量无上限**,只要各服务端口段(`[port, port+maxSlots]`)互不重叠即可。
73
71
 
74
72
  ### 服务定义原语
75
73
 
76
74
  | 原语 | 必填 | 类型 | 说明 |
77
75
  |---|---|---|---|
78
- | `offset` | ✅ | number | 本服务在块内的偏移;`1 offset < slotSpan`,各服务互不相同 |
76
+ | `port` | ✅ | number | 本服务端口段基址(= dev/槽0 端口);各服务的段 `[port, port+maxSlots]` 互不重叠 |
79
77
  | `repo` | | string | 仓库目录名(相对 workspaceRoot),**默认 = 服务名** |
80
78
  | `vars` | | object | 自定义模板变量,值里可引用基础变量,如 `{ "project": "myapi-{slug}" }` |
81
79
  | `copy` | | string[] | 挂入时从主 checkout **递归拷贝**到 worktree 的文件/目录(含依赖目录,如 `vendor`)。含符号链接也安全(会跟随拷目标内容) |
@@ -94,8 +92,7 @@ worktree-bay gc # 回收已合并的
94
92
  | 变量 | 含义 |
95
93
  |---|---|
96
94
  | `{slot}` | 槽位号 |
97
- | `{blockBase}` | 块基址 `portBase + slot*slotSpan` |
98
- | `{port}` | 本服务端口 `blockBase + offset` |
95
+ | `{port}` | 本服务端口 `service.port + slot` |
99
96
  | `{slug}` | worktree 目录名 `s<slot>-<分支归一化>` |
100
97
  | `{worktree}` | worktree 绝对路径 |
101
98
  | `{repo}` | 服务仓绝对路径 |
@@ -105,8 +102,8 @@ worktree-bay gc # 回收已合并的
105
102
 
106
103
  ### 加载时强制校验
107
104
 
108
- 1. 各服务 `offset` 互不相同;
109
- 2. `1 ≤ offset < slotSpan`;
105
+ 1. 各服务端口段 `[port, port+maxSlots]` 互不重叠(任意两服务 `|portA - portB| > maxSlots`);
106
+ 2. `port` 必填且为正数;
110
107
  3. `upstream.service` 必须存在于 `services`;
111
108
  4. 所有模板里引用的 `{var}` 可解析(基础变量或本服务 vars 已声明);
112
109
  5. `repo` 指向的目录存在。
@@ -117,12 +114,10 @@ worktree-bay gc # 回收已合并的
117
114
  ```jsonc
118
115
  {
119
116
  "workspaceRoot": "/path/to/workspace",
120
- "portBase": 6000,
121
- "slotSpan": 10,
122
117
  "maxSlots": 9,
123
118
  "services": {
124
119
  "api": {
125
- "offset": 1,
120
+ "port": 6001,
126
121
  "vars": { "project": "myapi-{slug}" },
127
122
  "copy": [".env", "vendor"],
128
123
  "env": { ".env": { "APP_PORT": "{port}", "CACHE_PREFIX": "dev:{slug}:" } },
@@ -132,7 +127,7 @@ worktree-bay gc # 回收已合并的
132
127
  "run": { "test": ["composer", "run", "test"], "migrate": ["php", "artisan", "migrate"] }
133
128
  },
134
129
  "web": {
135
- "offset": 2,
130
+ "port": 6011,
136
131
  "upstream": { "service": "api", "fallback": "http://localhost:6001" },
137
132
  "env": { ".env.local": { "VITE_API_BASE_URL": "{upstreamBase}" } },
138
133
  "setup": "pnpm install",