@yandy0725/memory-core 2.0.0 → 2.1.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/dist/config.d.ts +66 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +69 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +28 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -5
- package/dist/index.js.map +1 -1
- package/dist/injection.d.ts.map +1 -1
- package/dist/injection.js +1 -1
- package/dist/injection.js.map +1 -1
- package/dist/orchestration/conversation.d.ts.map +1 -1
- package/dist/orchestration/conversation.js +1 -2
- package/dist/orchestration/conversation.js.map +1 -1
- package/dist/orchestration/ports.d.ts.map +1 -1
- package/dist/orchestration/task.d.ts.map +1 -1
- package/dist/orchestration/task.js +1 -1
- package/dist/orchestration/task.js.map +1 -1
- package/dist/store/filename.d.ts +43 -0
- package/dist/store/filename.d.ts.map +1 -1
- package/dist/store/filename.js +72 -0
- package/dist/store/filename.js.map +1 -1
- package/dist/store/fs-lock.d.ts.map +1 -1
- package/dist/store/fs-lock.js +1 -3
- package/dist/store/fs-lock.js.map +1 -1
- package/dist/store/memory-store.d.ts +37 -0
- package/dist/store/memory-store.d.ts.map +1 -1
- package/dist/store/memory-store.js +169 -17
- package/dist/store/memory-store.js.map +1 -1
- package/dist/store/paths.d.ts.map +1 -1
- package/dist/store/paths.js +8 -2
- package/dist/store/paths.js.map +1 -1
- package/dist/store/process-lock.d.ts.map +1 -1
- package/dist/store/process-lock.js.map +1 -1
- package/dist/sync/backend-git.d.ts +27 -0
- package/dist/sync/backend-git.d.ts.map +1 -0
- package/dist/sync/backend-git.js +815 -0
- package/dist/sync/backend-git.js.map +1 -0
- package/dist/sync/backend.d.ts +213 -0
- package/dist/sync/backend.d.ts.map +1 -0
- package/dist/sync/backend.js +31 -0
- package/dist/sync/backend.js.map +1 -0
- package/dist/sync/conflict.d.ts +74 -0
- package/dist/sync/conflict.d.ts.map +1 -0
- package/dist/sync/conflict.js +164 -0
- package/dist/sync/conflict.js.map +1 -0
- package/dist/sync/device.d.ts +45 -0
- package/dist/sync/device.d.ts.map +1 -0
- package/dist/sync/device.js +227 -0
- package/dist/sync/device.js.map +1 -0
- package/dist/sync/diff.d.ts +78 -0
- package/dist/sync/diff.d.ts.map +1 -0
- package/dist/sync/diff.js +115 -0
- package/dist/sync/diff.js.map +1 -0
- package/dist/sync/engine.d.ts +142 -0
- package/dist/sync/engine.d.ts.map +1 -0
- package/dist/sync/engine.js +960 -0
- package/dist/sync/engine.js.map +1 -0
- package/dist/sync/hash.d.ts +11 -0
- package/dist/sync/hash.d.ts.map +1 -0
- package/dist/sync/hash.js +14 -0
- package/dist/sync/hash.js.map +1 -0
- package/dist/sync/manifest.d.ts +63 -0
- package/dist/sync/manifest.d.ts.map +1 -0
- package/dist/sync/manifest.js +117 -0
- package/dist/sync/manifest.js.map +1 -0
- package/dist/sync/paths.d.ts +70 -0
- package/dist/sync/paths.d.ts.map +1 -0
- package/dist/sync/paths.js +111 -0
- package/dist/sync/paths.js.map +1 -0
- package/dist/sync/scan.d.ts +44 -0
- package/dist/sync/scan.d.ts.map +1 -0
- package/dist/sync/scan.js +102 -0
- package/dist/sync/scan.js.map +1 -0
- package/dist/sync/scheduler.d.ts +81 -0
- package/dist/sync/scheduler.d.ts.map +1 -0
- package/dist/sync/scheduler.js +183 -0
- package/dist/sync/scheduler.js.map +1 -0
- package/dist/sync/types.d.ts +21 -0
- package/dist/sync/types.d.ts.map +1 -0
- package/dist/sync/types.js +8 -0
- package/dist/sync/types.js.map +1 -0
- package/dist/tool/handler.d.ts.map +1 -1
- package/dist/tool/handler.js +1 -3
- package/dist/tool/handler.js.map +1 -1
- package/package.json +15 -3
- package/src/config.ts +121 -0
- package/src/index.ts +101 -5
- package/src/injection.ts +1 -3
- package/src/orchestration/conversation.ts +1 -2
- package/src/orchestration/dream.ts +0 -1
- package/src/orchestration/extract.ts +0 -1
- package/src/orchestration/ports.ts +1 -5
- package/src/orchestration/prompts.ts +0 -1
- package/src/orchestration/surfacing.ts +0 -1
- package/src/orchestration/task.ts +2 -10
- package/src/store/filename.ts +71 -0
- package/src/store/fs-lock.ts +2 -6
- package/src/store/memory-store.ts +311 -134
- package/src/store/paths.ts +12 -3
- package/src/store/process-lock.ts +4 -1
- package/src/sync/backend-git.ts +902 -0
- package/src/sync/backend.ts +220 -0
- package/src/sync/conflict.ts +247 -0
- package/src/sync/device.ts +266 -0
- package/src/sync/diff.ts +152 -0
- package/src/sync/engine.ts +1258 -0
- package/src/sync/hash.ts +14 -0
- package/src/sync/manifest.ts +148 -0
- package/src/sync/paths.ts +117 -0
- package/src/sync/scan.ts +115 -0
- package/src/sync/scheduler.ts +241 -0
- package/src/sync/types.ts +22 -0
- package/src/tool/handler.ts +1 -3
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { withLock } from "../store/fs-lock.js";
|
|
7
|
+
import { NonFastForwardError, } from "./backend.js";
|
|
8
|
+
import { sha256Hex } from "./hash.js";
|
|
9
|
+
import { sameSyncRemote, syncLockPath } from "./paths.js";
|
|
10
|
+
/**
|
|
11
|
+
* 端口的 git 实现:一个**暂存 clone** 当传输通道。
|
|
12
|
+
*
|
|
13
|
+
* 全部状态都在 `<syncRoot>/repo` 底下,那是只有本模块会碰的工作树 —— **用户的记忆目录里永远
|
|
14
|
+
* 不会出现 `.git`**,也不会多出任何文件。这是整个设计的前提:记忆目录是用户的数据,
|
|
15
|
+
* 把它变成仓库就等于让两个写入者(用户/宿主与本功能)共用一棵工作树。
|
|
16
|
+
*
|
|
17
|
+
* git 在这里**纯粹是传输**。三方合并由引擎自己算完,交下来的是已经决定的增量,所以本模块:
|
|
18
|
+
* - 从不 `git merge`、从不解决冲突、从不让 git 决定任何一个字节的内容;
|
|
19
|
+
* - 发布前把暂存工作树**重置到远端那一个提交**,再把增量应用上去 —— 于是我们的提交是它的
|
|
20
|
+
* 子提交,push 天然是快进;
|
|
21
|
+
* - 只在一个前缀内活动(`add` / `clean` 都限定路径),远端别的项目与用户随手丢进暂存目录的
|
|
22
|
+
* 东西一概不碰。
|
|
23
|
+
*
|
|
24
|
+
* 命令一律走 `execFile`(不经 shell),口径沿用 `store/paths.ts`:`promisify(execFile)` + 超时 +
|
|
25
|
+
* 失败给出中立结果或抛错。
|
|
26
|
+
*/
|
|
27
|
+
const execFileP = promisify(execFile);
|
|
28
|
+
/** 批量读内容的输出随记忆总量线性增长,一次给足,别让它半路 ENOBUFS。 */
|
|
29
|
+
const MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
30
|
+
/** 探测 git 在不在 PATH 上的超时(与 `store/paths.ts` 驱动 git 的口径一致)。 */
|
|
31
|
+
const GIT_PROBE_TIMEOUT_MS = 3000;
|
|
32
|
+
/**
|
|
33
|
+
* 只读本地仓库、不联网的那几个命令(读 origin、读用户仓库的 remote 列表)的超时。
|
|
34
|
+
*
|
|
35
|
+
* `getRemote()` 在端口上没有运行参数形参(它不碰远端,也就无所谓「自动档 / 手动档」),
|
|
36
|
+
* 所以它自己带一份。下限仍然是一个有限值:绝不得把 `timeout: 0`(= 不限时)交给子进程。
|
|
37
|
+
*/
|
|
38
|
+
const LOCAL_COMMAND_TIMEOUT_MS = 10_000;
|
|
39
|
+
/** `cat-file --batch` 的分帧字节:`\n`。 */
|
|
40
|
+
const LINE_FEED = 0x0a;
|
|
41
|
+
/**
|
|
42
|
+
* 引导提交(远端还没有这个分支时那一次空提交)的作者。
|
|
43
|
+
*
|
|
44
|
+
* 它**不代表任何设备**:`init` 的入参里没有作者(端口只给 `publish` 传),而这里也不能依赖
|
|
45
|
+
* 环境里的 git 配置 —— CI runner 与全新机器上常常一个都没有,那样提交会直接失败。
|
|
46
|
+
* 固定身份既让提交过得去,也让 `git log` 里一眼看得出哪个提交是引导用的。
|
|
47
|
+
*/
|
|
48
|
+
const BOOTSTRAP_AUTHOR = { name: "memory sync", email: "sync@localhost" };
|
|
49
|
+
const BOOTSTRAP_MESSAGE = "chore: create the sync branch";
|
|
50
|
+
/** git 子进程失败。带退出码是因为有一处要靠它区分语义(`diff --cached --quiet` 用 0/1 说话)。 */
|
|
51
|
+
class GitCommandError extends Error {
|
|
52
|
+
exitCode;
|
|
53
|
+
constructor(message, exitCode, cause) {
|
|
54
|
+
super(message, { cause });
|
|
55
|
+
this.name = "GitCommandError";
|
|
56
|
+
this.exitCode = exitCode;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* git 子进程的环境:**绝不向用户要凭据**。
|
|
61
|
+
*
|
|
62
|
+
* 同步是挂在会话里跑的,而 git 的凭据提示会直接打进用户正在用的终端:
|
|
63
|
+
* `GIT_TERMINAL_PROMPT=0` 关掉 git 自己的提示,`ssh -oBatchMode=yes` 关掉 ssh 的 passphrase 提示 ——
|
|
64
|
+
* ssh 是直接开 `/dev/tty` 的,绕过了被 pipe 的 stdin,所以不关掉它,一轮同步会静静地卡在用户的
|
|
65
|
+
* TUI 上等一个看不见的输入(既不是失败也不是超时,而是挂死)。关掉之后得到的是一条能读的
|
|
66
|
+
* 「认证失败」,与设计里「离线 / 认证失败 → 跳过 + 一条提示」同一口径。
|
|
67
|
+
*
|
|
68
|
+
* 用户自己设过的 `GIT_SSH_COMMAND` 原样保留:那通常带着他的 IdentityFile / ProxyCommand,
|
|
69
|
+
* 覆盖掉会把本来连得上的远端变成连不上。代价是他那份命令里没写 BatchMode 时仍可能提示 ——
|
|
70
|
+
* 那是用户自己选的命令,比静默改掉它的语义好。
|
|
71
|
+
*/
|
|
72
|
+
function subprocessEnv() {
|
|
73
|
+
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
|
74
|
+
if (env.GIT_SSH_COMMAND === undefined)
|
|
75
|
+
env.GIT_SSH_COMMAND = "ssh -oBatchMode=yes";
|
|
76
|
+
return env;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* 把「这一次调用的时间上限」变成一份**共享**预算:每要跑一个子进程就重新问一次还剩多少。
|
|
80
|
+
*
|
|
81
|
+
* 端口给的 `timeoutMs` 若原样交给每个子进程,一次 `fetch()` 里的 `git fetch` 与失败复核的
|
|
82
|
+
* `ls-remote` 就各花满一份(离线路径上正是这两步),而调用方以为它买的是「一次调用的上限」。
|
|
83
|
+
*
|
|
84
|
+
* 用 getter 而不是先算好一个数:`inRepo(o)` 每跑一个子进程都会展开一次这个对象,
|
|
85
|
+
* 展开的那一刻才读到剩余预算。下限是 1ms 而不是 0:`execFile` 的 `timeout: 0` 是「不限时」,
|
|
86
|
+
* 预算耗尽时给出去的必须是「立刻超时」。
|
|
87
|
+
*/
|
|
88
|
+
function budgeted(o) {
|
|
89
|
+
const deadline = Date.now() + Math.max(0, o.timeoutMs);
|
|
90
|
+
// `defineProperty` 而不是对象字面量里的 getter:前者类型上仍是 `T`(publish 多带的
|
|
91
|
+
// `message`/`author` 不会在转换里丢掉),也不需要一处向下转型。
|
|
92
|
+
return Object.defineProperty({ ...o }, "timeoutMs", {
|
|
93
|
+
enumerable: true,
|
|
94
|
+
get: () => Math.max(1, deadline - Date.now()),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 抹掉 URL 里的口令。
|
|
99
|
+
*
|
|
100
|
+
* `https://user:<token>@host/...` 是 git 官方支持的凭据写法,也是 CI 上免交互推送的常规做法,
|
|
101
|
+
* 而 `args` 原样进错误信息就等于把 token 复制进同步错误 —— 错误会被宿主记进日志、被人贴进 issue。
|
|
102
|
+
* git 自己往终端与 stderr 里写的那份我们管不了,**我们拼出来的这一条**必须干净。
|
|
103
|
+
* HTTP(S)(含 git+http 别名)还允许 `https://<token>@host/...`:整个 userinfo 都是秘密,
|
|
104
|
+
* 必须变成 `***`;带冒号的既有 `user:pass` 仍保留用户名、只隐去口令。其它 scheme 继续走
|
|
105
|
+
* 原来的 user:pass 规则,本地路径里的 `:` 与 `@` 不受影响。
|
|
106
|
+
*/
|
|
107
|
+
function redactCredentials(text) {
|
|
108
|
+
return text
|
|
109
|
+
.replace(/\b((?:git\+)?https?:\/\/)([^/\s@]+)@/gi, (_whole, scheme, userinfo) => {
|
|
110
|
+
const colon = userinfo.indexOf(":");
|
|
111
|
+
return colon === -1 ? `${scheme}***@` : `${scheme}${userinfo.slice(0, colon)}:***@`;
|
|
112
|
+
})
|
|
113
|
+
.replace(/(\/\/[^/\s:@]+:)[^@\s]+@/g, "$1***@");
|
|
114
|
+
}
|
|
115
|
+
/** 把子进程失败拼成一条能读的错误:带上命令与 git 自己的 stderr(远端路径、拒绝原因都在里面)。 */
|
|
116
|
+
function asGitError(args, timeoutMs, err) {
|
|
117
|
+
const e = err;
|
|
118
|
+
const raw = e?.stderr;
|
|
119
|
+
const stderr = (Buffer.isBuffer(raw) ? raw.toString("utf8") : typeof raw === "string" ? raw : "").trim();
|
|
120
|
+
const exitCode = typeof e?.code === "number" ? e.code : undefined;
|
|
121
|
+
// 被超时杀掉时 stderr 是空的,只剩一句 “Command failed”,说明不了任何事
|
|
122
|
+
const detail = stderr || (e?.killed ? `timed out after ${timeoutMs}ms` : (e?.message ?? "unknown error"));
|
|
123
|
+
// message 与 cause 两边都得抹:宿主打印错误对象时走的是 cause 链(见 `redactCauseInPlace`)
|
|
124
|
+
return new GitCommandError(redactCredentials(`git ${args.join(" ")} failed: ${detail}`), exitCode, redactCauseInPlace(err));
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* **就地**抹掉一个将要被挂上 `cause` 的错误里的凭据。
|
|
128
|
+
*
|
|
129
|
+
* 只 redact 我们自己拼的那条 message 不够:宿主一句 `console.error(err)` 会经 `util.inspect`
|
|
130
|
+
* **递归打印整条 cause 链**(`[cause]: Error: Command failed: git clone https://user:token@…`),
|
|
131
|
+
* 于是 token 从旁边这条路照样进了日志与 issue。子进程的 `message` 与 `stderr` 里都带着完整命令行,
|
|
132
|
+
* 所以两个字段都要抹(stderr 是 Buffer 时按字节重建)。
|
|
133
|
+
*
|
|
134
|
+
* 就地改写是安全的:这个错误对象是 `execFile` 刚刚为我们这一次调用造的,不与任何其他人共享;
|
|
135
|
+
* 对象身份不变,所以调用方照旧读得到 `cause.name`(认 AbortError)与 `cause.code`(认退出码)。
|
|
136
|
+
*
|
|
137
|
+
* 沿着 cause 链一路抹(有深度上限):挂上去的可能是一个已经包过一层的错误,token 在里层那一份上。
|
|
138
|
+
*/
|
|
139
|
+
function redactCauseInPlace(cause, depth = 8) {
|
|
140
|
+
if (typeof cause !== "object" || cause === null || depth <= 0)
|
|
141
|
+
return cause;
|
|
142
|
+
// `cmd` 是 `execFile` 自己加的字段,内容就是完整命令行(含 `clone <url>` 里的那份凭据)
|
|
143
|
+
const c = cause;
|
|
144
|
+
if (typeof c.message === "string")
|
|
145
|
+
c.message = redactCredentials(c.message);
|
|
146
|
+
if (typeof c.cmd === "string")
|
|
147
|
+
c.cmd = redactCredentials(c.cmd);
|
|
148
|
+
if (typeof c.stderr === "string")
|
|
149
|
+
c.stderr = redactCredentials(c.stderr);
|
|
150
|
+
else if (Buffer.isBuffer(c.stderr))
|
|
151
|
+
c.stderr = Buffer.from(redactCredentials(c.stderr.toString("utf8")), "utf8");
|
|
152
|
+
if (c.cause !== undefined)
|
|
153
|
+
redactCauseInPlace(c.cause, depth - 1);
|
|
154
|
+
return cause;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* 把**前一步**的原始错误接到最终逃逸的错误上,两步的信息都不丢。
|
|
158
|
+
*
|
|
159
|
+
* `cause` 空着就直接挂上去(挂之前先就地 redact:宿主打印错误对象走的是 cause 链);已经被占
|
|
160
|
+
* (`GitCommandError` 那里放着子进程错误,调用方靠它认 AbortError 与退出码)时不覆盖 ——
|
|
161
|
+
* 改成拼进 message,并且拼进去的文案再过一遍凭据 redact(前一步的 `args` 里往往就带着远端 URL)。
|
|
162
|
+
*
|
|
163
|
+
* 这时**就地改写 `final.message` 并返回 `final` 自己**,而不是包一个新的 `Error`:包一层会把
|
|
164
|
+
* `GitCommandError` 的类型与 `exitCode` 留在里层,而调用方问的都是最外面那个对象 ——
|
|
165
|
+
* 认 AbortError、读退出码、判非快进全部落空,一条「认证失败」就被读成「未知错误」。
|
|
166
|
+
*/
|
|
167
|
+
function withEarlierCause(err, what, earlier) {
|
|
168
|
+
const final = err instanceof Error ? err : new Error(String(err));
|
|
169
|
+
const detail = redactCredentials(earlier?.message ?? String(earlier));
|
|
170
|
+
if (final.cause === undefined) {
|
|
171
|
+
final.cause = redactCauseInPlace(earlier);
|
|
172
|
+
return final;
|
|
173
|
+
}
|
|
174
|
+
final.message = `${final.message} (${what} had already failed: ${detail})`;
|
|
175
|
+
return final;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* 跑一次 git 子进程,输出按**字节**收。
|
|
179
|
+
*
|
|
180
|
+
* 底层是 Buffer 而不是字符串:`cat-file --batch` 用**字节长度**分帧,先解码成字符串就切不开了
|
|
181
|
+
*(记忆正文大量是 CJK,一个字符三字节)。其余调用方走下面的 `runGit` 拿 utf8。
|
|
182
|
+
*/
|
|
183
|
+
async function runGitRaw(args, o) {
|
|
184
|
+
const options = {
|
|
185
|
+
cwd: o.cwd,
|
|
186
|
+
timeout: o.timeoutMs,
|
|
187
|
+
signal: o.signal,
|
|
188
|
+
maxBuffer: MAX_BUFFER_BYTES,
|
|
189
|
+
encoding: "buffer",
|
|
190
|
+
env: subprocessEnv(),
|
|
191
|
+
};
|
|
192
|
+
const promise = execFileP("git", args, options);
|
|
193
|
+
if (o.input !== undefined) {
|
|
194
|
+
// git 可能在读完 stdin 之前就退出(仓库损坏、ref 不存在),这时写入会 EPIPE。
|
|
195
|
+
// 不接住 'error' 它会变成 uncaughtException —— 一次同步失败不该带走宿主进程。
|
|
196
|
+
promise.child.stdin?.on("error", () => { });
|
|
197
|
+
promise.child.stdin?.end(o.input, "utf8");
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const { stdout } = await promise;
|
|
201
|
+
return stdout;
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
throw asGitError(args, o.timeoutMs, err);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** 同一次调用,输出按 utf8 解码(除 `cat-file --batch` 之外的所有命令)。 */
|
|
208
|
+
async function runGit(args, o) {
|
|
209
|
+
return (await runGitRaw(args, o)).toString("utf8");
|
|
210
|
+
}
|
|
211
|
+
function exitCodeOf(err) {
|
|
212
|
+
return err instanceof GitCommandError ? err.exitCode : undefined;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* PATH 上有没有可用的 git。测试与适配层都用它做可用性判断:没有 git 时同步整体降级成
|
|
216
|
+
* 「未启用」+ 一条可读提示,其余记忆功能照常 —— 探测失败给中立结果,口径同 `store/paths.ts`。
|
|
217
|
+
*/
|
|
218
|
+
export async function gitAvailable() {
|
|
219
|
+
try {
|
|
220
|
+
await execFileP("git", ["--version"], {
|
|
221
|
+
timeout: GIT_PROBE_TIMEOUT_MS,
|
|
222
|
+
maxBuffer: MAX_BUFFER_BYTES,
|
|
223
|
+
encoding: "buffer",
|
|
224
|
+
});
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** 只有这两种 errno 意味着「不在」。其余(权限、IO)是真的出事了,不能压成中立结果。 */
|
|
232
|
+
function isNotFound(err) {
|
|
233
|
+
const code = err?.code;
|
|
234
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* 把前缀包成字面 pathspec。
|
|
238
|
+
*
|
|
239
|
+
* git 的 pathspec 默认按 shell glob 解释,而远端前缀里可能出现 `[`:`projectDirName` 会转义
|
|
240
|
+
* `<>:"|?*` 与控制字符,**但不转义方括号**,于是 `ssh://git@[::1]/o/r` 这类远端算出来的 key
|
|
241
|
+
* 就带着一个方括号对 —— 看上去就是个字符类。实测 git 2.56 在字面目录存在时仍按字面匹配
|
|
242
|
+
*(目录前缀走的是字面比较),但那是匹配器的行为细节、不是承诺;`:(literal)` 把「按字面」
|
|
243
|
+
* 写成显式契约,代价是零。万一哪个版本真的按 glob 解释,两个方向都出事:
|
|
244
|
+
* - **欠匹配**:`add` 匹配不到东西直接 fatal,`clean` 则静默什么都不清 —— 后者正是最难查的
|
|
245
|
+
* 失败形态(同步看起来成功了,其实一个字没推)。
|
|
246
|
+
* - **过匹配**(更要命,因为 `clean -fd` 是破坏性命令):字符类是「任选其一」,`git/a[b]`
|
|
247
|
+
* 于是同时匹配兄弟目录 `git/ab` —— 那很可能是**另一个项目**的记忆前缀,`clean` 会把它整个删掉。
|
|
248
|
+
* 护栏的意义主要在这一侧:欠匹配只是同步不动,过匹配是删别人的数据。
|
|
249
|
+
*/
|
|
250
|
+
function pathspec(prefix) {
|
|
251
|
+
return `:(literal)${prefix}`;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* 远端布局里「**另一个项目**的记忆文件」:`git/<项目 key>/<单层 .md>`。
|
|
255
|
+
*
|
|
256
|
+
* 这是设计规定的正常布局(一个远端仓库镜像整个记忆根,每次会话只同步当前那一个项目),
|
|
257
|
+
* 因此它**不是**异常,不该进 `outOfScope`。
|
|
258
|
+
*/
|
|
259
|
+
const OTHER_PROJECT_ENTRY_RE = /^git\/[^/]+\/[^/]+\.md$/;
|
|
260
|
+
/**
|
|
261
|
+
* 把整棵远端树分成「本 scope 内」与「**真正异常**的范围外路径」。
|
|
262
|
+
*
|
|
263
|
+
* 范围外报告的存在理由是「远端有东西没被同步,而用户不知道」—— 例如有人手工推上来的仓库根
|
|
264
|
+
* `MEMORY.md`、`local/...`、`.lock`,或本前缀底下**还有一层**的路径(`<prefix>/sub/x.md`:
|
|
265
|
+
* 记忆目录是扁平的,嵌套目录不是本功能写出来的东西)。
|
|
266
|
+
*
|
|
267
|
+
* **别的项目的记忆不在这个清单里**,哪怕它们确实「没被这一轮同步」:设计规定的部署形态就是
|
|
268
|
+
* 一个远端仓库镜像整个记忆根、而一次会话只同步当前项目,于是「兄弟项目」是每一轮的常态。
|
|
269
|
+
* 把它们算成异常,报告条数就随项目数 × 记忆数线性增长(20 个项目 × 100 条 = 每轮约 2000 条),
|
|
270
|
+
* 而异常报告一旦和正常数据同量级,就等于没有报告 —— 真正那一条信号(有人手工推了索引)
|
|
271
|
+
* 会被永久埋掉。所以判据不是「在不在本前缀下」,而是「像不像远端布局里的一条记忆」。
|
|
272
|
+
*
|
|
273
|
+
* 报出去的路径一律是仓库根相对的原样路径:静默丢掉的话用户永远不知道远端有内容没进同步。
|
|
274
|
+
*/
|
|
275
|
+
function classify(tree, prefix) {
|
|
276
|
+
const head = `${prefix}/`;
|
|
277
|
+
const inScope = [];
|
|
278
|
+
const outOfScope = [];
|
|
279
|
+
for (const path of tree) {
|
|
280
|
+
const rest = path.startsWith(head) ? path.slice(head.length) : "";
|
|
281
|
+
if (rest !== "" && !rest.includes("/"))
|
|
282
|
+
inScope.push(rest);
|
|
283
|
+
else if (!OTHER_PROJECT_ENTRY_RE.test(path))
|
|
284
|
+
outOfScope.push(path);
|
|
285
|
+
}
|
|
286
|
+
return { inScope, outOfScope };
|
|
287
|
+
}
|
|
288
|
+
export function createGitSyncBackend(options) {
|
|
289
|
+
// syncRoot 固化成绝对路径:`git init` 那一步要在它里面跑,而进程 cwd 未必还在
|
|
290
|
+
//(remote **不能** resolve —— 它可能是 URL 或 scp 形态的 host:path)。
|
|
291
|
+
const syncRoot = resolve(options.syncRoot);
|
|
292
|
+
// memoryRoot 同理:它要与用户仓库里的 remote(可能是相对路径)做 resolved 字节比较。
|
|
293
|
+
const memoryRoot = resolve(options.memoryRoot);
|
|
294
|
+
const { remote, branch } = options;
|
|
295
|
+
const repoDir = join(syncRoot, "repo");
|
|
296
|
+
/** 在指定暂存仓库里跑 git;init 的 staging 与已发布 repo 共用同一组命令助手。 */
|
|
297
|
+
const inRepo = (o, directory = repoDir) => ({ ...o, cwd: directory });
|
|
298
|
+
/** 断言 child 严格在 parent 之内(相等不算)。口径同 `store/paths.ts` 的 assertInsideRoot。 */
|
|
299
|
+
function assertWithin(parent, child, what) {
|
|
300
|
+
const rel = relative(parent, child);
|
|
301
|
+
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
|
|
302
|
+
throw new Error(`Refusing to use ${what}: it escapes ${parent}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/** 前缀在暂存仓库里的绝对路径。 */
|
|
306
|
+
function scopeDir(prefix) {
|
|
307
|
+
const root = resolve(repoDir);
|
|
308
|
+
const base = resolve(root, prefix);
|
|
309
|
+
assertWithin(root, base, `sync scope "${prefix}"`);
|
|
310
|
+
return base;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* 把端口给的相对路径落进暂存仓库,并断言它没有逃出 `<repoDir>/<prefix>`。
|
|
314
|
+
*
|
|
315
|
+
* 端口是公开的,传进来的 path 理论上可以是 `../../..`,而这里是**覆盖与删除**盘上文件的
|
|
316
|
+
* 地方 —— 越界即抛,绝不静默写到别处。
|
|
317
|
+
*/
|
|
318
|
+
function scopedPath(prefix, path) {
|
|
319
|
+
const base = scopeDir(prefix);
|
|
320
|
+
const target = resolve(base, path);
|
|
321
|
+
assertWithin(base, target, `remote path "${path}"`);
|
|
322
|
+
return target;
|
|
323
|
+
}
|
|
324
|
+
async function ready() {
|
|
325
|
+
try {
|
|
326
|
+
await stat(join(repoDir, ".git"));
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
// 只有「不存在」才是「还没初始化」。读不到(权限、坏盘)必须抛:静默 false 会让同步
|
|
331
|
+
// 无声停摆,而用户以为它开着。
|
|
332
|
+
if (isNotFound(err))
|
|
333
|
+
return false;
|
|
334
|
+
throw err;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* fetch/publish 的前置。给一条能读的错误,而不是 git 那句 spawn ENOENT ——
|
|
339
|
+
* cwd 不存在时 Node 报的是「找不到 git」,会把人往完全错误的方向引。
|
|
340
|
+
*/
|
|
341
|
+
async function requireRepo() {
|
|
342
|
+
if (!(await ready())) {
|
|
343
|
+
throw new Error(`The sync staging repository is missing (sync has not been initialised yet): ${repoDir}`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/** 本地 ref 的 sha;不存在时 null(`--verify --quiet` 就是为这个组合准备的,退出码 1)。 */
|
|
347
|
+
async function localRef(ref, o, directory = repoDir) {
|
|
348
|
+
try {
|
|
349
|
+
const out = (await runGit(["rev-parse", "--verify", "--quiet", ref], inRepo(o, directory))).trim();
|
|
350
|
+
return out === "" ? null : out;
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
if (exitCodeOf(err) === 1)
|
|
354
|
+
return null;
|
|
355
|
+
throw err;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/** 暂存仓库当前 HEAD 的 sha(init 之后一定有一个提交)。 */
|
|
359
|
+
async function localHead(o, directory = repoDir) {
|
|
360
|
+
return (await runGit(["rev-parse", "HEAD"], inRepo(o, directory))).trim();
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* 远端 `<branch>` 当前的 head;远端还没有这个分支时返回 null(首次同步之前的正常状态,
|
|
364
|
+
* 不是错误 —— 端口用空 revision 表示「没有基线」)。
|
|
365
|
+
*
|
|
366
|
+
* `fetch` 失败分不清「没这个 ref」与「连不上」:两者都退出 128,而 git 的文案会被 locale
|
|
367
|
+
* 改写,不能靠匹配字符串。所以失败时用 `ls-remote --exit-code --heads` 复核一次 ——
|
|
368
|
+
* 它对「没有匹配的 ref」退出 2、对「连不上」退出 128,与语言无关。这次额外往返只发生在
|
|
369
|
+
* 失败路径上;抛出去的仍是 fetch 的原始错误(信息里带着远端)。
|
|
370
|
+
*/
|
|
371
|
+
async function remoteHead(o) {
|
|
372
|
+
try {
|
|
373
|
+
await runGit(["fetch", "--quiet", "origin", branch], inRepo(o));
|
|
374
|
+
}
|
|
375
|
+
catch (fetchErr) {
|
|
376
|
+
try {
|
|
377
|
+
await runGit(["ls-remote", "--exit-code", "--heads", "origin", branch], inRepo(o));
|
|
378
|
+
}
|
|
379
|
+
catch (probeErr) {
|
|
380
|
+
if (exitCodeOf(probeErr) === 2)
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
throw fetchErr;
|
|
384
|
+
}
|
|
385
|
+
return (await runGit(["rev-parse", "FETCH_HEAD"], inRepo(o))).trim();
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* 远端整棵树的路径(仓库根相对)。**必须带 `-z`**:默认的 `ls-tree` 会把非 ASCII 路径
|
|
389
|
+
* C-quote 成 `"git/x/\346\226\207.md"`(`core.quotePath` 默认开),而记忆文件名大量是中文 ——
|
|
390
|
+
* 不解引号就会把每一条都判成范围外。`-z` 输出 NUL 分隔的原始字节,永不转义。
|
|
391
|
+
*/
|
|
392
|
+
async function listTree(revision, o) {
|
|
393
|
+
const out = await runGit(["ls-tree", "-r", "--name-only", "-z", revision], inRepo(o));
|
|
394
|
+
return out.split("\0").filter((path) => path !== "");
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* **一次** `cat-file --batch` 读出范围内全部 blob,逐个算 sha256 并把内容缓存下来。
|
|
398
|
+
*
|
|
399
|
+
* 为什么必须读内容:`ls-tree` 给的是 blob sha1(掺了 `blob <长度>\0` 头),与端口契约的
|
|
400
|
+
* sha256 不同源。记忆文本量小,一次批量读比维护两套 hash 语义简单得多;缓存之后
|
|
401
|
+
* `list()` 与 `read()` 都不再 spawn。
|
|
402
|
+
*/
|
|
403
|
+
async function readBlobs(revision, prefix, names, o) {
|
|
404
|
+
const entries = [];
|
|
405
|
+
if (names.length === 0)
|
|
406
|
+
return entries;
|
|
407
|
+
const input = names.map((path) => `${revision}:${prefix}/${path}\n`).join("");
|
|
408
|
+
const out = await runGitRaw(["cat-file", "--batch"], { ...inRepo(o), input });
|
|
409
|
+
// 输出按字节分帧:`<oid> SP <type> SP <size> LF <content> LF`。
|
|
410
|
+
// 对象不存在时是一行 `<输入原样> missing`,而**退出码仍是 0** —— 只能按行判。
|
|
411
|
+
let offset = 0;
|
|
412
|
+
for (const path of names) {
|
|
413
|
+
const lineFeed = out.indexOf(LINE_FEED, offset);
|
|
414
|
+
if (lineFeed === -1) {
|
|
415
|
+
throw new Error(`git cat-file --batch returned a truncated header for "${prefix}/${path}"`);
|
|
416
|
+
}
|
|
417
|
+
const header = out.subarray(offset, lineFeed).toString("utf8");
|
|
418
|
+
const fields = header.split(" ");
|
|
419
|
+
const size = fields.length === 3 && fields[1] === "blob" ? Number(fields[2]) : Number.NaN;
|
|
420
|
+
if (!Number.isInteger(size) || size < 0) {
|
|
421
|
+
throw new Error(`Remote object is not a readable blob: ${revision}:${prefix}/${path} (${header})`);
|
|
422
|
+
}
|
|
423
|
+
const start = lineFeed + 1;
|
|
424
|
+
const content = out.subarray(start, start + size).toString("utf8");
|
|
425
|
+
entries.push({ path, hash: sha256Hex(content), content });
|
|
426
|
+
offset = start + size + 1; // 内容后面那个换行
|
|
427
|
+
}
|
|
428
|
+
return entries;
|
|
429
|
+
}
|
|
430
|
+
/** 远端还没有这个分支时的视图:revision 为空串(= 没有基线),什么都不列。 */
|
|
431
|
+
function emptyView() {
|
|
432
|
+
return {
|
|
433
|
+
revision: "",
|
|
434
|
+
outOfScope: [],
|
|
435
|
+
async list() {
|
|
436
|
+
return [];
|
|
437
|
+
},
|
|
438
|
+
async read(path) {
|
|
439
|
+
throw new Error(`The remote has no synced content yet: cannot read "${path}"`);
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
async function fetch(scope, opts) {
|
|
444
|
+
// 一次调用一份共享预算:下面的 `remoteHead`(fetch + 失败时的 ls-remote 复核)、`ls-tree`
|
|
445
|
+
// 与 `cat-file` 加起来是五六个子进程,每个各拿一份满额超时就不是一次调用的上限了。
|
|
446
|
+
const o = budgeted(opts);
|
|
447
|
+
await requireRepo();
|
|
448
|
+
const revision = await remoteHead(o);
|
|
449
|
+
if (revision === null)
|
|
450
|
+
return emptyView();
|
|
451
|
+
const { inScope, outOfScope } = classify(await listTree(revision, o), scope.prefix);
|
|
452
|
+
const entries = await readBlobs(revision, scope.prefix, inScope, o);
|
|
453
|
+
const contents = new Map(entries.map((entry) => [entry.path, entry.content]));
|
|
454
|
+
return {
|
|
455
|
+
revision,
|
|
456
|
+
outOfScope,
|
|
457
|
+
async list() {
|
|
458
|
+
return entries.map((entry) => ({ path: entry.path, hash: entry.hash }));
|
|
459
|
+
},
|
|
460
|
+
async read(path) {
|
|
461
|
+
const content = contents.get(path);
|
|
462
|
+
// 只服务 list() 报出的路径:范围外的路径宁可抛错,也不能让人以为它被同步了
|
|
463
|
+
if (content === undefined)
|
|
464
|
+
throw new Error(`Not in the fetched remote snapshot: "${path}"`);
|
|
465
|
+
return content;
|
|
466
|
+
},
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
/** 远端还没有这个分支:建出来并推上去(一次空提交,只在远端留下一个分支起点)。 */
|
|
470
|
+
async function bootstrapBranch(o, directory = repoDir) {
|
|
471
|
+
await runGit(["checkout", "--quiet", "-B", branch], inRepo(o, directory));
|
|
472
|
+
await runGit([
|
|
473
|
+
"-c",
|
|
474
|
+
`user.name=${BOOTSTRAP_AUTHOR.name}`,
|
|
475
|
+
"-c",
|
|
476
|
+
`user.email=${BOOTSTRAP_AUTHOR.email}`,
|
|
477
|
+
"commit",
|
|
478
|
+
"--quiet",
|
|
479
|
+
"--allow-empty",
|
|
480
|
+
"-m",
|
|
481
|
+
BOOTSTRAP_MESSAGE,
|
|
482
|
+
], inRepo(o, directory));
|
|
483
|
+
await runGit(["push", "--quiet", "-u", "origin", branch], inRepo(o, directory));
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* 当前 origin 的远端 head;远端没有这个分支时交回空串。
|
|
487
|
+
*
|
|
488
|
+
* 已初始化的 init 也不能只读 tracking ref:`setRemote()` 只改 origin 地址,旧远端的
|
|
489
|
+
* `refs/remotes/origin/<branch>` 仍会留着。重新 fetch 当前 origin 才能保证交回的 revision
|
|
490
|
+
* 真的属于它;空 origin 则由 `remoteHead()` 的 ls-remote 复核明确识别成「没有基线」。
|
|
491
|
+
*/
|
|
492
|
+
async function currentRemoteRevision(o) {
|
|
493
|
+
return (await remoteHead(o)) ?? "";
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* 读用户自己那个仓库的 remote 列表(`<name> → <url>`)。只读 `git config`,不发网络请求。
|
|
497
|
+
* 口径同 `store/paths.ts` 读 remote 的那一处:`--get-regexp` 无匹配时退出码是 **1**,那是
|
|
498
|
+
* 「一个 remote 都没有」的正常出口,不是故障。
|
|
499
|
+
*/
|
|
500
|
+
async function readUserRemotes(o) {
|
|
501
|
+
let out;
|
|
502
|
+
try {
|
|
503
|
+
out = await runGit(["config", "--get-regexp", "^remote\\..*\\.url$"], { ...o, cwd: memoryRoot });
|
|
504
|
+
}
|
|
505
|
+
catch (err) {
|
|
506
|
+
if (exitCodeOf(err) === 1)
|
|
507
|
+
return [];
|
|
508
|
+
throw err;
|
|
509
|
+
}
|
|
510
|
+
const remotes = [];
|
|
511
|
+
for (const line of out.split("\n")) {
|
|
512
|
+
const trimmed = line.trim();
|
|
513
|
+
const space = trimmed.indexOf(" ");
|
|
514
|
+
if (space === -1)
|
|
515
|
+
continue;
|
|
516
|
+
remotes.push({
|
|
517
|
+
name: trimmed
|
|
518
|
+
.slice(0, space)
|
|
519
|
+
.replace(/^remote\./, "")
|
|
520
|
+
.replace(/\.url$/, ""),
|
|
521
|
+
url: trimmed.slice(space + 1),
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
return remotes;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* 初始化之前的**共存检查**:用户可能自己把记忆根做成了 git 仓库。
|
|
528
|
+
*
|
|
529
|
+
* 那不是错误(很多人事先就在备份自己的记忆),但「两个写入者推**同一个**远端」是丢数据的标准姿势:
|
|
530
|
+
* 用户自己的 `git push` 与同步的 push 互相覆盖,而两边都以为自己是唯一的写入者。所以分界按
|
|
531
|
+
* 「是否同一个远端」划:
|
|
532
|
+
* - 有**任何一个** remote 指向我们要配的那个地址 → **拒绝**初始化(抛错);
|
|
533
|
+
* - 指向别处、或一个 remote 都没有 → 允许,交回一条 warning(各推各的,互不干扰)。
|
|
534
|
+
*
|
|
535
|
+
* 它**只能排在任何写入之前**(调用处就在那里):最终 staging 一旦原子发布,`.sync/repo` 就让
|
|
536
|
+
* `ready()` 变 true —— 「拒绝了初始化、却已经把 clone 建好」会让拒绝分支变成假话。
|
|
537
|
+
*
|
|
538
|
+
* 只读不写:不发网络请求,也不动用户仓库一个字节。读不出来(权限、坏盘)时**抛错**,
|
|
539
|
+
* 口径同 `ready()` 与本地扫描:无从判断就不得假装「没有冲突」。
|
|
540
|
+
*/
|
|
541
|
+
async function memoryRootWarnings(o) {
|
|
542
|
+
try {
|
|
543
|
+
await stat(join(memoryRoot, ".git"));
|
|
544
|
+
}
|
|
545
|
+
catch (err) {
|
|
546
|
+
// 没有用户仓库(绝大多数情形):这条检查到此为止,也不为此建出那个目录
|
|
547
|
+
if (isNotFound(err))
|
|
548
|
+
return [];
|
|
549
|
+
throw err;
|
|
550
|
+
}
|
|
551
|
+
const remotes = await readUserRemotes(o);
|
|
552
|
+
const clash = remotes.find((item) => sameSyncRemote(item.url, remote));
|
|
553
|
+
if (clash !== undefined) {
|
|
554
|
+
// 文案里的 URL 必须过一遍脏敏:`https://user:token@host/...` 是 git 官方支持的凭据写法,
|
|
555
|
+
// 而这条错误会被宿主记进日志、被人贴进 issue。
|
|
556
|
+
throw new Error(redactCredentials(`refusing to initialise sync: the memory root ${memoryRoot} is already a git repository whose ` +
|
|
557
|
+
`remote "${clash.name}" points at the same remote (${clash.url}) — two writers pushing the same ` +
|
|
558
|
+
'remote is how memory gets lost; point "sync.remote" in memory.json at a different repository ' +
|
|
559
|
+
`(or remove that remote from ${memoryRoot})`));
|
|
560
|
+
}
|
|
561
|
+
// warning 里只列 remote 的**名字**,不列它们的地址:名字里没有凭据,而地址里可能有。
|
|
562
|
+
const listed = remotes.length === 0
|
|
563
|
+
? "no remotes"
|
|
564
|
+
: `remotes: ${remotes
|
|
565
|
+
.map((item) => item.name)
|
|
566
|
+
.sort()
|
|
567
|
+
.join(", ")}`;
|
|
568
|
+
return [
|
|
569
|
+
`the memory root ${memoryRoot} is already a git repository (${listed}) — memory sync keeps its own ` +
|
|
570
|
+
`staging clone under ${syncRoot} and does not touch that repository`,
|
|
571
|
+
];
|
|
572
|
+
}
|
|
573
|
+
/** 读最终 repo 的 origin;调用方负责先确认 repo 存在。 */
|
|
574
|
+
async function readOrigin(o) {
|
|
575
|
+
try {
|
|
576
|
+
const out = (await runGit(["remote", "get-url", "origin"], inRepo(o))).trim();
|
|
577
|
+
return out === "" ? null : out;
|
|
578
|
+
}
|
|
579
|
+
catch (err) {
|
|
580
|
+
// 退出码 2 = “No such remote 'origin'”(有人手工动过暂存仓库)。其余错误照旧抛(口径同 `ready()`):
|
|
581
|
+
// 「读不出来」与「没有 origin」是两件事,压成 null 会把仓库坏了报成「还没配远端」。
|
|
582
|
+
if (exitCodeOf(err) === 2)
|
|
583
|
+
return null;
|
|
584
|
+
throw err;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
/** 暂存 clone 的 origin;未初始化或根本没有 origin 时 null。 */
|
|
588
|
+
async function getRemote() {
|
|
589
|
+
if (!(await ready()))
|
|
590
|
+
return null;
|
|
591
|
+
return readOrigin({ timeoutMs: LOCAL_COMMAND_TIMEOUT_MS });
|
|
592
|
+
}
|
|
593
|
+
/** 把暂存 clone 的 origin 改到另一个地址(不对远端发写操作)。 */
|
|
594
|
+
async function setRemote(next, opts) {
|
|
595
|
+
const url = next.trim();
|
|
596
|
+
// 空地址 fail-closed:origin 一旦被清空,此后每一次 fetch/publish 都失败在 git 那句
|
|
597
|
+
// “'origin' does not appear to be a git repository”上,而用户看不出是谁把它清掉的。
|
|
598
|
+
if (url === "") {
|
|
599
|
+
throw new Error("refusing to set an empty sync remote: give the URL of the repository to sync with");
|
|
600
|
+
}
|
|
601
|
+
// 未初始化:连 syncRoot 都不建。锁内还会复查,覆盖等待期间另一实例完成 init 的情形。
|
|
602
|
+
if (!(await ready()))
|
|
603
|
+
return;
|
|
604
|
+
const o = budgeted(opts);
|
|
605
|
+
await mkdir(syncRoot, { recursive: true });
|
|
606
|
+
await withLock(syncLockPath(syncRoot), "sync-set-remote", { timeoutMs: o.timeoutMs }, async () => {
|
|
607
|
+
// 等锁期间宿主可能已经退出;拿到锁后第一件事就是尊重既成 abort,绝不改 origin。
|
|
608
|
+
opts.signal?.throwIfAborted();
|
|
609
|
+
if (!(await ready()))
|
|
610
|
+
return;
|
|
611
|
+
try {
|
|
612
|
+
await runGit(["remote", "set-url", "origin", url], inRepo(o));
|
|
613
|
+
}
|
|
614
|
+
catch (setUrlErr) {
|
|
615
|
+
// origin 被人删过:建回来。语义仍是「让暂存 clone 指向这个地址」,两步的错都跟着出去。
|
|
616
|
+
try {
|
|
617
|
+
await runGit(["remote", "add", "origin", url], inRepo(o));
|
|
618
|
+
}
|
|
619
|
+
catch (addErr) {
|
|
620
|
+
throw withEarlierCause(addErr, "git remote set-url", setUrlErr);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* 管理锁保证没有另一个 init 正在使用 staging,因此这里留下的同名目录只可能是崩溃残留。
|
|
627
|
+
* 永远只匹配 `repo.init-*`,最终的 `repo` 不在清理范围内。
|
|
628
|
+
*/
|
|
629
|
+
async function cleanupStagingResidue() {
|
|
630
|
+
for (const entry of await readdir(syncRoot, { withFileTypes: true })) {
|
|
631
|
+
if (entry.name.startsWith("repo.init-")) {
|
|
632
|
+
await rm(join(syncRoot, entry.name), { recursive: true, force: true });
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
async function init(opts) {
|
|
637
|
+
// 共享预算(同 `fetch`):等待管理锁、clone 与引导提交都属于这一次 init。
|
|
638
|
+
const o = budgeted(opts);
|
|
639
|
+
let warnings = null;
|
|
640
|
+
if (!(await ready())) {
|
|
641
|
+
// 未初始化而手上根本没地址:给一句指回配置的错误,而不是 git 那句 “You must specify a repository”。
|
|
642
|
+
if (remote.trim() === "") {
|
|
643
|
+
throw new Error('cannot initialise sync: no remote is configured (set "sync.remote" in memory.json)');
|
|
644
|
+
}
|
|
645
|
+
// 共存检查必须先于 syncRoot/锁文件等任何写入;same-remote 拒绝保持无 clone、无 device、无 .sync。
|
|
646
|
+
warnings = await memoryRootWarnings(o);
|
|
647
|
+
}
|
|
648
|
+
await mkdir(syncRoot, { recursive: true });
|
|
649
|
+
return withLock(syncLockPath(syncRoot), "sync-init", { timeoutMs: o.timeoutMs }, async () => {
|
|
650
|
+
// withLock 本身没有 signal 形参;一旦获得所有权,绝不再为一个已 abort 的调用做远端或磁盘工作。
|
|
651
|
+
opts.signal?.throwIfAborted();
|
|
652
|
+
await cleanupStagingResidue();
|
|
653
|
+
// 幂等是不重建仓库、不造提交,不是交回缓存的 revision。这里不改 origin(那是 setRemote 的职责),
|
|
654
|
+
// 但必须按当前 origin 重新核对 head,否则 setRemote 后旧 tracking ref 会污染首份基线。
|
|
655
|
+
if (await ready())
|
|
656
|
+
return { revision: await currentRemoteRevision(o), warnings: [] };
|
|
657
|
+
if (warnings === null) {
|
|
658
|
+
// 锁外看到的是 ready、锁内却被外部删除的罕见恢复路径,仍按首次初始化完整校验。
|
|
659
|
+
if (remote.trim() === "") {
|
|
660
|
+
throw new Error('cannot initialise sync: no remote is configured (set "sync.remote" in memory.json)');
|
|
661
|
+
}
|
|
662
|
+
warnings = await memoryRootWarnings(o);
|
|
663
|
+
}
|
|
664
|
+
// 唯一 staging 归本次 init 所有。ready() 只看最终 repo/.git,半成品永远不会被另一 runtime 接管。
|
|
665
|
+
const stagingDir = `${repoDir}.init-${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
666
|
+
try {
|
|
667
|
+
let revision;
|
|
668
|
+
try {
|
|
669
|
+
// 空仓库在现代 git 上是 clone **成功**的(只留一句 warning,本地分支 unborn),
|
|
670
|
+
// 所以 clone 失败基本等于「远端不可达 / 没有权限」。仍然退回本地 init:
|
|
671
|
+
// 紧跟其后的 push 会把不可达如实报出来,符合「init 失败要让人看见」的约定。
|
|
672
|
+
await runGit(["clone", "--quiet", remote, stagingDir], o);
|
|
673
|
+
}
|
|
674
|
+
catch (cloneErr) {
|
|
675
|
+
// clone 失败可能留下半个 staging;只清理本次自己拥有的目录,最终 repo 绝不动。
|
|
676
|
+
await rm(stagingDir, { recursive: true, force: true });
|
|
677
|
+
try {
|
|
678
|
+
await runGit(["init", "--quiet", stagingDir], { ...o, cwd: syncRoot });
|
|
679
|
+
await runGit(["remote", "add", "origin", remote], inRepo(o, stagingDir));
|
|
680
|
+
await bootstrapBranch(o, stagingDir);
|
|
681
|
+
// 引导提交已经 push 上去了(`push -u`),所以本地 HEAD 就是远端那一个。
|
|
682
|
+
revision = await localHead(o, stagingDir);
|
|
683
|
+
}
|
|
684
|
+
catch (fallbackErr) {
|
|
685
|
+
throw withEarlierCause(fallbackErr, "git clone", cloneErr);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
if (revision === undefined) {
|
|
689
|
+
// clone 成功:远端有没有这个分支,用 clone 下来的 refs 就能判断,不必再联网一次。
|
|
690
|
+
const head = await localRef(`refs/remotes/origin/${branch}`, o, stagingDir);
|
|
691
|
+
if (head === null) {
|
|
692
|
+
await bootstrapBranch(o, stagingDir);
|
|
693
|
+
revision = await localHead(o, stagingDir);
|
|
694
|
+
}
|
|
695
|
+
else {
|
|
696
|
+
// bare 远端的 HEAD 常指着别的默认分支;对齐到目标分支,避免制造第二个根提交。
|
|
697
|
+
await runGit(["checkout", "--quiet", "-B", branch, head], inRepo(o, stagingDir));
|
|
698
|
+
revision = head;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
// 原子发布是唯一让 ready() 变 true 的时刻。若外部已创建最终 repo,rename 失败,
|
|
702
|
+
// catch 只清 staging,绝不会回滚或删除那份已经成功的 clone。
|
|
703
|
+
await rename(stagingDir, repoDir);
|
|
704
|
+
return { revision, warnings };
|
|
705
|
+
}
|
|
706
|
+
catch (err) {
|
|
707
|
+
await rm(stagingDir, { recursive: true, force: true }).catch(() => { });
|
|
708
|
+
throw err;
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
/** 索引里有没有待提交的改动。`--quiet` 用退出码说话:0 = 无变化,1 = 有变化,其余是真出错。 */
|
|
713
|
+
async function hasStagedChanges(o) {
|
|
714
|
+
try {
|
|
715
|
+
await runGit(["diff", "--cached", "--quiet"], o);
|
|
716
|
+
return false;
|
|
717
|
+
}
|
|
718
|
+
catch (err) {
|
|
719
|
+
if (exitCodeOf(err) === 1)
|
|
720
|
+
return true;
|
|
721
|
+
throw err;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* 把本地 HEAD 推到远端分支,被拒时把「其实是远端又前进了」映射成 NonFastForwardError。
|
|
726
|
+
*
|
|
727
|
+
* push 被拒可能是「fetch 之后另一台设备又推了」= 非快进(引擎整轮重跑),也可能是
|
|
728
|
+
* 认证 / 网络 / hook。不解析 git 的文案(会被 locale 改写):再问一次远端的 head ——
|
|
729
|
+
* 它不再等于我们提交所挂的那一个,就是非快进。
|
|
730
|
+
* 本地那个没推上去的提交不算半推状态:下一轮的 reset --hard 会把它丢掉,远端一个字没动。
|
|
731
|
+
*/
|
|
732
|
+
async function pushBranch(o, start) {
|
|
733
|
+
try {
|
|
734
|
+
await runGit(["push", "--quiet", "origin", `HEAD:${branch}`], inRepo(o));
|
|
735
|
+
}
|
|
736
|
+
catch (err) {
|
|
737
|
+
const now = await remoteHead(o).catch(() => null);
|
|
738
|
+
if (now !== null && now !== start) {
|
|
739
|
+
throw new NonFastForwardError(`The remote moved on while publishing: based on ${start}, now ${now}`);
|
|
740
|
+
}
|
|
741
|
+
throw err;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
async function publish(scope, changes, base, opts) {
|
|
745
|
+
// 共享预算(同 `fetch`):一次发布是 reset + clean + add + commit + push 等好几个子进程。
|
|
746
|
+
const o = budgeted(opts);
|
|
747
|
+
await requireRepo();
|
|
748
|
+
// base.remote 来自 fetch 同一把根锁里的真实 origin。换远端也由该锁串行,所以这里的
|
|
749
|
+
// 比较与后续 publish 之间不会发生管理性 origin 变化;旧进程的快照绝不能写进新传输。
|
|
750
|
+
if (base.remote !== undefined) {
|
|
751
|
+
const current = await readOrigin(o);
|
|
752
|
+
if (current !== base.remote) {
|
|
753
|
+
throw new NonFastForwardError("The sync origin changed after the publish base was fetched; the stale round was not published");
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
const head = await remoteHead(o);
|
|
757
|
+
// base 为空串 = 没有基线(首次发布):不算非快进,直接以远端当前状态为准 ——
|
|
758
|
+
// 那一轮的差集是引擎按「远端全是新的」算出来的,backend 不替它做决定。
|
|
759
|
+
// 反过来,远端连分支都没有(head === null)而基线却记着一个 revision,说明远端被人动过,
|
|
760
|
+
// 同样按非快进报,绝不猜。
|
|
761
|
+
if (base.revision !== "" && head !== base.revision) {
|
|
762
|
+
throw new NonFastForwardError(`The remote moved on before publishing: base ${base.revision}, remote ${head ?? "(no such branch)"}`);
|
|
763
|
+
}
|
|
764
|
+
// 我们的提交要挂上去的那一个:远端的 head,或(远端还没这个分支时)本地已有的提交
|
|
765
|
+
const start = head ?? (await localHead(o));
|
|
766
|
+
// 前缀先算、先断言,**再跑任何破坏性命令**:`prefix` 是调用方给的,而紧随其后就是
|
|
767
|
+
// `clean -fd`(按 pathspec 删未跟踪文件)。唯一可达的坏值是空串 —— `:(literal)` 配空路径
|
|
768
|
+
// 匹配整个仓库,clean 会先把暂存 clone 里**所有**未跟踪文件清掉,然后才轮到断言报错。
|
|
769
|
+
// 破坏半径限于本模块自己的暂存目录(已跟踪内容下一轮 fetch+reset 就回来),但校验先于破坏是免费的。
|
|
770
|
+
const scopeBase = scopeDir(scope.prefix);
|
|
771
|
+
// 把暂存工作树对齐到 start。`reset --hard` 顺带修掉烂状态:改坏的已跟踪文件、
|
|
772
|
+
// 以及卡住的 merge 中间态(残留的 MERGE_HEAD 会让 commit 直接 fatal,
|
|
773
|
+
// 内容是个不存在的对象时 git 报的是 “Corrupt MERGE_HEAD file”)。
|
|
774
|
+
// 末尾的 `--` 与 clean/add 一致:明确宣告「后面没有 pathspec」,start 只可能被当成提交
|
|
775
|
+
//(放到 start 前面反而不行:`reset --hard -- <sha>` 会被当成路径限定形式,git 直接 fatal)。
|
|
776
|
+
await runGit(["reset", "--hard", "--quiet", start, "--"], inRepo(o));
|
|
777
|
+
// `clean` 只清**本前缀**下的未跟踪垃圾(上一轮失败留下的);范围外一律不碰,
|
|
778
|
+
// 用户随手丢进暂存目录的东西也不会被删。
|
|
779
|
+
await runGit(["clean", "--quiet", "-fd", "--", pathspec(scope.prefix)], inRepo(o));
|
|
780
|
+
// 先建出前缀目录:增量可能整个是空的(或删除的文件对端早已删掉),而 git add 对
|
|
781
|
+
// 「匹配不到任何文件」的 pathspec 是 fatal 退出 —— 那种情况的正确答案是「无变化」,不是报错。
|
|
782
|
+
// 空目录 git 不跟踪,因此它进不了提交,下一轮的 clean 会把它收掉。
|
|
783
|
+
await mkdir(scopeBase, { recursive: true });
|
|
784
|
+
for (const { path, content } of changes.writes) {
|
|
785
|
+
const target = scopedPath(scope.prefix, path);
|
|
786
|
+
// 先删再写:远端可能被人手工推了个同名**符号链接**,直接 writeFile 会顺着链接
|
|
787
|
+
// 写穿到暂存仓库外面去(rm 删的是链接本身,不是它的目标)。
|
|
788
|
+
await rm(target, { force: true });
|
|
789
|
+
await mkdir(dirname(target), { recursive: true });
|
|
790
|
+
await writeFile(target, content, "utf8");
|
|
791
|
+
}
|
|
792
|
+
for (const path of changes.deletions) {
|
|
793
|
+
await rm(scopedPath(scope.prefix, path), { force: true });
|
|
794
|
+
}
|
|
795
|
+
await runGit(["add", "-A", "--", pathspec(scope.prefix)], inRepo(o));
|
|
796
|
+
if (!(await hasStagedChanges(inRepo(o)))) {
|
|
797
|
+
// 没有变化就不提交、不推送:空提交会让 git log 里全是噪声,也白耗一次远端往返。
|
|
798
|
+
// **但这条捷径只在远端已经有这个分支时走得**:远端连分支都没有(被删了、或 init 那次
|
|
799
|
+
// push 其实没成功)时返回本地 head,交出去的是一个远端并不存在的 revision ——
|
|
800
|
+
// 引擎会把它写进基线,下一轮的 base 与远端 head 永远对不上(每轮白判一次非快进)。
|
|
801
|
+
// 那种情况必须真的 push 一次,把分支建在我们要返回的那个提交上。
|
|
802
|
+
if (head !== null)
|
|
803
|
+
return start;
|
|
804
|
+
await pushBranch(o, start);
|
|
805
|
+
return await localHead(o);
|
|
806
|
+
}
|
|
807
|
+
// 作者从入参传,不读环境里的 git 配置:设备归属靠 git log 查(而不是塞进文件名 ——
|
|
808
|
+
// 文件名必须由内容派生,两台设备才能收敛到同一个名字)。
|
|
809
|
+
await runGit(["-c", `user.name=${o.author.name}`, "-c", `user.email=${o.author.email}`, "commit", "--quiet", "-m", o.message], inRepo(o));
|
|
810
|
+
await pushBranch(o, start);
|
|
811
|
+
return await localHead(o);
|
|
812
|
+
}
|
|
813
|
+
return { ready, init, getRemote, setRemote, fetch, publish };
|
|
814
|
+
}
|
|
815
|
+
//# sourceMappingURL=backend-git.js.map
|