feat: 任务书 06,workspace 模块与 task.json 的"不存"清单

workspace 用一个裸仓库存代码版本,版本号是目录树编号,不造提交,每个版本挂 refs/versions/ 引用防清理。
存版本自己遍历目录、hash-object --no-filters 存,不用 git add;符号链接等特殊文件交回"存不了"。
取出用 ls-tree 加 cat-file 原样写出:实测 git archive 会按树里的 .gitattributes 丢文件、改换行符。
git 子进程不读系统和用户的配置,另关掉系统和用户的 attributes 文件:实测只关配置时 ~/.config/git/attributes 仍会被读。
比较交回文件级改动、新旧两边行号范围的块,以及带上下文的 diff 原文。
tasks:task.json 加必填的 unversionedNames,protectedFiles 删掉 solution.json。
全新上下文审阅一轮,采纳一条:注明块可以为空(空文件增删、只改可执行位)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 23:43:01 -04:00
parent a1a2feb135
commit 6850c0a1d4
8 changed files with 500 additions and 5 deletions
+192
View File
@@ -0,0 +1,192 @@
import { spawnSync } from "node:child_process";
import { lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
// 用 git 存代码版本、取出、比较(docs/结构.md 第二部分 workspace 一行)。
// 一份研究一个裸仓库;版本号是目录树编号(tree id),不造提交,"上一版是谁"由账本记。
// 只用 git 的底层命令:存版本自己列文件,不用 git add(它按 .gitignore 悄悄漏存);
// 取出自己按清单写文件,不用 git archive(它按树里的 .gitattributes 丢文件、改换行符)。
/** 存版本的结果:版本号,或者存不了、是哪个路径、为什么。 */
export type SaveOutcome = { saved: true; version: string } | { saved: false; path: string; reason: string };
/**
* 一个差异块在一侧的行号范围,行号从 1 起。
* 这一侧有行时是第 start 到第 end 行(含两端);
* 这一侧没有行时(只删不加的块在新版本一侧、只加不删的块在旧版本一侧)写成"在第 after 行之后",
* 与 `git diff -U0` 块头里行数为 0 时的写法相同,after 为 0 表示在文件开头。
*/
export type LineRange = { start: number; end: number } | { after: number };
/** 一个差异块:不带上下文的一段连续改动,旧版本(from)和新版本(to)两侧的行号范围。 */
export interface Hunk {
from: LineRange;
to: LineRange;
}
interface ChangedFile {
/** 相对代码根目录的路径,用 / 分隔。 */
path: string;
change: "added" | "deleted" | "modified";
/** 内容没变,只改了可执行位。 */
modeOnly: boolean;
}
/**
* 一个改动过的文件。二进制文件不拆块,当作整个文件改了。
* 块可以为空:空文件的新增、删除和只改可执行位都没有块,也按整个文件改了算。
*/
export type FileChange = (ChangedFile & { binary: true }) | (ChangedFile & { binary: false; hunks: Hunk[] });
/** 比较两个版本的结果:改动过的文件(按路径排序),以及给模型看的带上下文的差异原文。 */
export interface Comparison {
files: FileChange[];
diff: string;
}
const EXECUTABLE = "100755";
const REGULAR = "100644";
/** diff-tree 原始格式里的状态字母。只存普通文件、不查改名,所以只有这三种。 */
const CHANGES: Readonly<Record<string, ChangedFile["change"]>> = { A: "added", D: "deleted", M: "modified" };
/** 在 repo 位置建一个空的裸仓库。 */
export function createRepository(repo: string): void {
git(repo, ["init", "--bare", "--quiet"], "", null);
}
/**
* 把 dir 现在的内容存成一个版本。名字在 excludedNames 里的文件或目录,在任何一层都跳过。
* 遇到既不是普通文件也不是目录的东西(符号链接、设备文件等)交回存不了:这个目录是实验员交的,
* 不替它猜怎么存。空目录不进版本(git 的目录树只记文件)。
* 同样的内容永远得到同一个版本号,所以中断后重存一次得到的号和账本里的一样。
*/
export function saveVersion(repo: string, dir: string, excludedNames: readonly string[]): SaveOutcome {
const files: Array<{ path: string; mode: string }> = [];
const refused = listFiles(dir, "", new Set(excludedNames), files);
if (refused !== null) return { saved: false, ...refused };
// --no-filters:原样存字节,不按任何配置或 .gitattributes 转换换行符、走过滤器。
const paths = files.map((file) => `${join(dir, file.path)}\n`).join("");
const ids = text(git(repo, ["hash-object", "-w", "--no-filters", "--stdin-paths"], paths, null)).split("\n");
// 每次用自己的临时索引文件,不碰仓库里的索引。
const scratch = mkdtempSync(join(tmpdir(), "inforsi-index-"));
const index = join(scratch, "index");
try {
const entries = files.map((file, i) => `${file.mode} ${ids[i]}\t${file.path}\0`).join("");
git(repo, ["update-index", "-z", "--index-info"], entries, index);
const version = text(git(repo, ["write-tree"], "", index)).trim();
// 挂一个引用:没有引用指着的对象,git gc 会把它清掉。
git(repo, ["update-ref", `refs/versions/${version}`, version], "", null);
return { saved: true, version };
} finally {
rmSync(scratch, { recursive: true, force: true });
}
}
/** 遍历 root 下的 rel 目录,把普通文件收进 files;遇到存不了的东西就停,交回它的路径和原因。 */
function listFiles(
root: string,
rel: string,
excluded: ReadonlySet<string>,
files: Array<{ path: string; mode: string }>,
): { path: string; reason: string } | null {
for (const entry of readdirSync(join(root, rel), { withFileTypes: true })) {
if (excluded.has(entry.name)) continue;
const path = rel === "" ? entry.name : `${rel}/${entry.name}`;
if (entry.isDirectory()) {
const refused = listFiles(root, path, excluded, files);
if (refused !== null) return refused;
} else if (entry.isFile()) {
// 可执行位看文件主人的那一位,与 git 自己的判断相同。
const executable = (lstatSync(join(root, path)).mode & 0o100) !== 0;
files.push({ path, mode: executable ? EXECUTABLE : REGULAR });
} else {
const kind = entry.isSymbolicLink() ? "符号链接" : "既不是普通文件也不是目录";
return { path, reason: `${kind},代码版本只存普通文件和目录` };
}
}
return null;
}
/** 把一个版本的文件写进 dir(调用方建好的空目录),还原可执行位。dir 里不会有 .git。 */
export function checkoutVersion(repo: string, version: string, dir: string): void {
// 每条是"模式 类型 编号<TAB>路径",以 NUL 结尾。
const records = text(git(repo, ["ls-tree", "-r", "-z", version], "", null)).split("\0").slice(0, -1);
for (const record of records) {
const tab = record.indexOf("\t");
const [mode, , id] = record.slice(0, tab).split(" ") as [string, string, string];
const target = join(dir, record.slice(tab + 1));
mkdirSync(dirname(target), { recursive: true });
// cat-file 交回存进去的原始字节,不经过任何 .gitattributes 或配置的转换。
writeFileSync(target, git(repo, ["cat-file", "blob", id], "", null), { mode: mode === EXECUTABLE ? 0o755 : 0o644 });
}
}
/**
* 比较 from 和 to 两个版本。
* 文件清单来自 `git diff-tree -r -z` 的原始格式;块来自 `-U0` 的补丁,它和清单按同样的路径顺序
* 每个文件一段,按位置对上,不必解析补丁里加了引号的路径。
*/
export function compareVersions(repo: string, from: string, to: string): Comparison {
const range = ["--no-renames", from, to];
const raw = text(git(repo, ["diff-tree", "-r", "-z", ...range], "", null)).split("\0").slice(0, -1);
const sections = text(git(repo, ["diff-tree", "-r", "-p", "-U0", "--no-color", ...range], "", null))
.split(/^diff --git /m)
.slice(1);
const files: FileChange[] = [];
for (let i = 0; i < raw.length; i += 2) {
// 原始格式:":旧模式 新模式 旧编号 新编号 状态",NUL,路径。
const [, , oldId, newId, status] = (raw[i] as string).split(" ") as [string, string, string, string, string];
const section = sections[i / 2] as string;
const file = { path: raw[i + 1] as string, change: CHANGES[status] as ChangedFile["change"], modeOnly: oldId === newId };
// 补丁里内容行都以 +、-、空格开头,以这几个字开头的只能是 git 写的说明行。
if (/^Binary files /m.test(section)) files.push({ ...file, binary: true });
else files.push({ ...file, binary: false, hunks: hunks(section) });
}
const diff = text(git(repo, ["diff-tree", "-r", "-p", "--no-color", ...range], "", null));
return { files, diff };
}
/** 从一个文件的 -U0 补丁里读出全部块头 `@@ -旧起始[,旧行数] +新起始[,新行数] @@`,行数省略时是 1。 */
function hunks(section: string): Hunk[] {
return [...section.matchAll(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm)].map((m) => ({
from: lineRange(Number(m[1]), Number(m[2] ?? 1)),
to: lineRange(Number(m[3]), Number(m[4] ?? 1)),
}));
}
function lineRange(start: number, count: number): LineRange {
return count === 0 ? { after: start } : { start, end: start + count - 1 };
}
function text(output: Buffer): string {
return output.toString("utf8");
}
/**
* 在 repo 仓库上跑一条 git 命令,交回标准输出。失败就报错,写明命令和 git 的报错原文。
* - GIT_DIR 只放进子进程的环境,不写进 process.env:插件进程的环境会被实验员的 shell 继承。
* - 不读系统和用户的配置,也不读系统和用户的 attributes 文件:那里的换行符转换、比较算法、
* 过滤器、"按二进制处理"会悄悄改掉存进去的内容或比较的结果。
* core.attributesFile 不设时 git 会去读 ~/.config/git/attributes,所以用环境变量把它指到 /dev/null。
*/
function git(repo: string, args: string[], input: string, indexFile: string | null): Buffer {
const env: NodeJS.ProcessEnv = {
...process.env,
GIT_DIR: repo,
GIT_CONFIG_NOSYSTEM: "1",
GIT_CONFIG_GLOBAL: "/dev/null",
GIT_ATTR_NOSYSTEM: "1",
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: "core.attributesFile",
GIT_CONFIG_VALUE_0: "/dev/null",
};
if (indexFile !== null) env.GIT_INDEX_FILE = indexFile;
const result = spawnSync("git", args, { env, input, maxBuffer: Infinity });
if (result.error !== undefined) throw new Error(`git ${args.join(" ")} 起不来:${result.error.message}`, { cause: result.error });
if (result.status !== 0) {
throw new Error(`git ${args.join(" ")}(GIT_DIR=${repo})失败,退出码 ${result.status},信号 ${result.signal}:\n${text(result.stderr)}`);
}
return result.stdout;
}