// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! `zig-git ls-files`: the index, listed as `git ls-files` lists it. //! //! zig-git ls-files [-c | --cached] [-s | --stage] [-u | --unmerged] //! [--resolve-undo] [-z] [-t] [-v] [-f] [--debug] //! [--deduplicate] [--sparse] [--full-name] //! [--abbrev[=] | --no-abbrev] //! [--format=] [--] [...] //! //! Everything it shows comes from the index alone, and the objects for //! `%(objectsize)`: which entries, their modes, object IDs and stages, //! their stat data under `--debug`, the status tags `-t` gives tracked //! files (`H`, `S`, `M`, and `U` for resolve-undo), and the `--format` //! atoms `objectmode`, `objecttype`, `objectname`, `objectsize` (and //! `:padded`), `stage` and `path`. Paths are shown relative to the //! directory it is run from, quoted as `core.quotePath` says. A sparse //! index is expanded through its trees, with git's hint, unless //! `--sparse` asks for its sparse directories and the configuration //! allows them. //! //! Path arguments are a pathspec, with its magic and wildcards, read as //! git reads one; the `attr` magic is refused by name, as are //! the options that need the working tree (`--deleted`, `--modified`, //! `--others`, `--ignored`, `--killed`, `--directory`, the exclude //! options, `--eol`), `--error-unmatch`, `--with-tree`, //! `--recurse-submodules`, and the `eolinfo` and `eolattr` atoms. //! `--abbrev` shortens the object IDs of `-s`, `--resolve-undo` and //! `%(objectname)` as git does: to `core.abbrev`'s length without a //! number, to whole IDs with 0, and never under four digits; `core.abbrev` //! alone shortens nothing. With `core.fsmonitor` //! set, git asks the monitor which entries are unchanged before it //! answers `--debug` and `-f`; this cannot, so it refuses them then. const std = @import("std"); const Allocator = std.mem.Allocator; const Io = std.Io; const git = @import("git"); const Index = git.Index; const Entry = Index.Entry; const Diagnostics = git.Diagnostics; const tool = @import("zig_git.zig"); const object_names = @import("object_names.zig"); const discovery = @import("discovery.zig"); const pathspecs = @import("pathspecs.zig"); const Context = tool.Context; fn refuse(stderr: *Io.Writer, comptime fmt: []const u8, args: anytype) error{Fatal} { stderr.print("zig-git: ls-files: " ++ fmt ++ " is not supported\n", args) catch {}; return error.Fatal; } const Options = struct { cached: bool = false, stage: bool = false, unmerged: bool = false, resolve_undo: bool = false, nul: bool = false, /// `-t`. tags: bool = false, /// `-v`: tags, lower-case for assume-valid entries. valid_bit: bool = false, /// `-f`: tags, lower-case for entries fsmonitor vouches for. fsmonitor_bit: bool = false, debug: bool = false, deduplicate: bool = false, sparse: bool = false, full_name: bool = false, format: ?[]const u8 = null, abbrev: Abbrev = .none, paths: std.ArrayList([]const u8) = .empty, fn showTags(o: *const Options) bool { return o.format == null and (o.tags or o.valid_bit or o.fsmonitor_bit); } fn showStage(o: *const Options) bool { return o.stage or o.unmerged; } }; /// What `--abbrev` asked for. const Abbrev = union(enum) { /// Whole object IDs. none, /// `core.abbrev`'s length. default, /// At least this many digits, or whole if null. digits: ?usize, }; /// The options that need the working tree, or something else this does /// not have, refused by name. const refused_options = [_][]const u8{ "-d", "--deleted", "-m", "--modified", "-o", "--others", "-i", "--ignored", "-k", "--killed", "--directory", "--no-empty-directory", "--eol", "--exclude-standard", "--error-unmatch", "--recurse-submodules", "-x", "-X", "--exclude", "--exclude-from", "--with-tree", "--exclude-per-directory", }; fn parseOptions(gpa: Allocator, args: []const []const u8, o: *Options, stderr: *Io.Writer) !void { var only_paths = false; var i: usize = 0; while (i < args.len) : (i += 1) { const arg = args[i]; if (only_paths or arg.len < 2 or arg[0] != '-') { try o.paths.append(gpa, arg); continue; } if (std.mem.eql(u8, arg, "--")) { only_paths = true; continue; } if (std.mem.startsWith(u8, arg, "--")) { const eq = std.mem.findScalar(u8, arg, '='); const name = arg[0 .. eq orelse arg.len]; if (std.mem.eql(u8, name, "--format")) { if (eq) |e| { o.format = arg[e + 1 ..]; } else if (i + 1 < args.len) { i += 1; o.format = args[i]; } else { stderr.writeAll("error: option `format' requires a value\n") catch {}; return error.Usage; } continue; } if (std.mem.eql(u8, name, "--abbrev")) { if (eq) |e| { const n = object_names.optionNumber(arg[e + 1 ..]) orelse { stderr.writeAll("error: option `abbrev' expects a numerical value\n") catch {}; return error.Usage; }; o.abbrev = .{ .digits = if (n == 0) null else @intCast(@min(@max(n, git.abbrev.min_length), git.object_max_hex_length)) }; } else o.abbrev = .default; continue; } for (refused_options) |r| if (std.mem.eql(u8, name, r)) return refuse(stderr, "{s}", .{r}); if (eq != null) return error.Usage; const Long = enum { @"--cached", @"--stage", @"--unmerged", @"--resolve-undo", @"--debug", @"--deduplicate", @"--sparse", @"--full-name", @"--no-abbrev" }; const long = std.meta.stringToEnum(Long, name) orelse return error.Usage; switch (long) { .@"--cached" => o.cached = true, .@"--stage" => o.stage = true, .@"--unmerged" => o.unmerged = true, .@"--resolve-undo" => o.resolve_undo = true, .@"--debug" => o.debug = true, .@"--deduplicate" => o.deduplicate = true, .@"--sparse" => o.sparse = true, .@"--full-name" => o.full_name = true, .@"--no-abbrev" => o.abbrev = .none, } continue; } // Short options, which may be run together. for (arg[1..]) |c| switch (c) { 'c' => o.cached = true, 's' => o.stage = true, 'u' => o.unmerged = true, 'z' => o.nul = true, 't' => o.tags = true, 'v' => o.valid_bit = true, 'f' => o.fsmonitor_bit = true, 'd', 'm', 'o', 'i', 'k', 'x', 'X' => return refuse(stderr, "-{c}", .{c}), else => return error.Usage, }; } } /// Runs `ls-files` with `args`. pub fn run(ctx: Context, args: []const []const u8, stdout: *Io.Writer, stderr: *Io.Writer) !void { const gpa = ctx.gpa; var o: Options = .{}; defer o.paths.deinit(gpa); try parseOptions(gpa, args, &o, stderr); if (o.format != null and (o.stage or o.tags or o.resolve_undo or o.deduplicate)) { stderr.writeAll("fatal: --format cannot be used with -s, -o, -k, -t, --resolve-undo, --deduplicate, --eol\n\n") catch {}; return error.Usage; } var repo = try discovery.open(ctx, stderr); defer repo.close(ctx.io); const quote_path = try boolSetting(&repo, "core.quotePath", stderr) orelse true; const fsmonitor = fsmonitorEnabled(&repo); if (fsmonitor and o.debug) return refuse(stderr, "--debug with core.fsmonitor set", .{}); if (fsmonitor and o.fsmonitor_bit) return refuse(stderr, "-f with core.fsmonitor set", .{}); const prefix = repo.location.prefix orelse ""; // git reads the pathspec settings only when there is a pathspec. var specs: git.Pathspec = if (o.paths.items.len == 0) .empty else try pathspecs.parse(ctx, &repo, o.paths.items, "ls-files", stderr); defer if (o.paths.items.len != 0) specs.deinit(gpa); var sparse_allowed = false; var diag: Diagnostics = .{}; sparse_allowed = repo.sparseIndexAllowed(&diag) catch return badBool(stderr, &diag); if (sparse_allowed and (try boolSetting(&repo, "core.splitIndex", stderr) orelse false)) return tool.fatalMessage(stderr, "cannot use split index with a sparse index", .{}); diag = .{}; var index = repo.readIndex(ctx.io, .{ .index_file = ctx.env.get("GIT_INDEX_FILE") }, &diag) catch |err| return describe(stderr, err, &diag); defer index.deinit(); for (index.warnings) |w| try writeWarning(stderr, w); if (index.sparse and !(o.sparse and sparse_allowed)) { if (try boolSetting(&repo, "advice.sparseIndexExpanded", stderr) orelse true) try stderr.writeAll(sparse_hint); diag = .{}; index.expandSparseDirectories(ctx.io, repo.objects(), &diag) catch |err| { stderr.print("fatal: zig-git: cannot expand a sparse directory: {f}", .{tool.errorWords(err)}) catch {}; if (diag.object_id) |id| stderr.print(" ({f})", .{id}) catch {}; stderr.writeAll("\n") catch {}; return error.Fatal; }; } var names: object_names.Names = .init(ctx, &repo, try object_names.settings(&repo.config, stderr)); defer names.deinit(); const id_length: ?usize = switch (o.abbrev) { .none => null, .default => names.defaultLength(), .digits => |d| if (d) |n| (if (n >= repo.objectFormat().hexLength()) null else n) else null, }; const out: Output = .{ .ctx = ctx, .names = &names, .id_length = id_length, .repo = &repo, .o = &o, .prefix = prefix, .quote = .{ .quote_path = quote_path }, .stdout = stdout, .stderr = stderr, .fsmonitor = fsmonitor, }; const show_cached = o.cached or o.stage or !(o.unmerged or o.resolve_undo); if (o.unmerged or show_cached) { var previous: ?[]const u8 = null; for (index.entries) |*e| { if (o.unmerged and e.stage() == 0) continue; if (!matches(&specs, prefix, e.path, e.mode & 0o170000 == 0o160000)) continue; if (o.deduplicate and !o.showStage() and !out.o.showTags()) { if (previous) |p| if (std.mem.eql(u8, p, e.path)) continue; previous = e.path; } try out.entry(e); } } if (o.resolve_undo) if (index.resolve_undo) |ru| { for (ru.records) |r| { if (!matches(&specs, prefix, r.path, false)) continue; for (r.modes, r.ids, 1..) |mode, id, stage| { if (mode == 0) continue; if (o.showTags()) try stdout.writeAll("U "); try stdout.print("{o:0>6} ", .{mode}); try out.names.write(stdout, id.?, out.id_length); try stdout.print(" {d}\t", .{stage}); try out.path(stdout, r.path); try stdout.writeByte(out.terminator()); } } }; } const sparse_hint = \\hint: The sparse index is expanding to a full index, a slow operation. \\hint: Your working directory likely has contents that are outside of \\hint: your sparse-checkout patterns. Use 'git sparse-checkout list' to \\hint: see your sparse-checkout definition and compare it to your working \\hint: directory contents. Cleaning up any merge conflicts or staged \\hint: changes before running 'git sparse-checkout clean' or 'git \\hint: sparse-checkout reapply' may assist in this cleanup. \\hint: Disable this message with "git config set advice.sparseIndexExpanded false" \\ ; fn badBool(stderr: *Io.Writer, diag: *const Diagnostics) error{Fatal} { const d = diag.detail(); const eq = std.mem.findScalar(u8, d, '=') orelse d.len; return tool.fatalMessage(stderr, "bad boolean config value '{s}' for '{s}'", .{ d[@min(eq + 1, d.len)..], d[0..eq] }); } pub fn boolSetting(repo: *const git.Repository, key: []const u8, stderr: *Io.Writer) !?bool { var diag: Diagnostics = .{}; return repo.boolSetting(key, &diag) catch return badBool(stderr, &diag); } /// Whether `core.fsmonitor` names a monitor: true, or a hook's path. fn fsmonitorEnabled(repo: *const git.Repository) bool { const e = (repo.config.get("core.fsmonitor") catch null) orelse return false; const v = e.value orelse return true; if (git.Config.value.parseBool(v)) |b| return b; return v.len != 0; } /// Says what git says on reading the index fails, and fails. pub fn describe(stderr: *Io.Writer, err: anyerror, diag: *const Diagnostics) anyerror { const path = diag.path() orelse ""; const detail = diag.detail(); const corrupt = "fatal: index file corrupt\n"; switch (err) { error.IndexTooSmall => try stderr.print("fatal: {s}: index file smaller than expected\n", .{path}), error.BadIndexSignature => try stderr.print("error: bad signature {s}\n" ++ corrupt, .{detail}), error.UnsupportedIndexVersion => try stderr.print("error: bad index version {s}\n" ++ corrupt, .{detail}), error.UnknownRequiredExtension => try stderr.print("error: index uses {s} extension, which we do not understand\n" ++ corrupt, .{detail}), error.UnknownEntryFormat => try stderr.print("fatal: unknown index entry format {s}\n", .{detail}), error.MalformedEntryName => try stderr.print("fatal: malformed name field in the index, near path '{s}'\n", .{detail}), error.LinkTooShort => try stderr.writeAll("error: corrupt link extension (too short)\n" ++ corrupt), error.CorruptDeleteBitmap => try stderr.print("error: corrupt ewah bitmap: {s}\nerror: corrupt delete bitmap in link extension\n" ++ corrupt, .{detail}), error.CorruptReplaceBitmap => try stderr.print("error: corrupt ewah bitmap: {s}\nerror: corrupt replace bitmap in link extension\n" ++ corrupt, .{detail}), error.LinkGarbage => try stderr.writeAll("error: garbage at the end of link extension\n" ++ corrupt), error.DeletePositionOutOfRange, error.ReplacePositionOutOfRange, error.TooManyReplacements, error.EmptySplitEntryName, => try stderr.print("fatal: {s}\n", .{detail}), error.IndexUnreadable, error.SharedIndexUnreadable => try stderr.print("fatal: {s}: index file open failed: {s}\n", .{ path, detail }), error.MalformedIndex => try stderr.print("fatal: zig-git: corrupt index {s} at offset {d}: {s}\n", .{ path, diag.offset orelse 0, detail }), else => return err, } return error.Fatal; } pub fn writeWarning(stderr: *Io.Writer, w: Index.Warning) !void { switch (w.kind) { .ignored_extension => try stderr.print("ignoring {s} extension\n", .{&w.signature}), .invalid_resolve_undo => try stderr.writeAll("error: Index records invalid resolve-undo information\n"), .fsmonitor_too_short => try stderr.writeAll("error: corrupt fsmonitor extension (too short)\n"), .bad_fsmonitor_version => try stderr.print("error: bad fsmonitor version {s}\n", .{w.detail}), .bad_fsmonitor_bitmap => { if (w.detail.len != 0) try stderr.print("error: corrupt ewah bitmap: {s}\n", .{w.detail}); try stderr.writeAll("error: failed to parse ewah bitmap reading fsmonitor index extension\n"); }, .fsmonitor_bitmap_too_long => try stderr.print("error: zig-git: the fsmonitor bitmap has more entries than the index ({s})\n", .{w.detail}), .invalid_cache_tree, .invalid_untracked_cache, .invalid_end_of_entries, .invalid_offset_table => {}, } } /// Whether `path` is shown: matched by the pathspec, and under the prefix /// when the pathspec has nothing but exclusions or is empty, as git keeps /// to the current directory then. fn matches(specs: *const git.Pathspec, prefix: []const u8, path: []const u8, gitlink: bool) bool { if (!specs.matches(path, gitlink)) return false; for (specs.items) |it| if (!it.magic.exclude) return true; return std.mem.startsWith(u8, path, prefix); } const Output = struct { ctx: Context, repo: *git.Repository, o: *const Options, prefix: []const u8, quote: git.quote.Options, stdout: *Io.Writer, stderr: *Io.Writer, fsmonitor: bool, names: *object_names.Names, /// How many digits object IDs are shortened to, or null for whole. id_length: ?usize, fn terminator(out: *const Output) u8 { return if (out.o.nul) 0 else '\n'; } /// Writes `p` as it is shown: from the directory run in, unless /// `--full-name`, and quoted unless `-z`. fn path(out: *const Output, w: *Io.Writer, p: []const u8) !void { var list: std.ArrayList(u8) = .empty; defer list.deinit(out.ctx.gpa); const shown = if (out.o.full_name) p else blk: { try relative(out.ctx.gpa, &list, p, out.prefix); break :blk list.items; }; if (out.o.nul) return w.writeAll(shown); return git.quote.writePathWith(w, shown, out.quote); } fn tag(out: *const Output, e: *const Entry) u8 { const t: u8 = if (e.stage() != 0) 'M' else if (e.skipWorktree()) 'S' else 'H'; const lower = (out.o.valid_bit and e.assumeValid()) or (out.o.fsmonitor_bit and out.fsmonitor and e.fsmonitor_valid); return if (lower) std.ascii.toLower(t) else t; } fn entry(out: *const Output, e: *const Entry) !void { const w = out.stdout; if (out.o.format) |f| { var line: Io.Writer.Allocating = .init(out.ctx.gpa); defer line.deinit(); try out.expand(&line.writer, f, e); try w.writeAll(line.written()); } else { if (out.o.showTags()) try w.print("{c} ", .{out.tag(e)}); if (out.o.showStage()) { try w.print("{o:0>6} ", .{e.mode}); try out.names.write(w, e.id, out.id_length); try w.print(" {d}\t", .{e.stage()}); } try out.path(w, e.path); } try w.writeByte(out.terminator()); if (out.o.debug) { var flags: u32 = (e.flags & ~Entry.name_mask) | @as(u32, e.extended_flags) << 16; // git marks a split index's replacements. if (e.origin == .replacement) flags |= 1 << 27; if (out.fsmonitor and e.fsmonitor_valid) flags |= 1 << 21; try w.print( " ctime: {d}:{d}\n mtime: {d}:{d}\n dev: {d}\tino: {d}\n uid: {d}\tgid: {d}\n size: {d}\tflags: {x}\n", .{ e.ctime.seconds, e.ctime.nanoseconds, e.mtime.seconds, e.mtime.nanoseconds, e.dev, e.ino, e.uid, e.gid, e.size, flags }, ); } } /// Expands `--format` for one entry, failing as git does on what it /// does not know. fn expand(out: *const Output, w: *Io.Writer, f: []const u8, e: *const Entry) !void { var i: usize = 0; while (i < f.len) { const pct = std.mem.findScalarPos(u8, f, i, '%') orelse { try w.writeAll(f[i..]); return; }; try w.writeAll(f[i..pct]); const rest = f[pct + 1 ..]; if (rest.len != 0 and rest[0] == '%') { try w.writeByte('%'); i = pct + 2; continue; } if (rest.len >= 3 and rest[0] == 'x' and std.ascii.isHex(rest[1]) and std.ascii.isHex(rest[2])) { try w.writeByte(std.fmt.parseInt(u8, rest[1..3], 16) catch unreachable); i = pct + 4; continue; } if (rest.len == 0 or rest[0] != '(') return tool.fatalMessage(out.stderr, "bad ls-files format: element '{s}' does not start with '('", .{rest}); const close = std.mem.findScalar(u8, rest, ')') orelse return tool.fatalMessage(out.stderr, "bad ls-files format: element '{s}' does not end in ')'", .{rest}); try out.atom(w, rest[1..close], e); i = pct + 1 + close + 1; } } fn atom(out: *const Output, w: *Io.Writer, name: []const u8, e: *const Entry) !void { const Atom = enum { objectmode, objecttype, objectname, objectsize, @"objectsize:padded", stage, path, @"eolinfo:index", @"eolinfo:worktree", eolattr }; const a = std.meta.stringToEnum(Atom, name) orelse return tool.fatalMessage(out.stderr, "bad ls-files format: %({s})", .{name}); switch (a) { .objectmode => try w.print("{o:0>6}", .{e.mode}), .objecttype => try w.writeAll(kindOf(e.mode).name()), .objectname => try out.names.write(w, e.id, out.id_length), .objectsize, .@"objectsize:padded" => { var buffer: [24]u8 = undefined; const text = if (kindOf(e.mode) != .blob) "-" else blk: { var diag: Diagnostics = .{}; const header = out.repo.objects().readHeader(out.ctx.io, e.id, &diag) catch return tool.fatalMessage(out.stderr, "zig-git: cannot read object {f} for '{s}'", .{ e.id, e.path }); break :blk std.fmt.bufPrint(&buffer, "{d}", .{header.size}) catch unreachable; }; if (a == .objectsize) try w.writeAll(text) else try w.print("{s: >7}", .{text}); }, .stage => try w.print("{d}", .{e.stage()}), .path => try out.path(w, e.path), .@"eolinfo:index", .@"eolinfo:worktree", .eolattr => return refuse(out.stderr, "%({s})", .{name}), } } }; /// The kind of object an entry with `mode` names, as git takes it. fn kindOf(mode: u32) git.ObjectKind { return switch (mode & 0o170000) { 0o040000 => .tree, 0o160000 => .commit, else => .blob, }; } /// Appends `path` as seen from the directory `prefix` (empty, or ending /// in `/`): the directories they share dropped, `../` for each the /// prefix has beyond them; `./` when nothing is left. fn relative(gpa: Allocator, out: *std.ArrayList(u8), path: []const u8, prefix: []const u8) !void { var common: usize = 0; var i: usize = 0; while (i < path.len and i < prefix.len and path[i] == prefix[i]) : (i += 1) { if (prefix[i] == '/') common = i + 1; } for (prefix[common..]) |c| if (c == '/') try out.appendSlice(gpa, "../"); try out.appendSlice(gpa, path[common..]); if (out.items.len == 0) try out.appendSlice(gpa, "./"); } // -- tests ------------------------------------------------------------------- const testing = std.testing; test "paths are shown from the directory run in" { const gpa = testing.allocator; for ([_][3][]const u8{ .{ "a", "", "a" }, .{ "sub/x", "sub/", "x" }, .{ "a", "sub/", "../a" }, .{ "subx/z", "sub/", "../subx/z" }, .{ "x/z", "x/y/", "../z" }, .{ "", "", "./" }, }) |c| { var out: std.ArrayList(u8) = .empty; defer out.deinit(gpa); try relative(gpa, &out, c[0], c[1]); try testing.expectEqualStrings(c[2], out.items); } } const Repo = tool.Repo; fn same(repo: *Repo, args: []const []const u8) !void { return tool.expectSameAsGitHow(repo, args, .{}); } fn sameIn(repo: *Repo, cwd: []const u8, args: []const []const u8) !void { return tool.expectSameAsGitHow(repo, args, .{ .cwd = cwd }); } fn writeFile(repo: *Repo, path: []const u8, contents: []const u8) !void { if (std.fs.path.dirname(path)) |d| try repo.tmp.dir.createDirPath(repo.io, d); try repo.tmp.dir.writeFile(repo.io, .{ .sub_path = path, .data = contents }); } fn revParse(repo: *Repo, rev: []const u8) ![]u8 { const out = try repo.output(&.{ "rev-parse", rev }); defer repo.gpa.free(out); return repo.gpa.dupe(u8, std.mem.trimEnd(u8, out, "\n")); } /// The index file's bytes without their checksum, which the caller frees. fn readIndexBody(repo: *Repo, path: []const u8) ![]u8 { const bytes = try repo.tmp.dir.readFileAlloc(repo.io, path, repo.gpa, .unlimited); const n = repo.object_format.byteLength(); return repo.gpa.realloc(bytes, bytes.len - n); } /// Writes `body` as the index file `path` with its checksum after it. fn writeIndexBody(repo: *Repo, path: []const u8, body: []const u8) !void { var h: git.ObjectHasher = .init(repo.object_format); h.update(body); const sum = h.final(); const all = try std.mem.concat(repo.gpa, u8, &.{ body, sum.slice() }); defer repo.gpa.free(all); try repo.tmp.dir.writeFile(repo.io, .{ .sub_path = path, .data = all }); } /// Appends an extension to `.git/index`, saying it is `size` bytes long. fn appendExtension(repo: *Repo, sig: *const [4]u8, data: []const u8, size: ?u32) !void { const body = try readIndexBody(repo, ".git/index"); defer repo.gpa.free(body); var len: [4]u8 = undefined; std.mem.writeInt(u32, &len, size orelse @intCast(data.len), .big); const new = try std.mem.concat(repo.gpa, u8, &.{ body, sig, &len, data }); defer repo.gpa.free(new); try writeIndexBody(repo, ".git/index", new); } /// A repository with some of everything an index can hold: nested /// directories, an executable, a symbolic link, a submodule, paths git /// quotes, an unresolved conflict and a resolved one, an intent-to-add /// entry, and skip-worktree and assume-unchanged entries. fn makeRepo(repo: *Repo) !void { const gpa = repo.gpa; try writeFile(repo, "README", "hello\n"); try writeFile(repo, "src/main.c", "int main(void) { return 0; }\n"); try writeFile(repo, "src/lib/util.c", "void util(void) {}\n"); try writeFile(repo, "src-x", "beside\n"); try writeFile(repo, "tab\there", "tab\n"); try writeFile(repo, "caf\xc3\xa9", "unicode\n"); try writeFile(repo, "quote\"d", "quote\n"); try writeFile(repo, "run.sh", "#!/bin/sh\n"); try writeFile(repo, "f", "base\n"); try writeFile(repo, "g", "base\n"); try repo.tmp.dir.symLink(repo.io, "README", "link", .{}); try repo.git(&.{ "add", "." }); try repo.git(&.{ "update-index", "--chmod=+x", "run.sh" }); try repo.commit("first"); const head = try revParse(repo, "HEAD"); defer gpa.free(head); const info = try std.fmt.allocPrint(gpa, "160000,{s},vendor/sub", .{head}); defer gpa.free(info); try repo.git(&.{ "update-index", "--add", "--cacheinfo", info }); try repo.commit("a submodule"); try repo.git(&.{ "checkout", "-q", "-b", "side" }); try writeFile(repo, "f", "side\n"); try writeFile(repo, "g", "side\n"); try repo.git(&.{ "commit", "-q", "-a", "-m", "side" }); try repo.git(&.{ "checkout", "-q", "main" }); try writeFile(repo, "f", "main\n"); try writeFile(repo, "g", "main\n"); try repo.git(&.{ "commit", "-q", "-a", "-m", "main" }); if (repo.output(&.{ "merge", "-q", "side" })) |out| { gpa.free(out); return error.MergeDidNotConflict; } else |_| {} // g resolved, so that resolve-undo records its conflict. try writeFile(repo, "g", "resolved\n"); try repo.git(&.{ "add", "g" }); try writeFile(repo, "new", "new\n"); try repo.git(&.{ "add", "-N", "new" }); try repo.git(&.{ "update-index", "--skip-worktree", "src/main.c" }); try repo.git(&.{ "update-index", "--assume-unchanged", "README" }); } /// The ways of listing an index every test asks both programs. const listings = [_][]const []const u8{ &.{"ls-files"}, &.{ "ls-files", "-s" }, &.{ "ls-files", "--stage", "-z" }, &.{ "ls-files", "-sz" }, &.{ "ls-files", "-z" }, &.{ "ls-files", "--debug" }, &.{ "ls-files", "-s", "--debug" }, &.{ "ls-files", "-t" }, &.{ "ls-files", "-v" }, &.{ "ls-files", "-f" }, &.{ "ls-files", "-t", "-s" }, &.{ "ls-files", "-u" }, &.{ "ls-files", "--unmerged", "-t" }, &.{ "ls-files", "-u", "--debug" }, &.{ "ls-files", "--deduplicate" }, &.{ "ls-files", "--deduplicate", "-t" }, &.{ "ls-files", "--deduplicate", "-s" }, &.{ "ls-files", "--resolve-undo" }, &.{ "ls-files", "--resolve-undo", "-s" }, &.{ "ls-files", "--resolve-undo", "-t" }, &.{ "ls-files", "--resolve-undo", "-u" }, &.{ "ls-files", "-c", "-u" }, &.{ "ls-files", "--full-name" }, &.{ "ls-files", "--format=%(objectmode) %(objecttype) %(objectname) %(objectsize) [%(objectsize:padded)] %(stage)%x09%(path)%%" }, &.{ "ls-files", "--format=%(path)", "-z" }, &.{ "ls-files", "-u", "--format=%(stage) %(path)" }, &.{ "ls-files", "-s", "src" }, &.{ "ls-files", "--", "src/", "README", "nothing" }, &.{ "ls-files", "src/lib/util.c/" }, &.{ "ls-files", "./src/../f" }, &.{ "ls-files", "-t", "-v", "src", "README" }, &.{ "ls-files", "*.c" }, &.{ "ls-files", "-s", "--", ":(glob)*.c" }, &.{ "ls-files", "--", ":(glob)**/*.c", ":!src/lib" }, &.{ "ls-files", "--", ":(icase)SRC", ":^src/lib/" }, &.{ "ls-files", "--", ":!src" }, &.{ "ls-files", "--", "vendor/sub/" }, &.{ "ls-files", "--", "vendor/su*" }, &.{ "ls-files", "--", ":(literal)src*" }, &.{ "ls-files", "--", "src*" }, &.{ "ls-files", "--", ":/f", ":(top)g" }, &.{ "ls-files", "--", ":(foo)x" }, &.{ "ls-files", "--", ":(top" }, &.{ "ls-files", "--", ":#x" }, &.{ "ls-files", "--", ":(literal,glob)x" }, &.{ "ls-files", "--", ":" }, &.{ "ls-files", "--", ":(exclude)" }, &.{ "ls-files", "--", "/nowhere" }, }; /// Listings from a subdirectory, where paths are shown from there. const sub_listings = [_][]const []const u8{ &.{"ls-files"}, &.{ "ls-files", "-s" }, &.{ "ls-files", ".." }, &.{ "ls-files", "--full-name" }, &.{ "ls-files", "-s", "../f", "lib" }, &.{ "ls-files", "--resolve-undo", ".." }, &.{ "ls-files", "--format=%(path)", ".." }, &.{ "ls-files", "../../x" }, &.{ "ls-files", "" }, &.{ "ls-files", "*.c" }, &.{ "ls-files", ":/*.c" }, &.{ "ls-files", "--", ":!lib" }, &.{ "ls-files", "--", ":/!f" }, &.{ "ls-files", "--", ":(glob)../*" }, &.{ "ls-files", "--", ":(icase)LIB" }, &.{ "ls-files", "--", ":(icase)../SRC/LIB" }, &.{ "ls-files", "--", ":(top)src/./main.c", ":" }, &.{ "ls-files", "--", ":(exclude)*", ":(top)f" }, }; fn expectListingsSame(repo: *Repo) !void { for (listings) |args| try same(repo, args); for (sub_listings) |args| try sameIn(repo, "src", args); } test "zig-git ls-files lists every index version as git does" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeRepo(&repo); for ([_][]const u8{ "2", "3", "4" }) |version| { try repo.git(&.{ "update-index", "--index-version", version }); try expectListingsSame(&repo); } try repo.git(&.{ "config", "core.quotePath", "false" }); try same(&repo, &.{"ls-files"}); try repo.git(&.{ "config", "core.quotePath", "maybe" }); try same(&repo, &.{"ls-files"}); } test "zig-git ls-files reads the pathspec settings from the environment as git does" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeRepo(&repo); const Case = struct { env: []const [2][]const u8, args: []const []const u8 }; for ([_]Case{ .{ .env = &.{.{ "GIT_LITERAL_PATHSPECS", "1" }}, .args = &.{ "ls-files", "--", "*.c", ":!src" } }, .{ .env = &.{.{ "GIT_LITERAL_PATHSPECS", "bogus" }}, .args = &.{ "ls-files", "--", "src" } }, .{ .env = &.{.{ "GIT_LITERAL_PATHSPECS", "bogus" }}, .args = &.{"ls-files"} }, .{ .env = &.{.{ "GIT_GLOB_PATHSPECS", "yes" }}, .args = &.{ "ls-files", "--", "*.c", ":(literal)src*" } }, .{ .env = &.{.{ "GIT_NOGLOB_PATHSPECS", "true" }}, .args = &.{ "ls-files", "--", "*.c", ":(glob)src/*" } }, .{ .env = &.{.{ "GIT_ICASE_PATHSPECS", "on" }}, .args = &.{ "ls-files", "--", "readme" } }, .{ .env = &.{ .{ "GIT_GLOB_PATHSPECS", "1" }, .{ "GIT_NOGLOB_PATHSPECS", "1" } }, .args = &.{ "ls-files", "--", "src" } }, .{ .env = &.{ .{ "GIT_LITERAL_PATHSPECS", "1" }, .{ "GIT_ICASE_PATHSPECS", "1" } }, .args = &.{ "ls-files", "--", "src" } }, }) |case| { for (case.env) |kv| try repo.env.put(kv[0], kv[1]); defer for (case.env) |kv| { _ = repo.env.orderedRemove(kv[0]); }; try same(&repo, case.args); } } test "zig-git ls-files is used as git's is" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try writeFile(&repo, "a", "a\n"); try repo.git(&.{ "add", "a" }); for ([_][]const []const u8{ &.{ "ls-files", "--format=%(path)", "-s" }, &.{ "ls-files", "--format=%(path)", "-t" }, &.{ "ls-files", "--format=%(path)", "--resolve-undo" }, &.{ "ls-files", "--format=%(path)", "--deduplicate" }, &.{ "ls-files", "--bogus" }, &.{ "ls-files", "-q" }, &.{ "ls-files", "--format" }, }) |args| try tool.expectSameAsGitHow(&repo, args, .{ .compare = .output }); for ([_][]const []const u8{ &.{ "ls-files", "--format=%(ctime)" }, &.{ "ls-files", "--format=%(mtime)" }, &.{ "ls-files", "--format=%(objectsize:foo)" }, &.{ "ls-files", "--format=%x" }, &.{ "ls-files", "--format=%xZZ" }, &.{ "ls-files", "--format=trailing%" }, &.{ "ls-files", "--format=%(path" }, &.{ "ls-files", "--format=" }, &.{ "ls-files", "-v", "--format=%(path)" }, &.{ "ls-files", "--no-abbrev" }, }) |args| try same(&repo, args); } test "zig-git ls-files refuses what it does not do, by name" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try writeFile(&repo, "a", "a\n"); try repo.git(&.{ "add", "a" }); for ([_][]const []const u8{ &.{ "ls-files", "-o" }, &.{ "ls-files", "--others" }, &.{ "ls-files", "-m" }, &.{ "ls-files", "--deleted" }, &.{ "ls-files", "-i" }, &.{ "ls-files", "-k" }, &.{ "ls-files", "--eol" }, &.{ "ls-files", "--error-unmatch" }, &.{ "ls-files", "--with-tree=HEAD" }, &.{ "ls-files", "--recurse-submodules" }, &.{ "ls-files", "--exclude-standard" }, &.{ "ls-files", "-x", "*.o" }, &.{ "ls-files", "--", ":(attr:x)a" }, &.{ "ls-files", "--format=%(eolinfo:index)" }, }) |args| { const ours = try tool.runOurs(&repo, args, .{}); defer ours.deinit(repo.gpa); errdefer std.debug.print("zig-git {s} said: {s}\n", .{ args[1], ours.stderr }); try testing.expectEqual(128, ours.code); try testing.expect(std.mem.startsWith(u8, ours.stderr, "zig-git: ls-files: ")); try testing.expect(std.mem.endsWith(u8, ours.stderr, " is not supported\n")); try testing.expectEqualStrings("", ours.stdout); } } test "zig-git ls-files reads skipHash and feature.manyFiles indexes as git does" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeRepo(&repo); try repo.git(&.{ "config", "index.skipHash", "true" }); try repo.git(&.{ "update-index", "--index-version", "4" }); try writeFile(&repo, "more", "more\n"); try repo.git(&.{ "add", "more" }); var ix = try readIndexHere(&repo); defer ix.deinit(); try testing.expect(std.mem.allEqual(u8, ix.checksum, 0)); try expectListingsSame(&repo); try repo.git(&.{ "config", "--unset", "index.skipHash" }); try repo.git(&.{ "config", "feature.manyFiles", "true" }); try writeFile(&repo, "more", "more again\n"); try repo.git(&.{ "add", "more" }); try repo.git(&.{ "status", "--short" }); try expectListingsSame(&repo); } fn readIndexHere(repo: *Repo) !Index { const path = try std.fs.path.join(repo.gpa, &.{ repo.path, ".git/index" }); defer repo.gpa.free(path); return Index.load(repo.gpa, repo.io, std.Io.Dir.cwd(), path, repo.object_format, .{ .verify_checksum = true }, null); } test "zig-git ls-files reads a split index as git does" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeRepo(&repo); try repo.git(&.{ "config", "splitIndex.maxPercentChange", "100" }); try repo.git(&.{ "update-index", "--split-index" }); try expectListingsSame(&repo); // Changes recorded in the main file: a replacement, a deletion and an // addition. try writeFile(&repo, "README", "changed\n"); try repo.git(&.{ "update-index", "--no-assume-unchanged", "README" }); try repo.git(&.{ "add", "README" }); try repo.git(&.{ "update-index", "--force-remove", "src-x" }); try writeFile(&repo, "added", "added\n"); try repo.git(&.{ "add", "added" }); try repo.git(&.{ "update-index", "--skip-worktree", "added" }); { var ix = try readIndexHere(&repo); defer ix.deinit(); try testing.expect(ix.split_index != null); try testing.expect(ix.split_index.?.link.hasSharedIndex()); } try expectListingsSame(&repo); // From core.splitIndex, after the shared index is rewritten. try repo.git(&.{ "update-index", "--no-split-index" }); try repo.git(&.{ "config", "core.splitIndex", "true" }); try repo.git(&.{ "config", "splitIndex.maxPercentChange", "0" }); try repo.git(&.{ "update-index", "--no-skip-worktree", "added" }); try writeFile(&repo, "added", "again\n"); try repo.git(&.{ "add", "added" }); try expectListingsSame(&repo); // The shared index is found beside GIT_INDEX_FILE, which is read from // the top of the working tree wherever git is run. try repo.git(&.{ "config", "splitIndex.maxPercentChange", "100" }); try repo.env.put("GIT_INDEX_FILE", "alt-index"); try repo.git(&.{ "read-tree", "HEAD" }); try repo.git(&.{ "update-index", "--split-index" }); try writeFile(&repo, "README", "in the other index\n"); try repo.git(&.{ "add", "README" }); try same(&repo, &.{ "ls-files", "-s", "--debug" }); try sameIn(&repo, "src", &.{ "ls-files", "-s", ".." }); // A shared index that is missing. var it = try repo.tmp.dir.openDir(repo.io, ".", .{ .iterate = true }); defer it.close(repo.io); var walker = it.iterate(); while (try walker.next(repo.io)) |e| { if (std.mem.startsWith(u8, e.name, "sharedindex.")) try repo.tmp.dir.deleteFile(repo.io, e.name); } try same(&repo, &.{ "ls-files", "-s" }); } test "zig-git ls-files reads indexes with an untracked cache, EOIE and IEOT as git does" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeRepo(&repo); try writeFile(&repo, "untracked", "u\n"); try writeFile(&repo, "src/also/untracked", "u\n"); try repo.git(&.{ "update-index", "--untracked-cache" }); try repo.git(&.{ "status", "--short" }); try repo.git(&.{ "status", "--short" }); { var ix = try readIndexHere(&repo); defer ix.deinit(); try testing.expect(ix.untracked_cache != null); try testing.expectEqual(0, ix.warnings.len); } try expectListingsSame(&repo); for ([_][]const u8{ "2", "4" }) |version| { try repo.git(&.{ "-c", "index.threads=4", "-c", "index.recordOffsetTable=true", "-c", "index.recordEndOfIndexEntries=true", "update-index", "--index-version", version }); var ix = try readIndexHere(&repo); defer ix.deinit(); try testing.expect(ix.end_of_entries != null); try testing.expect(ix.offset_table != null); try testing.expectEqual(0, ix.warnings.len); try expectListingsSame(&repo); } } test "zig-git ls-files lists a sparse index as git does, expanded or not" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); for ([_][]const u8{ "top", "in/a", "out/a", "out/deep/b", "out/deep/c", "out-of/x", "out0", "in/deeper/d" }) |p| try writeFile(&repo, p, p); try repo.tmp.dir.symLink(repo.io, "a", "out/link", .{}); try repo.git(&.{ "add", "." }); try repo.commit("first"); try repo.git(&.{ "sparse-checkout", "init", "--cone", "--sparse-index" }); try repo.git(&.{ "sparse-checkout", "set", "in" }); { var ix = try readIndexHere(&repo); defer ix.deinit(); try testing.expect(ix.sparse); try testing.expect(ix.hasSparseDirectories()); } const cases = [_][]const []const u8{ &.{ "ls-files", "-s" }, &.{ "ls-files", "--sparse", "-s" }, &.{ "ls-files", "--sparse" }, &.{ "ls-files", "--sparse", "-t" }, &.{ "ls-files", "-t" }, &.{ "ls-files", "--sparse", "--debug" }, &.{ "ls-files", "--debug" }, &.{ "ls-files", "--sparse", "--format=%(objecttype) %(objectsize) %(path)" }, &.{ "ls-files", "--sparse", "out" }, &.{ "ls-files", "--sparse", "out/" }, &.{ "ls-files", "out/deep" }, &.{ "ls-files", "--sparse", "in" }, }; for (cases) |args| try same(&repo, args); try sameIn(&repo, "in", &.{ "ls-files", "--sparse", ".." }); try sameIn(&repo, "in", &.{ "ls-files", ".." }); try repo.git(&.{ "config", "advice.sparseIndexExpanded", "false" }); for (cases) |args| try same(&repo, args); for ([_][]const u8{ "-c", "-c", "-c", "-c" }, [_][]const u8{ "index.sparse=false", "core.sparseCheckoutCone=false", "core.sparseCheckout=false", "core.splitIndex=true" }) |c, setting| { try same(&repo, &.{ c, setting, "ls-files", "--sparse", "-s" }); } try repo.git(&.{ "config", "index.sparse", "maybe" }); try same(&repo, &.{ "ls-files", "--sparse" }); } test "zig-git ls-files reads an fsmonitor cache as git does without a monitor" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); for ([_][]const u8{ "a", "b", "c" }) |p| try writeFile(&repo, p, p); try repo.git(&.{ "add", "." }); try repo.commit("first"); // Version 2, the token "tok", and a bitmap saying the second entry is // not vouched for. const bitmap = "\x00\x00\x00\x03\x00\x00\x00\x02" ++ "\x00\x00\x00\x02\x00\x00\x00\x00" ++ "\x00\x00\x00\x00\x00\x00\x00\x02" ++ "\x00\x00\x00\x00"; try appendExtension(&repo, "FSMN", "\x00\x00\x00\x02tok\x00\x00\x00\x00\x1c" ++ bitmap, null); { var ix = try readIndexHere(&repo); defer ix.deinit(); try testing.expect(ix.fsmonitor_cache != null); try testing.expect(ix.entries[0].fsmonitor_valid); try testing.expect(!ix.entries[1].fsmonitor_valid); } try same(&repo, &.{ "ls-files", "--debug" }); try same(&repo, &.{ "ls-files", "-f" }); // With a monitor configured, git asks it before answering; this // cannot, and says so. try repo.git(&.{ "config", "core.fsmonitor", "true" }); for ([_][]const u8{ "--debug", "-f" }) |opt| { const ours = try tool.runOurs(&repo, &.{ "ls-files", opt }, .{}); defer ours.deinit(repo.gpa); try testing.expectEqual(128, ours.code); try testing.expect(std.mem.endsWith(u8, ours.stderr, " is not supported\n")); } } test "zig-git ls-files refuses corrupt indexes as git does" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); for ([_][]const u8{ "a", "b" }) |p| try writeFile(&repo, p, p); try repo.git(&.{ "add", "." }); const original = try readIndexBody(&repo, ".git/index"); defer repo.gpa.free(original); const n = repo.object_format.byteLength(); const gpa = repo.gpa; const Patch = struct { offset: usize, bytes: []const u8 }; for ([_]Patch{ .{ .offset = 0, .bytes = "DRID" }, .{ .offset = 4, .bytes = "\x00\x00\x00\x05" }, .{ .offset = 4, .bytes = "\x00\x00\x00\x01" }, // The first entry's flags: extended in version 2, with the name's // first bytes read as extended flags. .{ .offset = 12 + 40 + n, .bytes = "\x40\x01" }, // A mode git never writes, which it shows as it is. .{ .offset = 12 + 24, .bytes = "\x00\x00\xe1\xa4" }, }) |p| { const body = try gpa.dupe(u8, original); defer gpa.free(body); @memcpy(body[p.offset..][0..p.bytes.len], p.bytes); try writeIndexBody(&repo, ".git/index", body); try same(&repo, &.{ "ls-files", "-s" }); } // Extensions, known and not, readable and not. const id: [git.object_max_byte_length]u8 = @splat(0x11); const reuc = try std.mem.concat(gpa, u8, &.{ "p\x00100644\x000\x00100755\x00", id[0..n], id[0..n] }); defer gpa.free(reuc); const Ext = struct { sig: *const [4]u8, data: []const u8, size: ?u32 = null }; for ([_]Ext{ .{ .sig = "abcd", .data = "\x01\x02" }, .{ .sig = "aBCD", .data = "\x01\x02" }, .{ .sig = "ABCD", .data = "\x01\x02" }, .{ .sig = "Abcd", .data = "\x01\x02" }, .{ .sig = "ABCD", .data = "\x01\x02", .size = 100 }, .{ .sig = "abcd", .data = "\x01\x02", .size = 100 }, .{ .sig = "TREE", .data = "12" }, .{ .sig = "REUC", .data = "12" }, .{ .sig = "REUC", .data = reuc }, .{ .sig = "REUC", .data = "p\x000\x000\x000\x00" }, .{ .sig = "UNTR", .data = "\x00" }, .{ .sig = "FSMN", .data = "\x00" }, .{ .sig = "FSMN", .data = "\x00\x00\x00\x03tok\x00" }, .{ .sig = "FSMN", .data = "\x00\x00\x00\x02tok\x00\x00\x00\x00\x08" ++ "\x00\x00\x00\x00\x00\x00\x00\x00" }, .{ .sig = "FSMN", .data = "\x00\x00\x00\x02tok\x00\x00\x00\x00\x15" ++ "\x00\x00\x00\x00\x00\x00\x00\x01" ++ "\x00\x00\x00\x00\x00\x00\x00\x00" ++ "\x00\x00\x00\x00" }, .{ .sig = "EOIE", .data = "\x00" }, .{ .sig = "IEOT", .data = "\x00" }, .{ .sig = "link", .data = "" }, .{ .sig = "link", .data = id[0..10] }, }) |x| { try writeIndexBody(&repo, ".git/index", original); try appendExtension(&repo, x.sig, x.data, x.size); try same(&repo, &.{ "ls-files", "-s", "--resolve-undo" }); } // Too short for a header and a checksum. try repo.tmp.dir.writeFile(repo.io, .{ .sub_path = ".git/index", .data = original[0..10] }); try same(&repo, &.{"ls-files"}); try repo.tmp.dir.writeFile(repo.io, .{ .sub_path = ".git/index", .data = "" }); try same(&repo, &.{"ls-files"}); // Where git reads past what it was given, this refuses the index. for ([_][]const u8{ original[0..12], original[0 .. original.len - 4] }) |cut| { try writeIndexBody(&repo, ".git/index", cut); const ours = try tool.runOurs(&repo, &.{"ls-files"}, .{}); defer ours.deinit(gpa); try testing.expectEqual(128, ours.code); try testing.expect(std.mem.startsWith(u8, ours.stderr, "fatal: zig-git: corrupt index .git/index at offset ")); } } test "zig-git ls-files refuses corrupt split indexes as git does" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); for ([_][]const u8{ "a", "b", "c" }) |p| try writeFile(&repo, p, p); try repo.git(&.{ "add", "." }); try repo.commit("first"); try repo.git(&.{ "config", "splitIndex.maxPercentChange", "100" }); try repo.git(&.{ "update-index", "--split-index" }); try writeFile(&repo, "b", "changed\n"); try repo.git(&.{ "add", "b" }); try repo.git(&.{ "update-index", "--force-remove", "c" }); const gpa = repo.gpa; const original = try readIndexBody(&repo, ".git/index"); defer gpa.free(original); const n = repo.object_format.byteLength(); const at = std.mem.find(u8, original, "link").?; const size = std.mem.readInt(u32, original[at + 4 ..][0..4], .big); const data = original[at + 8 ..][0..size]; const hash = data[0..n]; const empty_bitmap = "\x00\x00\x00\x00\x00\x00\x00\x01" ++ "\x00\x00\x00\x00\x00\x00\x00\x00" ++ "\x00\x00\x00\x00"; // A literal chunk setting the low bits of `bits`. const Lit = struct { fn of(comptime bits: u8) [28]u8 { var b: [28]u8 = undefined; std.mem.writeInt(u32, b[0..4], 8, .big); std.mem.writeInt(u32, b[4..8], 2, .big); std.mem.writeInt(u64, b[8..16], 0x0000_0002_0000_0000, .big); std.mem.writeInt(u64, b[16..24], bits, .big); std.mem.writeInt(u32, b[24..28], 0, .big); return b; } }; const garbage = try std.mem.concat(gpa, u8, &.{ data[n..], "\x00" }); defer gpa.free(garbage); const Case = struct { hash: bool = true, tail: []const u8 }; for ([_]Case{ .{ .hash = false, .tail = hash[0 .. n - 5] }, // (A link of the object ID alone crashes git.) .{ .tail = "\x00\x00\x00\x00" }, .{ .tail = "\x00\x00\x00\x00\x00\x00\x00\x09" ++ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" }, .{ .tail = "\x00\x00\x00\x00\x00\x00\x00\x01" ++ "\x00\x00\x00\x00\x00\x00\x00\x00" ++ "\x00" }, .{ .tail = empty_bitmap }, .{ .tail = empty_bitmap ++ empty_bitmap }, .{ .tail = &Lit.of(0x80) ++ empty_bitmap }, .{ .tail = empty_bitmap ++ Lit.of(0xc0) }, .{ .tail = empty_bitmap ++ Lit.of(0x07) }, .{ .tail = garbage }, }) |c| { const link = try std.mem.concat(gpa, u8, &.{ if (c.hash) hash else "", c.tail }); defer gpa.free(link); var len: [4]u8 = undefined; std.mem.writeInt(u32, &len, @intCast(link.len), .big); const body = try std.mem.concat(gpa, u8, &.{ original[0 .. at + 4], &len, link, original[at + 8 + size ..] }); defer gpa.free(body); try writeIndexBody(&repo, ".git/index", body); try same(&repo, &.{ "ls-files", "-s" }); } } test "zig-git ls-files reads paths of 4095 bytes and more in every index version" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); const gpa = repo.gpa; try writeFile(&repo, "a", "a\n"); try repo.git(&.{ "add", "a" }); const blob = try revParse(&repo, ":a"); defer gpa.free(blob); // Paths whose length fills the flags' twelve bits, and more; made // with --cacheinfo, since no file system takes them. for ([_]usize{ 4094, 4095, 4096, 5000 }) |len| { const path = try gpa.alloc(u8, len); defer gpa.free(path); for (path, 0..) |*c, i| c.* = if (i % 200 == 199) '/' else 'x'; path[len - 1] = 'z'; const info = try std.fmt.allocPrint(gpa, "100644,{s},{s}", .{ blob, path }); defer gpa.free(info); try repo.git(&.{ "update-index", "--add", "--cacheinfo", info }); } for ([_][]const u8{ "2", "3", "4" }) |version| { try repo.git(&.{ "update-index", "--index-version", version }); try same(&repo, &.{ "ls-files", "-s" }); try same(&repo, &.{ "ls-files", "--debug" }); } }