@konglx/rotom 2.21.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 +417 -0
- package/bin/mesh-master.sh +439 -0
- package/bin/rotom +29 -0
- package/bin/rotom-link.sh +136 -0
- package/bin/rotom-send-with-status +57 -0
- package/bin/rotom-up.sh +428 -0
- package/dist/cli/ask.js +62 -0
- package/dist/cli/common.js +321 -0
- package/dist/cli/config.js +65 -0
- package/dist/cli/directory.js +17 -0
- package/dist/cli/executor.js +58 -0
- package/dist/cli/fed.js +91 -0
- package/dist/cli/group.js +273 -0
- package/dist/cli/identity.js +62 -0
- package/dist/cli/init.js +268 -0
- package/dist/cli/issue.js +202 -0
- package/dist/cli/join.js +170 -0
- package/dist/cli/link.js +47 -0
- package/dist/cli/master.js +51 -0
- package/dist/cli/memory.js +307 -0
- package/dist/cli/note.js +68 -0
- package/dist/cli/repo.js +77 -0
- package/dist/cli/rotom.js +277 -0
- package/dist/cli/routes.js +118 -0
- package/dist/cli/run.js +45 -0
- package/dist/cli/schedule.js +237 -0
- package/dist/cli/skill.js +173 -0
- package/dist/cli/team.js +106 -0
- package/dist/executor/claude-code-hook.cjs +80 -0
- package/dist/executor/cli-executor.js +8 -0
- package/dist/executor/executors/claude-code.js +780 -0
- package/dist/executor/executors/codex.js +719 -0
- package/dist/executor/executors/hermes-cli.js +855 -0
- package/dist/executor/executors/openclaw.js +467 -0
- package/dist/executor/executors/pi.js +514 -0
- package/dist/executor/index.js +269 -0
- package/dist/executor/jsonrpc-transport.js +125 -0
- package/dist/executor/process-runner.js +101 -0
- package/dist/executor/reasoning-status.js +83 -0
- package/dist/executor/repo-cache.js +502 -0
- package/dist/executor/session-store.js +188 -0
- package/dist/executor/worker-chat.js +257 -0
- package/dist/executor/worker-connection.js +89 -0
- package/dist/executor/worker-issue.js +264 -0
- package/dist/executor/worker.js +877 -0
- package/dist/link/pending-requests.js +72 -0
- package/dist/link/server.js +233 -0
- package/dist/link/visibility-store.js +58 -0
- package/dist/master/api/agents.js +333 -0
- package/dist/master/api/artifacts.js +271 -0
- package/dist/master/api/domains.js +64 -0
- package/dist/master/api/groups.js +635 -0
- package/dist/master/api/guidance-templates.js +147 -0
- package/dist/master/api/index.js +89 -0
- package/dist/master/api/issues-patrol.js +172 -0
- package/dist/master/api/issues.js +663 -0
- package/dist/master/api/links-patrol.js +168 -0
- package/dist/master/api/links.js +114 -0
- package/dist/master/api/memory.js +259 -0
- package/dist/master/api/messages.js +157 -0
- package/dist/master/api/notes.js +77 -0
- package/dist/master/api/schedule-patterns.js +133 -0
- package/dist/master/api/schedules.js +272 -0
- package/dist/master/api/sessions.js +158 -0
- package/dist/master/api/share.js +269 -0
- package/dist/master/api/skills.js +190 -0
- package/dist/master/api/teams.js +122 -0
- package/dist/master/api/uploads.js +245 -0
- package/dist/master/auth.js +134 -0
- package/dist/master/dashboard/animations/calico-dozing.apng +0 -0
- package/dist/master/dashboard/animations/calico-error.apng +0 -0
- package/dist/master/dashboard/animations/calico-happy.apng +0 -0
- package/dist/master/dashboard/animations/calico-notification.apng +0 -0
- package/dist/master/dashboard/animations/calico-sleeping.apng +0 -0
- package/dist/master/dashboard/animations/calico-thinking.apng +0 -0
- package/dist/master/dashboard/animations/calico-waking.apng +0 -0
- package/dist/master/dashboard/assets/ApprovalCard-C38VV6ko.css +1 -0
- package/dist/master/dashboard/assets/ApprovalCard-CHPh2dmE.js +17 -0
- package/dist/master/dashboard/assets/ArtifactPanel-P_2gAP7v.js +1 -0
- package/dist/master/dashboard/assets/ArtifactPanel-aGHySny5.css +1 -0
- package/dist/master/dashboard/assets/css.worker-DaIe3gwK.js +84 -0
- package/dist/master/dashboard/assets/editor.worker-BCzxt1at.js +12 -0
- package/dist/master/dashboard/assets/html.worker-CKrFyw_2.js +461 -0
- package/dist/master/dashboard/assets/index-CChrTn81.css +32 -0
- package/dist/master/dashboard/assets/index-Dhu4SN1z.js +181 -0
- package/dist/master/dashboard/assets/json.worker-B7c_PmGb.js +49 -0
- package/dist/master/dashboard/assets/markdown-CeN5IgdF.js +29 -0
- package/dist/master/dashboard/assets/monaco-core-DyX1CsEw.css +1 -0
- package/dist/master/dashboard/assets/monaco-core-oQiQUisy.js +833 -0
- package/dist/master/dashboard/assets/monaco-setup-CiOPQdmo.js +1 -0
- package/dist/master/dashboard/assets/react-vendor-C8IxlyCR.js +67 -0
- package/dist/master/dashboard/assets/ts.worker-BhkL8olL.js +51334 -0
- package/dist/master/dashboard/assets/useMonaco-ILb4vyPh.js +12 -0
- package/dist/master/dashboard/assets/vite-preload-CxJPbCTl.js +1 -0
- package/dist/master/dashboard/debug-auth.html +197 -0
- package/dist/master/dashboard/favicon.ico +0 -0
- package/dist/master/dashboard/index.html +20 -0
- package/dist/master/dashboard/rotom-avatar.png +0 -0
- package/dist/master/db/agent-sessions.js +60 -0
- package/dist/master/db/agent-visibility.js +64 -0
- package/dist/master/db/agents.js +119 -0
- package/dist/master/db/ask-bridges.js +157 -0
- package/dist/master/db/build-update.js +59 -0
- package/dist/master/db/core.js +82 -0
- package/dist/master/db/domains.js +80 -0
- package/dist/master/db/groups.js +316 -0
- package/dist/master/db/guidance-templates.js +58 -0
- package/dist/master/db/index.js +12 -0
- package/dist/master/db/internal.js +45 -0
- package/dist/master/db/issues-patrol.js +81 -0
- package/dist/master/db/issues.js +373 -0
- package/dist/master/db/links.js +221 -0
- package/dist/master/db/master-node.js +43 -0
- package/dist/master/db/memory.js +272 -0
- package/dist/master/db/messages.js +210 -0
- package/dist/master/db/notes.js +55 -0
- package/dist/master/db/schedule-patterns.js +56 -0
- package/dist/master/db/schedules.js +135 -0
- package/dist/master/db/skills.js +144 -0
- package/dist/master/db/team.js +88 -0
- package/dist/master/db/types.js +10 -0
- package/dist/master/db.js +12 -0
- package/dist/master/embedded.js +133 -0
- package/dist/master/federation/client.js +283 -0
- package/dist/master/federation/identity.js +133 -0
- package/dist/master/federation/manager.js +267 -0
- package/dist/master/federation/publisher.js +87 -0
- package/dist/master/federation/self-publisher.js +69 -0
- package/dist/master/federation/server.js +487 -0
- package/dist/master/group-paths.js +208 -0
- package/dist/master/offline-queue.js +38 -0
- package/dist/master/opc-bootstrap.js +245 -0
- package/dist/master/patrol-terminal.js +275 -0
- package/dist/master/repo-scan.js +188 -0
- package/dist/master/router.js +214 -0
- package/dist/master/scheduler-handlers.js +510 -0
- package/dist/master/scheduler.js +201 -0
- package/dist/master/server.js +203 -0
- package/dist/master/services/link-collector.js +82 -0
- package/dist/master/services/link-patrol-bootstrap.js +50 -0
- package/dist/master/services/memory-extract-prompt.js +34 -0
- package/dist/master/services/patrol-bootstrap.js +63 -0
- package/dist/master/share-tokens.js +56 -0
- package/dist/master/terminal-hub.js +300 -0
- package/dist/master/uploads.js +108 -0
- package/dist/master/util/fs.js +100 -0
- package/dist/master/util/paths.js +50 -0
- package/dist/master/util/persona.js +10 -0
- package/dist/master/ws-hub/connection.js +928 -0
- package/dist/master/ws-hub/conversation.js +290 -0
- package/dist/master/ws-hub/directory.js +70 -0
- package/dist/master/ws-hub/dispatch-enrich.js +34 -0
- package/dist/master/ws-hub/hub.js +136 -0
- package/dist/master/ws-hub/index.js +9 -0
- package/dist/master/ws-hub/internal.js +35 -0
- package/dist/master/ws-hub/routing.js +295 -0
- package/dist/master/ws-hub/sessions.js +130 -0
- package/dist/master/ws-hub.js +11 -0
- package/dist/shared/agent-profile.js +44 -0
- package/dist/shared/constants.js +55 -0
- package/dist/shared/dedup.js +33 -0
- package/dist/shared/group-context.js +62 -0
- package/dist/shared/json-codec.js +33 -0
- package/dist/shared/logger.js +136 -0
- package/dist/shared/mention.js +22 -0
- package/dist/shared/network.js +40 -0
- package/dist/shared/parse.js +18 -0
- package/dist/shared/prompt-composer.js +171 -0
- package/dist/shared/protocol/client-messages.js +8 -0
- package/dist/shared/protocol/enums.js +6 -0
- package/dist/shared/protocol/federation.js +62 -0
- package/dist/shared/protocol/guards.js +87 -0
- package/dist/shared/protocol/server-messages.js +8 -0
- package/dist/shared/protocol/types.js +8 -0
- package/dist/shared/protocol.js +19 -0
- package/dist/shared/readonly-allowlist.js +122 -0
- package/dist/shared/rotom-cli-prompt.js +23 -0
- package/dist/shared/skill-context.js +19 -0
- package/dist/shared/skill-md.js +43 -0
- package/dist/shared/slash-commands.js +50 -0
- package/dist/shared/time.js +80 -0
- package/dist/shared/title.js +46 -0
- package/dist/shared/url-extractor.js +99 -0
- package/migrations/001-schema.sql +942 -0
- package/package.json +68 -0
- package/scripts/fix-node-pty-perms.mjs +46 -0
- package/skill/rotom-a2a-communicate/SKILL.md +257 -0
- package/skill/rotom-bus-host/SKILL.md +78 -0
- package/skill/rotom-bus-host/scripts/poll-replies.sh +148 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executor-side bare clone + git worktree cache.
|
|
3
|
+
*
|
|
4
|
+
* 全局复用:同一 repo_url 在 `~/.rotom/repos/<repo-id>.git/` 只裸克隆一次,
|
|
5
|
+
* 跨 group/issue 共享对象库。每个 issue 在 `<groupDir>/<issueId>/repos/<id>/`
|
|
6
|
+
* 获得一个独立 worktree(分支隔离),多分支并行天然可用。
|
|
7
|
+
*
|
|
8
|
+
* 幂等性:ensureBareClone 二次调用只 `git fetch --prune`;addWorktree 在 worktree
|
|
9
|
+
* 已存在时直接复用、分支已存在时基于该分支创建。离线时 fetch 失败降级用本地缓存。
|
|
10
|
+
*
|
|
11
|
+
* 与 master 解耦:本模块只在 executor 侧运行,master 不碰工作 FS(跨机器部署安全)。
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { createLogger } from "../shared/logger.js";
|
|
19
|
+
const log = createLogger("mesh-executor-repo-cache", { stream: "stderr" });
|
|
20
|
+
/** Bare clone 缓存根目录。与 ARTIFACTS_ROOT 同源(~/.rotom/)。 */
|
|
21
|
+
export const REPOS_ROOT = path.join(os.homedir(), ".rotom", "repos");
|
|
22
|
+
/**
|
|
23
|
+
* 给定 repo URL 计算稳定 repo-id。URL 归一化(去 .git 后缀、去协议前缀)后取
|
|
24
|
+
* SHA-1 前 12 位,保证同 URL 在不同进程/机器算出同 id。短到能直接当目录名。
|
|
25
|
+
*/
|
|
26
|
+
export function repoIdFor(url) {
|
|
27
|
+
let u = url.trim();
|
|
28
|
+
// 去 .git 后缀
|
|
29
|
+
if (u.endsWith(".git"))
|
|
30
|
+
u = u.slice(0, -4);
|
|
31
|
+
// 去 ssh:// / https:// / git@ 前缀,只保留 host/path 部分
|
|
32
|
+
u = u.replace(/^ssh:\/\/([^@]+@)?/, "").replace(/^https?:\/\//, "");
|
|
33
|
+
u = u.replace(/^git@/, "");
|
|
34
|
+
// 去 trailing slash
|
|
35
|
+
u = u.replace(/\/$/, "");
|
|
36
|
+
return createHash("sha1").update(u).digest("hex").slice(0, 12);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 从 URL 提取仓库名(最后一段,去 .git 后缀)。用于路径可读性,让人一眼看出
|
|
40
|
+
* 是哪个仓库。仅做展示,唯一性仍由 repoId 保证。
|
|
41
|
+
*
|
|
42
|
+
* 例:
|
|
43
|
+
* git@github.com:org/repo.git → repo
|
|
44
|
+
* https://code.alipay.com/kael/kael-trade-h5.git → kael-trade-h5
|
|
45
|
+
* /tmp/origin.git → origin
|
|
46
|
+
*/
|
|
47
|
+
export function repoNameFor(url) {
|
|
48
|
+
let u = url.trim();
|
|
49
|
+
if (u.endsWith(".git"))
|
|
50
|
+
u = u.slice(0, -4);
|
|
51
|
+
// 取最后一段(去 query/hash/trailing slash)
|
|
52
|
+
u = u.split("?")[0].split("#")[0].replace(/\/$/, "");
|
|
53
|
+
const last = u.split("/").pop() || "repo";
|
|
54
|
+
// 兜底:若 last 为空(如 URL 是 host 根),用 "repo"
|
|
55
|
+
return last || "repo";
|
|
56
|
+
}
|
|
57
|
+
/** bare clone 路径:`~/.rotom/repos/<repoName>-<repoId8>.git/`。
|
|
58
|
+
* repoName 做可读性,repoId8 做唯一性(避免同名不同 URL 冲突)。 */
|
|
59
|
+
function barePath(url) {
|
|
60
|
+
const repoId = repoIdFor(url);
|
|
61
|
+
const repoName = repoNameFor(url);
|
|
62
|
+
return path.join(REPOS_ROOT, `${repoName}-${repoId.slice(0, 8)}.git`);
|
|
63
|
+
}
|
|
64
|
+
function runGit(args, opts) {
|
|
65
|
+
const r = spawnSync("git", args, {
|
|
66
|
+
cwd: opts?.cwd,
|
|
67
|
+
encoding: "utf-8",
|
|
68
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
69
|
+
});
|
|
70
|
+
return {
|
|
71
|
+
ok: r.status === 0,
|
|
72
|
+
stdout: typeof r.stdout === "string" ? r.stdout : "",
|
|
73
|
+
stderr: typeof r.stderr === "string" ? r.stderr : "",
|
|
74
|
+
code: r.status,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* 异步版 runGit,用 spawn 而非 spawnSync。issue 执行路径用这个,避免 bare clone
|
|
79
|
+
* (可能几分钟)阻塞 executor 的其他 WS 处理(心跳、chat 取消、其他 issue 进度等)。
|
|
80
|
+
*
|
|
81
|
+
* 返回 GitResult,与 runGit 同形。stdout/stderr 累积在内存(单次 git 输出不大,
|
|
82
|
+
* 不会爆)。进程被 signal 杀掉时 ok=false。
|
|
83
|
+
*/
|
|
84
|
+
function runGitAsync(args, opts) {
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
const child = spawn("git", args, {
|
|
87
|
+
cwd: opts?.cwd,
|
|
88
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
89
|
+
});
|
|
90
|
+
let stdout = "";
|
|
91
|
+
let stderr = "";
|
|
92
|
+
child.stdout?.on("data", (d) => { stdout += d.toString(); });
|
|
93
|
+
child.stderr?.on("data", (d) => { stderr += d.toString(); });
|
|
94
|
+
child.on("error", (err) => {
|
|
95
|
+
resolve({ ok: false, stdout, stderr: stderr + `\nspawn error: ${err.message}`, code: null });
|
|
96
|
+
});
|
|
97
|
+
child.on("close", (code) => {
|
|
98
|
+
resolve({ ok: code === 0, stdout, stderr, code });
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/** fs.realpathSync 的安全版:路径不存在时返回原值(不抛错)。用于幂等检查时归一化
|
|
103
|
+
* symlink(macOS /tmp → /private/tmp 等)。 */
|
|
104
|
+
function safeRealpath(p) {
|
|
105
|
+
try {
|
|
106
|
+
return fs.realpathSync(p);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return p;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** 确保 REPOS_ROOT 存在。幂等。 */
|
|
113
|
+
function ensureReposRoot() {
|
|
114
|
+
fs.mkdirSync(REPOS_ROOT, { recursive: true });
|
|
115
|
+
}
|
|
116
|
+
/** 给定 URL 算出 bare clone 路径(不克隆,只算路径)。用于 cleanup 时
|
|
117
|
+
* 拿到 barePath 做 `git worktree remove` 的 cwd,不需要真的 clone。 */
|
|
118
|
+
export function getBarePathForUrl(url) {
|
|
119
|
+
return barePath(url);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* 算出 worktree 的全局路径(不创建,只算路径)。
|
|
123
|
+
*
|
|
124
|
+
* 布局:~/.rotom/repos/<repo-id>-wt/<slot>/
|
|
125
|
+
* - group 模式:slot = `group-<groupId8>`(每 group 一个 worktree,跨群不共享)
|
|
126
|
+
* - issue 模式:slot = `<issueId8>`(per-issue)
|
|
127
|
+
*
|
|
128
|
+
* 全局放,不跟 group 走(用户需求:worktree 是机器级资源,不属于某个 group)。
|
|
129
|
+
* bare clone 对象库仍全局共享,worktree 只占 checkout 文件空间。
|
|
130
|
+
*/
|
|
131
|
+
export function getWorktreePathForUrl(url, slot) {
|
|
132
|
+
const repoId = repoIdFor(url);
|
|
133
|
+
const repoName = repoNameFor(url);
|
|
134
|
+
return path.join(REPOS_ROOT, `${repoName}-${repoId.slice(0, 8)}-wt`, slot);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* 确保某 URL 的 bare clone 存在并是最新的。
|
|
138
|
+
*
|
|
139
|
+
* - 不存在 → `git clone --bare <url> <repoId>.git/`
|
|
140
|
+
* - 已存在 → `git fetch --prune` 增量更新;fetch 失败(离线)降级 warn,不抛错,
|
|
141
|
+
* 让调用方继续用本地缓存起 worktree(可能 stale 但至少能跑)
|
|
142
|
+
*
|
|
143
|
+
* 返回 { repoId, barePath }。失败(首次 clone 失败、且本地无缓存)抛错。
|
|
144
|
+
*/
|
|
145
|
+
export function ensureBareClone(url) {
|
|
146
|
+
ensureReposRoot();
|
|
147
|
+
const repoId = repoIdFor(url);
|
|
148
|
+
const bp = barePath(url);
|
|
149
|
+
const alreadyExists = fs.existsSync(bp);
|
|
150
|
+
if (!alreadyExists) {
|
|
151
|
+
const r = runGit(["clone", "--bare", url, bp]);
|
|
152
|
+
if (!r.ok) {
|
|
153
|
+
throw new Error(`bare clone failed for ${url}: ${r.stderr || r.stdout || "(no stderr)"}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
// 已存在 → fetch --prune。离线时静默降级。
|
|
158
|
+
const r = runGit(["fetch", "--prune"], { cwd: bp });
|
|
159
|
+
if (!r.ok) {
|
|
160
|
+
log.warn(`fetch failed (offline?) for ${repoId}: ${r.stderr || r.stdout || "(no stderr)"} — using local cache`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { repoId, barePath: bp };
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* 在 bare clone 上创建 worktree。
|
|
167
|
+
*
|
|
168
|
+
* bare clone 把源仓库的 refs/heads/* 直接拷贝到自己的 refs/heads/*(不像普通
|
|
169
|
+
* clone 那样放在 refs/remotes/origin/*),所以 startPoint 直接用 `<branch>` 即可。
|
|
170
|
+
*
|
|
171
|
+
* 多 issue 同分支冲突:git worktree 不允许同一本地分支在多个 worktree 同时
|
|
172
|
+
* checkout。为避免冲突,本函数为每个 issue 派生一个独立本地分支:
|
|
173
|
+
* `<branch>-rotom-<issueId8>` (issueId8 取传入的 issueId 前 8 字符)
|
|
174
|
+
* 基于 `refs/heads/<branch>` 起点。agent 在 worktree 看到该派生分支,知道自己是
|
|
175
|
+
* 从目标分支派生的,可自行 push 回去(若 remote 配置允许)。
|
|
176
|
+
*
|
|
177
|
+
* issueId8 缺省时退化为 `tmp` 前缀(不推荐,可能冲突)。
|
|
178
|
+
*
|
|
179
|
+
* 幂等:worktree 路径已存在且是有效 worktree → 直接返回。分支已存在但 worktree
|
|
180
|
+
* 不存在 → `-B` 重置该分支到 startPoint 后再 add。
|
|
181
|
+
*/
|
|
182
|
+
export function addWorktree(barePath, worktreePath, branch, issueId8) {
|
|
183
|
+
// 已存在则视为幂等成功。检查是否是 worktree:用 `git worktree list` 看是否包含。
|
|
184
|
+
if (fs.existsSync(worktreePath)) {
|
|
185
|
+
const list = runGit(["worktree", "list", "--porcelain"], { cwd: barePath });
|
|
186
|
+
if (list.ok) {
|
|
187
|
+
// macOS 下 /tmp 是 /private/tmp 的 symlink,git 输出真实路径而传入的可能是
|
|
188
|
+
// symlink 路径,两边都 realpath 归一化后再比对。
|
|
189
|
+
const targetReal = safeRealpath(worktreePath);
|
|
190
|
+
const matched = list.stdout.split("\n").some(line => {
|
|
191
|
+
if (!line.startsWith("worktree "))
|
|
192
|
+
return false;
|
|
193
|
+
const listed = line.slice("worktree ".length);
|
|
194
|
+
return safeRealpath(listed) === targetReal;
|
|
195
|
+
});
|
|
196
|
+
if (matched)
|
|
197
|
+
return worktreePath;
|
|
198
|
+
}
|
|
199
|
+
// 路径存在但不是 worktree —— 可能是残留空目录,删掉重建
|
|
200
|
+
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
// 确保 worktree 父目录存在
|
|
203
|
+
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
|
|
204
|
+
// 派生本地分支名:为每个 issue 独立,避免同分支多 worktree 冲突
|
|
205
|
+
const suffix = issueId8 && issueId8.length >= 8 ? issueId8.slice(0, 8) : (issueId8 || "tmp");
|
|
206
|
+
const localBranch = branch ? `${branch}-rotom-${suffix}` : `rotom-${suffix}`;
|
|
207
|
+
// startPoint:若 branch 给了,从 refs/heads/<branch> 起(bare clone 的 head 引用);
|
|
208
|
+
// 否则用 HEAD(仓库默认分支)
|
|
209
|
+
const startPoint = branch ? `refs/heads/${branch}` : "HEAD";
|
|
210
|
+
// 先 -B 创建/重置本地分支,再 worktree add 到该分支
|
|
211
|
+
// (分两步是因为 `worktree add -B <branch> <path> <startPoint>` 在某些 git 版本下
|
|
212
|
+
// 会把 startPoint 误判成要 checkout 的引用而非分支起点,导致 detached)
|
|
213
|
+
const resetRes = runGit(["branch", "-f", localBranch, startPoint], { cwd: barePath });
|
|
214
|
+
if (!resetRes.ok) {
|
|
215
|
+
// 本地分支不存在时 `branch -f` 会创建,但若 startPoint 也不存在(分支名拼错)
|
|
216
|
+
// 则失败。尝试不指定 startPoint,用 HEAD 兜底
|
|
217
|
+
const fallbackBranch = runGit(["branch", localBranch], { cwd: barePath });
|
|
218
|
+
if (!fallbackBranch.ok && !resetRes.stdout.includes("already exists")) {
|
|
219
|
+
throw new Error(`worktree branch setup failed for ${localBranch} (start=${startPoint}): ${resetRes.stderr || resetRes.stdout}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const addRes = runGit(["worktree", "add", "--force", worktreePath, localBranch], { cwd: barePath });
|
|
223
|
+
if (!addRes.ok) {
|
|
224
|
+
// 兜底:detached HEAD checkout,保证至少能跑
|
|
225
|
+
const fallback = runGit(["worktree", "add", "--force", "--detach", worktreePath, startPoint], { cwd: barePath });
|
|
226
|
+
if (!fallback.ok) {
|
|
227
|
+
throw new Error(`worktree add failed for ${worktreePath} (branch=${localBranch}, start=${startPoint}): ${addRes.stderr || addRes.stdout}\n--- fallback detach ---\n${fallback.stderr || fallback.stdout}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return worktreePath;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* 把已存在的 worktree 切到目标分支。group 模式下,issue 执行前调这个
|
|
234
|
+
* 让共享 worktree 切到 issue 想要的分支(同分支连续 issue 零成本,切分支有成本)。
|
|
235
|
+
*
|
|
236
|
+
* branch 缺省时切到仓库默认分支(不动)。
|
|
237
|
+
* 切换失败(有未提交改动 / 分支不存在)只 warn,不抛——让 agent 在当前分支继续跑,
|
|
238
|
+
* 避免因 checkout 失败阻塞 issue 流程。
|
|
239
|
+
*/
|
|
240
|
+
export function checkoutWorktree(worktreePath, branch) {
|
|
241
|
+
if (!branch)
|
|
242
|
+
return true;
|
|
243
|
+
if (!fs.existsSync(worktreePath))
|
|
244
|
+
return false;
|
|
245
|
+
// 先检查当前分支是否已是目标,避免无谓的 checkout
|
|
246
|
+
const headRes = runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: worktreePath });
|
|
247
|
+
if (headRes.ok && headRes.stdout.trim() === branch)
|
|
248
|
+
return true;
|
|
249
|
+
// checkout 目标分支(本地分支名,refs/heads/<branch>)
|
|
250
|
+
const r = runGit(["checkout", branch], { cwd: worktreePath });
|
|
251
|
+
if (!r.ok) {
|
|
252
|
+
// 分支可能不存在(没 fetch 到),尝试从 origin 拉
|
|
253
|
+
const fetchR = runGit(["fetch", "origin", `${branch}:${branch}`], { cwd: worktreePath });
|
|
254
|
+
if (fetchR.ok) {
|
|
255
|
+
const r2 = runGit(["checkout", branch], { cwd: worktreePath });
|
|
256
|
+
if (!r2.ok) {
|
|
257
|
+
log.warn(`checkout ${branch} failed in ${worktreePath}: ${r2.stderr || r2.stdout}`);
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
log.warn(`checkout ${branch} failed in ${worktreePath}: ${r.stderr || r.stdout}`);
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* 异步版 addWorktree。语义与同步版完全一致,用 spawn 避免 spawnSync 阻塞。
|
|
269
|
+
* worktree 路径已存在且是有效 worktree → 幂等返回;否则创建派生分支 + add。
|
|
270
|
+
*/
|
|
271
|
+
export async function addWorktreeAsync(barePath, worktreePath, branch, issueId8) {
|
|
272
|
+
if (fs.existsSync(worktreePath)) {
|
|
273
|
+
const list = await runGitAsync(["worktree", "list", "--porcelain"], { cwd: barePath });
|
|
274
|
+
if (list.ok) {
|
|
275
|
+
const targetReal = safeRealpath(worktreePath);
|
|
276
|
+
const matched = list.stdout.split("\n").some(line => {
|
|
277
|
+
if (!line.startsWith("worktree "))
|
|
278
|
+
return false;
|
|
279
|
+
const listed = line.slice("worktree ".length);
|
|
280
|
+
return safeRealpath(listed) === targetReal;
|
|
281
|
+
});
|
|
282
|
+
if (matched)
|
|
283
|
+
return worktreePath;
|
|
284
|
+
}
|
|
285
|
+
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
286
|
+
}
|
|
287
|
+
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
|
|
288
|
+
const suffix = issueId8 && issueId8.length >= 8 ? issueId8.slice(0, 8) : (issueId8 || "tmp");
|
|
289
|
+
const localBranch = branch ? `${branch}-rotom-${suffix}` : `rotom-${suffix}`;
|
|
290
|
+
const startPoint = branch ? `refs/heads/${branch}` : "HEAD";
|
|
291
|
+
const resetRes = await runGitAsync(["branch", "-f", localBranch, startPoint], { cwd: barePath });
|
|
292
|
+
if (!resetRes.ok) {
|
|
293
|
+
const fallbackBranch = await runGitAsync(["branch", localBranch], { cwd: barePath });
|
|
294
|
+
if (!fallbackBranch.ok && !resetRes.stdout.includes("already exists")) {
|
|
295
|
+
throw new Error(`worktree branch setup failed for ${localBranch} (start=${startPoint}): ${resetRes.stderr || resetRes.stdout}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const addRes = await runGitAsync(["worktree", "add", "--force", worktreePath, localBranch], { cwd: barePath });
|
|
299
|
+
if (!addRes.ok) {
|
|
300
|
+
const fallback = await runGitAsync(["worktree", "add", "--force", "--detach", worktreePath, startPoint], { cwd: barePath });
|
|
301
|
+
if (!fallback.ok) {
|
|
302
|
+
throw new Error(`worktree add failed for ${worktreePath} (branch=${localBranch}, start=${startPoint}): ${addRes.stderr || addRes.stdout}\n--- fallback detach ---\n${fallback.stderr || fallback.stdout}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return worktreePath;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* 异步版 checkoutWorktree。group 模式下 issue 执行前调,切到目标分支。
|
|
309
|
+
*/
|
|
310
|
+
export async function checkoutWorktreeAsync(worktreePath, branch) {
|
|
311
|
+
if (!branch)
|
|
312
|
+
return true;
|
|
313
|
+
if (!fs.existsSync(worktreePath))
|
|
314
|
+
return false;
|
|
315
|
+
const headRes = await runGitAsync(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: worktreePath });
|
|
316
|
+
if (headRes.ok && headRes.stdout.trim() === branch)
|
|
317
|
+
return true;
|
|
318
|
+
const r = await runGitAsync(["checkout", branch], { cwd: worktreePath });
|
|
319
|
+
if (!r.ok) {
|
|
320
|
+
const fetchR = await runGitAsync(["fetch", "origin", `${branch}:${branch}`], { cwd: worktreePath });
|
|
321
|
+
if (fetchR.ok) {
|
|
322
|
+
const r2 = await runGitAsync(["checkout", branch], { cwd: worktreePath });
|
|
323
|
+
if (!r2.ok) {
|
|
324
|
+
log.warn(`checkout ${branch} failed in ${worktreePath}: ${r2.stderr || r2.stdout}`);
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
log.warn(`checkout ${branch} failed in ${worktreePath}: ${r.stderr || r.stdout}`);
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* 移除一个 worktree。幂等:不存在的路径直接返回 true。
|
|
336
|
+
* 失败(进程占用等)也返回 true 而不抛错 —— 调用方不应因清理失败阻塞 issue 流程。
|
|
337
|
+
*/
|
|
338
|
+
export function removeWorktree(barePath, worktreePath) {
|
|
339
|
+
if (!fs.existsSync(worktreePath))
|
|
340
|
+
return true;
|
|
341
|
+
const r = runGit(["worktree", "remove", "--force", worktreePath], { cwd: barePath });
|
|
342
|
+
if (!r.ok) {
|
|
343
|
+
// 兜底:prune + 物理删除
|
|
344
|
+
runGit(["worktree", "prune"], { cwd: barePath });
|
|
345
|
+
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
346
|
+
}
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* 列出本机所有 bare clone + 各自活跃 worktree 数。供 `rotom repo list` 用。
|
|
351
|
+
*/
|
|
352
|
+
export function listBareClones() {
|
|
353
|
+
if (!fs.existsSync(REPOS_ROOT))
|
|
354
|
+
return [];
|
|
355
|
+
const entries = fs.readdirSync(REPOS_ROOT, { withFileTypes: true });
|
|
356
|
+
const result = [];
|
|
357
|
+
for (const e of entries) {
|
|
358
|
+
if (!e.isDirectory() || !e.name.endsWith(".git"))
|
|
359
|
+
continue;
|
|
360
|
+
const repoId = e.name.slice(0, -4);
|
|
361
|
+
const bp = path.join(REPOS_ROOT, e.name);
|
|
362
|
+
const wtList = runGit(["worktree", "list", "--porcelain"], { cwd: bp });
|
|
363
|
+
const worktrees = [];
|
|
364
|
+
if (wtList.ok) {
|
|
365
|
+
let curPath = "";
|
|
366
|
+
for (const line of wtList.stdout.split("\n")) {
|
|
367
|
+
if (line.startsWith("worktree "))
|
|
368
|
+
curPath = line.slice("worktree ".length);
|
|
369
|
+
else if (line.startsWith("branch ") && curPath) {
|
|
370
|
+
// porcelain 输出 refs/heads/<branch>,剥成短分支名
|
|
371
|
+
const ref = line.slice("branch ".length);
|
|
372
|
+
const short = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref;
|
|
373
|
+
worktrees.push({ path: curPath, branch: short });
|
|
374
|
+
curPath = "";
|
|
375
|
+
}
|
|
376
|
+
else if (line === "" && curPath) {
|
|
377
|
+
curPath = "";
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const sizeBytes = dirSize(bp);
|
|
382
|
+
result.push({ repoId, barePath: bp, worktrees, sizeBytes });
|
|
383
|
+
}
|
|
384
|
+
return result;
|
|
385
|
+
}
|
|
386
|
+
function dirSize(p) {
|
|
387
|
+
let total = 0;
|
|
388
|
+
try {
|
|
389
|
+
const walk = (dir) => {
|
|
390
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
391
|
+
const full = path.join(dir, e.name);
|
|
392
|
+
if (e.isDirectory())
|
|
393
|
+
walk(full);
|
|
394
|
+
else if (e.isFile()) {
|
|
395
|
+
try {
|
|
396
|
+
total += fs.statSync(full).size;
|
|
397
|
+
}
|
|
398
|
+
catch { /* skip */ }
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
walk(p);
|
|
403
|
+
}
|
|
404
|
+
catch { /* skip */ }
|
|
405
|
+
return total;
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* 异步版 ensureBareClone。issue 执行路径用这个,避免 clone(可能几分钟)阻塞
|
|
409
|
+
* executor 的其他 WS 处理。失败语义与同步版一致:首次 clone 失败抛错,fetch 失败降级。
|
|
410
|
+
*/
|
|
411
|
+
export async function ensureBareCloneAsync(url) {
|
|
412
|
+
ensureReposRoot();
|
|
413
|
+
const repoId = repoIdFor(url);
|
|
414
|
+
const bp = barePath(url);
|
|
415
|
+
const alreadyExists = fs.existsSync(bp);
|
|
416
|
+
if (!alreadyExists) {
|
|
417
|
+
const r = await runGitAsync(["clone", "--bare", url, bp]);
|
|
418
|
+
if (!r.ok) {
|
|
419
|
+
throw new Error(`bare clone failed for ${url}: ${r.stderr || r.stdout || "(no stderr)"}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
const r = await runGitAsync(["fetch", "--prune"], { cwd: bp });
|
|
424
|
+
if (!r.ok) {
|
|
425
|
+
log.warn(`fetch failed (offline?) for ${repoId}: ${r.stderr || r.stdout || "(no stderr)"} — using local cache`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return { repoId, barePath: bp };
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* 显式 fetch 某 bare clone(供 `rotom repo fetch` 用)。
|
|
432
|
+
* 返回 git 输出供 CLI 展示。
|
|
433
|
+
*/
|
|
434
|
+
/**
|
|
435
|
+
* 显式 fetch 某 bare clone(供 `rotom repo fetch` 用)。
|
|
436
|
+
* repoKey 是 listBareClones 返回的 repoId 字段(完整目录名,含 repoName)。
|
|
437
|
+
*/
|
|
438
|
+
export function fetchBareClone(repoKey) {
|
|
439
|
+
const bp = path.join(REPOS_ROOT, `${repoKey}.git`);
|
|
440
|
+
if (!fs.existsSync(bp))
|
|
441
|
+
return { ok: false, output: `repo ${repoKey} not found` };
|
|
442
|
+
const r = runGit(["fetch", "--prune"], { cwd: bp });
|
|
443
|
+
return { ok: r.ok, output: r.ok ? r.stdout : `${r.stderr || r.stdout}` };
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* 删除 bare clone。要求无活跃 worktree(否则 git 会拒绝,我们也再保险一层)。
|
|
447
|
+
* 供 `rotom repo remove` 用。bare clone 全局共享,删除前必须确认。
|
|
448
|
+
* repoKey 是 listBareClones 返回的 repoId 字段(完整目录名)。
|
|
449
|
+
*/
|
|
450
|
+
export function removeBareClone(repoKey) {
|
|
451
|
+
const bp = path.join(REPOS_ROOT, `${repoKey}.git`);
|
|
452
|
+
if (!fs.existsSync(bp))
|
|
453
|
+
return { ok: false, error: `repo ${repoKey} not found` };
|
|
454
|
+
// 检查 worktree list 是否除了 bare 自己以外还有其他 worktree
|
|
455
|
+
const list = runGit(["worktree", "list"], { cwd: bp });
|
|
456
|
+
if (list.ok) {
|
|
457
|
+
const lines = list.stdout.split("\n").filter(l => l.trim());
|
|
458
|
+
if (lines.length > 1) {
|
|
459
|
+
return { ok: false, error: `repo ${repoKey} 仍有 ${lines.length - 1} 个活跃 worktree,先清理它们` };
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
fs.rmSync(bp, { recursive: true, force: true });
|
|
463
|
+
return { ok: true };
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* prune:清理孤儿 worktree 元数据 + 可选删除无引用的 bare clone。
|
|
467
|
+
* `removeOrphans=true` 时删除无任何 worktree 引用且 30 天未 fetch 的 bare clone。
|
|
468
|
+
*/
|
|
469
|
+
export function pruneRepoCache(opts) {
|
|
470
|
+
let prunedWorktrees = 0;
|
|
471
|
+
const removedClones = [];
|
|
472
|
+
if (!fs.existsSync(REPOS_ROOT))
|
|
473
|
+
return { prunedWorktrees: 0, removedClones };
|
|
474
|
+
for (const e of fs.readdirSync(REPOS_ROOT, { withFileTypes: true })) {
|
|
475
|
+
if (!e.isDirectory() || !e.name.endsWith(".git"))
|
|
476
|
+
continue;
|
|
477
|
+
const repoId = e.name.slice(0, -4);
|
|
478
|
+
const bp = path.join(REPOS_ROOT, e.name);
|
|
479
|
+
const prune = runGit(["worktree", "prune", "-v"], { cwd: bp });
|
|
480
|
+
if (prune.ok && prune.stdout.trim())
|
|
481
|
+
prunedWorktrees++;
|
|
482
|
+
if (opts?.removeOrphans) {
|
|
483
|
+
const list = runGit(["worktree", "list"], { cwd: bp });
|
|
484
|
+
if (list.ok) {
|
|
485
|
+
const lines = list.stdout.split("\n").filter(l => l.trim());
|
|
486
|
+
if (lines.length <= 1) {
|
|
487
|
+
// 仅 bare 自己,无活跃 worktree
|
|
488
|
+
try {
|
|
489
|
+
const st = fs.statSync(bp);
|
|
490
|
+
const ageDays = (Date.now() - st.mtimeMs) / (1000 * 60 * 60 * 24);
|
|
491
|
+
if (ageDays > 30) {
|
|
492
|
+
fs.rmSync(bp, { recursive: true, force: true });
|
|
493
|
+
removedClones.push(repoId);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
catch { /* skip */ }
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return { prunedWorktrees, removedClones };
|
|
502
|
+
}
|