Files
InfoRSI/src/workspace/workspace.ts
T
iomgaa 6850c0a1d4 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>
2026-10-02 23:43:01 -04:00

193 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}