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:
@@ -0,0 +1,13 @@
|
||||
// workspace:用 git 存代码版本、取出、比较两个版本(docs/结构.md 第二部分)。
|
||||
// 仓库位置由调用方给;不碰账本,版本之间的关系由 flow 记进账本。
|
||||
export {
|
||||
checkoutVersion,
|
||||
compareVersions,
|
||||
createRepository,
|
||||
saveVersion,
|
||||
type Comparison,
|
||||
type FileChange,
|
||||
type Hunk,
|
||||
type LineRange,
|
||||
type SaveOutcome,
|
||||
} from "./workspace.ts";
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user