fix(gitea): push 撞到別台機器時是本機贏,但不能靜靜地贏

`pushArea` 遇到 non-fast-forward 時會 `git reset --hard origin/<branch>`、
把本機工作副本重新疊上去再推一次,然後回 `{ok:true, changed:true}`——
沒有任何衝突訊號。而 Stop hook 每一輪都在背景 push,所以兩台機器同時聊同一個
人格時,對方的 emotion.json/short-term.jsonl 會被靜默取代,誰都不知道。

「本機工作副本是這台機器的真相來源」這個設計選擇保留,但那條路徑現在要記帳:

* 算出「對方在分歧後改過、而我們正要蓋掉」的檔案交集,連同覆蓋前的遠端 sha
  一起回傳 `overwrote`,並寫進 state/sync.json(留最近 10 筆)。
* `sync push` 一律往 stderr 寫一行警告(--quiet 也寫,背景 push 才有痕跡),
  正常輸出與 `sync status` 都列得出「蓋掉幾個檔案、上一版是誰」。
* 背景 push 是 detached、輸出丟掉的,所以由 Stop hook 認領未回報的紀錄,
  講給使用者聽一次(劇場模式也照講——那是資料被蓋掉)。
* commit 訊息也寫進「覆蓋 N 個檔案,上一版 <sha>」,被蓋掉的內容仍可用
  `git -C <人格>/.sync/<區> show <sha>:<檔案>` 取回。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 09:34:29 +00:00
co-authored by Claude Opus 5
parent b937c9ad7a
commit 5d27051293
5 changed files with 96 additions and 4 deletions
+24 -1
View File
@@ -1853,6 +1853,7 @@ commands.sync = async ({ flags, positional }) => {
if (action === "status") {
const state = gt.loadSyncState(slug);
const problem = gt.giteaProblem();
const overwrites = state.overwrites || [];
emit({ persona: slug, code: gt.personaCode(slug), problem, sync: state }, flags.json, [
`人格 \`${slug}\`|編號 ${gt.personaCode(slug) || "(無)"}Gitea ${problem ? `${problem}` : "✔ 已設定"}`,
` 存取庫:${state.repo_url || "(尚未建立,跑 \`sync init\`"}`,
@@ -1861,6 +1862,15 @@ commands.sync = async ({ flags, positional }) => {
return ` ${gt.AREAS[key].label}${gt.AREAS[key].why}):` +
`最後 push ${info.pushed_at || "—"}|最後 pull ${info.pulled_at || "—"}${info.files ?? "?"} 個檔案`;
}),
// 「本機是真相來源」的代價:push 撞到別台機器時是本機贏。贏了要記帳。
...(overwrites.length
? [" ⚠ 本機曾覆蓋遠端(別台機器同時在用同一個人格):"]
.concat(overwrites.slice(-5).map((o) =>
` ${o.at} ${gt.AREAS[o.area]?.label || o.area} ${o.files.length} 個檔案` +
`${o.files.slice(0, 4).join(", ")}${o.files.length > 4 ? "…" : ""}` +
` 上一版 ${String(o.previous || "").slice(0, 8)}`))
.concat([` 要看回被蓋掉的內容:\`git -C ${gt.syncDir(slug, overwrites.at(-1).area)} show <上一版>:<檔案>\``])
: []),
]);
return;
}
@@ -1892,9 +1902,20 @@ commands.sync = async ({ flags, positional }) => {
out.push({ area: key, ok: false, reason: err.message });
}
}
// 覆蓋遠端這件事在 --quiet(背景 push)下也要留下痕跡:stderr 一定寫,sync.json 也記著
for (const r of out) {
if (!r.overwrote) continue;
process.stderr.write(
`${gt.AREAS[r.area].label}:以本機為準覆蓋了遠端 ${r.overwrote.files.length} 個檔案` +
`${r.overwrote.files.slice(0, 6).join(", ")}),上一版是 ${String(r.overwrote.previous).slice(0, 8)}\n` +
` 別台機器可能正在用同一個人格。要看回被蓋掉的內容:` +
`git -C ${gt.syncDir(slug, r.area)} show ${String(r.overwrote.previous).slice(0, 8)}:<檔案>\n`,
);
}
emit({ persona: slug, results: out }, flags.json, out.map((r) =>
r.skipped ? ` ${gt.AREAS[r.area].label}:略過(${r.reason}`
: r.ok ? `${gt.AREAS[r.area].label}${r.changed ? `已推送 ${r.files} 個檔案` : "沒有變更"}`
: r.ok ? `${gt.AREAS[r.area].label}${r.changed ? `已推送 ${r.files} 個檔案` : "沒有變更"}` +
(r.overwrote ? ` ⚠ 其中覆蓋了遠端 ${r.overwrote.files.length} 個檔案(上一版 ${String(r.overwrote.previous).slice(0, 8)}` : "")
: `${gt.AREAS[r.area].label}${String(r.reason).slice(0, 160)}`));
return;
}
@@ -2045,6 +2066,8 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
verify 會確認「本機 = Gitea」,不一致就以非零結束(形象圖必須同步)
檔案區=高頻活狀態(情緒/短期記憶/心裡話/逐字),每輪對話後背景 push
Wiki 區=低頻設定(IDENTITY/SOUL/長期記憶/心智圖/關係圖),固化或改身分時 push
push 撞到別台機器時以**本機為準**覆蓋遠端,並回報蓋掉哪些檔案與上一版 sha
sync.json 記帳、sync status 列得出來、下一輪的 Stop hook 會提醒一次)
環境變數:GITEA_HOST / GITEA_TOKEN(或 PERSONA_GITEA_HOST / _TOKEN / _OWNER),
PERSONA_GITEA=off 可整個關掉,PERSONA_SYNC_MIN_SECONDS 調 push 間隔