// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! The index: the file listing what the next commit's tree will hold, //! read whole and made into one list of index entries, its extensions //! read alongside. //! //! `load` reads it from a file, and `parse` from bytes already in hand; //! `Repository.readIndex` finds the file as git does, `GIT_INDEX_FILE` //! included. A split index is made transparent: the shared index is read //! from beside the index file and its entries merged with the main //! file's, so the entries are the list git works with, marked where they //! came from the shared index or replaced its entries. A sparse index's sparse directories are //! entries like any other until `expandSparseDirectories` replaces them //! with the files in their trees, as git does for a command that needs //! every path. //! //! git reads some extensions it cannot make sense of as though they were //! not there, saying so or not, and goes on; those are dropped here too, //! and `warnings` says what git would have said. Anything git refuses the //! index for is an error. //! //! Every allocation the index makes, the file's bytes among them, is in //! one arena, freed by `deinit`. const std = @import("std"); const Allocator = std.mem.Allocator; const Io = std.Io; const Diagnostics = @import("Diagnostics.zig"); const ObjectDatabase = @import("ObjectDatabase.zig"); const object = @import("object.zig"); const Format = object.Format; const Id = object.Id; const tree = @import("tree.zig"); /// One index file read from its bytes. pub const file = @import("index/file.zig"); /// EWAH bitmaps, as the split index, untracked cache and fsmonitor /// extensions hold them. pub const ewah = @import("index/ewah.zig"); /// The extensions, each read from its bytes. pub const extension = struct { /// `TREE`: the cache tree. pub const cache_tree = @import("index/cache_tree.zig"); /// `REUC`: resolve-undo. pub const resolve_undo = @import("index/resolve_undo.zig"); /// `link`: the split index, and merging its two files. pub const split = @import("index/split.zig"); /// `UNTR`: the untracked cache. pub const untracked = @import("index/untracked.zig"); /// `FSMN`: the fsmonitor cache. pub const fsmonitor = @import("index/fsmonitor.zig"); /// `EOIE` and `IEOT`: the end of index entries and the index entry /// offset table. pub const offsets = @import("index/offsets.zig"); }; const cache_tree_ext = extension.cache_tree; const resolve_undo_ext = extension.resolve_undo; const split = extension.split; const untracked = extension.untracked; const fsmonitor = extension.fsmonitor; const offsets = extension.offsets; /// One path in the index. pub const Entry = file.Entry; /// A time as the index records it. pub const Timestamp = file.Timestamp; /// The cache tree. pub const CacheTree = cache_tree_ext.CacheTree; /// Resolve-undo. pub const ResolveUndo = resolve_undo_ext.ResolveUndo; /// The untracked cache. pub const UntrackedCache = untracked.UntrackedCache; /// The fsmonitor cache. pub const Fsmonitor = fsmonitor.Fsmonitor; const Index = @This(); arena: std.heap.ArenaAllocator, /// The object format the index's object IDs are in. format: Format, /// Whether there was an index file; when there was not, the index is /// empty, as git takes it to be. exists: bool = false, /// The index file's version: 2, 3 or 4; 2 when there was no file. version: u32 = 2, /// Every entry, in index order: sorted by path, then by stage, when git /// wrote the file, which `sorted` says. entries: []Entry = &.{}, /// Whether the entries are in index order. git does not check; an index /// that is not is looked up entry by entry rather than by halving. sorted: bool = true, /// The cache tree, if there is one git could read. cache_tree: ?CacheTree = null, /// Resolve-undo, if there is one git could read. resolve_undo: ?ResolveUndo = null, /// The untracked cache, if there is one git could read. untracked_cache: ?UntrackedCache = null, /// The fsmonitor cache, if there is one git could read. Which entries it /// vouches for is also marked on the entries (`fsmonitor_valid`). fsmonitor_cache: ?Fsmonitor = null, /// The end of index entries extension, if there is one and it fits the /// file. end_of_entries: ?offsets.EndOfEntries = null, /// The index entry offset table, if there is one and it fits the file. offset_table: ?offsets.OffsetTable = null, /// Whether the index is sparse: it has the `sdir` extension, which allows /// sparse directories among its entries. sparse: bool = false, /// The split index's link and shared index, when it is one. split_index: ?SplitIndex = null, /// The trailing checksum of the index file; all zeros when /// `index.skipHash` wrote it. checksum: []const u8 = &.{}, /// What git says while reading the index, in the order it says it. warnings: []Warning = &.{}, /// A split index's other half. pub const SplitIndex = struct { link: split.Link, /// The shared index's file name, beside the index file, as git names /// it; null when the link names none. shared_path: ?[]const u8, }; /// Something git says while reading an index, and goes on. pub const Warning = struct { kind: Kind, /// The extension's signature, for `ignored_extension`. signature: [4]u8 = .{ 0, 0, 0, 0 }, /// More, for the kinds that have it: the version for /// `bad_fsmonitor_version`, the bitmap's own complaint (or nothing) /// for `bad_fsmonitor_bitmap`. detail: []const u8 = "", pub const Kind = enum { /// "ignoring XXXX extension": an optional extension git does not /// know. ignored_extension, /// "Index records invalid resolve-undo information". invalid_resolve_undo, /// "corrupt fsmonitor extension (too short)". fsmonitor_too_short, /// "bad fsmonitor version N". bad_fsmonitor_version, /// "failed to parse ewah bitmap reading fsmonitor index /// extension", after "corrupt ewah bitmap: …" when the detail is /// not empty. bad_fsmonitor_bitmap, /// An fsmonitor bitmap describing more entries than the index has, /// which git stops at as a bug in itself. fsmonitor_bitmap_too_long, /// A cache tree git could not read, which it drops without a word. invalid_cache_tree, /// An untracked cache git could not read, dropped without a word. invalid_untracked_cache, /// An end of index entries extension that does not fit the file, /// passed over without a word. invalid_end_of_entries, /// An index entry offset table that does not fit the file, passed /// over without a word. invalid_offset_table, }; /// Whether git says anything of it. pub fn isSaid(w: Warning) bool { return switch (w.kind) { .ignored_extension, .invalid_resolve_undo, .fsmonitor_too_short, .bad_fsmonitor_version, .bad_fsmonitor_bitmap, .fsmonitor_bitmap_too_long => true, .invalid_cache_tree, .invalid_untracked_cache, .invalid_end_of_entries, .invalid_offset_table => false, }; } }; /// Why an index could not be read: the errors of one file /// (`file.ParseError`), of the `link` extension (`split.ParseError`) and /// of merging a split index's two files (`split.MergeError`), and: /// /// - `IndexUnreadable`: git's "index file open failed"; the diagnostic's /// path is the file and its detail why. /// - `SharedIndexUnreadable`: the same, of the shared index. /// - `IndexChecksumMismatch`: asked to check the checksum, it was wrong. /// /// The diagnostic's path names the file the error was found in. pub const LoadError = file.ParseError || split.ParseError || split.MergeError || Io.Cancelable || error{ IndexUnreadable, SharedIndexUnreadable, IndexChecksumMismatch, }; /// How to read an index. pub const Options = struct { /// Check each file's trailing checksum, which git does not do when it /// reads an index. An all-zero checksum, as `index.skipHash` writes, /// passes. verify_checksum: bool = false, /// The git directory, where git looks for a split index's shared index /// before looking beside the index file; null to look only beside it. git_dir: ?[]const u8 = null, }; /// Frees everything. pub fn deinit(ix: *Index) void { ix.arena.deinit(); ix.* = undefined; } /// Reads the index file `path`, relative to `dir`, whose object IDs are /// in `format`. A file that does not exist is an empty index, as git /// takes it to be. A split index's shared index is read from the git /// directory `options` names, or failing that from beside `path`. pub fn load(gpa: Allocator, io: Io, dir: Io.Dir, path: []const u8, format: Format, options: Options, diag: ?*Diagnostics) LoadError!Index { var ix: Index = .{ .arena = .init(gpa), .format = format }; errdefer ix.arena.deinit(); const a = ix.arena.allocator(); const bytes = readFile(a, io, dir, path, diag) catch |err| switch (err) { error.FileNotFound => return ix, error.Unreadable => return error.IndexUnreadable, else => |e| return e, }; errdefer if (diag) |d| if (d.path() == null) d.setPath(&.{path}); const main = try parseFile(a, format, bytes, options, diag); var shared: ?file.File = null; var shared_path: ?[]const u8 = null; if (main.extension("link")) |x| { const link = try split.parse(format, x.data, diag); if (link.hasSharedIndex()) { var hex: [object.max_hex_length]u8 = undefined; const name = try std.fmt.allocPrint(a, "sharedindex.{s}", .{link.shared_id.toHex(&hex)}); const beside = if (std.fs.path.dirname(path)) |d| try std.mem.concat(a, u8, &.{ d, "/", name }) else name; // The git directory's copy first, as git looks; one beside // the index file only when it has none, and that is the one // named when there is neither. var shared_bytes: ?[]u8 = null; if (options.git_dir) |g| { const in_git_dir = try joinNormal(a, g, name); if (readFile(a, io, dir, in_git_dir, diag)) |b| { shared_bytes = b; shared_path = in_git_dir; } else |err| switch (err) { error.FileNotFound => {}, error.Unreadable => return error.SharedIndexUnreadable, else => |e| return e, } } if (shared_bytes == null) { shared_path = beside; shared_bytes = readFile(a, io, dir, beside, diag) catch |err| switch (err) { error.FileNotFound => { if (diag) |d| { d.reset(); d.setPath(&.{beside}); d.setDetail("{s}", .{"No such file or directory"}); } return error.SharedIndexUnreadable; }, error.Unreadable => return error.SharedIndexUnreadable, else => |e| return e, }; } shared = parseFile(a, format, shared_bytes.?, options, diag) catch |err| { if (diag) |d| d.setPath(&.{shared_path.?}); return err; }; } } try ix.assemble(main, shared, shared_path, diag); return ix; } /// `dir` and `name` joined as git joins them: `./` dropped. fn joinNormal(a: Allocator, dir: []const u8, name: []const u8) Allocator.Error![]u8 { var d = dir; while (std.mem.startsWith(u8, d, "./")) d = std.mem.trimStart(u8, d[2..], "/"); if (std.mem.eql(u8, d, ".") or d.len == 0) return a.dupe(u8, name); d = std.mem.trimEnd(u8, d, "/"); if (d.len == 0) return std.mem.concat(a, u8, &.{ "/", name }); return std.mem.concat(a, u8, &.{ d, "/", name }); } /// Reads an index from its bytes, with no I/O: `bytes` the index file and /// `shared` the shared index a split index names, which must be given /// when it names one. Both must outlive the index. pub fn parse(gpa: Allocator, format: Format, bytes: []const u8, shared: ?[]const u8, options: Options, diag: ?*Diagnostics) LoadError!Index { var ix: Index = .{ .arena = .init(gpa), .format = format }; errdefer ix.arena.deinit(); const a = ix.arena.allocator(); const main = try parseFile(a, format, bytes, options, diag); var shared_file: ?file.File = null; if (main.extension("link")) |x| { const link = try split.parse(format, x.data, diag); if (link.hasSharedIndex()) { const s = shared orelse { if (diag) |d| d.setDetail("{s}", .{"No such file or directory"}); return error.SharedIndexUnreadable; }; shared_file = try parseFile(a, format, s, options, diag); } } try ix.assemble(main, shared_file, null, diag); return ix; } fn parseFile(a: Allocator, format: Format, bytes: []const u8, options: Options, diag: ?*Diagnostics) LoadError!file.File { const f = try file.parse(a, format, bytes, diag); if (options.verify_checksum and file.verifyChecksum(format, bytes) == .mismatch) return error.IndexChecksumMismatch; return f; } const ReadFileError = error{ FileNotFound, Unreadable } || Allocator.Error || Io.Cancelable; fn readFile(a: Allocator, io: Io, dir: Io.Dir, path: []const u8, diag: ?*Diagnostics) ReadFileError![]u8 { return dir.readFileAlloc(io, path, a, .unlimited) catch |err| switch (err) { error.FileNotFound => error.FileNotFound, error.OutOfMemory, error.Canceled => |e| e, else => { if (diag) |d| { d.reset(); d.setPath(&.{path}); d.setDetail("{s}", .{reasonFor(err)}); } return error.Unreadable; }, }; } fn reasonFor(err: anyerror) []const u8 { return switch (err) { error.AccessDenied, error.PermissionDenied => "Permission denied", error.IsDir => "Is a directory", error.NotDir => "Not a directory", error.SymLinkLoop => "Too many levels of symbolic links", error.NameTooLong => "File name too long", else => @errorName(err), }; } /// Makes the index from its parsed file and, for a split index, the /// shared index's. fn assemble(ix: *Index, main: file.File, shared: ?file.File, shared_path: ?[]const u8, diag: ?*Diagnostics) LoadError!void { const a = ix.arena.allocator(); const format = ix.format; ix.exists = true; ix.version = main.version; ix.checksum = main.checksum; var warnings: std.ArrayList(Warning) = .empty; for (main.ignored) |sig| try warnings.append(a, .{ .kind = .ignored_extension, .signature = sig }); ix.entries = main.entries; if (main.extension("link")) |x| { const link = split.parse(format, x.data, null) catch unreachable; ix.split_index = .{ .link = link, .shared_path = shared_path }; if (shared) |s| { for (s.ignored) |sig| try warnings.append(a, .{ .kind = .ignored_extension, .signature = sig }); ix.entries = try split.merge(a, s.entries, main.entries, link, diag); } else { for (ix.entries, 0..) |e, i| { if (e.path.len == 0) { if (diag) |d| { d.offset = main.entry_offsets[i]; d.setDetail("entry {d} has an empty path", .{i}); } return error.MalformedIndex; } } } } else { for (ix.entries, 0..) |e, i| { if (e.path.len == 0) { if (diag) |d| { d.offset = main.entry_offsets[i]; d.setDetail("entry {d} has an empty path", .{i}); } return error.MalformedIndex; } } } for (ix.entries[0..ix.entries.len -| 1], 0..) |e, i| { if (!Entry.lessThan({}, e, ix.entries[i + 1])) { ix.sorted = false; break; } } for (main.extensions) |x| { const sig = &x.signature; if (std.mem.eql(u8, sig, "TREE")) { ix.cache_tree = cache_tree_ext.parse(a, format, x.data) catch |err| switch (err) { error.OutOfMemory => |e| return e, error.MalformedCacheTree => blk: { try warnings.append(a, .{ .kind = .invalid_cache_tree }); break :blk null; }, }; } else if (std.mem.eql(u8, sig, "REUC")) { ix.resolve_undo = resolve_undo_ext.parse(a, format, x.data) catch |err| switch (err) { error.OutOfMemory => |e| return e, error.InvalidResolveUndo => blk: { try warnings.append(a, .{ .kind = .invalid_resolve_undo }); break :blk null; }, }; } else if (std.mem.eql(u8, sig, "UNTR")) { ix.untracked_cache = untracked.parse(a, format, x.data) catch |err| switch (err) { error.OutOfMemory => |e| return e, error.MalformedUntrackedCache => blk: { try warnings.append(a, .{ .kind = .invalid_untracked_cache }); break :blk null; }, }; } else if (std.mem.eql(u8, sig, "FSMN")) { var d: Diagnostics = .{}; if (fsmonitor.parse(x.data, &d)) |f| { if (f.dirty.bit_size > ix.entries.len) { try warnings.append(a, .{ .kind = .fsmonitor_bitmap_too_long, .detail = try std.fmt.allocPrint(a, "{d} > {d}", .{ f.dirty.bit_size, ix.entries.len }) }); } else { ix.fsmonitor_cache = f; } } else |err| try warnings.append(a, .{ .kind = switch (err) { error.FsmonitorTooShort => .fsmonitor_too_short, error.BadFsmonitorVersion => .bad_fsmonitor_version, error.BadFsmonitorBitmap => .bad_fsmonitor_bitmap, }, .detail = try a.dupe(u8, d.detail()), }); } else if (std.mem.eql(u8, sig, "EOIE")) { const ok = blk: { const e = offsets.parseEndOfEntries(format, x.data) catch break :blk null; offsets.checkEndOfEntries(format, &main, x, e) catch break :blk null; break :blk e; }; if (ok) |e| ix.end_of_entries = e else try warnings.append(a, .{ .kind = .invalid_end_of_entries }); } else if (std.mem.eql(u8, sig, "IEOT")) { const ok = blk: { const t = offsets.parseOffsetTable(x.data) catch break :blk null; offsets.checkOffsetTable(&main, t) catch break :blk null; break :blk t; }; if (ok) |t| ix.offset_table = t else try warnings.append(a, .{ .kind = .invalid_offset_table }); } else if (std.mem.eql(u8, sig, "sdir")) { ix.sparse = true; } } if (ix.fsmonitor_cache) |f| { for (ix.entries) |*e| e.fsmonitor_valid = true; var it = f.dirty.iterator(); while (it.next()) |p| { if (p >= ix.entries.len) break; ix.entries[@intCast(p)].fsmonitor_valid = false; } } ix.warnings = try warnings.toOwnedSlice(a); } /// The position of the entry for `path` at `stage`, if there is one. pub fn find(ix: *const Index, path: []const u8, stage: u2) ?usize { if (!ix.sorted) { for (ix.entries, 0..) |e, i| { if (e.stage() == stage and std.mem.eql(u8, e.path, path)) return i; } return null; } const Key = struct { path: []const u8, stage: u2 }; return std.sort.binarySearch(Entry, ix.entries, Key{ .path = path, .stage = stage }, struct { fn order(k: Key, e: Entry) std.math.Order { const o = std.mem.order(u8, k.path, e.path); if (o != .eq) return o; return std.math.order(k.stage, e.stage()); } }.order); } /// The entry for `path` at `stage`, if there is one. pub fn get(ix: *const Index, path: []const u8, stage: u2) ?*const Entry { const i = ix.find(path, stage) orelse return null; return &ix.entries[i]; } /// The entries for `path` at every stage, in stage order: one for a path /// that is not conflicted, up to three for one that is, none for a path /// not in the index. pub fn stages(ix: *const Index, path: []const u8) []const Entry { if (!ix.sorted) { for (ix.entries, 0..) |e, i| { if (!std.mem.eql(u8, e.path, path)) continue; var j = i; while (j < ix.entries.len and std.mem.eql(u8, ix.entries[j].path, path)) j += 1; return ix.entries[i..j]; } return &.{}; } const lo = std.sort.lowerBound(Entry, ix.entries, path, orderPath); const hi = std.sort.upperBound(Entry, ix.entries, path, orderPath); return ix.entries[lo..hi]; } fn orderPath(path: []const u8, e: Entry) std.math.Order { return std.mem.order(u8, path, e.path); } /// Steps through the entries in index order. pub fn iterator(ix: *const Index) Iterator { return .{ .entries = ix.entries }; } /// The entries one at a time, in index order. pub const Iterator = struct { entries: []const Entry, index: usize = 0, /// The next entry, or null after the last. pub fn next(it: *Iterator) ?*const Entry { if (it.index == it.entries.len) return null; it.index += 1; return &it.entries[it.index - 1]; } }; /// Whether any entry is a sparse directory. pub fn hasSparseDirectories(ix: *const Index) bool { for (ix.entries) |*e| if (e.isSparseDirectory()) return true; return false; } /// Why sparse directories could not be expanded: a tree that could not /// be read (`ObjectDatabase.ReadError`), an object that is not a tree /// (`NotATree`, the diagnostic's object the one it is), or one that is /// malformed (`tree.Iterator.Error`). pub const ExpandError = ObjectDatabase.ReadError || tree.Iterator.Error || error{NotATree}; /// Replaces each sparse directory with the files in its tree, as git does /// for a command that needs every path: each a skip-worktree entry with /// no stat data, in index order where the directory was. The index is no /// longer sparse afterwards. pub fn expandSparseDirectories(ix: *Index, io: Io, db: ObjectDatabase, diag: ?*Diagnostics) ExpandError!void { const a = ix.arena.allocator(); var out: std.ArrayList(Entry) = .empty; for (ix.entries) |e| { if (!e.isSparseDirectory()) { try out.append(a, e); continue; } const start = out.items.len; try ix.expandTree(io, db, e.id, e.path, &out, diag); std.mem.sort(Entry, out.items[start..], {}, Entry.lessThan); } ix.entries = try out.toOwnedSlice(a); ix.sparse = false; } fn expandTree(ix: *Index, io: Io, db: ObjectDatabase, id: Id, prefix: []const u8, out: *std.ArrayList(Entry), diag: ?*Diagnostics) ExpandError!void { const a = ix.arena.allocator(); // Directories still to read, as a stack, so that a deep tree cannot // exhaust the call stack. const Pending = struct { id: Id, prefix: []const u8 }; var stack: std.ArrayList(Pending) = .empty; try stack.append(a, .{ .id = id, .prefix = prefix }); const gpa = ix.arena.child_allocator; while (stack.pop()) |dir| { var obj = try db.read(io, gpa, dir.id, diag); defer obj.deinit(gpa); if (obj.kind != .tree) { if (diag) |d| d.object_id = dir.id; return error.NotATree; } var it = tree.iterate(ix.format, obj.data); while (try it.next()) |te| { const mode = te.mode(); if (mode == .directory) { try stack.append(a, .{ .id = te.id, .prefix = try std.mem.concat(a, u8, &.{ dir.prefix, te.name, "/" }) }); continue; } try out.append(a, .{ .ctime = .{ .seconds = 0, .nanoseconds = 0 }, .mtime = .{ .seconds = 0, .nanoseconds = 0 }, .dev = 0, .ino = 0, .mode = @backingInt(mode), .uid = 0, .gid = 0, .size = 0, .id = te.id, .flags = Entry.extended_flag, .extended_flags = Entry.skip_worktree_flag, .path = try std.mem.concat(a, u8, &.{ dir.prefix, te.name }), }); } } } /// Why an index could not be written: not yet, by anything. pub const WriteError = error{IndexWritingUnsupported}; /// Where writing an index will go: this library reads indexes and does /// not yet write them, so this refuses by name rather than doing /// something nearly right. Writing will take the entries as they are /// (sorted, a split index's merged), the version to write, and the /// extensions to keep. pub fn write(ix: *const Index, io: Io, dir: Io.Dir, path: []const u8) WriteError!void { _ = ix; _ = io; _ = dir; _ = path; return error.IndexWritingUnsupported; } // -- tests ----------------------------------------------------------------------- const testing = std.testing; test { _ = file; _ = ewah; _ = cache_tree_ext; _ = resolve_undo_ext; _ = split; _ = untracked; _ = fsmonitor; _ = offsets; } const fixture = @import("testing/fixture.zig"); const Repository = @import("Repository.zig"); const discover = @import("discover.zig"); fn openRepo(repo: *fixture.Repo) !Repository { return Repository.discoverAndOpen(testing.allocator, testing.io, .{ .start = repo.path, .ownership = if (discover.effectiveUid()) |uid| .{ .uid = uid } else null, }, .{ .environment = &repo.env }, null); } fn gitLine(repo: *fixture.Repo, args: []const []const u8) ![]u8 { const out = try repo.output(args); defer testing.allocator.free(out); return testing.allocator.dupe(u8, std.mem.trimEnd(u8, out, "\n")); } fn expectId(repo: *fixture.Repo, rev: []const u8, id: ?Id) !void { const hex = try gitLine(repo, &.{ "rev-parse", rev }); defer testing.allocator.free(hex); var buffer: [object.max_hex_length]u8 = undefined; try testing.expectEqualStrings(hex, id.?.toHex(&buffer)); } const fuzz_input = @import("testing/fuzz.zig"); test "the cache tree holds the trees git wrote, and is invalidated as git invalidates it" { var repo = try fixture.Repo.init(testing.allocator, testing.io); defer repo.deinit(); try repo.tmp.dir.createDirPath(testing.io, "src/lib"); try repo.tmp.dir.writeFile(testing.io, .{ .sub_path = "src/lib/a", .data = "a\n" }); try repo.tmp.dir.writeFile(testing.io, .{ .sub_path = "src/b", .data = "b\n" }); try repo.tmp.dir.writeFile(testing.io, .{ .sub_path = "top", .data = "top\n" }); try repo.git(&.{ "add", "." }); try repo.commit("first"); var r = try openRepo(&repo); defer r.close(testing.io); { var ix = try r.readIndex(testing.io, .{}, null); defer ix.deinit(); const t = ix.cache_tree.?; try expectId(&repo, "HEAD^{tree}", t.root().?.id); try testing.expectEqual(3, t.root().?.entry_count); try expectId(&repo, "HEAD:src", t.find("src").?.id); try expectId(&repo, "HEAD:src/lib", t.find("src/lib").?.id); try testing.expectEqual(1, t.find("src/lib").?.entry_count); } try repo.tmp.dir.writeFile(testing.io, .{ .sub_path = "src/lib/a", .data = "changed\n" }); try repo.git(&.{ "add", "src/lib/a" }); var ix = try r.readIndex(testing.io, .{}, null); defer ix.deinit(); const t = ix.cache_tree.?; try testing.expectEqual(null, t.root().?.entry_count); try testing.expectEqual(null, t.find("src").?.entry_count); try testing.expectEqual(null, t.find("src/lib").?.entry_count); } test "the untracked cache holds what git status found untracked" { var repo = try fixture.Repo.init(testing.allocator, testing.io); defer repo.deinit(); try repo.tmp.dir.createDirPath(testing.io, "d/e/f"); for ([_][]const u8{ "a", "d/b", "u", "d/v", "d/e/f/w" }, 0..) |p, i| { try repo.tmp.dir.writeFile(testing.io, .{ .sub_path = p, .data = p }); if (i == 1) { try repo.git(&.{ "add", "." }); try repo.commit("first"); } } try repo.git(&.{ "update-index", "--untracked-cache" }); try repo.git(&.{ "status", "--short" }); const status = try repo.output(&.{ "status", "--porcelain" }); defer testing.allocator.free(status); var r = try openRepo(&repo); defer r.close(testing.io); var ix = try r.readIndex(testing.io, .{}, null); defer ix.deinit(); const c = ix.untracked_cache.?; // Every name git status calls untracked, `?? `, is one the cache // holds in its directory. var found: usize = 0; var lines = std.mem.tokenizeScalar(u8, status, '\n'); while (lines.next()) |line| { try testing.expect(std.mem.startsWith(u8, line, "?? ")); const path = line[3..]; const trimmed = std.mem.trimEnd(u8, path, "/"); const slash = if (std.mem.findScalarLast(u8, trimmed, '/')) |s| s + 1 else 0; const dir = path[0..slash]; const name = path[slash..]; for (c.directories) |d| { if (!std.mem.eql(u8, d.path, dir)) continue; for (d.untracked) |u| { if (std.mem.eql(u8, u, name)) found += 1; } } } try testing.expectEqual(std.mem.count(u8, status, "\n"), found); } test "entries are found by path and stage" { var repo = try fixture.Repo.init(testing.allocator, testing.io); defer repo.deinit(); try repo.commitFile("f", "base\n", "base"); try repo.git(&.{ "checkout", "-q", "-b", "side" }); try repo.commitFile("f", "side\n", "side"); try repo.git(&.{ "checkout", "-q", "main" }); try repo.commitFile("f", "main\n", "main"); try repo.commitFile("g", "g\n", "g"); if (repo.output(&.{ "merge", "-q", "side" })) |out| { testing.allocator.free(out); return error.MergeDidNotConflict; } else |_| {} var r = try openRepo(&repo); defer r.close(testing.io); var ix = try r.readIndex(testing.io, .{ .read = .{ .verify_checksum = true } }, null); defer ix.deinit(); try testing.expectEqual(3, ix.stages("f").len); try testing.expectEqual(1, ix.stages("g").len); try testing.expectEqual(0, ix.stages("nothing").len); try testing.expect(ix.get("f", 0) == null); try expectId(&repo, ":2:f", ix.get("f", 2).?.id); try expectId(&repo, ":3:f", ix.get("f", 3).?.id); try expectId(&repo, ":0:g", ix.get("g", 0).?.id); var it = ix.iterator(); var n: usize = 0; while (it.next()) |_| n += 1; try testing.expectEqual(4, n); try testing.expectError(error.IndexWritingUnsupported, ix.write(testing.io, r.work_dir, "index")); } test "fuzz: whole indexes, extensions and all" { try fuzz_input.fuzz({}, struct { fn one(_: void, smith: *testing.Smith) anyerror!void { var buffer: [4096]u8 = undefined; const input = @import("testing/fuzz.zig").bytes(smith, &buffer); for ([_]Format{ .sha1, .sha256 }) |format| { // The same bytes as their own shared index, if they name // one: enough to reach the merge. var ix = parse(testing.allocator, format, input, input, .{}, null) catch continue; defer ix.deinit(); for (ix.entries) |e| _ = ix.find(e.path, e.stage()); } } }.one, .{ .corpus = &.{ @embedFile("fuzz/index-v2"), @embedFile("fuzz/index-v4"), @embedFile("fuzz/index-conflict"), @embedFile("fuzz/index-split"), @embedFile("fuzz/index-sparse"), @embedFile("fuzz/index-eoie"), @embedFile("fuzz/index-eoie-v4"), @embedFile("fuzz/index-untracked-file"), @embedFile("fuzz/index-fsmonitor-file"), @embedFile("fuzz/index-reuc-file"), @embedFile("fuzz/index-sha256"), } }); }