Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
26 changes: 26 additions & 0 deletions scripts/check
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
2 changes: 1 addition & 1 deletion spec/state.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"schema_version": 1,
"current_phase": 14,
"current_phase": 15,
"total_phases": 18
}
94 changes: 94 additions & 0 deletions src/core/partial-clone/promisor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
export interface PartialCloneState {
filterSpec: string | null;
capabilities: string[];
promisorObjects: Record<string, number[]>;
}

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<string, unknown>;
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<string, number[]> = {};
for (const [oid, payload] of Object.entries(rawPromisor)) {
if (!isValidPromisedPayload(payload)) {
return null;
}
promisorObjects[oid] = [...payload];
}
return {
filterSpec,
capabilities,
promisorObjects,
};
}
98 changes: 98 additions & 0 deletions src/core/sparse-checkout/rules.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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<string>();
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));
}
Loading