Something went wrong. Try again.
A Zig library that reads git repositories without running git, with log, show and diff output byte-identical to git's.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! `zig-git rev-list`: commits listed as `git rev-list` lists them, by//! the library's `RevWalk`, its output limited to object IDs and what can//! be printed beside them.//!//! zig-git rev-list [<option>...] <commit>... [--] [<path>...]//!//! Revisions, sets of commits (`A..B`, `A...B`, `^A`, `A^@`, `A^!`,//! `A^-<n>`), `--not`, `--all`, `--branches`, `--tags`, `--remotes`,//! `--glob` and `--exclude` choose the commits; the orderings,//! `--max-count`, `--skip`, the dates, the parent counts, `--first-parent`,//! `--ancestry-path`, `--boundary` and the sides of a symmetric difference//! limit them; and `--parents`, `--children`, `--timestamp`,//! `--left-right`, `--count`, `--abbrev-commit` and `--format=%H` say what//! is printed. Paths after `--`, or before it where they are there, limit//! the walk, simplified as `--full-history`, `--dense`, `--sparse`,//! `--simplify-merges`, `--show-pulls` and `--remove-empty` say. What//! git's rev-list has and this does not is refused by name://! `--follow`, `--simplify-by-decoration` in the combinations it was not//! matched in, the combinations with paths//! the library refuses, `--objects`, the patch-equivalence options (which//! need diffs), message and identity searches, reflog walks and the rest.
const std = @import("std");const Allocator = std.mem.Allocator;const Io = std.Io;
const git = @import("git");const ObjectId = git.ObjectId;const Diagnostics = git.Diagnostics;const RevWalk = git.RevWalk;const tool = @import("zig_git.zig");const Context = tool.Context;const discovery = @import("discovery.zig");const object_names = @import("object_names.zig");const revisions = @import("revisions.zig");const history = @import("history.zig");const pathspecs = @import("pathspecs.zig");const describe = @import("describe.zig");const log_diff = @import("log_diff.zig");const repo_diff = @import("repo_diff.zig");const line_prefix = @import("line_prefix.zig");
/// What is said for an option neither this nor git knows, or no revision/// at all, where git prints its own usage; the exit status is git's.pub const usage = \\usage: zig-git rev-list [<option>...] <commit>... [--] [<path>...] \\;
/// The options git's rev-list has that this does not, refused by name.const unsupported = [_][]const u8{ "--cherry-pick", "--cherry-mark", "--cherry", "--objects", "--objects-edge", "--objects-edge-aggressive", "--unpacked", "--grep-reflog", "--reflog", "--alternate-refs", "--stdin", "--quiet", "--disk-usage", "--bisect", "--bisect-vars", "--bisect-all", "--walk-reflogs", "--merge", "--max-count-oldest", "--maximal-only", "--exclude-first-parent-only", "--exclude-hidden", "--single-worktree", "--ignore-missing", "--no-walk", "--do-walk", "--graph", "--use-bitmap-index", "--progress", "--missing", "--filter", "--filter-provided-objects", "--in-commit-order", "--indexed-objects", "--show-linear-break", "--no-replace-objects", "--no-object-names", "--object-names", "--show-notes", "--notes", "--abbrev-commit-short", "--early-output", "--line-prefix", "--full-diff", "--follow", "--no-max-age", "--show-signature", "--bisect-all", "-g", "-z",};
fn isUnsupported(arg: []const u8) bool { const eq = std.mem.findScalar(u8, arg, '='); const name = arg[0 .. eq orelse arg.len]; for (unsupported) |u| if (std.mem.eql(u8, u, name)) return true; return false;}
const Output = struct { parents: bool = false, children: bool = false, timestamp: bool = false, left_right: bool = false, count: bool = false, commit_header: bool = true,};
/// Runs `rev-list` with `args`, the arguments after its name.pub fn run(ctx: Context, args: []const []const u8, stdout: *Io.Writer, stderr: *Io.Writer) !void { return runCommand(ctx, args, stdout, stderr, .rev_list);}
/// What `log` says for an option neither it nor git knows.pub const log_usage = \\usage: zig-git log [<options>] [<revision-range>] [[--] <path>...] \\;
/// Runs `rev-list` or `log` with `args`, the arguments after its name:/// one walk, described as each describes commits.pub fn runCommand(ctx: Context, args: []const []const u8, stdout: *Io.Writer, stderr: *Io.Writer, command: describe.Command) !void { return runWith(ctx, args, stdout, stderr, command, false);}
fn runWith(ctx: Context, args: []const []const u8, stdout: *Io.Writer, stderr: *Io.Writer, command: describe.Command, as_whatchanged: bool) !void { // --line-prefix puts its prefix before every line `log` writes. if (command == .log) if (line_prefix.find(args)) |prefix| { var buffer: [256]u8 = undefined; var lp: line_prefix.LinePrefix = .init(stdout, prefix, &buffer); defer lp.writer.flush() catch {}; return runPrefixed(ctx, args, &lp.writer, stderr, command, as_whatchanged); }; return runPrefixed(ctx, args, stdout, stderr, command, as_whatchanged);}
/// `zig-git whatchanged`: `log` with the raw format unless another is/// given, leaving out every commit whose diff is empty (so merges, unless/// `-m`), as git-whatchanged(1) describes it and git was seen to; only/// with `--i-still-use-this`, as git 2.56 asks.pub fn whatchanged(ctx: Context, args: []const []const u8, stdout: *Io.Writer, stderr: *Io.Writer) !void { const gpa = ctx.gpa; var rest: std.ArrayList([]const u8) = .empty; defer rest.deinit(gpa); var allowed = false; for (args, 0..) |a, i| { if (std.mem.eql(u8, a, "--")) { try rest.appendSlice(gpa, args[i..]); break; } if (std.mem.eql(u8, a, "--i-still-use-this")) allowed = true else try rest.append(gpa, a); } if (!allowed) { stderr.writeAll(@embedFile("whatchanged_refusal.txt")) catch {}; return error.Fatal; } return runWith(ctx, rest.items, stdout, stderr, .log, true);}
fn runPrefixed(ctx: Context, args: []const []const u8, stdout: *Io.Writer, stderr: *Io.Writer, command: describe.Command, as_whatchanged: bool) !void { const gpa = ctx.gpa; // What is said names the command run. const command_name = commandName(command, as_whatchanged); const diff_command: repo_diff.Command = if (as_whatchanged) .whatchanged else .log; var fmt: describe.Options = .{}; defer fmt.deinit(gpa); var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator();
// Options first, refusing what is not supported before anything is // said; revisions are kept in order for later. var options: RevWalk.Options = .{}; var out: Output = .{}; const Word = union(enum) { revision: struct { text: []const u8, at: usize }, not, listing: struct { what: history.Listing, value: ?[]const u8 }, exclude: []const u8 }; var simplify: RevWalk.Simplify = .{ .pathspec = &git.Pathspec.empty }; var paths: std.ArrayList([]const u8) = .empty; defer paths.deinit(gpa); // Where each option was, so that one after a path can be refused as // git refuses it. var option_at: std.ArrayList(usize) = .empty; defer option_at.deinit(gpa); var dashdash: ?usize = null; var words: std.ArrayList(Word) = .empty; defer words.deinit(gpa); var diff_args: log_diff.Collected = .{ .whatchanged = as_whatchanged }; defer diff_args.deinit(gpa); var follow = false; var since_text: ?[]const u8 = null; var until_text: ?[]const u8 = null; var since_filter_text: ?[]const u8 = null; var any_listing = false; var by_decoration = false; var i: usize = 0; while (i < args.len) : (i += 1) { const arg = args[i]; if (std.mem.eql(u8, arg, "--")) { dashdash = i; try paths.appendSlice(gpa, args[i + 1 ..]); break; } if (arg.len < 2 or arg[0] != '-') { try words.append(gpa, .{ .revision = .{ .text = arg, .at = i } }); continue; } try option_at.append(gpa, i); if (command == .log and std.mem.eql(u8, arg, "--follow")) { follow = true; continue; } // The diff options, kept for once the configuration is read; some // mean something to the log as well. if (command == .log and !describe.isUnsupported(arg)) { const took = try diff_args.take(gpa, diff_command, arg, if (i + 1 < args.len) args[i + 1] else null, stderr); if (took != 0) { const shared = std.mem.startsWith(u8, arg, "--abbrev") or std.mem.eql(u8, arg, "--no-abbrev") or std.mem.eql(u8, arg, "-s") or std.mem.eql(u8, arg, "--no-patch") or std.mem.eql(u8, arg, "-z") or std.mem.startsWith(u8, arg, "--color") or std.mem.eql(u8, arg, "--no-color") or std.mem.eql(u8, arg, "-i") or std.mem.eql(u8, arg, "--regexp-ignore-case"); if (!shared) { i += took - 1; continue; } } } // -z is log's, which ends its commits with NULs; rev-list's is // another thing, not matched. const log_z = command == .log and std.mem.eql(u8, arg, "-z"); if ((isUnsupported(arg) and !log_z) or (command == .log and describe.isUnsupported(arg))) return tool.fatalMessage(stderr, "zig-git {s}: '{s}' is not supported", .{ command_name, arg }); const mailmap_option = std.mem.eql(u8, arg, "--use-mailmap") or std.mem.eql(u8, arg, "--mailmap") or std.mem.eql(u8, arg, "--no-use-mailmap") or std.mem.eql(u8, arg, "--no-mailmap"); if (command == .rev_list and (mailmap_option or describe.isLogOnly(arg))) { try stderr.writeAll(usage); return error.UsageQuiet; } if (command == .log and std.mem.eql(u8, arg, "--header")) return tool.fatalMessage(stderr, "unrecognized argument: {s}", .{arg}); if (std.mem.eql(u8, arg, "-s") or std.mem.eql(u8, arg, "--no-patch")) continue; switch (try describe.parseOption(&fmt, gpa, arg, if (i + 1 < args.len) args[i + 1] else null, stderr)) { .yes => continue, .took_next => { i += 1; continue; }, .no => {}, } if (command == .log) { const LogOnly = enum { @"--no-decorate", @"--no-notes", @"--no-standard-notes", @"--no-show-signature", @"--no-graph", @"--no-follow" }; if (std.meta.stringToEnum(LogOnly, arg) != null) continue; } const eq = std.mem.findScalar(u8, arg, '='); const name = arg[0 .. eq orelse arg.len]; const value: ?[]const u8 = if (eq) |e| arg[e + 1 ..] else null; if (std.mem.eql(u8, arg, "-n")) { i += 1; if (i == args.len) { try stderr.writeAll("error: -n requires an argument\n"); return error.Fatal; } options.max_count = try count(args[i], stderr); continue; } if (std.mem.startsWith(u8, arg, "-n") and arg[1] == 'n') { options.max_count = try count(arg[2..], stderr); continue; } if (arg[0] == '-' and arg[1] != '-' and allDigits(arg[1..])) { options.max_count = try count(arg[1..], stderr); continue; } const Option = enum { @"--max-count", @"--skip", @"--max-age", @"--min-age", @"--since", @"--after", @"--until", @"--before", @"--since-as-filter", @"--merges", @"--no-merges", @"--min-parents", @"--max-parents", @"--no-min-parents", @"--no-max-parents", @"--first-parent", @"--ancestry-path", @"--boundary", @"--left-only", @"--right-only", @"--topo-order", @"--date-order", @"--author-date-order", @"--reverse", @"--parents", @"--children", @"--timestamp", @"--left-right", @"--count", @"--no-commit-header", @"--commit-header", @"--not", @"--all", @"--branches", @"--tags", @"--remotes", @"--glob", @"--exclude", @"--full-history", @"--dense", @"--sparse", @"--simplify-merges", @"--show-pulls", @"--remove-empty", @"--simplify-by-decoration", }; const o = std.meta.stringToEnum(Option, name) orelse { if (command == .log) { try stderr.print("fatal: unrecognized argument: {s}\n", .{arg}); return error.Fatal; } try stderr.writeAll(usage); return error.UsageQuiet; }; const needs_value = switch (o) { .@"--max-count", .@"--skip", .@"--max-age", .@"--min-age", .@"--since", .@"--after", .@"--until", .@"--before", .@"--since-as-filter", .@"--min-parents", .@"--max-parents", .@"--glob", .@"--exclude" => true, else => false, }; const takes_value = needs_value or o == .@"--ancestry-path" or o == .@"--branches" or o == .@"--tags" or o == .@"--remotes"; if ((needs_value and value == null) or (!takes_value and value != null)) { try stderr.writeAll(if (command == .log) log_usage else usage); return error.UsageQuiet; } switch (o) { .@"--max-count" => options.max_count = try count(value.?, stderr), .@"--skip" => options.skip = (try count(value.?, stderr)) orelse 0, .@"--max-age" => options.since = try integer(value.?, stderr), .@"--min-age" => options.until = try integer(value.?, stderr), .@"--since", .@"--after" => since_text = value.?, .@"--until", .@"--before" => until_text = value.?, .@"--since-as-filter" => since_filter_text = value.?, .@"--merges" => options.min_parents = 2, .@"--no-merges" => options.max_parents = 1, .@"--min-parents" => options.min_parents = @intCast(@max(0, try integer(value.?, stderr))), .@"--max-parents" => { const n = try integer(value.?, stderr); options.max_parents = if (n < 0) null else @intCast(n); }, .@"--no-min-parents" => options.min_parents = 0, .@"--no-max-parents" => options.max_parents = null, .@"--first-parent" => options.first_parent = true, .@"--ancestry-path" => if (value != null) { return tool.fatalMessage(stderr, "zig-git {s}: '{s}' is not supported; only --ancestry-path without a commit is", .{ command_name, arg }); } else { options.ancestry_path = true; }, .@"--boundary" => options.boundary = true, .@"--left-only" => options.left_only = true, .@"--right-only" => options.right_only = true, .@"--topo-order" => options.order = .topo, .@"--date-order" => options.order = .date, .@"--author-date-order" => options.order = .author_date, .@"--reverse" => options.reverse = !options.reverse, .@"--parents" => out.parents = true, .@"--children" => { out.children = true; options.children = true; }, .@"--timestamp" => out.timestamp = true, .@"--left-right" => out.left_right = true, .@"--count" => out.count = true, .@"--no-commit-header" => out.commit_header = false, .@"--commit-header" => out.commit_header = true, .@"--not" => try words.append(gpa, .not), .@"--all", .@"--branches", .@"--tags", .@"--remotes", .@"--glob" => { any_listing = true; const what: history.Listing = switch (o) { .@"--all" => .all, .@"--branches" => .branches, .@"--tags" => .tags, .@"--remotes" => .remotes, else => .glob, }; try words.append(gpa, .{ .listing = .{ .what = what, .value = value } }); }, .@"--exclude" => try words.append(gpa, .{ .exclude = value.? }), .@"--full-history" => simplify.full_history = true, .@"--dense" => simplify.sparse = false, .@"--sparse" => simplify.sparse = true, .@"--simplify-merges" => { simplify.simplify_merges = true; simplify.full_history = true; }, .@"--show-pulls" => simplify.show_pulls = true, .@"--remove-empty" => simplify.remove_empty = true, .@"--simplify-by-decoration" => by_decoration = true, } } // git rewrites parents for --simplify-merges as for --parents, which // it will not have with --children. if (out.children and (out.parents or simplify.simplify_merges)) return tool.fatalMessage(stderr, "options '--parents' and '--children' cannot be used together", .{}); simplify.rewrite_parents = out.parents or out.children; // Which boundary commits' children git lists in a symmetric difference // depends on which commits finding its merge bases read, which is not // matched. if (out.children and options.boundary) for (words.items) |w| switch (w) { .revision => |r| if (std.mem.find(u8, r.text, "...") != null) return tool.fatalMessage(stderr, "zig-git {s}: --children with --boundary and a symmetric difference is not supported", .{command_name}), else => {}, }; var has_revision = false; for (words.items) |w| if (w == .revision) { has_revision = true; }; var path_phase = false; var path_count: usize = 0; var revision_count: usize = 0; if (!has_revision and !any_listing and command == .rev_list) { try stderr.writeAll(usage); return error.UsageQuiet; }
var repo = try discovery.open(ctx, stderr); defer repo.close(ctx.io); const settings = try object_names.settings(&repo.config, stderr); var names: object_names.Names = .init(ctx, &repo, settings); defer names.deinit(); var revs = try revisions.Revisions.init(ctx, &repo, settings, .rev_parse, stderr); defer revs.deinit(); revs.resolver.options.sets = false; var h = try history.History.open(ctx, &repo, stderr); h.command = command_name; defer h.close();
// Describing commits, for log and for rev-list asked to format them, // and the searches, which choose commits for either; their settings are // read before any revision, as git reads them. const formatting = command == .log or fmt.formatted(); var describer: ?describe.Describer = null; defer if (describer) |*d| d.deinit(); const searching = fmt.messages.items.len != 0 or fmt.authors.items.len != 0 or fmt.committers.items.len != 0; if (formatting or searching) { describer = try describe.Describer.init(ctx, &repo, &names, &revs, &fmt, command, stderr); describer.?.commit_header = out.commit_header; }
if (since_text) |t| options.since = git.date.approximate(t, revisions.now(ctx), revs.zone).timestamp; if (until_text) |t| options.until = git.date.approximate(t, revisions.now(ctx), revs.zone).timestamp; if (since_filter_text) |t| options.since_filter = git.date.approximate(t, revisions.now(ctx), revs.zone).timestamp;
// The starting points, in the order given. var starts: std.ArrayList(RevWalk.Start) = .empty; defer starts.deinit(gpa); var negate = false; var excludes: std.ArrayList([]const u8) = .empty; defer excludes.deinit(gpa); var parts: std.ArrayList(history.Part) = .empty; defer parts.deinit(gpa); var decorated: Decorated = undefined; var refs: std.ArrayList(history.Ref) = .empty; defer refs.deinit(gpa); // What each starting point is called, for `--source` and `%S`. var source_names: std.ArrayList([]const u8) = .empty; defer source_names.deinit(gpa); for (words.items) |w| switch (w) { .not => negate = !negate, .exclude => |x| try excludes.append(gpa, x), .listing => |l| { refs.clearRetainingCapacity(); try history.listRefs(ctx, &repo, arena, l.what, l.value, excludes.items, &refs, stderr); excludes.clearRetainingCapacity(); for (refs.items) |r| { const c = try revs.resolver.peelToCommit(r.id) orelse continue; try source_names.append(gpa, sourceName(l.what, r.name)); try starts.append(gpa, .{ .commit = try h.store.name(c), .excluded = negate, .source = @intCast(source_names.items.len - 1) }); } }, .revision => |word| { const text = word.text; if (path_phase) { try pathAfterPath(ctx, &repo, text, stderr); try paths.insert(gpa, path_count, text); path_count += 1; continue; } var diag: Diagnostics = .{}; parts.clearRetainingCapacity(); if (try history.expandSet(&revs, &h, arena, text, &parts, &diag, stderr)) |_| { if (dashdash == null) try notAlsoPath(ctx, &repo, text, stderr); revision_count += 1; // git queues the excluded end of `A..B` and `A^-<n>` // first, which decides the order of commits with one date. if (git.revision.parseSet(text)) |set| switch (set) { .range, .parent_range => std.mem.reverse(history.Part, parts.items), else => {}, }; for (parts.items) |p| { const c = try revs.resolver.peelToCommit(p.id) orelse continue; try source_names.append(gpa, p.symbolic); try starts.append(gpa, .{ .commit = try h.store.name(c), .excluded = p.excluded != negate, .side = p.side, .source = @intCast(source_names.items.len - 1) }); } continue; } const found = revs.resolver.resolve(text, &diag) catch |err| { const sev = git.Resolver.severity(err); if (sev != .not_found and sev != .diagnosed) { try revs.flushMessages(stderr, 1, false); try revisions.stopAt(err, &diag, false, revs.resolver.loud, stderr); unreachable; } try revs.flushMessages(stderr, 1, false); // With `--` given, git takes nothing before it for a path. if (text[0] == '^' or dashdash != null) return tool.fatalMessage(stderr, "bad revision '{s}'", .{text}); if (try pathAccepted(ctx, &repo, text)) { // The first path: what follows must be paths too, and // no option may. for (option_at.items) |at| if (at > word.at) return tool.fatalMessage(stderr, "option '{s}' must come before non-option arguments", .{args[at]}); path_phase = true; try paths.insert(gpa, path_count, text); path_count += 1; continue; } // What the resolver could say of it, git says. if (sev == .diagnosed) return tool.fatalMessage(stderr, "{s}", .{diag.detail()}); try stderr.print( \\fatal: ambiguous argument '{s}': unknown revision or path not in the working tree. \\Use '--' to separate paths from revisions, like this: \\'git <command> [<revision>...] -- [<file>...]' \\ , .{text}); return error.Fatal; }; try revs.flushMessages(stderr, 1, false); if (dashdash == null) try notAlsoPath(ctx, &repo, text, stderr); revision_count += 1; const c = try revs.resolver.peelToCommit(found.id) orelse continue; try source_names.append(gpa, text); try starts.append(gpa, .{ .commit = try h.store.name(c), .excluded = negate, .source = @intCast(source_names.items.len - 1) }); }, }; if (revision_count == 0 and !any_listing) { if (command == .rev_list) { try stderr.writeAll(usage); return error.UsageQuiet; } // log shows HEAD's history when given nothing to start from. if (!has_revision) { var diag: Diagnostics = .{}; const head = revs.resolver.resolve("HEAD", &diag) catch { revs.resolver.clearMessages(); const branch = repo.branch() orelse "HEAD"; return tool.fatalMessage(stderr, "your current branch '{s}' does not have any commits yet", .{branch}); }; revs.resolver.clearMessages(); try source_names.append(gpa, "HEAD"); if (try revs.resolver.peelToCommit(head.id)) |c| try starts.append(gpa, .{ .commit = try h.store.name(c), .source = @intCast(source_names.items.len - 1) }); } }
// The pathspec, read only when there are paths, as git reads its // settings only then. var pathspec: git.Pathspec = .empty; defer if (paths.items.len != 0) pathspec.deinit(gpa); var follower: ?log_diff.Follower = null; defer if (follower) |*f| f.deinit(); // log.follow follows a single path as --follow would. if (command == .log and !follow and paths.items.len == 1) { if (repo.config.get("log.follow") catch null) |e| { follow = git.Config.value.parseBool(e.value) orelse return tool.fatalMessage(stderr, "bad boolean config value '{s}' for 'log.follow'", .{e.value orelse ""}); } } if (follow) { if (paths.items.len != 1) return tool.fatalMessage(stderr, "--follow requires exactly one pathspec", .{}); // The commits are chosen by the pathspec they followed, not // limited by the one given. if (options.reverse) return tool.fatalMessage(stderr, "zig-git {s}: --follow with --reverse is not supported", .{command_name}); follower = try log_diff.Follower.init(ctx, &repo, try pathspecs.parse(ctx, &repo, paths.items, command_name, stderr), &diff_args, diff_command, stderr); } else if (paths.items.len != 0) { pathspec = try pathspecs.parse(ctx, &repo, paths.items, command_name, stderr); simplify.pathspec = &pathspec; // Merges shown against their parents walk otherwise with paths // (as --full-history walks, for some), and are shown against the // rewritten parents, by rules not wholly matched. // whatchanged was seen to walk otherwise with paths, by a rule // not matched. if (diff_args.whatchanged) return tool.fatalMessage(stderr, "zig-git {s}: paths are not supported", .{command_name}); if (command == .log) switch (diff_args.merges) { .on, .separate, .first_parent => return tool.fatalMessage(stderr, "zig-git {s}: -m, --dd and --diff-merges with paths are not supported", .{command_name}), else => {}, }; if (options.ancestry_path) return tool.fatalMessage(stderr, "zig-git {s}: --ancestry-path with paths is not supported", .{command_name}); if (simplify.simplify_merges and !by_decoration) { const excluding = for (starts.items) |st| { if (st.excluded) break true; } else false; const refusal: ?[]const u8 = if (excluding) "an excluded commit" else if (simplify.remove_empty) "--remove-empty" else if (simplify.sparse) "--sparse" else if (options.first_parent) "--first-parent" else if (options.boundary) "--boundary" else if (simplify.show_pulls) "--show-pulls" else if (options.min_parents != 0 or options.max_parents != null) "a parent count" else if (h.graph.layers.len != 0) "a commit-graph" else null; if (refusal) |r| return tool.fatalMessage(stderr, "zig-git {s}: --simplify-merges with paths and {s} is not supported", .{ command_name, r }); } // Walked by generation number, git simplifies as the library // does, and none of these is refused. options.simplify = simplify; const by_generation = RevWalk.walksByGeneration(options, h.graph.layers.len != 0); if (options.first_parent and !by_generation) { const refusal: ?[]const u8 = if (out.parents) "--parents" else if (out.children) "--children" else if (options.boundary) "--boundary" else if (options.order != .default) "an ordering" else if (simplify.remove_empty) "--remove-empty" else null; if (refusal) |r| return tool.fatalMessage(stderr, "zig-git {s}: --first-parent with paths and {s} is not supported", .{ command_name, r }); } if (simplify.remove_empty and options.boundary and !by_generation) return tool.fatalMessage(stderr, "zig-git {s}: --remove-empty with paths and --boundary is not supported", .{command_name}); if (simplify.remove_empty and options.reverse and out.parents and !by_generation) return tool.fatalMessage(stderr, "zig-git {s}: --remove-empty with paths, --reverse and --parents is not supported", .{command_name}); simplify.bloom = h.changed_paths; // git's --remove-empty lists other commits when its changed-path // filters answer for it than when it compares trees. if (simplify.remove_empty and simplify.bloom and pathspec.bloomKeys()) { for (h.graph.layers) |l| if (l.bloom_settings != null) return tool.fatalMessage(stderr, "zig-git {s}: --remove-empty with paths in a repository with changed-path Bloom filters is not supported", .{command_name}); } } // `--simplify-by-decoration`: the decorations the options choose, // whether or not they are shown. var decorations: ?git.Decorations = null; defer if (decorations) |*x| x.deinit(); if (by_decoration) { // As --simplify-merges does with paths, it was seen to list // commits git's rules did not account for with these. const excluding = for (starts.items) |st| { if (st.excluded) break true; } else false; // With --ancestry-path, it was matched with exclusions; without // it, not. const refusal: ?[]const u8 = if (excluding and !options.ancestry_path) "an excluded commit and without --ancestry-path" else if (paths.items.len != 0) "paths" else if (simplify.remove_empty) "--remove-empty" else if (simplify.sparse) "--sparse" else if (options.first_parent) "--first-parent" else if (options.boundary) "--boundary" else if (simplify.show_pulls) "--show-pulls" else if (options.min_parents != 0 or options.max_parents != null) "a parent count" else if (out.children) "--children" else null; if (refusal) |r| return tool.fatalMessage(stderr, "zig-git {s}: --simplify-by-decoration with {s} is not supported", .{ command_name, r }); decorations = try describe.loadDecorations(ctx, &repo, &fmt, command, false, stderr); decorated = .{ .h = &h, .decorations = &decorations.? }; simplify.decorated = .{ .context = &decorated, .has = Decorated.has }; simplify.full_history = true; simplify.simplify_merges = true; } options.simplify = simplify; if (options.ancestry_path) { const any_bottom = for (starts.items) |st| { if (st.excluded) break true; } else false; if (!any_bottom) return tool.fatalMessage(stderr, "--ancestry-path given but there are no bottom commits", .{}); }
var diffs: ?*log_diff.Writer = null; defer if (diffs) |dw| dw.deinit(); if (command == .log) diffs = try log_diff.Writer.init(ctx, &repo, &revs, &names, settings, &diff_args, diff_command, &pathspec, options.first_parent, false, stderr); if (follower) |*f| if (diffs) |dw| { if (dw.settings.pickaxe != null) return tool.fatalMessage(stderr, "zig-git {s}: --follow with the pickaxe is not supported", .{command_name}); // The diff of each commit finds where the file came from as the // follower does. dw.follower = f; dw.warnings.quiet = true; dw.settings.renames = true; };
const abbrev_commit = fmt.abbrev_commit orelse false; var filter: Filter = .{ .h = &h, .describer = if (describer) |*d| d else undefined, .repo = &repo, .ctx = ctx, .searching = searching }; if (diffs) |dw| if (dw.filtering()) { filter.diffs = dw; }; if (follower) |*f| filter.follower = f; if (searching or filter.diffs != null or filter.follower != null) options.filter = .{ .context = &filter, .keep = Filter.keep };
var walk: RevWalk = .init(gpa, &h.store, options); defer walk.deinit(); for (starts.items) |s| try walk.addStart(s); try h.report(stderr);
const id_length: ?usize = if (abbrev_commit and !fmt.no_abbrev) fmt.abbrev orelse names.defaultLength() else null; var left: u64 = 0; var right: u64 = 0; var parents: std.ArrayList(ObjectId) = .empty; defer parents.deinit(gpa); var children: std.ArrayList(ObjectId) = .empty; defer children.deinit(gpa); // Whether the walk's parents are shown in place of the commits' own: // when they are rewritten. const rewritten = out.parents or out.children or simplify.simplify_merges; while (true) { var diag: Diagnostics = .{}; const entry = walk.next(&diag) catch |err| { try h.report(stderr); if (err == error.FilterFailed) return filter.fail(stderr); return h.fail(err, &diag, walk.failed_child, stderr); } orelse break; try h.report(stderr); if (out.count) { if (entry.boundary) continue; if (walk.isLeft(entry.commit)) left += 1 else right += 1; continue; } const id = h.store.id(entry.commit); if (formatting) { const d = &describer.?; parents.clearRetainingCapacity(); children.clearRetainingCapacity(); if (rewritten) { for (walk.parentsOf(entry)) |p| try parents.append(gpa, h.store.id(p)); } else { try h.store.load(entry.commit, &diag); for (h.store.parents(entry.commit)) |p| try parents.append(gpa, h.store.id(p)); } if (out.children) for (walk.childrenOf(entry.commit)) |c| try children.append(gpa, h.store.id(c)); const mark: git.pretty.Mark = if (entry.boundary) .boundary else if (walk.isLeft(entry.commit)) .left else .right; d.settings.show_parents = out.parents; d.settings.show_children = out.children; var real: std.ArrayList(ObjectId) = .empty; defer real.deinit(gpa); if (diffs != null) { try h.store.load(entry.commit, &diag); for (h.store.parents(entry.commit)) |p| try real.append(gpa, h.store.id(p)); } const each: []const ?ObjectId = if (diffs) |dw| (if (dw.separately(real.items)) blk: { const froms = try gpa.alloc(?ObjectId, real.items.len); for (froms, real.items) |*f, p| f.* = p; break :blk froms; } else &.{null}) else &.{null}; defer if (each.len > 1) gpa.free(each); if (diffs) |dw| try dw.checkCombined(real.items); for (each) |from| { // A merge has no entry for a parent against which nothing // differs (or nothing is kept), as git was seen to leave // it out. if (from) |p| if (!try diffs.?.keepsAgainst(id, p)) continue; try d.writeCommit(stdout, .{ .id = id, .parents = parents.items, .children = children.items, .mark = mark, .show_mark = entry.boundary or out.left_right, .header_parents = out.parents, .time = if (out.timestamp) h.store.time(entry.commit) else null, // rev-list knows no sources. .source = if (command == .rev_list) null else if (walk.sourceOf(entry.commit)) |n| source_names.items[n] else null, .from = from, }, stderr); if (diffs) |dw| { if (from) |p| try dw.writeAgainst(stdout, id, p, d.framing()) else try dw.write(stdout, id, real.items, d.framing()); } } continue; } if (out.timestamp) try stdout.print("{d} ", .{h.store.time(entry.commit)}); try writeMarker(stdout, entry, out, &walk); try names.write(stdout, id, id_length); if (out.parents) for (walk.parentsOf(entry)) |p| try stdout.print(" {f}", .{h.store.id(p)}); if (out.children) for (walk.childrenOf(entry.commit)) |c| try stdout.print(" {f}", .{h.store.id(c)}); try stdout.writeByte('\n'); } if (out.count) { if (out.left_right) try stdout.print("{d}\t{d}\n", .{ left, right }) else try stdout.print("{d}\n", .{left + right}); } if (diffs) |dw| if (dw.problems) return error.CheckProblems;}
/// Whether a commit has decorations, for `--simplify-by-decoration`.const Decorated = struct { h: *history.History, decorations: *const git.Decorations,
fn has(context: *const anyopaque, commit: git.Commits.Index) bool { const d: *const Decorated = @ptrCast(@alignCast(context)); return d.decorations.of(d.h.store.id(commit)).len != 0; }};
/// What `--source` calls a starting point a listing chose: `--all` and/// `--glob` the ref's whole name, the others the name below their/// namespace.fn sourceName(what: history.Listing, name: []const u8) []const u8 { const strip: []const u8 = switch (what) { .branches => "refs/heads/", .tags => "refs/tags/", .remotes => "refs/remotes/", .all, .glob => "", }; return if (std.mem.startsWith(u8, name, strip)) name[strip.len..] else name;}
fn writeMarker(w: *Io.Writer, entry: RevWalk.Entry, out: Output, walk: *RevWalk) !void { if (entry.boundary) return w.writeByte('-'); if (out.left_right) try w.writeByte(if (walk.isLeft(entry.commit)) '<' else '>');}
fn allDigits(text: []const u8) bool { if (text.len == 0) return false; for (text) |c| if (!std.ascii.isDigit(c)) return false; return true;}
/// A count as git's revision options read one: an integer, negative for/// no limit.fn count(text: []const u8, stderr: *Io.Writer) !?u64 { const n = try integer(text, stderr); if (n < 0) return null; return @intCast(n);}
fn integer(text: []const u8, stderr: *Io.Writer) !i64 { return object_names.optionNumber(text) orelse tool.fatalMessage(stderr, "'{s}': not an integer", .{text});}
/// The command run, as what it says names it: `whatchanged` runs as/// `log`, and is named for itself.fn commandName(command: describe.Command, as_whatchanged: bool) []const u8 { if (as_whatchanged) return "whatchanged"; return switch (command) { .rev_list => "rev-list", .log => "log", .show => "show", };}
/// The searches, asked of each commit the walk would list.const Filter = struct { ctx: Context, h: *history.History, repo: *git.Repository, describer: *describe.Describer, /// Whether commits are chosen by their messages and identities. searching: bool, /// Set when commits are chosen by their diffs (`--diff-filter`). diffs: ?*log_diff.Writer = null, /// Set under `--follow`. follower: ?*log_diff.Follower = null, failure: ?anyerror = null, failed_id: ?ObjectId = null,
fn keep(context: *anyopaque, commit: git.Commits.Index) error{FilterFailed}!bool { const f: *Filter = @ptrCast(@alignCast(context)); if (f.searching and !try f.keepMessage(commit)) return false; if (f.diffs == null and f.follower == null) return true; const id = f.h.store.id(commit); var diag: Diagnostics = .{}; f.h.store.load(commit, &diag) catch |err| { f.failure = err; f.failed_id = id; return error.FilterFailed; }; var parents: std.ArrayList(ObjectId) = .empty; defer parents.deinit(f.ctx.gpa); for (f.h.store.parents(commit)) |p| parents.append(f.ctx.gpa, f.h.store.id(p)) catch |err| { f.failure = err; f.failed_id = id; return error.FilterFailed; }; if (f.follower) |fl| { const kept = fl.keep(id, parents.items) catch |err| { f.failure = err; f.failed_id = id; return error.FilterFailed; }; if (!kept) return false; } const dw = f.diffs orelse return true; return dw.keeps(id, parents.items) catch |err| { f.failure = err; f.failed_id = id; return error.FilterFailed; }; }
fn keepMessage(f: *Filter, commit: git.Commits.Index) error{FilterFailed}!bool { const id = f.h.store.id(commit); var diag: Diagnostics = .{}; var obj = f.repo.objects().read(f.ctx.io, f.ctx.gpa, id, &diag) catch |err| { f.failure = err; f.failed_id = id; return error.FilterFailed; }; defer obj.deinit(f.ctx.gpa); return f.describer.keeps(obj.data) catch |err| { f.failure = err; f.failed_id = id; return error.FilterFailed; }; }
fn fail(f: *Filter, stderr: *Io.Writer) error{Fatal} { // What the diff writer refused, it has said already; what the // search could not re-encode, the describer says. if (f.failure.? == error.Fatal) { if (f.searching and f.describer.encoding_failure.detail().len != 0) return f.describer.reportEncodingFailure(stderr); return error.Fatal; } return tool.fatalMessage(stderr, "zig-git: cannot read commit {f}: {f}", .{ f.failed_id.?, tool.errorWords(f.failure.?) }); }};
fn abbrevLength(text: []const u8) ?usize { const n = object_names.optionNumber(text) orelse return null; if (n <= 0) return null; return @intCast(@max(4, @min(n, git.object_max_hex_length)));}
fn pathAccepted(ctx: Context, repo: *const git.Repository, arg: []const u8) !bool { return pathspecs.takenForPath(ctx, repo, arg);}
/// Whether `arg` names a file in the working tree, `:/<path>` from its/// top.fn fileExists(ctx: Context, repo: *const git.Repository, arg: []const u8) !bool { const gpa = ctx.gpa; if (std.mem.startsWith(u8, arg, ":/")) { const top = repo.location.work_tree orelse return false; const full = try std.fs.path.join(gpa, &.{ top, arg[2..] }); defer gpa.free(full); _ = Io.Dir.cwd().statFile(ctx.io, full, .{ .follow_symlinks = false }) catch return false; return true; } const full = if (std.fs.path.isAbsolute(arg)) try gpa.dupe(u8, arg) else try std.fs.path.join(gpa, &.{ ctx.cwd, arg }); defer gpa.free(full); _ = Io.Dir.cwd().statFile(ctx.io, full, .{ .follow_symlinks = false }) catch return false; return true;}
/// A revision given without `--` must not also be a file, as git insists.fn notAlsoPath(ctx: Context, repo: *const git.Repository, arg: []const u8, stderr: *Io.Writer) !void { if (!try fileExists(ctx, repo, arg)) return; try stderr.print( \\fatal: ambiguous argument '{s}': both revision and filename \\Use '--' to separate paths from revisions, like this: \\'git <command> [<revision>...] -- [<file>...]' \\ , .{arg}); return error.Fatal;}
/// An argument after the first path, which must be one too.fn pathAfterPath(ctx: Context, repo: *const git.Repository, arg: []const u8, stderr: *Io.Writer) !void { if (try pathAccepted(ctx, repo, arg)) return; try stderr.print("fatal: {s}: no such path in the working tree.\nUse 'git <command> -- <path>...' to specify paths that do not exist locally.\n", .{arg}); return error.Fatal;}