const std = @import("std");
pub const WorktreePathError = error{
InvalidPath,
ReservedPath,
};
/// Valid relative working-tree paths use `/` separators, contain no empty or
/// dot components, and never claim a reserved root. Import and update share
/// this check so neither can admit a path the other would later reject.
pub fn validateWorktreePath(path: []const u8) WorktreePathError!void {
if (path.len == 0 or std.fs.path.isAbsolute(path)) return error.InvalidPath;
if (std.mem.indexOfScalar(u8, path, '\\') != null) return error.InvalidPath;
var components = std.mem.splitScalar(u8, path, '/');
const root = components.next() orelse return error.InvalidPath;
if (root.len == 0 or std.mem.eql(u8, root, ".") or std.mem.eql(u8, root, "..")) return error.InvalidPath;
if (isReservedRoot(path)) return error.ReservedPath;
while (components.next()) |component| {
if (component.len == 0 or std.mem.eql(u8, component, ".") or std.mem.eql(u8, component, "..")) return error.InvalidPath;
}
}
/// What losing the file costs -- the property that dictates its format, its
/// durability discipline, and who may delete it. The registry below is the
/// single authority on which names zit owns at a checkout root; every other
/// list (extras exclusions, the ignore-glob pins, the case tables) is derived
/// from or tested against it, so a name added here without its glob line or
/// case coverage is a TEST FAILURE, not quiet drift.
pub const Tier = enum {
/// Cannot be rebuilt: losing it loses work (the store, and the checkout
/// container carrying the record, managed set, undo image and bisect
/// session).
authoritative,
/// Discardable by construction, rebuilt on demand; deleting it must never
/// change an answer (repocache, vstat).
advisory,
/// Exists only while an operation is in flight; a crash strands one, and a
/// stranger must be able to identify and sweep it without understanding it.
scratch,
};
pub const Reserved = struct {
name: []const u8,
tier: Tier,
/// A prefix entry reserves every root beginning with `name`, not one name.
prefix: bool = false,
};
/// The whole policy: six names and two prefixes. The legacy tier (absorbed
/// container siblings, the pre-directory cache/vstat spellings, retired
/// scratch spellings, import provenance) was deleted after the consolidation's
/// migration release: a leftover in a never-re-opened old checkout is now
/// visible to `zit extras` and deletable by `zit clean` — the correct end
/// state. A legacy AUTHORITATIVE sibling still migrates losslessly: the
/// container's byte-1 dispatch folds `.zit-managed`/`.zit-undo`/`.zit-bisect`
/// in on first contact (checkout.zig §8); only the reservation is gone.
pub const reserved = [_]Reserved{
.{ .name = ".zit", .tier = .authoritative },
.{ .name = ".zit-checkout", .tier = .authoritative },
.{ .name = ".zit-stash", .tier = .authoritative },
// What an imported destination holds of its Git source, which a later
// `import-git --into` needs; the check-ins carry no Git identity, so
// losing it costs a full re-import.
.{ .name = ".zit-import-map", .tier = .authoritative },
.{ .name = ".zit-cache", .tier = .advisory },
.{ .name = ".zit-precedent", .tier = .advisory },
.{ .name = ".zit-tmp-", .tier = .scratch, .prefix = true },
// The writer lock and its break lock (`storelock`).
.{ .name = ".zit.lock", .tier = .scratch, .prefix = true },
};
/// Root-level checkout metadata names that imported and materialized paths
/// must never own. Inspects only the first slash-delimited path component.
pub fn isReservedRoot(path: []const u8) bool {
const slash = std.mem.indexOfScalar(u8, path, '/') orelse path.len;
const root = path[0..slash];
for (reserved) |entry| {
if (entry.prefix) {
if (asciiStartsWithIgnoreCase(root, entry.name)) return true;
} else if (asciiEqlIgnoreCase(root, entry.name)) return true;
}
return false;
}
fn asciiEqlIgnoreCase(a: []const u8, b: []const u8) bool {
if (a.len != b.len) return false;
for (a, b) |left, right| {
if (std.ascii.toLower(left) != std.ascii.toLower(right)) return false;
}
return true;
}
fn asciiStartsWithIgnoreCase(value: []const u8, prefix: []const u8) bool {
return value.len >= prefix.len and asciiEqlIgnoreCase(value[0..prefix.len], prefix);
}
test "control path policy reserves complete roots case-insensitively" {
// Generated from the registry, so a new entry gets its coverage by
// existing: the bare name, a child path, a case-flipped copy, and the
// near-miss (`name ++ "x"`) -- which for a PREFIX entry must be reserved
// rather than refused, that being what a prefix means.
inline for (reserved) |entry| {
try std.testing.expect(isReservedRoot(entry.name));
try std.testing.expect(isReservedRoot(entry.name ++ "/sub"));
var flipped: [entry.name.len]u8 = undefined;
for (entry.name, 0..) |c, i| {
flipped[i] = if (std.ascii.isLower(c)) std.ascii.toUpper(c) else std.ascii.toLower(c);
}
try std.testing.expect(isReservedRoot(&flipped));
try std.testing.expectEqual(entry.prefix, isReservedRoot(entry.name ++ "x"));
}
// Hand-written rows the generator cannot derive: shapes that LOOK like
// control names and must not be.
const negatives = [_][]const u8{
"",
"zit",
".zit-other/data",
".zit-cacheable/data",
".zit-vstats/x",
".zit-checkout-user/file",
".zit-update-txn",
".zit-update-txns/user",
"refs-old/heads/main",
"sub/.zit/object",
// Deleted legacy tier, pinned unreserved: these are ordinary
// working-tree names now.
".zit-managed",
".zit-undo",
".zit-bisect",
".zit-vstat",
".zit-pack-tmp",
".zit-update-txn-x",
".zit-import-tmp-x",
".zit-import-map.jsonl",
"refs",
"refs/heads/main",
};
for (negatives) |path| try std.testing.expect(!isReservedRoot(path));
}