docs(persona-chat): clarify auto-hook boundary #13
+43
-23
@@ -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 = [];
|
||||||
|
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) {
|
for (const rel of AREAS[area].paths) {
|
||||||
const abs = path.join(root, rel);
|
|
||||||
if (rel.endsWith("/")) {
|
if (rel.endsWith("/")) {
|
||||||
let entries = [];
|
walk(rel.replace(/\/$/, ""));
|
||||||
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));
|
|
||||||
}
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user