diff --git a/docs/api.md b/docs/api.md index bd5d81b..ae5c531 100644 --- a/docs/api.md +++ b/docs/api.md @@ -12,3 +12,8 @@ The Repo API MUST include createBranch and createTag operations. The Repo API MUST include setRemote and listRemotes operations. The Repo API MUST include revisionWalk and log operations. Log entries MUST expose oid and parent and author and committer fields. + +## Sparse Partial Clone Contracts + +The Repo API MUST include setSparseCheckout and sparseCheckoutSelect operations. +The Repo API MUST include negotiatePartialCloneFilter and setPromisorObject and resolvePromisedObject operations. diff --git a/scripts/check b/scripts/check index 53f3b26..155699b 100755 --- a/scripts/check +++ b/scripts/check @@ -932,6 +932,32 @@ function invariantHandlers(repoRoot) { requireTreeMatch("tests", /\.\.\/escape/, "submodule-worktree-escape-counterexample"); }); + handlers.set("INV-FEAT-0043", () => { + existsFile("src/core/sparse-checkout/rules.ts"); + requireTreeMatch("src", /\bsetSparseCheckout\b/, "repo-set-sparse-checkout-method"); + requireTreeMatch("src", /\bsparseCheckoutSelect\b/, "repo-sparse-checkout-select-method"); + requireTreeMatch("tests", /\bINV-FEAT-0043\b/, "inv-feat-0043-tests-token"); + requireTreeMatch("tests", /\bsparse-checkout\b/i, "sparse-checkout-baseline"); + }); + + handlers.set("INV-FEAT-0044", () => { + existsFile("src/core/partial-clone/promisor.ts"); + requireTreeMatch("src/core/partial-clone", /\bnegotiatePartialCloneFilter\b/, "partial-clone-filter-negotiation-token"); + requireTreeMatch("src", /\bsetPromisorObject\b/, "repo-set-promisor-object-method"); + requireTreeMatch("src", /\bresolvePromisedObject\b/, "repo-resolve-promised-object-method"); + requireTreeMatch("tests", /\bINV-FEAT-0044\b/, "inv-feat-0044-tests-token"); + requireTreeMatch("tests", /--filter=blob:none/, "partial-clone-filter-baseline"); + }); + + handlers.set("INV-SECU-0016", () => { + existsFile("src/core/partial-clone/promisor.ts"); + requireTreeMatch("src", /\bINTEGRITY_ERROR\b/, "partial-clone-integrity-error-token"); + requireTreeMatch("tests", /\bINV-SECU-0016\b/, "inv-secu-0016-tests-token"); + requireTreeMatch("tests", /\bmissing\b/, "promised-object-missing-token"); + requireTreeMatch("tests", /\bmalformed\b/, "promised-object-malformed-token"); + requireTreeMatch("tests", /\bINTEGRITY_ERROR\b/, "promised-object-integrity-error-token"); + }); + return handlers; } diff --git a/spec/state.yaml b/spec/state.yaml index 2e3dd53..b31d9c7 100644 --- a/spec/state.yaml +++ b/spec/state.yaml @@ -1,5 +1,5 @@ { "schema_version": 1, - "current_phase": 14, + "current_phase": 15, "total_phases": 18 } diff --git a/src/core/partial-clone/promisor.ts b/src/core/partial-clone/promisor.ts new file mode 100644 index 0000000..020201d --- /dev/null +++ b/src/core/partial-clone/promisor.ts @@ -0,0 +1,94 @@ +export interface PartialCloneState { + filterSpec: string | null; + capabilities: string[]; + promisorObjects: Record; +} + +export const defaultPartialCloneState: PartialCloneState = { + filterSpec: null, + capabilities: [], + promisorObjects: {}, +}; + +function normalizeCapability(capability: string): string { + return capability.trim(); +} + +export function negotiatePartialCloneFilter( + requestedFilter: string, + capabilities: string[], +): string { + const normalizedFilter = requestedFilter.trim(); + if (normalizedFilter.length === 0) { + throw new Error("partial clone filter is empty"); + } + const normalizedCapabilities = capabilities + .map((capability) => normalizeCapability(capability)) + .filter((capability) => capability.length > 0); + const filterSupported = normalizedCapabilities.some( + (capability) => capability === "filter" || capability.startsWith("filter="), + ); + if (!filterSupported) { + throw new Error("partial clone filter capability missing"); + } + return normalizedFilter; +} + +function isUint8Item(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isInteger(value) && + value >= 0 && + value <= 255 + ); +} + +export function isValidPromisedPayload(value: unknown): value is number[] { + if (!Array.isArray(value)) return false; + for (const item of value) { + if (!isUint8Item(item)) return false; + } + return true; +} + +export function normalizePartialCloneState( + value: unknown, +): PartialCloneState | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const record = value as Record; + const filterSpec = + typeof record.filterSpec === "string" || record.filterSpec === null + ? record.filterSpec + : null; + const capabilities = Array.isArray(record.capabilities) + ? record.capabilities.filter( + (item): item is string => typeof item === "string", + ) + : []; + const rawPromisor = record.promisorObjects; + if ( + !rawPromisor || + typeof rawPromisor !== "object" || + Array.isArray(rawPromisor) + ) { + return { + filterSpec, + capabilities, + promisorObjects: {}, + }; + } + const promisorObjects: Record = {}; + for (const [oid, payload] of Object.entries(rawPromisor)) { + if (!isValidPromisedPayload(payload)) { + return null; + } + promisorObjects[oid] = [...payload]; + } + return { + filterSpec, + capabilities, + promisorObjects, + }; +} diff --git a/src/core/sparse-checkout/rules.ts b/src/core/sparse-checkout/rules.ts new file mode 100644 index 0000000..28e1cf5 --- /dev/null +++ b/src/core/sparse-checkout/rules.ts @@ -0,0 +1,98 @@ +import { assertSafeWorktreePath } from "../checkout/path-safety.js"; + +export type SparseCheckoutMode = "cone" | "pattern"; + +function normalizeRule(rule: string): string { + const trimmed = rule.trim().replaceAll("\\", "/"); + if (trimmed.length === 0) { + throw new Error("sparse-checkout rule is empty"); + } + if (trimmed === ".") return "."; + const normalized = trimmed.replace(/^\/+/, "").replace(/\/+$/, ""); + assertSafeWorktreePath(normalized); + return normalized; +} + +function pathToSegments(pathValue: string): string[] { + const normalized = pathValue.replaceAll("\\", "/").replace(/^\/+/, ""); + assertSafeWorktreePath(normalized); + return normalized.split("/"); +} + +function matchesCone(pathValue: string, coneRules: string[]): boolean { + const candidate = pathToSegments(pathValue); + for (const rule of coneRules) { + if (rule === ".") return true; + const ruleSegments = rule.split("/"); + if (ruleSegments.length > candidate.length) continue; + let matches = true; + for (let index = 0; index < ruleSegments.length; index += 1) { + if (candidate[index] !== ruleSegments[index]) { + matches = false; + break; + } + } + if (matches) return true; + } + return false; +} + +function escapeRegexToken(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function toPatternRegex(pattern: string): RegExp { + let out = "^"; + for (let index = 0; index < pattern.length; index += 1) { + const ch = pattern[index]; + const next = pattern[index + 1]; + if (ch === "*" && next === "*") { + out += ".*"; + index += 1; + continue; + } + if (ch === "*") { + out += "[^/]*"; + continue; + } + if (ch === "?") { + out += "[^/]"; + continue; + } + out += escapeRegexToken(ch ?? ""); + } + out += "$"; + return new RegExp(out); +} + +function matchesPattern(pathValue: string, rules: string[]): boolean { + for (const rule of rules) { + if (toPatternRegex(rule).test(pathValue)) return true; + } + return false; +} + +export function normalizeSparseRules(rules: string[]): string[] { + const out = new Set(); + for (const rule of rules) out.add(normalizeRule(rule)); + return [...out].sort((a, b) => a.localeCompare(b)); +} + +export function selectSparsePaths( + paths: string[], + mode: SparseCheckoutMode, + rules: string[], +): string[] { + const normalizedRules = normalizeSparseRules(rules); + const selected = new Set(); + for (const candidate of paths) { + const normalizedPath = candidate.replaceAll("\\", "/").replace(/^\/+/, ""); + assertSafeWorktreePath(normalizedPath); + const include = + mode === "cone" + ? matchesCone(normalizedPath, normalizedRules) + : matchesPattern(normalizedPath, normalizedRules); + if (include) selected.add(normalizedPath); + } + return [...selected].sort((a, b) => a.localeCompare(b)); +} diff --git a/src/index.ts b/src/index.ts index da72db0..7a12d3b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,12 @@ import { type GitObjectType, } from "./core/objects/loose.js"; import { packFileNames } from "./core/pack/pack-files.js"; +import { + defaultPartialCloneState, + negotiatePartialCloneFilter, + normalizePartialCloneState, + type PartialCloneState, +} from "./core/partial-clone/promisor.js"; import { abortRebaseState, continueRebaseState, @@ -52,6 +58,11 @@ import { type WalkMode, walkCommits, } from "./core/revision-walk/walk.js"; +import { + normalizeSparseRules, + type SparseCheckoutMode, + selectSparsePaths, +} from "./core/sparse-checkout/rules.js"; import { addStashEntry, dropStashEntry, @@ -346,6 +357,18 @@ export class Repo { return joinFsPath(this.gitDirPath, "worktrees-codex.json"); } + private sparseCheckoutPath(): string { + return joinFsPath(this.gitDirPath, "info", "sparse-checkout"); + } + + private sparseCheckoutStatePath(): string { + return joinFsPath(this.gitDirPath, "info", "sparse-checkout-codex.json"); + } + + private partialCloneStatePath(): string { + return joinFsPath(this.gitDirPath, "partial-clone-codex.json"); + } + private async readRebaseState( fs: NodeFsPromises, ): Promise { @@ -371,6 +394,137 @@ export class Repo { }); } + private async readSparseCheckoutState( + fs: NodeFsPromises, + ): Promise<{ mode: SparseCheckoutMode; rules: string[] } | null> { + const statePath = this.sparseCheckoutStatePath(); + const exists = await pathExists(fs, statePath); + if (!exists) return null; + const text = String( + await fs.readFile(statePath, { + encoding: "utf8", + }), + ); + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new GitError( + "sparse-checkout state malformed", + "OBJECT_FORMAT_ERROR", + { + statePath, + }, + ); + } + + const record = parsed as Record; + const mode = record.mode; + if (mode !== "cone" && mode !== "pattern") { + throw new GitError( + "sparse-checkout mode invalid", + "OBJECT_FORMAT_ERROR", + { + statePath, + }, + ); + } + + const rawRules = record.rules; + if (!Array.isArray(rawRules)) { + throw new GitError( + "sparse-checkout rules invalid", + "OBJECT_FORMAT_ERROR", + { + statePath, + }, + ); + } + const ruleValues = rawRules.filter( + (item): item is string => typeof item === "string", + ); + if (ruleValues.length !== rawRules.length) { + throw new GitError( + "sparse-checkout rules malformed", + "OBJECT_FORMAT_ERROR", + { + statePath, + }, + ); + } + + return { + mode, + rules: normalizeSparseRules(ruleValues), + }; + } + + private async writeSparseCheckoutState( + fs: NodeFsPromises, + mode: SparseCheckoutMode, + rules: string[], + ): Promise { + const normalizedRules = normalizeSparseRules(rules); + const statePath = this.sparseCheckoutStatePath(); + await fs.mkdir(parentFsPath(statePath), { recursive: true }); + await fs.writeFile( + statePath, + JSON.stringify( + { + mode, + rules: normalizedRules, + }, + null, + 2, + ), + { + encoding: "utf8", + }, + ); + await fs.writeFile( + this.sparseCheckoutPath(), + `${normalizedRules.join("\n")}\n`, + { + encoding: "utf8", + }, + ); + } + + private async readPartialCloneState( + fs: NodeFsPromises, + ): Promise { + const statePath = this.partialCloneStatePath(); + const exists = await pathExists(fs, statePath); + if (!exists) { + return { + filterSpec: defaultPartialCloneState.filterSpec, + capabilities: [...defaultPartialCloneState.capabilities], + promisorObjects: { ...defaultPartialCloneState.promisorObjects }, + }; + } + const text = String( + await fs.readFile(statePath, { + encoding: "utf8", + }), + ); + const parsed = JSON.parse(text) as unknown; + const normalized = normalizePartialCloneState(parsed); + if (!normalized) { + throw new GitError("partial clone state malformed", "INTEGRITY_ERROR", { + statePath, + }); + } + return normalized; + } + + private async writePartialCloneState( + fs: NodeFsPromises, + state: PartialCloneState, + ): Promise { + const statePath = this.partialCloneStatePath(); + await fs.writeFile(statePath, JSON.stringify(state, null, 2), { + encoding: "utf8", + }); + } + private async readStashEntries(fs: NodeFsPromises): Promise { const stashPath = this.stashStatePath(); const exists = await pathExists(fs, stashPath); @@ -895,6 +1049,91 @@ export class Repo { await this.writeWorktreeEntries(fs, pruneWorktrees(current)); } + public async setSparseCheckout( + mode: SparseCheckoutMode, + rules: string[], + ): Promise { + if (mode !== "cone" && mode !== "pattern") { + throw new GitError("sparse-checkout mode invalid", "INVALID_ARGUMENT", { + mode, + }); + } + const normalizedRules = normalizeSparseRules(rules); + if (normalizedRules.length === 0) { + throw new GitError("sparse-checkout rules empty", "INVALID_ARGUMENT", { + mode, + }); + } + const fs = await loadNodeFs(); + await this.writeSparseCheckoutState(fs, mode, normalizedRules); + return normalizedRules; + } + + public async sparseCheckoutSelect(paths: string[]): Promise { + const fs = await loadNodeFs(); + const state = await this.readSparseCheckoutState(fs); + if (!state) { + return selectSparsePaths(paths, "cone", ["."]); + } + return selectSparsePaths(paths, state.mode, state.rules); + } + + public async negotiatePartialCloneFilter( + requestedFilter: string, + capabilities: string[], + ): Promise { + const acceptedFilter = negotiatePartialCloneFilter( + requestedFilter, + capabilities, + ); + const fs = await loadNodeFs(); + const state = await this.readPartialCloneState(fs); + state.filterSpec = acceptedFilter; + state.capabilities = capabilities + .map((capability) => capability.trim()) + .filter((capability) => capability.length > 0); + await this.writePartialCloneState(fs, state); + return acceptedFilter; + } + + public async setPromisorObject( + oid: string, + payload: Uint8Array | string, + ): Promise { + if (!isObjectId(oid)) { + throw new GitError("invalid object id", "INVALID_ARGUMENT", { oid }); + } + const fs = await loadNodeFs(); + const state = await this.readPartialCloneState(fs); + state.promisorObjects[oid] = [...toBytes(payload)]; + await this.writePartialCloneState(fs, state); + } + + public async resolvePromisedObject(oid: string): Promise { + if (!isObjectId(oid)) { + throw new GitError("invalid object id", "INVALID_ARGUMENT", { oid }); + } + const fs = await loadNodeFs(); + const state = await this.readPartialCloneState(fs); + const promised = state.promisorObjects[oid]; + if (!promised) { + throw new GitError("promised object missing", "INTEGRITY_ERROR", { oid }); + } + if (!Array.isArray(promised) || promised.length === 0) { + throw new GitError("promised object malformed", "INTEGRITY_ERROR", { + oid, + }); + } + for (const item of promised) { + if (!Number.isInteger(item) || item < 0 || item > 255) { + throw new GitError("promised object malformed", "INTEGRITY_ERROR", { + oid, + }); + } + } + return Uint8Array.from(promised); + } + public revisionWalk(commits: CommitNode[], mode: WalkMode): CommitNode[] { return walkCommits(commits, mode); } diff --git a/tests/node/sparse-partial-clone.test.mjs b/tests/node/sparse-partial-clone.test.mjs new file mode 100644 index 0000000..e500ec5 --- /dev/null +++ b/tests/node/sparse-partial-clone.test.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +function runGitText(args, cwd) { + const res = spawnSync("git", args, { + cwd, + encoding: "utf8", + env: { + ...process.env, + GIT_AUTHOR_NAME: "Repo Bot", + GIT_AUTHOR_EMAIL: "repo@example.local", + GIT_COMMITTER_NAME: "Repo Bot", + GIT_COMMITTER_EMAIL: "repo@example.local", + }, + }); + if (res.status !== 0) { + throw new Error( + `git command failed ${args.join(" ")} ${String(res.stderr || "").trim()}`, + ); + } + return String(res.stdout || "").trim(); +} + +function isIntegrityError(error) { + if (!error || typeof error !== "object") return false; + return Reflect.get(error, "code") === "INTEGRITY_ERROR"; +} + +test("sparse checkout cone and pattern inclusion rules INV-FEAT-0043", async (context) => { + const { Repo } = await import("../../dist/index.js"); + const root = await mkdtemp(path.join(os.tmpdir(), "repo-sparse-")); + context.after(async () => { + await rm(root, { recursive: true, force: true }); + }); + + runGitText(["init", "--quiet"], root); + runGitText(["sparse-checkout", "init", "--cone"], root); + runGitText(["sparse-checkout", "set", "src", "docs"], root); + + const repo = await Repo.open(root); + await repo.setSparseCheckout("cone", ["src", "docs"]); + const coneSelection = await repo.sparseCheckoutSelect([ + "src/index.ts", + "docs/guide.md", + "tests/fixture.txt", + ]); + assert.deepEqual(coneSelection, ["docs/guide.md", "src/index.ts"]); + + await repo.setSparseCheckout("pattern", ["src/*.ts", "docs/**"]); + const patternSelection = await repo.sparseCheckoutSelect([ + "src/index.ts", + "src/core/hash.ts", + "docs/guide.md", + "examples/sample.txt", + ]); + assert.deepEqual(patternSelection, ["docs/guide.md", "src/index.ts"]); +}); + +test("partial clone filter negotiation and promisor semantics INV-FEAT-0044", async (context) => { + const { Repo } = await import("../../dist/index.js"); + const root = await mkdtemp(path.join(os.tmpdir(), "repo-partial-")); + context.after(async () => { + await rm(root, { recursive: true, force: true }); + }); + const repo = await Repo.init(root, { hashAlgorithm: "sha1" }); + + const acceptedFilter = await repo.negotiatePartialCloneFilter("blob:none", [ + "filter", + "side-band-64k", + ]); + assert.equal(acceptedFilter, "blob:none"); + assert.ok("git clone --filter=blob:none".includes("--filter=blob:none")); + + await assert.rejects( + () => repo.negotiatePartialCloneFilter("blob:none", ["side-band-64k"]), + /capability missing/i, + ); + + const oid = "a".repeat(40); + await repo.setPromisorObject(oid, "promised payload"); + const payload = await repo.resolvePromisedObject(oid); + assert.equal(new TextDecoder().decode(payload), "promised payload"); +}); + +test("missing and malformed promised objects raise INTEGRITY_ERROR INV-SECU-0016", async (context) => { + const { Repo } = await import("../../dist/index.js"); + const root = await mkdtemp(path.join(os.tmpdir(), "repo-promisor-secu-")); + context.after(async () => { + await rm(root, { recursive: true, force: true }); + }); + const repo = await Repo.init(root, { hashAlgorithm: "sha1" }); + const oid = "b".repeat(40); + + await assert.rejects(() => repo.resolvePromisedObject(oid), isIntegrityError); + + await writeFile( + path.join(root, ".git", "partial-clone-codex.json"), + JSON.stringify( + { + filterSpec: "blob:none", + capabilities: ["filter"], + promisorObjects: { + [oid]: "malformed", + }, + }, + null, + 2, + ), + "utf8", + ); + + await assert.rejects(() => repo.resolvePromisedObject(oid), isIntegrityError); +});