From b937c9ad7a9df61d9c3a9052472f7aed1a231d11 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 31 Jul 2026 09:32:49 +0000 Subject: [PATCH] =?UTF-8?q?fix(gitea):=20=E5=90=8C=E6=AD=A5=E6=9C=83?= =?UTF-8?q?=E6=BC=8F=E6=8E=89=E5=AD=90=E8=B3=87=E6=96=99=E5=A4=BE=E8=88=87?= =?UTF-8?q?=E9=9D=9E=20ASCII=20=E6=AA=94=E5=90=8D=E7=9A=84=E6=AA=94?= =?UTF-8?q?=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 兩個各自獨立、但都是「檔案在本機有、在存取庫沒有/拉不回來」的靜默失真: 1. `listAreaFiles` 只收資料夾第一層的 `entry.isFile()`,所以 `mindmap/threads/archive/`(睡眠時把太久沒動的思維導圖收進去的地方) 從來沒有被同步過。改成遞迴。 2. `git ls-files` / `status --porcelain` / `diff --name-only` 預設會把 非 ASCII 路徑引號跳脫成 `"Memory-\350\267\250…"`。長期記憶的檔名正好是中文的, 於是這些檔名跟工作副本比對不上:pull 時被當成「clone 裡沒有的東西」整批略過 (拉回一個沒有長期記憶的人格),本機刪掉的也不會從遠端消失。 一律改用 `-z`,並把三處解析收斂成 `diffPaths()` / `statusPaths()`。 順便修掉 porcelain 第一筆因為整體 trim 而少一個字元的解析錯誤。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/persona-gitea.mjs | 66 +++++++++++++++++++++++++-------------- scripts/selftest.mjs | 8 +++++ 2 files changed, 51 insertions(+), 23 deletions(-) diff --git a/scripts/persona-gitea.mjs b/scripts/persona-gitea.mjs index 25c7e0f..d0a3230 100644 --- a/scripts/persona-gitea.mjs +++ b/scripts/persona-gitea.mjs @@ -305,30 +305,57 @@ export function ensureClone(slug, area, url) { // 檔案搬運:工作副本 <-> clone // --------------------------------------------------------------------------- // -function listAreaFiles(root, area) { +/** + * 這一區在工作副本裡有哪些檔案。 + * 資料夾要**遞迴**收:`mindmap/threads/archive/`(睡眠時收起來的舊思維導圖) + * 是子資料夾,只看第一層的話它整個不會被同步。 + */ +export function listAreaFiles(root, area) { const out = []; + const walk = (relDir) => { + let entries = []; + try { + entries = fs.readdirSync(path.join(root, relDir), { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const rel = path.posix.join(relDir, entry.name); + if (entry.isDirectory()) walk(rel); + else if (entry.isFile()) out.push(rel); + } + }; for (const rel of AREAS[area].paths) { - const abs = path.join(root, rel); if (rel.endsWith("/")) { - let entries = []; - try { - entries = fs.readdirSync(abs, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - if (entry.isFile()) out.push(path.posix.join(rel.replace(/\/$/, ""), entry.name)); - } + walk(rel.replace(/\/$/, "")); continue; } + const abs = path.join(root, rel); if (fs.existsSync(abs) && fs.statSync(abs).isFile()) out.push(rel); } return out.sort(); } +// `-z`:路徑用 NUL 分隔、不做跳脫。少了它,含非 ASCII 的檔名(長期記憶的檔名就是中文的) +// 會被 git 引號跳脫成 `"Memory-\350\267\250…"`,跟工作副本比對不上——結果是那些檔案 +// pull 時被靜靜略過、本機刪掉後也不會從遠端消失。 function listTrackedFiles(dir) { - const res = git(["ls-files"], dir); - return res.ok ? res.stdout.split("\n").map((s) => s.trim()).filter(Boolean).sort() : []; + const res = git(["ls-files", "-z"], dir); + return res.ok ? res.stdout.split("\0").filter(Boolean).sort() : []; +} + +/** `git diff --name-only -z` → 檔名清單(理由同上)。 */ +function diffPaths(dir, args) { + return git(["diff", "--name-only", "-z", ...args], dir).stdout.split("\0").filter(Boolean); +} + +/** `git status --porcelain -z` → 檔名清單(同樣為了非 ASCII 檔名而用 -z)。 */ +function statusPaths(dir) { + return git(["status", "--porcelain", "-z"], dir) + .stdout.split("\0") + // git() 會把輸出整個 trim 掉,所以第一筆的狀態欄前導空白可能已經不見了(" M x" → "M x") + .map((entry) => entry.replace(/^\s*[A-Z?!]{1,2}\s+/, "").trim()) + .filter(Boolean); } export const WIKI_MANIFEST = "_paths.json"; @@ -623,19 +650,14 @@ export async function pullArea(slug, area, { code = null, owner = null, force = const dir = ensureClone(slug, area, repoUrl(host, theOwner, theCode, area)); stageArea(slug, area, dir); // 先把本機現況放進 clone,才看得出本機動過什麼 clearStaleIndexLock(dir); - const localChanged = new Set( - git(["status", "--porcelain"], dir) - .stdout.split("\n") - .map((l) => l.slice(3).trim()) - .filter(Boolean), - ); + const localChanged = new Set(statusPaths(dir)); const fetched = git(["fetch", "--quiet", "origin"], dir); if (!fetched.ok) return { ok: false, area, reason: fetched.stderr || "fetch 失敗" }; const head = git(["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout || "main"; const remoteRef = `origin/${head}`; const exists = git(["rev-parse", "--verify", "--quiet", remoteRef], dir); if (!exists.ok) return { ok: true, area, code: theCode, empty: true, changed: [] }; - const incoming = git(["diff", "--name-only", "HEAD", remoteRef], dir).stdout.split("\n").filter(Boolean); + const incoming = diffPaths(dir, ["HEAD", remoteRef]); const conflicts = incoming.filter((f) => localChanged.has(f)); if (conflicts.length && !force) { git(["checkout", "--", "."], dir); @@ -684,9 +706,7 @@ export async function verifyArea(slug, area, { code = null, owner = null } = {}) pl.writeText(path.join(dir, "Icon.md"), wikiIconPage(slug, theCode)); } const files = stageArea(slug, area, dir); - const dirty = git(["status", "--porcelain"], dir) - // git() 會把輸出 trim 掉,所以 porcelain 開頭那個空白可能已經不見了(" M x" → "M x") - .stdout.split("\n").map((l) => l.replace(/^\s*[A-Z?!]{1,2}\s+/, "").trim()).filter(Boolean); + const dirty = statusPaths(dir); git(["checkout", "--", "."], dir); git(["clean", "-qfd"], dir); return { diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 0bb714e..a7e3e89 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -864,6 +864,14 @@ check("高頻資料在檔案區、低頻資料在 Wiki 區", covered("journal/2026-01.jsonl")[0] === "files" && covered("state/said.jsonl")[0] === "files" && covered("IDENTITY.md")[0] === "wiki" && covered("memory/long-term/x.md")[0] === "wiki" && covered("relations/graph.json")[0] === "wiki"); +// 分區的路徑有好幾個是資料夾,而資料夾裡還有資料夾:`mindmap/threads/archive/` +// (睡眠時把太久沒動的思維導圖收進去)只看第一層的話整個不會被同步。 +check("同步範圍會遞迴進子資料夾(mindmap/threads/archive 以前整個漏掉)", (() => { + const dir = path.join(pl.personaDir("alpha"), "mindmap", "threads", "archive"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "old-thread.mmd"), "%% 收起來的舊思維導圖\nflowchart TD\n"); + return gt.listAreaFiles(pl.personaDir("alpha"), "files").includes("mindmap/threads/archive/old-thread.mmd"); +})(), gt.listAreaFiles(pl.personaDir("alpha"), "files").join(", ")); check("沒設定 Gitea 時同步只是略過,不會爆炸", (() => { const res = cli(["sync", "status", "--session", S_CODE]); const push = cli(["sync", "push", "--session", S_CODE], { expectOk: false });