docs(persona-chat): clarify auto-hook boundary #13

Merged
admin merged 19 commits from pr/persona-master-sync-20260731 into master 2026-07-31 17:37:42 +00:00
2 changed files with 51 additions and 23 deletions
Showing only changes of commit b937c9ad7a - Show all commits
+39 -19
View File
@@ -305,30 +305,57 @@ export function ensureClone(slug, area, url) {
// 檔案搬運:工作副本 <-> clone // 檔案搬運:工作副本 <-> clone
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
function listAreaFiles(root, area) { /**
* 這一區在工作副本裡有哪些檔案。
* 資料夾要**遞迴**收:`mindmap/threads/archive/`(睡眠時收起來的舊思維導圖)
* 是子資料夾,只看第一層的話它整個不會被同步。
*/
export function listAreaFiles(root, area) {
const out = []; const out = [];
for (const rel of AREAS[area].paths) { const walk = (relDir) => {
const abs = path.join(root, rel);
if (rel.endsWith("/")) {
let entries = []; let entries = [];
try { try {
entries = fs.readdirSync(abs, { withFileTypes: true }); entries = fs.readdirSync(path.join(root, relDir), { withFileTypes: true });
} catch { } catch {
continue; return;
} }
for (const entry of entries) { for (const entry of entries) {
if (entry.isFile()) out.push(path.posix.join(rel.replace(/\/$/, ""), entry.name)); 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) {
if (rel.endsWith("/")) {
walk(rel.replace(/\/$/, ""));
continue; continue;
} }
const abs = path.join(root, rel);
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) out.push(rel); if (fs.existsSync(abs) && fs.statSync(abs).isFile()) out.push(rel);
} }
return out.sort(); return out.sort();
} }
// `-z`:路徑用 NUL 分隔、不做跳脫。少了它,含非 ASCII 的檔名(長期記憶的檔名就是中文的)
// 會被 git 引號跳脫成 `"Memory-\350\267\250…"`,跟工作副本比對不上——結果是那些檔案
// pull 時被靜靜略過、本機刪掉後也不會從遠端消失。
function listTrackedFiles(dir) { function listTrackedFiles(dir) {
const res = git(["ls-files"], dir); const res = git(["ls-files", "-z"], dir);
return res.ok ? res.stdout.split("\n").map((s) => s.trim()).filter(Boolean).sort() : []; 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"; 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)); const dir = ensureClone(slug, area, repoUrl(host, theOwner, theCode, area));
stageArea(slug, area, dir); // 先把本機現況放進 clone,才看得出本機動過什麼 stageArea(slug, area, dir); // 先把本機現況放進 clone,才看得出本機動過什麼
clearStaleIndexLock(dir); clearStaleIndexLock(dir);
const localChanged = new Set( const localChanged = new Set(statusPaths(dir));
git(["status", "--porcelain"], dir)
.stdout.split("\n")
.map((l) => l.slice(3).trim())
.filter(Boolean),
);
const fetched = git(["fetch", "--quiet", "origin"], dir); const fetched = git(["fetch", "--quiet", "origin"], dir);
if (!fetched.ok) return { ok: false, area, reason: fetched.stderr || "fetch 失敗" }; if (!fetched.ok) return { ok: false, area, reason: fetched.stderr || "fetch 失敗" };
const head = git(["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout || "main"; const head = git(["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout || "main";
const remoteRef = `origin/${head}`; const remoteRef = `origin/${head}`;
const exists = git(["rev-parse", "--verify", "--quiet", remoteRef], dir); const exists = git(["rev-parse", "--verify", "--quiet", remoteRef], dir);
if (!exists.ok) return { ok: true, area, code: theCode, empty: true, changed: [] }; 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)); const conflicts = incoming.filter((f) => localChanged.has(f));
if (conflicts.length && !force) { if (conflicts.length && !force) {
git(["checkout", "--", "."], dir); 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)); pl.writeText(path.join(dir, "Icon.md"), wikiIconPage(slug, theCode));
} }
const files = stageArea(slug, area, dir); const files = stageArea(slug, area, dir);
const dirty = git(["status", "--porcelain"], dir) const dirty = statusPaths(dir);
// git() 會把輸出 trim 掉,所以 porcelain 開頭那個空白可能已經不見了(" M x" → "M x"
.stdout.split("\n").map((l) => l.replace(/^\s*[A-Z?!]{1,2}\s+/, "").trim()).filter(Boolean);
git(["checkout", "--", "."], dir); git(["checkout", "--", "."], dir);
git(["clean", "-qfd"], dir); git(["clean", "-qfd"], dir);
return { return {
+8
View File
@@ -864,6 +864,14 @@ check("高頻資料在檔案區、低頻資料在 Wiki 區",
covered("journal/2026-01.jsonl")[0] === "files" && covered("state/said.jsonl")[0] === "files" && 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("IDENTITY.md")[0] === "wiki" && covered("memory/long-term/x.md")[0] === "wiki" &&
covered("relations/graph.json")[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 時同步只是略過,不會爆炸", (() => { check("沒設定 Gitea 時同步只是略過,不會爆炸", (() => {
const res = cli(["sync", "status", "--session", S_CODE]); const res = cli(["sync", "status", "--session", S_CODE]);
const push = cli(["sync", "push", "--session", S_CODE], { expectOk: false }); const push = cli(["sync", "push", "--session", S_CODE], { expectOk: false });