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
+43 -3
View File
@@ -19,6 +19,8 @@ import * as pl from "./persona-lib.mjs";
export const SYNC_DIRNAME = ".sync";
export const DEFAULT_MIN_PUSH_SECONDS = 60;
// sync.json 裡「本機覆蓋遠端」的紀錄留幾筆
export const OVERWRITE_LOG_KEEP = 10;
// --------------------------------------------------------------------------- //
// 人格編號:英文名全大寫 + 兩位索引(同名才遞增)
@@ -608,11 +610,21 @@ export async function pushArea(slug, area, { message = "", code = null, owner =
}
gitOrThrow(["commit", "-q", "-m", message || `sync(${area}): ${pl.nowIso()}`], dir, "git commit");
let pushed = git(["push", "-q", "-u", "origin", "HEAD"], dir);
let overwrote = null;
if (!pushed.ok) {
// 通常是別台機器先推了(non-fast-forward)。工作副本才是這台機器的真相來源,
// 所以對齊遠端後把本機內容重新疊上去再推一次;真的有人同時在用,load 時的 pull 會擋下來。
//
// 但這條路徑**等同 force**:對方推上去的內容會被本機取代。所以要算出「蓋掉了哪幾個檔案、
// 上一版是哪個 commit」,一路回報到 sync 狀態裡——這條路可以走,但不能安靜地走。
const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout || "main";
if (git(["fetch", "--quiet", "origin"], dir).ok && git(["rev-parse", "--verify", "--quiet", `origin/${branch}`], dir).ok) {
const previous = git(["rev-parse", `origin/${branch}`], dir).stdout;
const base = git(["merge-base", "HEAD", `origin/${branch}`], dir).stdout;
// 分歧之後「對方」動過的檔案
const theirs = new Set(
base ? diffPaths(dir, [base, `origin/${branch}`]) : [],
);
git(["reset", "--hard", "--quiet", `origin/${branch}`], dir);
stageArea(slug, area, dir);
if (area === "wiki") {
@@ -621,8 +633,13 @@ export async function pushArea(slug, area, { message = "", code = null, owner =
}
clearStaleIndexLock(dir);
git(["add", "-A"], dir);
if (!git(["diff", "--cached", "--quiet"], dir).ok) {
git(["commit", "-q", "-m", `${message || "sync"}(與遠端合併後重推)`], dir);
// 疊上本機工作副本後仍與遠端不同的檔案 = 這次要改寫的;其中對方也動過的 = 真的被蓋掉的
const ours = diffPaths(dir, ["--cached"]);
const clobbered = ours.filter((f) => theirs.has(f)).sort();
if (clobbered.length) overwrote = { files: clobbered, previous, branch, area };
if (ours.length) {
git(["commit", "-q", "-m",
`${message || "sync"}(以本機為準覆蓋遠端 ${clobbered.length} 個檔案,上一版 ${previous.slice(0, 8)}`], dir);
}
}
pushed = git(["push", "-q", "-u", "origin", "HEAD"], dir);
@@ -632,8 +649,31 @@ export async function pushArea(slug, area, { message = "", code = null, owner =
state.code = theCode;
state.owner = theOwner;
state.areas[area] = { pushed_at: pl.nowIso(), checked_at: pl.nowIso(), files: staged.length };
if (overwrote) {
// 每輪對話後的 push 是背景執行、輸出丟掉的,所以覆蓋紀錄一定要落地:
// 留在 sync.json 裡等人來認領(`sync status` 會列,Stop hook 會提醒一次)。
state.overwrites = [...(state.overwrites || []), { at: pl.nowIso(), ...overwrote }].slice(-OVERWRITE_LOG_KEEP);
}
saveSyncState(slug, state);
return { ok: true, changed: true, files: staged.length, area, code: theCode };
return { ok: true, changed: true, files: staged.length, area, code: theCode, overwrote };
}
/** 還沒回報給使用者的「本機覆蓋遠端」紀錄。 */
export function pendingOverwrites(slug) {
return (loadSyncState(slug).overwrites || []).filter((entry) => !entry.reported_at);
}
/** 標記為已回報(同一次覆蓋只吵一次)。回傳這次標掉幾筆。 */
export function markOverwritesReported(slug) {
const state = loadSyncState(slug);
let marked = 0;
for (const entry of state.overwrites || []) {
if (entry.reported_at) continue;
entry.reported_at = pl.nowIso();
marked += 1;
}
if (marked) saveSyncState(slug, state);
return marked;
}
/**