diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f94d89..8a42038 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -122,6 +122,8 @@ running it, or the build fails as if the file were missing. | `src/trailers.zig` | Trailer blocks, found and written as `%(trailers)` writes them | | `src/Mailmap.zig` | Mailmaps: their lines, lookups, and a repository's sources | | `src/grep.zig` | Choosing commits by message and identity: `--grep`, `--author`, `--committer` | +| `src/encoding.zig` | Commits re-encoded for output as git re-encodes them, UTF-8 and ISO-8859-1 | +| `src/contents.zig` | A commit's or tag's message as `for-each-ref`'s `%(contents...)` atoms take it: the signature and the subject | | `src/Decorations.zig` | Decorations: the refs, `HEAD`, grafted and replaced commits shown beside commits, and the patterns that choose them | | `src/describe.zig` | Naming a commit by the nearest tag, as `git describe` does | | `src/discover.zig` | Finding a repository as git does: the walk, `.git` files, ceilings, ownership | @@ -191,6 +193,7 @@ running it, or the build fails as if the file were missing. | `tools/line_prefix.zig` | `--line-prefix`: a writer putting the prefix before every line | | `tools/whatchanged_refusal.txt` | What git 2.56 says to `whatchanged` without `--i-still-use-this` | | `tools/diffcore_tests.zig` | The pickaxe, orderfiles, rotation, `--relative`, `--line-prefix`, `-D`, `--check`, `--dirstat` and `whatchanged` compared with git | +| `tools/grebe_tests.zig` | Every question grebe asks git, in the shapes it asks them, compared with git | | `tools/diff_tree.zig` | `zig-git diff-tree` | | `tools/diff_tree_usage.txt`, `tools/diff_index_usage.txt`, `tools/diff_files_usage.txt` | git's usage texts for `diff-tree`, `diff-index` and `diff-files` | | `tools/diff_tree_tests.zig` | `diff-tree` compared with git over every kind of change, format and pathspec | diff --git a/README.md b/README.md index bd37b1d..435f1ec 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,9 @@ do: `--ancestry-path`, `--since`, `--until`, `--since-as-filter`, `--boundary`, the sides of a symmetric difference and children — every rule found by comparing a model with git over thousands of generated - histories, merges, octopus merges and criss-cross merges included; + histories, merges, octopus merges and criss-cross merges included — + and, with a commit-graph, the exact list git's ordered walks give for a + range, however the dates in it run; - merge bases as `git merge-base` finds them, `--all` in git's order for criss-cross histories, `--octopus`, `--independent`, `--is-ancestor` and `--fork-point` from the reflog. @@ -193,7 +195,10 @@ do: - describing commits as `git log --pretty` does, as a library that writes to an `Io.Writer` and nothing else: the built-in formats `oneline`, `short`, `medium`, `full`, `fuller`, `reference`, `email` (with its RFC - 2047 encoded words and folded headers), `mboxrd` and `raw`, and + 2047 encoded words and folded headers), `mboxrd` and `raw`, written in + the output encoding (`--encoding`, `i18n.logOutputEncoding`, + `i18n.commitEncoding`) with commits in UTF-8 and ISO-8859-1 converted + as git converts them, and `format:`, `tformat:` and bare `%` strings with every placeholder that needs no decorations, notes, signatures or reflogs — object IDs whole and shortened, identities in every date mode and through the mailmap, @@ -221,7 +226,8 @@ do: `log.initialDecorationSet` with git's rules for its patterns, colored by `color.decorate.*`; `%d`, `%D` and `%(decorate:...)` with every option; `--source` and `%S`; and `--simplify-by-decoration`, simplified as git - simplifies it; + simplifies it, from a range with `--ancestry-path` too, where git lets + go of parents off the path as it was seen to; - naming commits by the nearest tag, as `git describe` does: annotated tags, lightweight ones or every ref, `--match` and `--exclude`, git's choice among the names of one commit, and its depth, which is counted @@ -289,7 +295,9 @@ do: `--numstat`, `--stat` with git's layout (fitted to over a thousand of git's outputs: the widths, `COLUMNS`, `diff.statNameWidth`, `diff.statGraphWidth`, `--stat-count`, and the bars' scaling), - `--shortstat`, `--summary` and the patch, in git's order, with `-R`, + `--compact-summary` (what `--summary` says, in brief, beside each name + in the diffstat), `--shortstat`, `--summary` and the patch, in git's + order, with `-R`, binary files told by attributes, contents and `core.bigFileThreshold`, gitlinks as `Subproject commit` lines, and what whitespace options and `-I` drop from each format; @@ -432,12 +440,14 @@ types, and GNU's regular expression extensions and back references. `--path-format` — as `git rev-parse` does, and answers single revisions with `--verify`, `-q`, `--short[=]`, `--symbolic`, `--symbolic-full-name`, `--abbrev-ref[=(strict|loose)]`, `--default`, -`--end-of-options`, `--` and `--disambiguate=`, telling revisions -from paths as git does, and lists refs with `--all`, `--branches`, -`--tags`, `--remotes`, `--glob` and `--exclude`. It prints ranges and -the other sets of commits as the revisions they stand for (`A..B` as `B` -and `^A`), in each of its ways of naming them, and refuses by name the -options it does not have (`--not`, `--sq`, `--revs-only` and the like). +`--end-of-options`, `--`, `--not`, `--revs-only` (keeping the options +git keeps for `rev-list`, which were found by trying every one its manual +names) and `--disambiguate=`, telling revisions from paths as git +does, and lists refs with `--all`, `--branches`, `--tags`, `--remotes`, +`--glob` and `--exclude`. It prints ranges and the other sets of commits +as the revisions they stand for (`A..B` as `B` and `^A`), in each of its +ways of naming them, and refuses by name the options it does not have +(`--sq`, `--no-revs`, `--since` and the like). `zig-git rev-list` walks commits as `git rev-list` does, with every option above and `--parents`, `--children`, `--timestamp`, `--left-right`, `--count`, `--abbrev-commit`, the searches by message @@ -454,8 +464,9 @@ names commits with every option above, `--long`, `--abbrev`, included. `zig-git log` walks as `rev-list` does, every option above included, and describes each commit in any format above, with `--oneline`, `--abbrev-commit`, `--date`, -`--expand-tabs`, `--use-mailmap`, `--color`, the searches, decorations -and `--source`, and +`--expand-tabs`, `--use-mailmap`, `--color`, `-z` (a NUL where a line +feed ends a commit or separates two, and before a merge's combined +diff), the searches, decorations and `--source`, and `format.pretty`, `pretty.`, `log.date`, `log.abbrevCommit`, `log.mailmap`, `log.decorate`, `color.ui`, `color.diff`, `color.diff.commit` and `color.decorate.*`; `zig-git @@ -466,7 +477,8 @@ They refuse by name what needs what the library does not have yet: notes (`--notes`, `%N`), signatures (`--show-signature`, `%G?` and the rest), reflog walks and `%g*`, `--graph`, Perl's regular expressions (`-P` and -`grep.patternType=perl`), and output in any encoding but UTF-8. Both read through the +`grep.patternType=perl`), and commits or output in any encoding but +UTF-8 and ISO-8859-1. Both read through the commit-graph when git would, say what git says of a corrupt one, and walk the history replace refs, grafts and a shallow boundary make. `zig-git check-ref-format` (`--normalize`, `--allow-onelevel`, @@ -476,7 +488,10 @@ the history replace refs, grafts and a shallow boundary make. `zig-git for-each-ref` (patterns, `--exclude`, `--sort=[-]refname`, `--count`, the reachability filters, and the `--format` atoms `refname` with its `:short`, `:lstrip` and `:rstrip`, `objectname` and `*objectname` with their -`:short[=]`, `objecttype`, `symref`, `*objecttype` and `HEAD`) and +`:short[=]`, `objecttype`, `symref`, `*objecttype`, `HEAD`, +`subject` and `contents:subject`, and `%(if)`, `%(if:equals=...)`, +`%(if:notequals=...)`, `%(then)`, `%(else)` and `%(end)`, nested, with +git's complaints about them) and `zig-git symbolic-ref` (`-q`, `--short`, `--no-recurse`) read refs as git's do, warnings about broken refs included, and refuse by name the atoms and options they do not have. `zig-git branch` and `zig-git tag` @@ -555,8 +570,8 @@ deliberate or not yet matched: not to take the nearest, nor one kind over another; with candidates of one kind it matches git's choice and depth; `--dirty`, `--broken`, `--debug` and describing a blob are refused; -- `--simplify-by-decoration` is refused with paths, exclusions, a - commit-graph, `--sparse`, `--first-parent`, `--boundary`, +- `--simplify-by-decoration` is refused with paths, exclusions without + `--ancestry-path`, `--sparse`, `--first-parent`, `--boundary`, `--show-pulls`, `--remove-empty`, `--children` or a parent count, the combinations where `--simplify-merges`, whose rules it shares, was seen to differ or was not compared; @@ -602,12 +617,13 @@ deliberate or not yet matched: is set, since git asks the monitor before it answers them, and long options cannot be abbreviated as git allows; - with a commit-graph, git orders `--topo-order`, `--date-order` and - `--author-date-order` walks by generation number, and with exclusions, - `--first-parent`, `--since`, `--until` or `--since-as-filter` it lists - something other than the same walk without a commit-graph — in one - split chain whose dates ran out of order, nothing at all for a range of - dozens of commits — so those combinations are refused there; without - them, and in the default order, the walk is the same either way; + `--author-date-order` walks by generation number; with exclusions that + lists exactly the commits the range holds (except with `--children`, + `--right-only` or `--simplify-merges`), which is matched, but with + `--first-parent`, `--since`, `--until` or `--since-as-filter`, and with + exclusions under paths, it lists something not matched, so those + combinations are refused there; without them, and in the default + order, the walk is the same either way; - `--ancestry-path=` is refused, and so is `--children` with `--boundary` for a symmetric difference, whose boundary commits' children depend on which commits git's merge-base search happened to @@ -634,10 +650,12 @@ deliberate or not yet matched: - `--follow` is refused across a merge (at the first one the walk reaches, after the commits before it are written) and with `--reverse`; -- a commit whose `encoding` header names anything but UTF-8 is refused - wherever its message or identities are shown, as is - `i18n.logOutputEncoding` or `--encoding` naming anything else, since - messages are not re-encoded; +- a commit whose `encoding` header names anything but UTF-8 or + ISO-8859-1 (under one of the names of it git was seen to accept) is + refused wherever its message or identities are shown or searched, as + is an output encoding naming anything else, since what git makes of + them depends on the C library's `iconv`; and the `email` format is + refused in any output encoding but UTF-8; - colors that git turns on only for a terminal (`--color=auto`, `color.ui=auto`) stay off, as `zig-git` takes its output never to be one; - a few corners of padding are not matched: `%>>()` taking back spaces diff --git a/src/RevWalk.zig b/src/RevWalk.zig index 51e4da6..20147ef 100644 --- a/src/RevWalk.zig +++ b/src/RevWalk.zig @@ -59,10 +59,19 @@ //! With a commit-graph, git orders `--topo-order`, `--date-order` and //! `--author-date-order` walks by generation number. That lists the same //! commits in the same order as the rules above for a walk without -//! exclusions, but not always with them, nor with `--first-parent`, -//! `--since`, `--until` or `--since-as-filter`: in a split chain whose -//! dates run out of order, git was seen to list nothing at all for a -//! range with hundreds of commits. Those combinations are refused. +//! exclusions. With them, the list is exact: the stop rule's tolerance +//! of clocks that run backwards is not needed, and nothing an excluded +//! commit reaches is listed, however its dates run, whether or not the +//! graph lists every commit and in one file or a split chain; the list +//! is then sorted as above. So the walk goes on until no queued commit +//! could reach a listed one by generation number. (The range of +//! hundreds of commits git was once seen to list nothing for is this: +//! everything in it was reachable from the excluded commit, through +//! dates far out of order.) With `--children`, `--right-only` or +//! `--simplify-merges`, though, git lists what the walk without a graph +//! lists. With `--first-parent`, `--since`, `--until` or +//! `--since-as-filter`, or with exclusions and paths, git lists what has +//! not been matched, and those combinations are refused. //! //! **History simplification**, for a walk limited to paths, was found the //! same way, over histories whose commits change files, and agrees with @@ -138,6 +147,9 @@ sequence: u64 = 0, child_lists: std.AutoHashMapUnmanaged(Index, std.ArrayList(Index)) = .empty, /// What `next` hands out, when it has been worked out whole. output: std.ArrayList(Entry) = .empty, +/// The commits `--ancestry-path` took out of the list that are not +/// excluded, which `--simplify-merges` passes over as parents. +off_path: std.AutoHashMapUnmanaged(Index, void) = .empty, output_at: usize = 0, /// How the walk is going. state: enum { new, streaming, listed, done } = .new, @@ -338,10 +350,10 @@ pub const Entry = struct { /// What walking can fail with: reading a commit failed, and when a parent /// could not be read, `failed_child` names the commit that named it; or -/// `UnsupportedGraphOrdering`: an ordering with exclusions, -/// `--first-parent`, `--since`, `--until` or `--since-as-filter` in a -/// repository with a commit-graph, where git walks by generation number -/// and lists what has not been matched. +/// `UnsupportedGraphOrdering`: an ordering with `--first-parent`, +/// `--since`, `--until`, `--since-as-filter`, or exclusions and paths, +/// in a repository with a commit-graph, where git walks by generation +/// number and lists what has not been matched. /// /// With paths, comparing trees can fail with `TreeDiff.Error`; and /// `UnsupportedParentRewrite` is rewritten parents with paths and @@ -373,6 +385,7 @@ pub fn deinit(w: *RevWalk) void { while (it.next()) |l| l.deinit(w.gpa); w.child_lists.deinit(w.gpa); w.output.deinit(w.gpa); + w.off_path.deinit(w.gpa); w.* = undefined; } @@ -978,6 +991,7 @@ fn simplifyOneMerge( var list: std.ArrayList(Parent) = .empty; defer list.deinit(w.gpa); const ps = w.simplifiedParents(c); + const treesame_commit = w.peekState(c).treesame; for (ps) |p0| { const same = !try w.differs(p0, c, null); const p = w.replacementOf(p0, in_list); @@ -1005,11 +1019,24 @@ fn simplifyOneMerge( defer step2.deinit(w.gpa); for (step1.items) |e| { const is_ancestor = for (step1.items) |o| { - if (o.commit != e.commit and try w.reaches(o.commit, e.commit, reach)) break true; + if (o.commit != e.commit and try w.reaches(o.commit, e.commit, in_list, reach)) break true; } else false; if (!is_ancestor) try step2.append(w.gpa, e); } try protect(w.gpa, step1.items, &step2, true); + // A commit TREESAME to its parents with one parent left on the + // ancestry path is replaced by it, whatever parents it has off the + // path; with more, it keeps those too. + var on_path: ?Parent = null; + var on_path_count: usize = 0; + for (step2.items) |e| if (!w.off_path.contains(e.commit)) { + on_path = e; + on_path_count += 1; + }; + if (treesame_commit and on_path_count == 1 and on_path_count != step2.items.len) { + step2.clearRetainingCapacity(); + try step2.append(w.gpa, on_path.?); + } const st = try w.simState(c); st.replaced = true; if (step2.items.len != 1 or !step2.items[0].same or st.pull) { @@ -1053,19 +1080,40 @@ fn protect(gpa: Allocator, before: anytype, after: anytype, condition: bool) All } /// Whether `ancestor` is reachable from `from` through real parents. -fn reaches(w: *RevWalk, from: Index, ancestor: Index, memo: *std.AutoHashMapUnmanaged(Index, std.AutoHashMapUnmanaged(Index, void))) Error!bool { +/// +/// With `--ancestry-path` it is ancestry as simplification has left it: +/// through the parents each listed commit was simplified to, the real +/// parents of excluded commits, and none of a commit off the path, as +/// git was seen to reckon it. +fn reaches( + w: *RevWalk, + from: Index, + ancestor: Index, + in_list: *const std.AutoHashMapUnmanaged(Index, void), + memo: *std.AutoHashMapUnmanaged(Index, std.AutoHashMapUnmanaged(Index, void)), +) Error!bool { + const simplified = w.off_path.count() != 0; const gop = try memo.getOrPut(w.gpa, from); if (!gop.found_existing) { gop.value_ptr.* = .empty; var stack: std.ArrayList(Index) = .empty; defer stack.deinit(w.gpa); try stack.append(w.gpa, from); + var one: [1]Index = undefined; while (stack.pop()) |x| { try w.store.load(x, null); - const n = w.store.parents(x).len; - var i: usize = 0; - while (i < n) : (i += 1) { - const p = w.store.parents(x)[i]; + const onward: []const Index = if (simplified and in_list.contains(x)) blk: { + const st = w.peekState(x); + if (!st.replaced) break :blk w.store.parents(x); + if (st.remains) break :blk w.span(st.rewritten_parents); + one[0] = st.replacement; + break :blk &one; + } else w.store.parents(x); + // Reading a parent may move the parent pools; take a copy. + var copy: std.ArrayList(Index) = .empty; + defer copy.deinit(w.gpa); + try copy.appendSlice(w.gpa, onward); + for (copy.items) |p| { const set_ptr = memo.getPtr(from).?; if ((try set_ptr.getOrPut(w.gpa, p)).found_existing) continue; try stack.append(w.gpa, p); @@ -1128,10 +1176,34 @@ const slop = 5; fn limit(w: *RevWalk, list: *std.ArrayList(Index), diag: ?*Diagnostics) Error!void { var last: ?i64 = null; var budget: u32 = slop; + // With an ordering and a commit-graph, git's list is exact: nothing an + // excluded commit reaches is left in it, however the dates run. The + // walk goes on until no queued commit could reach a listed one, which + // the generation numbers tell: a commit reaches only commits of lower + // generation, and one the graph does not list (whose generation is + // infinite) may reach anything. + // With `--children`, `--right-only` or `--simplify-merges` git walks + // by date as it does without a graph (with `--left-only`, + // `--left-right`, `--boundary`, `--parents` and the rest, it does + // not), as it was seen to. + const simplifying_merges = if (w.options.simplify) |s| s.simplify_merges else false; + const exact = w.options.order != .default and w.store.graph.layers.len != 0 and + !w.options.children and !w.options.right_only and !simplifying_merges; + var lowest_listed: u64 = Commits.infinite_generation; while (w.pop()) |c| { if (w.options.since) |s| if (w.store.time(c) < s) try w.set(c, excluded_flag); try w.process(c, diag); if (w.excluded(c)) { + if (exact) { + if (w.queue.items.len == 0) break; + if (!w.everyQueuedExcluded()) continue; + const unreachable_from_queue = for (w.queue.items) |q| { + const g = w.store.generation(q.commit); + if (g == Commits.infinite_generation or g > lowest_listed) break false; + } else true; + if (unreachable_from_queue) break; + continue; + } const newest = w.newestQueued() orelse break; const listed_older = if (last) |l| l <= newest else false; if (listed_older) { @@ -1151,6 +1223,7 @@ fn limit(w: *RevWalk, list: *std.ArrayList(Index), diag: ?*Diagnostics) Error!vo if (w.options.since_filter) |f| if (w.store.time(c) < f) continue; try list.append(w.gpa, c); last = w.store.time(c); + lowest_listed = @min(lowest_listed, w.store.generation(c)); } } @@ -1208,7 +1281,7 @@ fn limitToAncestry(w: *RevWalk, list: *std.ArrayList(Index)) Allocator.Error!voi if (related[c] != 0) { list.items[kept] = c; kept += 1; - } + } else if (!w.excluded(c)) try w.off_path.put(w.gpa, c, {}); } list.shrinkRetainingCapacity(kept); } @@ -1428,10 +1501,11 @@ pub fn next(w: *RevWalk, diag: ?*Diagnostics) Error!?Entry { .new => { const o = w.options; if (o.order != .default and w.store.graph.layers.len != 0) { + // With exclusions and paths, git's list was not matched. const excluding = for (w.starts.items) |st| { if (st.excluded) break true; } else false; - if (excluding or o.first_parent or o.since != null or o.until != null or o.since_filter != null) + if (o.first_parent or o.since != null or o.until != null or o.since_filter != null or (excluding and w.pruning())) return error.UnsupportedGraphOrdering; } if (w.rewriting() and !w.isLimited()) { diff --git a/src/contents.zig b/src/contents.zig new file mode 100644 index 0000000..04ee41f --- /dev/null +++ b/src/contents.zig @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie +// SPDX-License-Identifier: MIT + +//! A commit's or an annotated tag's message taken apart as +//! git-for-each-ref(1)'s `%(contents...)` atoms take it, read on bytes with +//! no I/O. +//! +//! The documentation says the subject is the first paragraph, joined into +//! one line, and that a signature is set apart from the rest. The details +//! came from messages written with `git commit-tree` and `git hash-object +//! -t tag --literally` and read back with `git for-each-ref`; they are not +//! those of `git log`'s `%s`: +//! +//! - **The signature** begins at the last line that begins with +//! `-----BEGIN PGP SIGNATURE-----`, `-----BEGIN PGP MESSAGE-----`, +//! `-----BEGIN SSH SIGNATURE-----` or `-----BEGIN SIGNED MESSAGE-----`, +//! and runs to the end. It is set apart from a commit's message as well +//! as a tag's, though a commit's real signature is in its headers. +//! - **The subject** is what is left after line feeds at the start, up to +//! the first empty line: the first `\n\n` or `\r\n\r\n`. A line of +//! spaces or a lone carriage return is not empty, and a line feed after +//! `\n\r` does not end it. Carriage returns and line feeds at its end +//! are dropped, but not spaces or tabs, and each line break inside it, +//! `\r\n` or `\n`, becomes one space; a carriage return anywhere else is +//! kept. + +const std = @import("std"); +const Io = std.Io; + +/// The lines a signature begins with. +const signature_starts = [_][]const u8{ + "-----BEGIN PGP SIGNATURE-----", + "-----BEGIN PGP MESSAGE-----", + "-----BEGIN SSH SIGNATURE-----", + "-----BEGIN SIGNED MESSAGE-----", +}; + +/// Where the signature at the end of `message` begins: `message.len` if it +/// has none. +pub fn signatureStart(message: []const u8) usize { + var found = message.len; + var line: usize = 0; + while (line < message.len) { + for (signature_starts) |s| if (std.mem.startsWith(u8, message[line..], s)) { + found = line; + break; + }; + line = if (std.mem.findScalarPos(u8, message, line, '\n')) |nl| nl + 1 else break; + } + return found; +} + +/// The part of `message` the subject is made of, before its line breaks +/// are made spaces. +fn subjectSpan(message: []const u8) []const u8 { + var rest = message[0..signatureStart(message)]; + while (rest.len != 0 and rest[0] == '\n') rest = rest[1..]; + const lf = std.mem.find(u8, rest, "\n\n") orelse rest.len; + const crlf = std.mem.find(u8, rest, "\r\n\r\n") orelse rest.len; + rest = rest[0..@min(lf, crlf)]; + return std.mem.trimEnd(u8, rest, "\r\n"); +} + +/// Writes the subject of `message` (`%(contents:subject)`, `%(subject)`). +pub fn writeSubject(w: *Io.Writer, message: []const u8) Io.Writer.Error!void { + const span = subjectSpan(message); + var i: usize = 0; + while (i < span.len) : (i += 1) { + const c = span[i]; + if (c == '\r' and i + 1 < span.len and span[i + 1] == '\n') { + try w.writeByte(' '); + i += 1; + } else if (c == '\n') { + try w.writeByte(' '); + } else try w.writeByte(c); + } +} + +const testing = std.testing; +const fuzz_input = @import("testing/fuzz.zig"); + +fn expectSubject(want: []const u8, message: []const u8) !void { + var out: Io.Writer.Allocating = .init(testing.allocator); + defer out.deinit(); + try writeSubject(&out.writer, message); + try testing.expectEqualStrings(want, out.written()); +} + +test "the subject is the first paragraph on one line, as git-for-each-ref wrote it" { + // Each pair was read back from git 2.56. + try expectSubject("a", "a\r\n\nb"); + try expectSubject("a b", "a\n\r\nb"); + try expectSubject("a\rb c", "a\rb\nc"); + try expectSubject("", "\r\n\r\na"); + try expectSubject("a", "\n\n\na\n"); + try expectSubject("a ", "a \n\nb"); + try expectSubject("a", "a"); + try expectSubject("", ""); + try expectSubject("a ", "a\n \n\nb"); + try expectSubject("\ta \tb", "\ta\n\tb"); + try expectSubject("a\r b", "a\r\r\nb\r\n"); + try expectSubject(" a", "\r\na"); + try expectSubject("x", "x\n\r\n\r\ny"); + try expectSubject("a", "a\r\r"); + try expectSubject("", "\r\n\na"); + try expectSubject(" \t real body", " \t \nreal\n \nbody"); +} + +test "a signature is no part of the subject; the last line starting one starts it" { + try expectSubject("a", "a\n-----BEGIN PGP SIGNATURE-----\nxx\n-----END PGP SIGNATURE-----\n"); + try expectSubject("", "-----BEGIN PGP SIGNATURE-----\nxx\n"); + try expectSubject("a b", "a\nb\n-----BEGIN SSH SIGNATURE-----\nxx\n"); + try expectSubject("a b", "a\nb\n-----BEGIN SIGNED MESSAGE-----\nxx\n"); + try expectSubject("a b", "a\nb\n-----BEGIN PGP MESSAGE-----\nxx\n"); + try expectSubject("a b", "a\nb\n-----BEGIN PGP SIGNATURE-----"); + try expectSubject("a b -----BEGIN PGP SIGNATURE----- xx", "a\nb\n -----BEGIN PGP SIGNATURE-----\nxx\n"); + try expectSubject("a b -----BEGIN PGP SIGNATURE----- x", "a\nb\n-----BEGIN PGP SIGNATURE-----\nx\n-----BEGIN PGP SIGNATURE-----\ny\n"); + try expectSubject("a b -----BEGIN PGP SIGNATURE---- xx", "a\nb\n-----BEGIN PGP SIGNATURE----\nxx\n"); + try testing.expectEqual(@as(usize, 2), signatureStart("a\n-----BEGIN PGP SIGNATURE-----\n")); + try testing.expectEqual(@as(usize, 3), signatureStart("abc")); +} + +test "fuzz: subjects" { + try fuzz_input.fuzz({}, struct { + fn one(_: void, smith: *testing.Smith) anyerror!void { + var buffer: [512]u8 = undefined; + const message = fuzz_input.bytes(smith, &buffer); + var out: Io.Writer.Allocating = .init(testing.allocator); + defer out.deinit(); + try writeSubject(&out.writer, message); + const subject = out.written(); + // One line, never longer than the message, and nothing of it + // after the signature. + try testing.expect(std.mem.findScalar(u8, subject, '\n') == null); + try testing.expect(subject.len <= message.len); + try testing.expect(signatureStart(message) <= message.len); + } + }.one, .{ .corpus = &.{ "a\r\n\nb", "\n\n-----BEGIN PGP SIGNATURE-----\nx", "a\nb\r\nc\r\n\r\nd" } }); +} diff --git a/src/diff/output.zig b/src/diff/output.zig index 1563ca0..beef3e3 100644 --- a/src/diff/output.zig +++ b/src/diff/output.zig @@ -162,6 +162,9 @@ pub const StatOptions = struct { graph_width: ?usize = null, /// `--stat-count`: lines shown before `...`. count: ?usize = null, + /// `--compact-summary`: after each name, what `--summary` would say + /// of it, in brief. + compact_summary: bool = false, }; /// A side's contents. @@ -470,6 +473,37 @@ fn writeStatus(w: *Io.Writer, p: *const Pair) Io.Writer.Error!void { if (p.moved() or p.rewrite) try w.print("{d:0>3}", .{similarity.percent(p.score)}); } +/// What `--compact-summary` writes after a name, as git was seen to +/// write it: `(new)` or `(gone)`, with ` +l` for a new symbolic link and +/// ` +x` for a new executable; `(mode +l)` or `(mode -l)` when the path +/// becomes or stops being a symbolic link; `(mode +x)` or `(mode -x)` +/// when a file's executable bit changes; nothing for any other change, +/// one to or from a submodule among them. +fn writeCompactSummary(w: *Io.Writer, p: *const Pair) Io.Writer.Error!void { + const link = 0o120000; + const isLink = struct { + fn f(mode: u32) bool { + return mode & 0o170000 == link; + } + }.f; + const isFile = struct { + fn f(mode: u32) bool { + return mode & 0o170000 == 0o100000; + } + }.f; + if (!p.old.present()) { + try w.writeAll(" (new"); + if (isLink(p.new.mode)) try w.writeAll(" +l") else if (isFile(p.new.mode) and p.new.mode & 0o100 != 0) try w.writeAll(" +x"); + return w.writeByte(')'); + } + if (!p.new.present()) return w.writeAll(" (gone)"); + if (p.old.mode == p.new.mode) return; + if (isLink(p.new.mode) and !isLink(p.old.mode)) return w.writeAll(" (mode +l)"); + if (isLink(p.old.mode) and !isLink(p.new.mode)) return w.writeAll(" (mode -l)"); + if (isFile(p.old.mode) and isFile(p.new.mode) and (p.old.mode & 0o100) != (p.new.mode & 0o100)) + return w.writeAll(if (p.new.mode & 0o100 != 0) " (mode +x)" else " (mode -x)"); +} + /// A rename's or copy's two paths as `--stat`, `--numstat` and /// `--summary` write them: what the paths share at the front up to a `/` /// and at the back from a `/` written once, and the parts that differ in @@ -882,6 +916,8 @@ fn writeStat(gpa: Allocator, w: *Io.Writer, pairs: []const Pair, counts: []const var nw: Io.Writer.Allocating = .init(gpa); errdefer nw.deinit(); if (p.moved()) try writeMovedName(&nw.writer, p, options) else try quote.writePathWith(&nw.writer, p.path, .{ .quote_path = options.quote_path }); + // The note is part of the name, measured and cut with it. + if (options.stat.compact_summary) try writeCompactSummary(&nw.writer, p); const name = try nw.toOwnedSlice(); try names.append(gpa, name); if (c.same) continue; diff --git a/src/encoding.zig b/src/encoding.zig new file mode 100644 index 0000000..09db4de --- /dev/null +++ b/src/encoding.zig @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie +// SPDX-License-Identifier: MIT + +//! A commit re-encoded for output, as `git log --encoding` and +//! `i18n.logOutputEncoding` ask, read on bytes with no I/O. +//! +//! git-log(1) says a commit is written in the output encoding, taking its +//! `encoding` header to say what it is in and UTF-8 when it has none. The +//! rest was found by writing commits with `git hash-object -t commit +//! --literally` and reading them back: +//! +//! - **Which commits** are converted: those whose encoding is not the +//! output encoding, the two compared without regard to case, and +//! `UTF-8` and `UTF8` taken for one another. +//! - **The whole commit** is converted, headers and message: an author's +//! name too. +//! - **A conversion that fails** — bytes that are not valid in the +//! encoding the commit is in, or a character the output encoding has +//! none for — leaves the commit as it was, `encoding` header and all. +//! - **The header**: otherwise, the first `encoding` header, when there +//! is one, says the output encoding, by the name it was given, or is +//! dropped when that is UTF-8. A commit without one is not given one. +//! +//! Only UTF-8 and ISO-8859-1 are known here, by the names git, through +//! the C library's `iconv`, was seen to accept for them. A conversion +//! from or to any other encoding is `UnsupportedEncoding`, since what +//! git makes of it depends on the C library. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +/// What converting cannot do: an encoding other than UTF-8 and ISO-8859-1. +pub const Error = error{UnsupportedEncoding} || Allocator.Error; + +/// Whether git takes `name` for UTF-8. +pub fn isUtf8(name: []const u8) bool { + return std.ascii.eqlIgnoreCase(name, "utf-8") or std.ascii.eqlIgnoreCase(name, "utf8"); +} + +/// The names of ISO-8859-1 git was seen to convert from and to, compared +/// without regard to case. +const latin1_names = [_][]const u8{ + "iso-8859-1", "iso8859-1", "iso_8859-1", "iso88591", "latin1", "latin-1", + "l1", "cp819", "ibm819", "csisolatin1", "iso-ir-100", "iso_8859-1:1987", +}; + +/// Whether `name` is one of the names of ISO-8859-1 known here. +pub fn isLatin1(name: []const u8) bool { + for (latin1_names) |n| if (std.ascii.eqlIgnoreCase(name, n)) return true; + return false; +} + +/// Whether `name` is an encoding this can convert from and to. +pub fn isKnown(name: []const u8) bool { + return isUtf8(name) or isLatin1(name); +} + +/// Whether git takes the two names for one encoding, and converts nothing. +pub fn same(a: []const u8, b: []const u8) bool { + return (isUtf8(a) and isUtf8(b)) or std.ascii.eqlIgnoreCase(a, b); +} + +/// The commit `contents`, whose own encoding is `declared` (its +/// `encoding` header; null for none, which is UTF-8), as git writes it in +/// `output`: newly allocated, or null when it is written as it is. +pub fn reencodeCommit(gpa: Allocator, contents: []const u8, declared: ?[]const u8, output: []const u8) Error!?[]u8 { + const from = declared orelse "UTF-8"; + var converted: ?[]u8 = null; + errdefer if (converted) |c| gpa.free(c); + if (!same(from, output)) { + if (!isKnown(from) or !isKnown(output)) return error.UnsupportedEncoding; + converted = (try convert(gpa, contents, isLatin1(from), isLatin1(output))) orelse return null; + } + if (declared == null) return converted; + const text = converted orelse contents; + const header = findEncodingHeader(text) orelse return converted; + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(gpa); + try out.appendSlice(gpa, text[0..header.start]); + if (!isUtf8(output)) { + try out.appendSlice(gpa, "encoding "); + try out.appendSlice(gpa, output); + try out.append(gpa, '\n'); + } + try out.appendSlice(gpa, text[header.end..]); + if (converted) |c| gpa.free(c); + converted = null; + return try out.toOwnedSlice(gpa); +} + +/// The first `encoding` header among the headers of `text`, from the start +/// of its line to the start of the next that does not continue it. +fn findEncodingHeader(text: []const u8) ?struct { start: usize, end: usize } { + var at: usize = 0; + while (at < text.len and text[at] != '\n') { + const nl = std.mem.findScalarPos(u8, text, at, '\n') orelse text.len; + if (std.mem.startsWith(u8, text[at..nl], "encoding ")) { + var end = @min(nl + 1, text.len); + while (end < text.len and text[end] == ' ') end = @min((std.mem.findScalarPos(u8, text, end, '\n') orelse text.len) + 1, text.len); + return .{ .start = at, .end = end }; + } + at = nl + 1; + } + return null; +} + +/// `text` from ISO-8859-1 or UTF-8 to the other (or to itself), or null +/// where it cannot be. +fn convert(gpa: Allocator, text: []const u8, from_latin1: bool, to_latin1: bool) Allocator.Error!?[]u8 { + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(gpa); + try out.ensureTotalCapacity(gpa, text.len); + if (from_latin1) { + for (text) |b| { + if (to_latin1 or b < 0x80) { + try out.append(gpa, b); + } else { + var buf: [2]u8 = undefined; + const n = std.unicode.utf8Encode(b, &buf) catch unreachable; + try out.appendSlice(gpa, buf[0..n]); + } + } + return try out.toOwnedSlice(gpa); + } + var i: usize = 0; + while (i < text.len) { + const n = std.unicode.utf8ByteSequenceLength(text[i]) catch { + out.deinit(gpa); + return null; + }; + if (i + n > text.len) { + out.deinit(gpa); + return null; + } + const cp = std.unicode.utf8Decode(text[i .. i + n]) catch { + out.deinit(gpa); + return null; + }; + if (to_latin1) { + if (cp > 0xff) { + out.deinit(gpa); + return null; + } + try out.append(gpa, @intCast(cp)); + } else try out.appendSlice(gpa, text[i .. i + n]); + i += n; + } + return try out.toOwnedSlice(gpa); +} + +const testing = std.testing; +const fuzz_input = @import("testing/fuzz.zig"); + +fn expectReencoded(want: ?[]const u8, contents: []const u8, declared: ?[]const u8, output: []const u8) !void { + const got = try reencodeCommit(testing.allocator, contents, declared, output); + defer if (got) |g| testing.allocator.free(g); + if (want) |w| try testing.expectEqualStrings(w, got.?) else try testing.expectEqual(@as(?[]u8, null), got); +} + +test "a commit is re-encoded whole, its header saying the output encoding, as git was seen to" { + const t = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n"; + try expectReencoded(t ++ "author Jos\xc3\xa9 0 +0000\n\ncaf\xc3\xa9\n", t ++ "author Jos\xe9 0 +0000\nencoding latin1\n\ncaf\xe9\n", "latin1", "UTF-8"); + try expectReencoded(t ++ "encoding ISO-8859-1\n\ncaf\xe9\n", t ++ "encoding latin1\n\ncaf\xe9\n", "latin1", "ISO-8859-1"); + // Taken for the same encoding: nothing converted, the header renamed. + try expectReencoded(t ++ "encoding latin1\n\nx\n", t ++ "encoding LATIN1\n\nx\n", "LATIN1", "latin1"); + try expectReencoded(t ++ "\nx\n", t ++ "encoding utf8\n\nx\n", "utf8", "UTF-8"); + // No header: converted, and given none. + try expectReencoded(t ++ "\ncaf\xe9\n", t ++ "\ncaf\xc3\xa9\n", null, "latin1"); + try expectReencoded(null, t ++ "\ncaf\xc3\xa9\n", null, "utf8"); + // Only the first header goes. + try expectReencoded(t ++ "encoding x\n\nx\n", t ++ "encoding latin1\nencoding x\n\nx\n", "latin1", "UTF-8"); +} + +test "a conversion that fails leaves the commit as it was" { + const t = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n"; + try expectReencoded(null, t ++ "encoding UTF-8\n\n\xe6\x97\xa5\n", "UTF-8", "latin1"); + try expectReencoded(null, t ++ "\ncaf\xe9 bad\n", null, "ISO-8859-1"); + try expectReencoded(null, t ++ "\n\xed\xa0\x80\n", null, "latin1"); +} + +test "encodings other than UTF-8 and ISO-8859-1 are refused" { + try testing.expectError(error.UnsupportedEncoding, reencodeCommit(testing.allocator, "\nx\n", "windows-1252", "UTF-8")); + try testing.expectError(error.UnsupportedEncoding, reencodeCommit(testing.allocator, "\nx\n", null, "Shift_JIS")); + try testing.expect(isLatin1("csISOLatin1") and !isLatin1("8859-1") and !isLatin1("ISO-8859-1//")); +} + +test "fuzz: re-encoding commits" { + try fuzz_input.fuzz({}, struct { + fn one(_: void, smith: *testing.Smith) anyerror!void { + var buffer: [512]u8 = undefined; + const contents = fuzz_input.bytes(smith, &buffer); + const names = [_]?[]const u8{ null, "UTF-8", "latin1", "ISO-8859-1", "windows-1252" }; + for (names) |declared| for ([_][]const u8{ "UTF-8", "latin1" }) |output| { + const got = reencodeCommit(testing.allocator, contents, declared, output) catch |err| switch (err) { + error.UnsupportedEncoding => continue, + else => |e| return e, + }; + defer if (got) |g| testing.allocator.free(g); + const text = got orelse continue; + // Into UTF-8 from ISO-8859-1, what comes out is valid UTF-8. + if (declared != null and isLatin1(declared.?) and isUtf8(output)) + try testing.expect(std.unicode.utf8ValidateSlice(text)); + // Each byte of ISO-8859-1 is at most two of UTF-8. + try testing.expect(text.len <= contents.len * 2 + 32); + }; + } + }.one, .{ .corpus = &.{ "tree x\nencoding latin1\n\ncaf\xe9\n", "tree x\n\n\xe6\x97\xa5\n", "encoding x\n encoding y\n\n" } }); +} diff --git a/src/pretty.zig b/src/pretty.zig index d565dab..46165d3 100644 --- a/src/pretty.zig +++ b/src/pretty.zig @@ -64,9 +64,11 @@ //! specials, folds an ASCII subject at 78 columns, and adds MIME headers //! when the subject or body is past ASCII; `git rev-list` encodes nothing. //! -//! **Encodings**: messages are taken to be UTF-8; a commit whose -//! `encoding` header names any other is refused (`UnsupportedEncoding`), -//! as re-encoding is not done. +//! **Encodings**: a commit is written in `Settings.output_encoding`, re-encoded +//! as `encoding.reencodeCommit` says, before anything is read from it; +//! only UTF-8 and ISO-8859-1 are converted, and a commit in any other +//! encoding, or for any other output encoding, is refused +//! (`UnsupportedEncoding`). `%e` is the commit's own `encoding` header. const std = @import("std"); const Allocator = std.mem.Allocator; @@ -78,6 +80,7 @@ const Commit = @import("commit.zig").Commit; const Identity = @import("Identity.zig"); const date = @import("date.zig"); const Mailmap = @import("Mailmap.zig"); +const encoding = @import("encoding.zig"); const trailers = @import("trailers.zig"); const width = @import("width.zig"); const Diagnostics = @import("Diagnostics.zig"); @@ -822,6 +825,9 @@ pub const ShortenError = Writer.Error || error{ShortenFailed}; /// How commits are described, besides the format. pub const Settings = struct { abbreviator: Abbreviator, + /// The encoding commits are written in: `--encoding`, + /// `i18n.logOutputEncoding` or `i18n.commitEncoding`. + output_encoding: []const u8 = "UTF-8", /// `--abbrev-commit`: the built-in formats' commit IDs shortened. abbrev_commit: bool = false, /// `--no-abbrev`: the `Merge:` line's object IDs written whole. @@ -892,18 +898,17 @@ pub const Error = error{ pub fn write(gpa: Allocator, w: *Writer, format: *const Format, shown: Shown, settings: Settings, diag: ?*Diagnostics) Error!void { var out: std.ArrayList(u8) = .empty; defer out.deinit(gpa); - const commit = Commit.parse(shown.id.object_format, shown.contents) catch return error.MalformedCommit; + const original = Commit.parse(shown.id.object_format, shown.contents) catch return error.MalformedCommit; const reads_text = switch (format.*) { .builtin => true, .user => |u| u.reads_text, }; - if (commit.encoding()) |e| { - if (reads_text and !isUtf8Name(e)) { - if (diag) |d| d.setDetail("a commit encoded in {s}", .{e}); - return error.UnsupportedEncoding; - } - } - var r: Renderer = .{ .gpa = gpa, .out = &out, .shown = shown, .commit = commit, .settings = settings, .diag = diag }; + const reencoded: ?[]u8 = if (reads_text) try reencode(gpa, shown.contents, original.encoding(), settings.output_encoding, diag) else null; + defer if (reencoded) |x| gpa.free(x); + var as_written = shown; + if (reencoded) |x| as_written.contents = x; + const commit = if (reencoded) |x| Commit.parse(shown.id.object_format, x) catch return error.MalformedCommit else original; + var r: Renderer = .{ .gpa = gpa, .out = &out, .shown = as_written, .commit = commit, .declared_encoding = original.encoding(), .settings = settings, .diag = diag }; switch (format.*) { .builtin => |b| try r.builtin(b), .user => |u| try r.user(u.items), @@ -911,6 +916,25 @@ pub fn write(gpa: Allocator, w: *Writer, format: *const Format, shown: Shown, se w.writeAll(out.items) catch return error.WriteFailed; } +/// The commit `contents`, declaring `declared`, as it is written in +/// `output`: newly allocated, or null if it is written as it is. +/// `UnsupportedEncoding` names, through `diag`, the encoding not +/// converted. +pub fn reencode(gpa: Allocator, contents: []const u8, declared: ?[]const u8, output: []const u8, diag: ?*Diagnostics) Error!?[]u8 { + return encoding.reencodeCommit(gpa, contents, declared, output) catch |err| switch (err) { + error.UnsupportedEncoding => { + if (diag) |d| { + if (declared != null and !encoding.isKnown(declared.?)) + d.setDetail("a commit encoded in {s}", .{declared.?}) + else + d.setDetail("writing commits in {s}", .{output}); + } + return error.UnsupportedEncoding; + }, + error.OutOfMemory => return error.OutOfMemory, + }; +} + /// Writes an annotated tag as `git show` writes it before the object it /// tags: `tag `, the tagger as `format` writes a commit's author (in /// `medium` with the date, in `fuller` as `TaggerDate:`, in `email` as @@ -985,10 +1009,6 @@ pub fn writeFirstLine(gpa: Allocator, w: *Writer, format: *const Format, shown: w.writeAll(out.items) catch return error.WriteFailed; } -fn isUtf8Name(name: []const u8) bool { - return std.ascii.eqlIgnoreCase(name, "utf-8") or std.ascii.eqlIgnoreCase(name, "utf8"); -} - const Who = enum { author, committer }; /// One commit's message, taken apart as the placeholders want it. @@ -1075,7 +1095,10 @@ const Renderer = struct { gpa: Allocator, out: *std.ArrayList(u8), shown: Shown, + /// The commit as it is written, in the output encoding. commit: Commit, + /// The commit's own `encoding` header, for `%e`. + declared_encoding: ?[]const u8 = null, settings: Settings, diag: ?*Diagnostics, /// `%C(auto)` is in effect. @@ -1406,7 +1429,7 @@ const Renderer = struct { .sanitized_subject => try writeSanitized(out, gpa, lineAt(r.message().subject_lines, 0).line), .body => try out.appendSlice(gpa, r.message().body), .raw_body => try out.appendSlice(gpa, r.commit.message), - .encoding => if (r.commit.encoding()) |e| try out.appendSlice(gpa, e), + .encoding => if (r.declared_encoding) |e| try out.appendSlice(gpa, e), .mark => try out.append(gpa, switch (r.shown.mark) { .boundary => '-', .left => '<', @@ -1509,21 +1532,12 @@ const Renderer = struct { .email, .mboxrd => try r.email(b == .mboxrd), .raw => { try r.firstLine("commit "); - // The headers as they are, less the `encoding` header git - // drops when it has written the message in its output - // encoding. + // The headers as they are written in the output encoding, + // the `encoding` header already made to say so. var at: usize = 0; - var dropped = false; const h = r.commit.headers; while (at < h.len) { const l = lineAt(h, at); - if (!dropped and std.mem.startsWith(u8, l.line, "encoding ")) { - dropped = true; - at = l.next; - // Continuation lines go with it. - while (at < h.len and h[at] == ' ') at = lineAt(h, at).next; - continue; - } try r.append(l.line); try r.append("\n"); at = l.next; diff --git a/src/root.zig b/src/root.zig index ca312d0..6234644 100644 --- a/src/root.zig +++ b/src/root.zig @@ -171,6 +171,11 @@ pub const width = @import("width.zig"); /// Choosing commits by their messages and identities: `--grep`, /// `--author` and `--committer`. pub const grep = @import("grep.zig"); +/// A commit's or a tag's message taken apart as `for-each-ref`'s +/// `%(contents...)` atoms take it: the signature, and the subject. +pub const contents = @import("contents.zig"); +/// Commits re-encoded for output: UTF-8 and ISO-8859-1. +pub const encoding = @import("encoding.zig"); /// Repositories made with git for tests, including those of programs /// built on this module. diff --git a/tools/decorate_tests.zig b/tools/decorate_tests.zig index 124d6db..2232982 100644 --- a/tools/decorate_tests.zig +++ b/tools/decorate_tests.zig @@ -117,6 +117,87 @@ test "decorations are found, filtered and written as git writes them" { try same(&repo, &.{ "log", "--decorate", "--oneline", "--all", "--decorate-refs-exclude=refs/heads/main" }); } +/// The tags after `commit` and before it, as grebe finds them: each +/// tag's commit, of those that contain it (or that it contains), but not +/// those on the commit itself, once each, and space-separated. +fn tagCommits(repo: *Repo, flag: []const u8, commit: []const u8, out: *std.ArrayList([]u8)) !void { + const gpa = repo.gpa; + const filter = try std.fmt.allocPrint(gpa, "{s}{s}", .{ flag, commit }); + defer gpa.free(filter); + const listed = try repo.output(&.{ "for-each-ref", "--format=%(if)%(*objectname)%(then)%(*objectname)%(else)%(objectname)%(end) %(refname:short)", filter, "refs/tags/" }); + defer gpa.free(listed); + var lines = std.mem.tokenizeScalar(u8, listed, '\n'); + while (lines.next()) |line| { + const id = line[0 .. std.mem.findScalar(u8, line, ' ') orelse line.len]; + if (std.mem.eql(u8, id, commit)) continue; + const dup = for (out.items) |x| { + if (std.mem.eql(u8, x, id)) break true; + } else false; + if (!dup) try out.append(gpa, try gpa.dupe(u8, id)); + } +} + +test "the nearest tags after a commit are found as grebe asks git for them" { + const gpa = testing.allocator; + var repo = try Repo.init(gpa, testing.io); + defer repo.deinit(); + try makeRefs(&repo, 11); + // More tags, so that most commits have some after them. + const ids_out = try repo.output(&.{ "rev-list", "--all" }); + defer gpa.free(ids_out); + var ids: std.ArrayList([]const u8) = .empty; + defer ids.deinit(gpa); + var it = std.mem.tokenizeScalar(u8, ids_out, '\n'); + while (it.next()) |id| try ids.append(gpa, id); + for (ids.items, 0..) |id, k| if (k % 7 == 3) { + var name: [16]u8 = undefined; + try repo.git(&.{ "tag", try std.fmt.bufPrint(&name, "extra{d}", .{k}), id }); + }; + for ([_]bool{ false, true }) |graph| { + if (graph) try repo.git(&.{ "commit-graph", "write", "--reachable" }); + for (ids.items, 0..) |x, k| { + if (k % 3 != 0) continue; + var after: std.ArrayList([]u8) = .empty; + defer { + for (after.items) |a| gpa.free(a); + after.deinit(gpa); + } + try tagCommits(&repo, "--contains=", x, &after); + const contains = try std.fmt.allocPrint(gpa, "--contains={s}", .{x}); + defer gpa.free(contains); + try same(&repo, &.{ "for-each-ref", "--format=%(refname:short)", contains, "refs/heads/" }); + if (after.items.len != 0) { + var args: std.ArrayList([]const u8) = .empty; + defer args.deinit(gpa); + try args.appendSlice(gpa, &.{ "-c", "log.showSignature=false", "log", "--simplify-by-decoration", "--decorate-refs=refs/tags/", "--ancestry-path", "--format=%H %P" }); + for (after.items) |a| try args.append(gpa, a); + const not = try std.fmt.allocPrint(gpa, "^{s}", .{x}); + defer gpa.free(not); + try args.appendSlice(gpa, &.{ not, "--" }); + try same(&repo, args.items); + } + var before: std.ArrayList([]u8) = .empty; + defer { + for (before.items) |b| gpa.free(b); + before.deinit(gpa); + } + try tagCommits(&repo, "--merged=", x, &before); + if (before.items.len != 0) { + var args: std.ArrayList([]const u8) = .empty; + defer args.deinit(gpa); + try args.appendSlice(gpa, &.{ "merge-base", "--independent" }); + for (before.items) |b| try args.append(gpa, b); + try same(&repo, args.items); + } + } + } + // Without --ancestry-path, an excluded commit is refused. + const ours = try tool.runOurs(&repo, &.{ "log", "--simplify-by-decoration", "--format=%H", "main", "^main~3" }, .{}); + defer ours.deinit(gpa); + try testing.expectEqual(128, ours.code); + try testing.expect(std.mem.find(u8, ours.stderr, "not supported") != null); +} + test "grafted and replaced commits are decorated as git decorates them" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); diff --git a/tools/describe.zig b/tools/describe.zig index cfc24aa..baeb8d3 100644 --- a/tools/describe.zig +++ b/tools/describe.zig @@ -46,6 +46,8 @@ pub const Options = struct { color: ?enum { always, never, auto } = null, /// `rev-list --header`. header: bool = false, + /// `--encoding=`. + encoding: ?[]const u8 = null, /// `--grep`. messages: std.ArrayList([]const u8) = .empty, /// `--author`. @@ -66,6 +68,9 @@ pub const Options = struct { clear_decorations: bool = false, /// `--source`. source: bool = false, + /// `-z`: each commit ended, or the commits separated, by a NUL + /// rather than a line feed. + nul: bool = false, pub fn deinit(o: *Options, gpa: Allocator) void { o.messages.deinit(gpa); @@ -157,6 +162,7 @@ pub fn parseOption(o: *Options, gpa: Allocator, arg: []const u8, next: ?[]const @"--decorate-refs-exclude", @"--clear-decorations", @"--source", + @"-z", }; const n = std.meta.stringToEnum(Name, name) orelse return .no; switch (n) { @@ -189,7 +195,8 @@ pub fn parseOption(o: *Options, gpa: Allocator, arg: []const u8, next: ?[]const .@"--no-color" => o.color = .never, .@"--encoding" => { const v = value orelse return .no; - if (!utf8Name(v)) return tool.fatalMessage(stderr, "zig-git: '--encoding={s}' is not supported; only UTF-8 is", .{v}); + if (!git.encoding.isKnown(v)) return tool.fatalMessage(stderr, "zig-git: '--encoding={s}' is not supported; only UTF-8 and ISO-8859-1 are", .{v}); + o.encoding = v; }, .@"--header" => o.header = true, .@"--grep", .@"--author", .@"--committer" => { @@ -224,6 +231,7 @@ pub fn parseOption(o: *Options, gpa: Allocator, arg: []const u8, next: ?[]const o.decorate_refs_exclude.clearRetainingCapacity(); }, .@"--source" => o.source = true, + .@"-z" => o.nul = true, } if (value != null) switch (n) { .@"--pretty", .@"--format", .@"--abbrev", .@"--date", .@"--expand-tabs", .@"--color", .@"--encoding", .@"--grep", .@"--author", .@"--committer", .@"--decorate", .@"--decorate-refs", .@"--decorate-refs-exclude" => {}, @@ -238,10 +246,6 @@ fn abbrevLength(text: []const u8) ?usize { return @intCast(@max(4, @min(n, git.object_max_hex_length))); } -fn utf8Name(name: []const u8) bool { - return std.ascii.eqlIgnoreCase(name, "utf-8") or std.ascii.eqlIgnoreCase(name, "utf8"); -} - /// The options that need what this does not have, refused by name. pub const unsupported = [_][]const u8{ "-L", @@ -255,7 +259,6 @@ pub const unsupported = [_][]const u8{ "-g", "--grep-reflog", "--show-linear-break", - "-z", "--word-diff", "--color-words", "--binary", @@ -288,6 +291,10 @@ pub const Describer = struct { arena: std.heap.ArenaAllocator, /// Whether a commit has been written, for the separators. written: bool = false, + /// `-z`: the line feed ending a commit, or between two, is a NUL. + nul: bool = false, + /// What `keeps` could not re-encode. + encoding_failure: Diagnostics = .{}, /// `--no-commit-header`. commit_header: bool = true, /// `--timestamp`. @@ -310,8 +317,15 @@ pub const Describer = struct { const a = arena.allocator(); const config = &repo.config; - if (config.getString("i18n.logOutputEncoding") catch null) |e| if (!utf8Name(e)) - return tool.fatalMessage(stderr, "zig-git: i18n.logOutputEncoding '{s}' is not supported; only UTF-8 is", .{e}); + // The output encoding, as git-config(1) says: the option, + // `i18n.logOutputEncoding`, or `i18n.commitEncoding`. + const output_encoding: []const u8 = if (options.encoding) |e| e else if (config.getString("i18n.logOutputEncoding") catch null) |e| blk: { + if (!git.encoding.isKnown(e)) return tool.fatalMessage(stderr, "zig-git: i18n.logOutputEncoding '{s}' is not supported; only UTF-8 and ISO-8859-1 are", .{e}); + break :blk try a.dupe(u8, e); + } else if (config.getString("i18n.commitEncoding") catch null) |e| blk: { + if (!git.encoding.isKnown(e)) return tool.fatalMessage(stderr, "zig-git: i18n.commitEncoding '{s}' is not supported; only UTF-8 and ISO-8859-1 are", .{e}); + break :blk try a.dupe(u8, e); + } else "UTF-8"; if (command != .rev_list) { if ((repo.boolSetting("log.showSignature", null) catch null) orelse false) return tool.fatalMessage(stderr, "zig-git: log.showSignature is not supported", .{}); @@ -466,6 +480,10 @@ pub const Describer = struct { if (wants_decorations) decorations = try loadDecorations(ctx, repo, options, command, mode == .full, stderr); const columns: usize = if (ctx.env.get("COLUMNS")) |c| (std.fmt.parseInt(usize, c, 10) catch 80) else 80; + // email names the charset it writes in, which is not matched for + // any but UTF-8. + if (!git.encoding.isUtf8(output_encoding)) if (format) |f| if (f == .builtin and (f.builtin == .email or f.builtin == .mboxrd)) + return tool.fatalMessage(stderr, "zig-git: the email format in {s} is not supported", .{output_encoding}); return .{ .ctx = ctx, .repo = repo, @@ -477,7 +495,9 @@ pub const Describer = struct { .configured = configured.items, .arena = arena, .header = header, + .nul = options.nul, .settings = .{ + .output_encoding = output_encoding, .abbreviator = .{ .context = names, .shorten = shorten, .length = if (options.no_abbrev) git.object_max_hex_length else options.abbrev orelse names.defaultLength() }, .abbrev_commit = abbrev_commit and !options.no_abbrev, .full_merge_ids = options.no_abbrev, @@ -573,7 +593,20 @@ pub const Describer = struct { /// Whether the searches keep a commit, by its contents. pub fn keeps(d: *Describer, contents: []const u8) !bool { const g = &(d.grep orelse return true); - return g.matches(d.ctx.gpa, d.repo.objectFormat(), contents, if (d.settings.use_mailmap) d.mailmap else null); + // git searches the commit as it would write it, in the output + // encoding. + const declared = if (git.Commit.parse(d.repo.objectFormat(), contents)) |c| c.encoding() else |_| null; + const reencoded = pretty.reencode(d.ctx.gpa, contents, declared, d.settings.output_encoding, &d.encoding_failure) catch |err| switch (err) { + error.UnsupportedEncoding => return error.Fatal, + else => |e| return e, + }; + defer if (reencoded) |x| d.ctx.gpa.free(x); + return g.matches(d.ctx.gpa, d.repo.objectFormat(), reencoded orelse contents, if (d.settings.use_mailmap) d.mailmap else null); + } + + /// Says what `keeps` failed to re-encode, when it failed with `Fatal`. + pub fn reportEncodingFailure(d: *const Describer, stderr: *Io.Writer) error{Fatal} { + return tool.fatalMessage(stderr, "zig-git: {s} is not supported", .{d.encoding_failure.detail()}); } /// What one commit is shown as, besides its contents. @@ -645,11 +678,14 @@ pub const Describer = struct { const oneline = f.* == .builtin and f.builtin == .oneline; // What comes before the commit is written first, as git writes it // before anything that can fail. + // Under -z each of the line feeds that end a commit or separate + // two is a NUL, and nothing else is. + const end: u8 = if (d.nul) 0 else '\n'; switch (d.command) { .log, .show => { if (user_like) { - if (!f.terminated() and d.written) try stdout.writeByte('\n'); - } else if (d.written and !oneline) try stdout.writeByte('\n'); + if (!f.terminated() and d.written) try stdout.writeByte(end); + } else if (d.written and !oneline) try stdout.writeByte(end); }, .rev_list => { if (entry.time) |t| try stdout.print("{d} ", .{t}); @@ -677,11 +713,16 @@ pub const Describer = struct { const out = text.written(); switch (d.command) { .log, .show => { - try stdout.writeAll(out); + // --oneline's line ends in the line feed ending the + // commit. + if (oneline and d.nul and std.mem.endsWith(u8, out, "\n")) { + try stdout.writeAll(out[0 .. out.len - 1]); + try stdout.writeByte(0); + } else try stdout.writeAll(out); // An empty format string writes nothing, not even the // line feed ending the commit. const empty = f.* == .user and f.user.empty; - if (user_like and f.terminated() and !empty) try stdout.writeByte('\n'); + if (user_like and f.terminated() and !empty) try stdout.writeByte(end); }, .rev_list => { if (d.header) { diff --git a/tools/diff_tree_tests.zig b/tools/diff_tree_tests.zig index 401c6e4..d235385 100644 --- a/tools/diff_tree_tests.zig +++ b/tools/diff_tree_tests.zig @@ -113,6 +113,55 @@ test "diff-tree: every format over every kind of change" { try same(&repo, &.{ "diff-tree", "--bogus", "HEAD" }); } +test "diff-tree: --compact-summary over every kind of change, its commit line included" { + const gpa = testing.allocator; + var repo = try Repo.init(gpa, testing.io); + defer repo.deinit(); + try makeHistory(&repo); + // Mode changes both ways, links made and unmade, renames with a mode + // change, and submodules, in trees written whole. + const blob_out = try repo.outputWithInput(&.{ "hash-object", "-w", "--stdin" }, "x\n"); + defer gpa.free(blob_out); + const blob = std.mem.trimEnd(u8, blob_out, "\n"); + const link_out = try repo.outputWithInput(&.{ "hash-object", "-w", "--stdin" }, "target"); + defer gpa.free(link_out); + const link = std.mem.trimEnd(u8, link_out, "\n"); + const head = try revParse(&repo, "HEAD"); + defer gpa.free(head); + const before = try std.fmt.allocPrint(gpa, "120000 blob {s}\ta\n160000 commit {s}\tb\n100755 blob {s}\tc\n100644 blob {s}\td\n100755 blob {s}\tx.sh\n160000 commit {s}\tsub\n", .{ link, head, blob, blob, blob, head }); + defer gpa.free(before); + const after = try std.fmt.allocPrint(gpa, "160000 commit {s}\ta\n120000 blob {s}\tb\n100644 blob {s}\tc\n100755 blob {s}\td\n100644 blob {s}\ty.sh\n120000 blob {s}\tnewlink\n100755 blob {s}\tnewexec\n", .{ head, link, blob, blob, blob, link, blob }); + defer gpa.free(after); + const t1_out = try repo.outputWithInput(&.{"mktree"}, before); + defer gpa.free(t1_out); + const t2_out = try repo.outputWithInput(&.{"mktree"}, after); + defer gpa.free(t2_out); + const t1 = std.mem.trimEnd(u8, t1_out, "\n"); + const t2 = std.mem.trimEnd(u8, t2_out, "\n"); + const options = [_][]const []const u8{ + &.{}, &.{"-r"}, &.{"--stat"}, &.{"-p"}, &.{"--numstat"}, + &.{"--summary"}, &.{"-z"}, &.{"--stat=40"}, &.{"--name-only"}, &.{"--raw"}, + &.{"--shortstat"}, &.{"-M"}, &.{ "-M", "-r" }, &.{"-B"}, &.{"--stat-name-width=5"}, + &.{"--no-commit-id"}, &.{"--root"}, &.{"--patch-with-stat"}, &.{"-R"}, &.{ "--name-only", "--compact-summary" }, + }; + var args: std.ArrayList([]const u8) = .empty; + defer args.deinit(gpa); + for (options) |o| for ([_][]const []const u8{ &.{"HEAD~"}, &.{ "HEAD~2", "HEAD~" }, &.{"HEAD"}, &.{ t1, t2 }, &.{ t2, t1 } }) |revs| { + args.clearRetainingCapacity(); + try args.appendSlice(gpa, &.{ "diff-tree", "--compact-summary" }); + try args.appendSlice(gpa, o); + try args.appendSlice(gpa, revs); + try same(&repo, args.items); + }; + // The other commands that write a diffstat take it too. + for ([_][]const []const u8{ + &.{ "diff", "--compact-summary", "HEAD~2" }, + &.{ "log", "--compact-summary", "--format=%h" }, + &.{ "show", "--compact-summary" }, + &.{ "diff-index", "--compact-summary", "HEAD~2" }, + }) |a| try same(&repo, a); +} + test "diff-tree: --exit-code when only --summary or --dirstat is written" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); diff --git a/tools/grebe_tests.zig b/tools/grebe_tests.zig new file mode 100644 index 0000000..7063b93 --- /dev/null +++ b/tools/grebe_tests.zig @@ -0,0 +1,275 @@ +// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie +// SPDX-License-Identifier: MIT + +//! Every question grebe asks git, in the shapes grebe asks it, compared +//! with git: its `log` of the history and its searches, `show` of a +//! commit and `diff` of what is not one, the summary it copies, the +//! branches and tags around a commit, and what it asks of `HEAD`, the +//! index and the working tree. The history has merges (an octopus and an +//! evil one among them), renames, binary files, tags of every kind and a +//! commit in ISO-8859-1; the working tree has changes staged and not. +//! Every test runs under SHA-1 and SHA-256, with and without a +//! commit-graph. + +const std = @import("std"); +const Io = std.Io; + +const tool = @import("zig_git.zig"); +const Repo = tool.Repo; + +const testing = std.testing; + +fn same(repo: *Repo, args: []const []const u8) !void { + try tool.expectSameAsGitHow(repo, args, .{}); +} + +/// `zig-git ` refuses by name what git would answer. +fn refused(repo: *Repo, args: []const []const u8) !void { + const ours = try tool.runOurs(repo, args, .{}); + defer ours.deinit(repo.gpa); + try testing.expectEqual(128, ours.code); + try testing.expect(std.mem.find(u8, ours.stderr, "not supported") != null); +} + +fn write(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 line(repo: *Repo, args: []const []const u8) ![]u8 { + const out = try repo.output(args); + defer repo.gpa.free(out); + return repo.gpa.dupe(u8, std.mem.trimEnd(u8, out, "\n")); +} + +/// grebe's `LogParser.format` and `ShowParser.format`. +const log_format = "--format=%H%x00%P%x00%an%x00%ae%x00%ad%x00%s%x00%D"; +const show_format = "--format=%H%x00%P%x00%an%x00%ae%x00%ad%x00%cn%x00%ce%x00%cd%x00%B%x00"; +const tag_format = "--format=%(if)%(*objectname)%(then)%(*objectname)%(else)%(objectname)%(end) %(refname:short)"; + +fn makeHistory(repo: *Repo) !void { + try write(repo, "a.txt", "one\ntwo\nthree\n"); + try write(repo, "d/b.txt", "b\n"); + try write(repo, "bin.dat", "\x00\x01\x02"); + try repo.git(&.{ "add", "." }); + try repo.commit("root"); + try repo.git(&.{ "tag", "-a", "-m", "the first", "v1" }); + try repo.git(&.{ "checkout", "-q", "-b", "side" }); + try write(repo, "a.txt", "one\ntwo side\nthree\n"); + try repo.git(&.{ "add", "." }); + try repo.commit("side change"); + try repo.git(&.{ "checkout", "-q", "-b", "other", "v1" }); + try write(repo, "c.txt", "c\n"); + try repo.git(&.{ "add", "." }); + try repo.commit("other change"); + try repo.git(&.{ "tag", "lt" }); + try repo.git(&.{ "checkout", "-q", "main" }); + try write(repo, "a.txt", "one main\ntwo\nthree\n"); + try repo.git(&.{ "add", "." }); + try repo.commit("main change"); + // An evil merge, conflicting and resolved with a line neither side had. + const merged = try repo.runIn(".", &.{ "merge", "-q", "--no-commit", "side" }, ""); + merged.deinit(repo.gpa); + try write(repo, "a.txt", "one main\ntwo side\nthree\nevil\n"); + try repo.git(&.{ "add", "." }); + try repo.commit("Merge side"); + try repo.git(&.{ "tag", "-a", "-m", "second\n\nwith a body", "v2" }); + try repo.git(&.{ "tag", "-a", "-m", "of a tag", "v2-nested", "v2" }); + try repo.git(&.{ "tag", "tree-tag", "HEAD^{tree}" }); + try repo.git(&.{ "mv", "d/b.txt", "d/renamed.txt" }); + try write(repo, "bin.dat", "\x00\x01\x03"); + try repo.git(&.{ "add", "." }); + try repo.commit("rename and binary"); + // An octopus. + try repo.git(&.{ "branch", "o1", "HEAD~1" }); + try repo.git(&.{ "checkout", "-q", "o1" }); + try write(repo, "o1.txt", "1\n"); + try repo.git(&.{ "add", "." }); + try repo.commit("o1"); + try repo.git(&.{ "checkout", "-q", "main" }); + try repo.git(&.{ "merge", "-q", "--no-edit", "o1", "other" }); + // A commit in ISO-8859-1. + const tree = try line(repo, &.{ "rev-parse", "HEAD^{tree}" }); + defer repo.gpa.free(tree); + const head = try line(repo, &.{ "rev-parse", "HEAD" }); + defer repo.gpa.free(head); + const text = try std.fmt.allocPrint(repo.gpa, "tree {s}\nparent {s}\nauthor Jos\xe9 1790009000 +0100\ncommitter C 1790009000 +0100\nencoding ISO-8859-1\n\ncaf\xe9\n\nbody\n", .{ tree, head }); + defer repo.gpa.free(text); + const latin = try repo.hashObject("commit", text); + defer repo.gpa.free(latin); + try repo.git(&.{ "update-ref", "refs/heads/main", latin }); + try repo.git(&.{ "tag", "v3", latin }); + // Changes not committed: one staged, one not. + try write(repo, "c2.txt", "new\n"); + try repo.git(&.{ "add", "c2.txt" }); + try write(repo, "a.txt", "one main\ntwo side\nthree\nevil\nunstaged\n"); +} + +/// What grebe runs, over one repository. +fn everything(repo: *Repo, graph: bool) !void { + const gpa = repo.gpa; + const ids_out = try repo.output(&.{ "rev-list", "--all" }); + defer gpa.free(ids_out); + var ids: std.ArrayList([]const u8) = .empty; + defer ids.deinit(gpa); + var it = std.mem.tokenizeScalar(u8, ids_out, '\n'); + while (it.next()) |id| try ids.append(gpa, id); + var args: std.ArrayList([]const u8) = .empty; + defer args.deinit(gpa); + const sig = [_][]const u8{ "-c", "log.showSignature=false" }; + + // The history (Log.zig), and the searches (Find.zig) over it. + const range = try std.fmt.allocPrint(gpa, "{s}..{s}", .{ ids.items[ids.items.len - 1], ids.items[0] }); + defer gpa.free(range); + const revs = [_][]const []const u8{ + &.{}, &.{"--all"}, &.{ "--", "a.txt" }, &.{ "--all", "--", "d" }, + &.{"--date-order"}, &.{"--author-date-order"}, &.{"side"}, &.{"main~2..main"}, + &.{range}, &.{"side...other"}, &.{ "^side", "--all" }, &.{"--first-parent"}, + }; + for (revs) |r| { + args.clearRetainingCapacity(); + try args.appendSlice(gpa, &sig); + try args.appendSlice(gpa, &.{ "log", "--parents", "--no-color", "--encoding=UTF-8", "-z", "--date=raw", "--decorate=full", log_format }); + const ordered = for (r) |x| { + if (std.mem.eql(u8, x, "--date-order") or std.mem.eql(u8, x, "--author-date-order")) break true; + } else false; + if (!ordered) try args.append(gpa, "--topo-order"); + try args.appendSlice(gpa, r); + // An ordering with --first-parent is refused with a commit-graph. + const first_parent = r.len != 0 and std.mem.eql(u8, r[0], "--first-parent"); + if (graph and first_parent) try refused(repo, args.items) else try same(repo, args.items); + } + const finds = [_][]const []const u8{ + &.{ "--fixed-strings", "--grep=change" }, + &.{ "--fixed-strings", "--regexp-ignore-case", "--grep=MAIN" }, + &.{ "--extended-regexp", "--grep=^(o1|root)$" }, + &.{ "--fixed-strings", "--author=Jos\xc3\xa9" }, + &.{ "--fixed-strings", "--grep=caf\xc3\xa9" }, + &.{ "--fixed-strings", "--committer=C" }, + &.{"-Sside"}, + &.{ "-Sev.l", "--pickaxe-regex" }, + &.{"-Gtwo"}, + }; + for (finds) |f| for (revs[0..4]) |r| { + args.clearRetainingCapacity(); + try args.appendSlice(gpa, &sig); + try args.appendSlice(gpa, &.{ "log", "--no-color", "--encoding=UTF-8", "-z", "--format=%H" }); + try args.appendSlice(gpa, f); + try args.appendSlice(gpa, r); + try same(repo, args.items); + }; + try same(repo, &.{ "-c", "log.showSignature=false", "log", "--no-color", "--encoding=UTF-8", "-z", "--format=%H", "--all", "--", "a.txt", "d" }); + + // Each commit shown (Show.zig), and summarized (reference.zig). + for (ids.items) |id| { + for ([_][]const u8{ "-U3", "-U0" }) |context| { + try same(repo, &.{ "-c", "log.showSignature=false", "show", "--encoding=UTF-8", "--cc", "--date=raw", show_format, "--no-color", "--no-ext-diff", "--no-textconv", "-M", "--patch-with-raw", "-z", context, id, "--" }); + } + try same(repo, &.{ "-c", "log.showSignature=false", "show", "--encoding=UTF-8", "--cc", "--date=raw", show_format, "--no-color", "--no-ext-diff", "--no-textconv", "-M", "--patch-with-raw", "-z", "-U3", "-w", id, "--" }); + try same(repo, &.{ "-c", "log.showSignature=false", "show", "-s", "--no-color", "--pretty=reference", id, "--" }); + } + // What is not one commit. + const common = [_][]const u8{ "--no-color", "--no-ext-diff", "--no-textconv", "-M", "--patch-with-raw", "-z", "-U3" }; + for ([_][]const []const u8{ &.{}, &.{"-w"} }) |w| for ([_][]const []const u8{ &.{}, &.{"--cached"}, &.{ ids.items[3], ids.items[0] } }) |target| for ([_][]const []const u8{ &.{}, &.{"a.txt"} }) |paths| { + args.clearRetainingCapacity(); + try args.append(gpa, "diff"); + try args.appendSlice(gpa, &common); + try args.appendSlice(gpa, w); + try args.appendSlice(gpa, target); + try args.append(gpa, "--"); + try args.appendSlice(gpa, paths); + try same(repo, args.items); + }; + + // The branches and tags around a commit (Relate.zig). + for (ids.items) |id| { + const contains = try std.fmt.allocPrint(gpa, "--contains={s}", .{id}); + defer gpa.free(contains); + const merged = try std.fmt.allocPrint(gpa, "--merged={s}", .{id}); + defer gpa.free(merged); + try same(repo, &.{ "for-each-ref", "--format=%(refname:short)", contains, "refs/heads/" }); + try same(repo, &.{ "for-each-ref", tag_format, contains, "refs/tags/" }); + try same(repo, &.{ "for-each-ref", tag_format, merged, "refs/tags/" }); + } + try same(repo, &.{ "for-each-ref", "--format=%(refname:short) %(objecttype) %(contents:subject)", "refs/tags" }); + const tagged = [_][]const u8{ "v1^{}", "v2^{}", "v3", "lt" }; + var tag_ids: [tagged.len][]u8 = undefined; + for (tagged, &tag_ids) |t, *out| out.* = try line(repo, &.{ "rev-parse", t }); + defer for (tag_ids) |t| gpa.free(t); + const root = try std.fmt.allocPrint(gpa, "^{s}", .{tag_ids[0]}); + defer gpa.free(root); + try same(repo, &.{ "-c", "log.showSignature=false", "log", "--simplify-by-decoration", "--decorate-refs=refs/tags/", "--ancestry-path", "--format=%H %P", tag_ids[1], tag_ids[2], tag_ids[3], root, "--" }); + try same(repo, &.{ "merge-base", "--independent", tag_ids[0], tag_ids[1], tag_ids[3] }); + try same(repo, &.{ "log", "--all", "--format=%H", "--grep=^Merge side$" }); + + // HEAD, the index and the working tree (main.zig, Local.zig). + for ([_][]const []const u8{ &.{}, &.{"a.txt"}, &.{ "d", "c2.txt" }, &.{"nothere"} }) |paths| { + args.clearRetainingCapacity(); + try args.appendSlice(gpa, &.{ "rev-list", "-1", "HEAD", "--" }); + try args.appendSlice(gpa, paths); + try same(repo, args.items); + args.clearRetainingCapacity(); + try args.appendSlice(gpa, &.{ "diff-files", "--quiet", "--" }); + try args.appendSlice(gpa, paths); + try same(repo, args.items); + args.clearRetainingCapacity(); + try args.appendSlice(gpa, &.{ "diff-index", "--quiet", "--cached", "HEAD", "--" }); + try args.appendSlice(gpa, paths); + try same(repo, args.items); + } + for (revs) |r| { + const dashdash = for (r, 0..) |x, i| { + if (std.mem.eql(u8, x, "--")) break i; + } else r.len; + args.clearRetainingCapacity(); + try args.appendSlice(gpa, &.{ "rev-parse", "--revs-only" }); + try args.appendSlice(gpa, r[0..dashdash]); + try same(repo, args.items); + } + for ([_][]const []const u8{ + &.{ "rev-parse", "--show-toplevel" }, + &.{ "rev-parse", "--verify", "-q", "HEAD" }, + &.{ "rev-parse", "HEAD" }, + &.{ "rev-parse", "HEAD~1" }, + &.{ "symbolic-ref", "HEAD" }, + &.{ "diff", "--cached", "--name-only" }, + &.{ "diff", "--name-only" }, + }) |a| try same(repo, a); +} + +test "grebe's questions are answered as git answers them" { + var repo = try Repo.init(testing.allocator, testing.io); + defer repo.deinit(); + try makeHistory(&repo); + try everything(&repo, false); + try repo.git(&.{ "commit-graph", "write", "--reachable", "--changed-paths" }); + try everything(&repo, true); + // Detached, and then on a branch not yet born. + try repo.git(&.{ "checkout", "-q", "--detach", "HEAD~2" }); + try everything(&repo, true); + try repo.git(&.{ "symbolic-ref", "HEAD", "refs/heads/unborn" }); + for ([_][]const []const u8{ + &.{ "-c", "log.showSignature=false", "log", "--parents", "--no-color", "--encoding=UTF-8", "-z", "--date=raw", "--decorate=full", log_format, "--topo-order" }, + &.{ "rev-parse", "--verify", "-q", "HEAD" }, + &.{ "rev-parse", "--revs-only" }, + &.{ "symbolic-ref", "HEAD" }, + &.{ "rev-list", "-1", "HEAD", "--" }, + }) |a| try same(&repo, a); +} + +test "grebe's questions of a repository with no commits" { + var repo = try Repo.init(testing.allocator, testing.io); + defer repo.deinit(); + for ([_][]const []const u8{ + &.{ "-c", "log.showSignature=false", "log", "--parents", "--no-color", "--encoding=UTF-8", "-z", "--date=raw", "--decorate=full", log_format, "--topo-order" }, + &.{ "rev-parse", "--show-toplevel" }, + &.{ "rev-parse", "--verify", "-q", "HEAD" }, + &.{ "rev-parse", "--revs-only" }, + &.{ "rev-parse", "--revs-only", "--all" }, + &.{ "symbolic-ref", "HEAD" }, + &.{ "for-each-ref", "--format=%(refname:short) %(objecttype) %(contents:subject)", "refs/tags" }, + &.{ "diff", "--cached", "--name-only" }, + &.{ "diff", "--name-only" }, + &.{ "diff-files", "--quiet", "--" }, + }) |a| try same(&repo, a); +} diff --git a/tools/history.zig b/tools/history.zig index 4aff5ec..c869ee7 100644 --- a/tools/history.zig +++ b/tools/history.zig @@ -105,7 +105,7 @@ pub const History = struct { error.InvalidParentPosition => return tool.fatalMessage(stderr, "invalid parent position {s}", .{diag.detail()}), error.MissingGenerationOverflow => return tool.fatalMessage(stderr, "commit-graph requires overflow generation data but has none", .{}), error.GenerationOverflowTooSmall => return tool.fatalMessage(stderr, "commit-graph overflow generation data is too small", .{}), - error.UnsupportedGraphOrdering => return tool.fatalMessage(stderr, "zig-git: an ordering with exclusions, --first-parent, --since, --until or --since-as-filter is not supported in a repository with a commit-graph", .{}), + error.UnsupportedGraphOrdering => return tool.fatalMessage(stderr, "zig-git: an ordering with --first-parent, --since, --until, --since-as-filter, or exclusions and paths, is not supported in a repository with a commit-graph", .{}), error.UnsupportedParentRewrite => return tool.fatalMessage(stderr, "zig-git rev-list: --parents with paths and --first-parent, or --full-history with --reverse or --skip, is not supported without exclusions or an ordering", .{}), error.NotATree, error.TruncatedTree, error.MalformedMode, error.EmptyName => return tool.fatalMessage(stderr, "zig-git: cannot read a tree: {t}", .{err}), else => return tool.fatalMessage(stderr, "zig-git: cannot read a commit: {t}", .{err}), diff --git a/tools/log.zig b/tools/log.zig index 60879d9..1a9ba50 100644 --- a/tools/log.zig +++ b/tools/log.zig @@ -79,7 +79,7 @@ fn showPrefixed(ctx: Context, args: []const []const u8, stdout: *Io.Writer, stde const took = try diff_args.take(gpa, .show, 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, "-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) { diff --git a/tools/log_diff.zig b/tools/log_diff.zig index fa48b92..22c004c 100644 --- a/tools/log_diff.zig +++ b/tools/log_diff.zig @@ -12,7 +12,10 @@ //! empty format string (which writes nothing for the commit); `---` //! when the patch comes with `--stat`; otherwise an empty line. It is //! written for every commit with a changed path, even where the formats -//! write nothing for them, and only then. +//! write nothing for them, and only then. Under `-z` the empty line +//! before a combined diff is a NUL, and so is the one between its raw +//! lines and its patch, while the one before a two-way diff stays a line +//! feed, as git was seen to write them. const std = @import("std"); const Allocator = std.mem.Allocator; @@ -485,15 +488,20 @@ pub const Writer = struct { } // --oneline still takes the empty line here; an empty format // string takes nothing. - if (framing != .empty) try stdout.writeByte('\n'); + // Under -z the empty line is a NUL, as git was seen to write it + // before a combined diff and never before a two-way one, and the + // paths are written as they are, each ended by a NUL. + const nul = w.settings.out.nul; + const end: u8 = if (nul) 0 else '\n'; + if (framing != .empty) try stdout.writeByte(end); if (f.name_only or f.name_status) { for (paths.items) |row| { if (f.name_status) { for (row) |q| try stdout.writeByte(q.status.letter()); - try stdout.writeByte('\t'); + try stdout.writeByte(if (nul) 0 else '\t'); } - try quotePath(stdout, row[0].path, &w.settings); - try stdout.writeByte('\n'); + try rawPath(stdout, row[0].path, &w.settings); + try stdout.writeByte(end); } return; } @@ -527,13 +535,13 @@ pub const Writer = struct { try w.writeId(stdout, row[0].new.id, w.settings.out.raw_abbrev); try stdout.writeByte(' '); for (row) |q| try stdout.writeByte(q.status.letter()); - try stdout.writeByte('\t'); - try quotePath(stdout, row[0].path, &w.settings); - try stdout.writeByte('\n'); + try stdout.writeByte(if (nul) 0 else '\t'); + try rawPath(stdout, row[0].path, &w.settings); + try stdout.writeByte(end); written = true; }; if (f.patch and paths.items.len != 0) { - if (written) try stdout.writeByte('\n'); + if (written) try stdout.writeByte(end); for (paths.items) |row| try w.writeCombinedPatch(stdout, row); } } @@ -906,6 +914,13 @@ pub const Follower = struct { } }; +/// A path in the raw format or a list of names: quoted, or under -z as +/// it is. +fn rawPath(out: *Io.Writer, path: []const u8, s: *const repo_diff.Settings) !void { + if (s.out.nul) return out.writeAll(path); + return quotePath(out, path, s); +} + fn quotePath(out: *Io.Writer, path: []const u8, s: *const repo_diff.Settings) !void { try git.quote.writePathWith(out, path, .{ .quote_path = s.out.quote_path }); } diff --git a/tools/log_tests.zig b/tools/log_tests.zig index 977c9db..4acf21f 100644 --- a/tools/log_tests.zig +++ b/tools/log_tests.zig @@ -403,20 +403,22 @@ test "log refuses by name what needs what this does not have" { defer repo.deinit(); try repo.commitFile("x", "x\n", "x"); for ([_][]const []const u8{ - &.{ "log", "-z", "--raw" }, + &.{ "rev-list", "-z", "HEAD" }, &.{ "log", "--graph" }, &.{ "log", "--notes" }, &.{ "log", "--show-signature" }, &.{ "log", "--format=%N" }, &.{ "log", "--format=%G?" }, &.{ "log", "--format=%gD" }, - &.{ "log", "--encoding=ISO-8859-1" }, - &.{ "-c", "i18n.logOutputEncoding=latin1", "log" }, + &.{ "log", "--encoding=Shift_JIS" }, + &.{ "-c", "i18n.logOutputEncoding=EUC-JP", "log" }, + &.{ "-c", "i18n.commitEncoding=windows-1252", "log" }, + &.{ "log", "--encoding=latin1", "--pretty=email" }, }) |c| try refused(&repo, c); - // A commit in another encoding. + // A commit in an encoding other than UTF-8 and ISO-8859-1. const tree_out = try repo.output(&.{ "mktree", "--missing" }); defer gpa.free(tree_out); - const contents = try std.fmt.allocPrint(gpa, "tree {s}\nauthor A 1 +0000\ncommitter C 1 +0000\nencoding ISO-8859-1\n\ncaf\xe9\n", .{std.mem.trimEnd(u8, tree_out, "\n")}); + const contents = try std.fmt.allocPrint(gpa, "tree {s}\nauthor A 1 +0000\ncommitter C 1 +0000\nencoding windows-1252\n\ncaf\xe9\n", .{std.mem.trimEnd(u8, tree_out, "\n")}); defer gpa.free(contents); const id = try repo.hashObject("commit", contents); defer gpa.free(id); @@ -424,6 +426,133 @@ test "log refuses by name what needs what this does not have" { try same(&repo, &.{ "log", "--format=%H", id }); } +test "log, show and rev-list write commits in the output encoding as git does" { + const gpa = testing.allocator; + var repo = try Repo.init(gpa, testing.io); + defer repo.deinit(); + const tree_out = try repo.output(&.{ "mktree", "--missing" }); + defer gpa.free(tree_out); + const tree = std.mem.trimEnd(u8, tree_out, "\n"); + // What each commit declares, and its author and message: in + // ISO-8859-1 under a name of it git knows, in UTF-8 with and without + // the header, what neither encoding can hold, bytes that are not + // UTF-8, and two headers. + const commits = [_]struct { header: []const u8, author: []const u8, message: []const u8 }{ + .{ .header = "encoding ISO-8859-1\n", .author = "Jos\xe9", .message = "caf\xe9\n\nbody \xe9\n" }, + .{ .header = "encoding latin1\n", .author = "J", .message = "caf\xe9\n" }, + .{ .header = "encoding LATIN1\nencoding x\n", .author = "J", .message = "\xe9\n" }, + .{ .header = "encoding csISOLatin1\n", .author = "J", .message = "\xe9\n" }, + .{ .header = "", .author = "Jos\xc3\xa9", .message = "caf\xc3\xa9\n" }, + .{ .header = "encoding UTF-8\n", .author = "J", .message = "\xe6\x97\xa5 caf\xc3\xa9\n" }, + .{ .header = "encoding utf8\n", .author = "J", .message = "x\n" }, + .{ .header = "", .author = "J", .message = "caf\xe9 not UTF-8\n" }, + }; + var ids: std.ArrayList([]u8) = .empty; + defer { + for (ids.items) |id| gpa.free(id); + ids.deinit(gpa); + } + for (commits, 0..) |c, k| { + var text: Io.Writer.Allocating = .init(gpa); + defer text.deinit(); + try text.writer.print("tree {s}\n", .{tree}); + if (k > 0) try text.writer.print("parent {s}\n", .{ids.items[k - 1]}); + try text.writer.print("author {s} 1 +0000\ncommitter C 1 +0000\n{s}\n{s}", .{ c.author, c.header, c.message }); + try ids.append(gpa, try repo.hashObject("commit", text.written())); + } + try repo.git(&.{ "update-ref", "HEAD", ids.items[ids.items.len - 1] }); + const outputs = [_][]const []const u8{ + &.{}, &.{"--encoding=UTF-8"}, + &.{"--encoding=latin1"}, &.{"--encoding=ISO-8859-1"}, + &.{ "-c", "i18n.commitEncoding=latin1" }, &.{ "-c", "i18n.logOutputEncoding=ISO-8859-1" }, + &.{ "-c", "i18n.commitEncoding=latin1", "--encoding=utf8" }, + }; + const formats = [_][]const u8{ "--pretty=raw", "--pretty=fuller", "--format=%an%x00%s%x00%e%x00%B", "--pretty=reference", "--oneline" }; + var args: std.ArrayList([]const u8) = .empty; + defer args.deinit(gpa); + for (outputs) |o| for (formats) |f| for ([_][]const u8{ "log", "show", "rev-list" }) |command| { + args.clearRetainingCapacity(); + // `-c` goes before the command, the rest after. + const before = o.len != 0 and std.mem.eql(u8, o[0], "-c"); + if (before) try args.appendSlice(gpa, o[0..2]); + try args.append(gpa, command); + if (before) try args.appendSlice(gpa, o[2..]) else try args.appendSlice(gpa, o); + try args.append(gpa, f); + if (std.mem.eql(u8, command, "show")) { + try args.appendSlice(gpa, &.{ "-s", ids.items[0], ids.items[4], ids.items[5], ids.items[7] }); + } else try args.append(gpa, "HEAD"); + try same(&repo, args.items); + }; + // A search sees each commit as it is written. + for ([_][]const u8{ "caf\xc3\xa9", "caf\xe9", "Jos\xc3\xa9", "\xe9$" }) |pattern| { + const grep = try std.fmt.allocPrint(gpa, "--grep={s}", .{pattern}); + defer gpa.free(grep); + const author = try std.fmt.allocPrint(gpa, "--author={s}", .{pattern}); + defer gpa.free(author); + for ([_][]const []const u8{ &.{}, &.{"--encoding=latin1"} }) |o| { + args.clearRetainingCapacity(); + try args.append(gpa, "log"); + try args.appendSlice(gpa, o); + try args.appendSlice(gpa, &.{ "--format=%H", grep }); + try same(&repo, args.items); + args.items[args.items.len - 1] = author; + try same(&repo, args.items); + } + } +} + +test "log and show end each commit with a NUL under -z as git does" { + const gpa = testing.allocator; + var repo = try Repo.init(gpa, testing.io); + defer repo.deinit(); + try writeFile(&repo, "a", "1\n2\n3\n"); + try writeFile(&repo, "caf\xc3\xa9", "x\n"); + try repo.git(&.{ "add", "." }); + try repo.commit("base"); + try repo.git(&.{ "checkout", "-q", "-b", "side" }); + try writeFile(&repo, "a", "1\n2s\n3\n"); + try writeFile(&repo, "caf\xc3\xa9", "x\ny\n"); + try repo.git(&.{ "add", "." }); + try repo.commit("side"); + try repo.git(&.{ "checkout", "-q", "-" }); + try writeFile(&repo, "a", "1m\n2\n3\n"); + try writeFile(&repo, "caf\xc3\xa9", "z\n"); + try repo.git(&.{ "add", "." }); + try repo.commit("main"); + // It conflicts; what is committed is what is written below. + const merged = try repo.runIn(".", &.{ "merge", "-q", "--no-commit", "side" }, ""); + merged.deinit(gpa); + // An evil merge: a line neither parent has. + try writeFile(&repo, "a", "1m\n2s\n3\nevil\n"); + try writeFile(&repo, "caf\xc3\xa9", "w\n"); + try repo.git(&.{ "add", "." }); + try repo.commit("merge"); + try repo.git(&.{ "mv", "a", "b" }); + try writeFile(&repo, "bin", "\x00\x01"); + try repo.git(&.{ "add", "." }); + try repo.commit("rename and binary"); + const formats = [_][]const u8{ "--format=%h", "--format=%h%n", "--pretty=format:%h", "--pretty=medium", "--oneline", "--pretty=raw", "--pretty=reference", "--format=", "--format=%H%x00%B%x00" }; + const diffs = [_][]const []const u8{ + &.{}, &.{"--raw"}, &.{"-p"}, &.{"--patch-with-raw"}, &.{"--stat"}, + &.{"--name-only"}, &.{"--name-status"}, &.{"--numstat"}, &.{"--patch-with-stat"}, &.{"--summary"}, + &.{"--cc"}, &.{ "--cc", "--raw" }, &.{ "-c", "--name-status" }, &.{ "-m", "--raw" }, &.{ "--cc", "--stat" }, + &.{ "--cc", "--name-only" }, &.{ "--first-parent", "-p" }, &.{ "-M", "--raw" }, + }; + var args: std.ArrayList([]const u8) = .empty; + defer args.deinit(gpa); + for ([_][]const u8{ "log", "show" }) |command| for (formats) |f| for (diffs) |d| { + args.clearRetainingCapacity(); + try args.appendSlice(gpa, &.{ command, "-z", f }); + try args.appendSlice(gpa, d); + try same(&repo, args.items); + }; + // As grebe asks. + try same(&repo, &.{ "-c", "log.showSignature=false", "log", "--parents", "--no-color", "--encoding=UTF-8", "-z", "--date=raw", "--decorate=full", "--format=%H%x00%P%x00%an%x00%ae%x00%ad%x00%s%x00%D", "--topo-order" }); + for ([_][]const u8{ "HEAD", "HEAD~1", "HEAD~2" }) |rev| { + try same(&repo, &.{ "-c", "log.showSignature=false", "show", "--encoding=UTF-8", "--cc", "--date=raw", "--format=%H%x00%P%x00%an%x00%ae%x00%ad%x00%cn%x00%ce%x00%cd%x00%B%x00", "--no-color", "--no-ext-diff", "--no-textconv", "-M", "--patch-with-raw", "-z", "-U3", rev, "--" }); + } +} + test "log on an unborn branch and with no commits says what git says" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); diff --git a/tools/refs_command.zig b/tools/refs_command.zig index 19d3f22..6fb7de0 100644 --- a/tools/refs_command.zig +++ b/tools/refs_command.zig @@ -16,7 +16,12 @@ //! objects they name: `refname` (with `:short`, `:lstrip=`, `:rstrip=` //! and `:strip=`), `objectname` and `*objectname` (with `:short` and //! `:short=`), `objecttype`, `symref` (with the same modifiers as -//! `refname`), `*objecttype` and `HEAD`. Object IDs are shortened as git +//! `refname`), `*objecttype`, `HEAD`, and of the message only `subject` +//! and `contents:subject` (see `git.contents`); and the conditions +//! `%(if)`, `%(if:equals=)`, `%(if:notequals=)`, +//! `%(then)`, `%(else)` and `%(end)`, nested, whose structure is checked +//! as each ref is written, with git's complaints, so that a misused one +//! says nothing until there is a ref to write. Object IDs are shortened as git //! shortens them (see `object_names.zig`): `show-ref --abbrev` and //! `--hash=`, and `%(objectname:short)`. Every other atom is refused //! by name; so are `show-ref --exclude-existing`, `check-ref-format @@ -441,6 +446,19 @@ const for_each_ref_usage = const Piece = union(enum) { literal: []const u8, atom: Atom, + /// `%(if)`, `%(if:equals=)`, `%(if:notequals=)`. + @"if": Condition, + then, + @"else", + end, +}; + +/// What an `%(if)` asks of the text up to its `%(then)`. +const Condition = union(enum) { + /// That it is not empty once blank space is trimmed from it. + plain, + equals: []const u8, + notequals: []const u8, }; const Atom = struct { @@ -453,7 +471,7 @@ const Atom = struct { length: Length = .whole, const Length = union(enum) { whole, default, digits: i64 }; - const Field = enum { refname, objectname, objecttype, symref, head }; + const Field = enum { refname, objectname, objecttype, symref, head, subject }; const Strip = union(enum) { none, short, lstrip: i64, rstrip: i64 }; }; @@ -489,7 +507,7 @@ fn parseFormat(arena: Allocator, format: []const u8, stderr: *Io.Writer) ![]Piec if (literal.items.len != 0) { try pieces.append(arena, .{ .literal = try literal.toOwnedSlice(arena) }); } - try pieces.append(arena, .{ .atom = try parseAtom(format[i + 2 .. close], stderr) }); + try pieces.append(arena, try parsePiece(format[i + 2 .. close], stderr)); i = close + 1; continue; } @@ -500,6 +518,26 @@ fn parseFormat(arena: Allocator, format: []const u8, stderr: *Io.Writer) ![]Piec return pieces.toOwnedSlice(arena); } +/// An atom, or one of the atoms that make a condition: `%(if)` and the +/// rest, whose structure is checked as each ref is written, as git checks +/// it. +fn parsePiece(text: []const u8, stderr: *Io.Writer) !Piece { + const colon = std.mem.findScalar(u8, text, ':'); + const name = text[0 .. colon orelse text.len]; + const modifier: ?[]const u8 = if (colon) |c| text[c + 1 ..] else null; + // git takes no notice of what follows %(then:, %(else: and %(end:. + if (std.mem.eql(u8, name, "then")) return .then; + if (std.mem.eql(u8, name, "else")) return .@"else"; + if (std.mem.eql(u8, name, "end")) return .end; + if (std.mem.eql(u8, name, "if")) { + const m = modifier orelse return .{ .@"if" = .plain }; + if (std.mem.cutPrefix(u8, m, "equals=")) |v| return .{ .@"if" = .{ .equals = v } }; + if (std.mem.cutPrefix(u8, m, "notequals=")) |v| return .{ .@"if" = .{ .notequals = v } }; + return tool.fatalMessage(stderr, "unrecognized %(if) argument: {s}", .{m}); + } + return .{ .atom = try parseAtom(text, stderr) }; +} + fn parseAtom(text: []const u8, stderr: *Io.Writer) !Atom { var a: Atom = .{ .field = .refname }; var rest = text; @@ -510,7 +548,7 @@ fn parseAtom(text: []const u8, stderr: *Io.Writer) !Atom { const colon = std.mem.findScalar(u8, rest, ':'); const field_name = rest[0 .. colon orelse rest.len]; const modifier: ?[]const u8 = if (colon) |cpos| rest[cpos + 1 ..] else null; - const known = std.meta.stringToEnum(enum { refname, objectname, objecttype, symref, HEAD }, field_name) orelse + const known = std.meta.stringToEnum(enum { refname, objectname, objecttype, symref, HEAD, subject, contents }, field_name) orelse return refuse(stderr, "for-each-ref", "%({s})", .{text}); a.field = switch (known) { .refname => .refname, @@ -518,7 +556,15 @@ fn parseAtom(text: []const u8, stderr: *Io.Writer) !Atom { .objecttype => .objecttype, .symref => .symref, .HEAD => .head, + .subject, .contents => .subject, }; + // Of the message only its subject is known here: %(subject) and + // %(contents:subject). + if (a.field == .subject) { + const ok = if (known == .subject) modifier == null else (modifier != null and std.mem.eql(u8, modifier.?, "subject")); + if (!ok or a.deref) return refuse(stderr, "for-each-ref", "%({s})", .{text}); + return a; + } // Of the dereferenced object only its name and type are known here. if (a.deref and a.field != .objectname and a.field != .objecttype) return refuse(stderr, "for-each-ref", "%({s})", .{text}); if (modifier) |m| if (a.field == .objectname) { @@ -747,16 +793,102 @@ const Formatter = struct { try f.stdout.writeAll(line.written()); } - fn format(f: *const Formatter, w: *Io.Writer, name: []const u8, direct: RefStore.Direct, symref: ?[]const u8) !void { + fn format(f: *const Formatter, line: *Io.Writer, name: []const u8, direct: RefStore.Direct, symref: ?[]const u8) !void { + const gpa = f.ctx.gpa; var kind: ?git.ObjectKind = null; var peeled: ?RefStore.Peeled = null; var peel_done = false; - for (f.pieces) |piece| switch (piece) { - .literal => |l| try w.writeAll(l), - .atom => |atom| { - if ((atom.deref or atom.field == .objecttype) and kind == null) kind = try f.header(direct.id, name); - if (atom.deref and !peel_done) { - peel_done = true; + var object: ?git.ObjectDatabase.Object = null; + defer if (object) |*o| o.deinit(gpa); + // The conditions open, innermost last, each writing into its own + // buffer until its %(end) chooses what of it to keep. + var frames: std.ArrayList(Frame) = .empty; + defer { + for (frames.items) |*fr| fr.deinit(); + frames.deinit(gpa); + } + for (f.pieces) |piece| { + const w = if (frames.items.len != 0) &frames.items[frames.items.len - 1].text.writer else line; + switch (piece) { + .literal => |l| try w.writeAll(l), + .@"if" => |c| try frames.append(gpa, .{ .condition = c, .text = .init(gpa) }), + .then => { + const fr = if (frames.items.len != 0) &frames.items[frames.items.len - 1] else return tool.fatalMessage(f.stderr, "format: %(then) atom used without a %(if) atom", .{}); + if (fr.state != .condition) return tool.fatalMessage(f.stderr, "format: %(then) atom used more than once", .{}); + const text = fr.text.written(); + fr.holds = switch (fr.condition) { + .plain => std.mem.trim(u8, text, " \t\n\r\x0b\x0c").len != 0, + .equals => |v| std.mem.eql(u8, text, v), + .notequals => |v| !std.mem.eql(u8, text, v), + }; + fr.text.clearRetainingCapacity(); + fr.state = .then; + }, + .@"else" => { + const fr = if (frames.items.len != 0) &frames.items[frames.items.len - 1] else return tool.fatalMessage(f.stderr, "format: %(else) atom used without a %(if) atom", .{}); + switch (fr.state) { + .condition => return tool.fatalMessage(f.stderr, "format: %(else) atom used without a %(then) atom", .{}), + .@"else" => return tool.fatalMessage(f.stderr, "format: %(else) atom used more than once", .{}), + .then => {}, + } + fr.then_text = try fr.text.toOwnedSlice(); + fr.state = .@"else"; + }, + .end => { + if (frames.items.len == 0) return tool.fatalMessage(f.stderr, "format: %(end) atom used without corresponding atom", .{}); + var fr = frames.pop().?; + defer fr.deinit(); + const outer = if (frames.items.len != 0) &frames.items[frames.items.len - 1].text.writer else line; + switch (fr.state) { + .condition => return tool.fatalMessage(f.stderr, "format: %(if) atom used without a %(then) atom", .{}), + .then => if (fr.holds) try outer.writeAll(fr.text.written()), + .@"else" => try outer.writeAll(if (fr.holds) fr.then_text else fr.text.written()), + } + }, + .atom => |atom| try f.writeAtom(w, atom, name, direct, symref, &kind, &peeled, &peel_done, &object), + } + } + if (frames.items.len != 0) return tool.fatalMessage(f.stderr, "format: %(end) atom missing", .{}); + try line.writeByte('\n'); + } + + /// An `%(if)` open while a ref is written. + const Frame = struct { + condition: Condition, + /// The condition's text, then the text after %(then), then after + /// %(else). + text: Io.Writer.Allocating, + state: enum { condition, then, @"else" } = .condition, + holds: bool = false, + then_text: []u8 = &.{}, + + fn deinit(fr: *Frame) void { + fr.text.allocator.free(fr.then_text); + fr.text.deinit(); + } + }; + + fn writeAtom( + f: *const Formatter, + w: *Io.Writer, + atom: Atom, + name: []const u8, + direct: RefStore.Direct, + symref: ?[]const u8, + kind_slot: *?git.ObjectKind, + peeled_slot: *?RefStore.Peeled, + peel_done: *bool, + object: *?git.ObjectDatabase.Object, + ) !void { + var kind = kind_slot.*; + defer kind_slot.* = kind; + var peeled = peeled_slot.*; + defer peeled_slot.* = peeled; + { + { + if ((atom.deref or atom.field == .objecttype or atom.field == .subject) and kind == null) kind = try f.header(direct.id, name); + if (atom.deref and !peel_done.*) { + peel_done.* = true; if (kind.? == .tag) { if (direct.peeled) |p| { peeled = .{ .id = p, .kind = try f.header(p, name) }; @@ -779,10 +911,22 @@ const Formatter = struct { if (peeled) |p| try w.writeAll(p.kind.name()); } else try w.writeAll(kind.?.name()), .head => try w.writeAll(if (f.head_target != null and std.mem.eql(u8, f.head_target.?, name)) "*" else " "), + // Only commits and tags have messages. + .subject => if (kind.? == .commit or kind.? == .tag) { + if (object.* == null) { + var diag: Diagnostics = .{}; + object.* = f.db.read(f.ctx.io, f.ctx.gpa, direct.id, &diag) catch |err| switch (err) { + error.ObjectNotFound => return tool.fatalMessage(f.stderr, "missing object {f} for {s}", .{ direct.id, name }), + else => |x| return x, + }; + } + const data = object.*.?.data; + const message = if (std.mem.find(u8, data, "\n\n")) |blank| data[blank + 2 ..] else ""; + try git.contents.writeSubject(w, message); + }, } - }, - }; - try w.writeByte('\n'); + } + } } }; @@ -1010,6 +1154,28 @@ const listing_commands = [_][]const []const u8{ &.{ "for-each-ref", "--format=%(HEAD) %(refname) %(symref:short) %(symref:lstrip=1)" }, &.{ "for-each-ref", "--format", "%%%0a%41%(refname)%x41%" }, &.{ "for-each-ref", "--format=%(refname" }, + &.{ "for-each-ref", "--format=%(refname:short) %(objecttype) %(contents:subject)", "refs/tags" }, + &.{ "for-each-ref", "--format=%(refname) [%(subject)]" }, + &.{ "for-each-ref", "--format=%(if)%(*objectname)%(then)%(*objectname)%(else)%(objectname)%(end) %(refname:short)", "refs/tags/" }, + &.{ "for-each-ref", "--format=%(if)%(HEAD)%(then)[%(HEAD)]%(else)-%(end)%(refname)" }, + &.{ "for-each-ref", "--format=%(if) %(then)space%(else)none%(end)" }, + &.{ "for-each-ref", "--format=%(if:equals=tag)%(objecttype)%(then)T%(else)F%(end) %(if:notequals=tag)%(objecttype)%(then)N%(end)" }, + &.{ "for-each-ref", "--format=%(if:equals=tag) %(objecttype) %(then)T%(else)F%(end)" }, + &.{ "for-each-ref", "--format=%(if:equals=)%(*objecttype)%(then)E%(else)N%(end) %(if:equals=a:b)a:b%(then)Y%(end)" }, + &.{ "for-each-ref", "--format=%(if)%(if)%(*objecttype)%(then)x%(end)%(then)A%(else)B%(end)" }, + &.{ "for-each-ref", "--format=%(if) x %(then)[%(if)%(*objecttype)%(then)in%(end)]%(end)" }, + &.{ "for-each-ref", "--format=%(then:x)" }, + &.{ "for-each-ref", "--format=%(else)" }, + &.{ "for-each-ref", "--format=%(end)" }, + &.{ "for-each-ref", "--format=%(if)x%(then)y" }, + &.{ "for-each-ref", "--format=%(if)%(else)%(then)%(end)" }, + &.{ "for-each-ref", "--format=%(if)a%(then)b%(else)c%(else)d%(end)" }, + &.{ "for-each-ref", "--format=%(if)a%(then)b%(then)c%(end)" }, + &.{ "for-each-ref", "--format=%(if)a%(end)" }, + &.{ "for-each-ref", "--format=%(if)%(then)%(if)x%(end)" }, + &.{ "for-each-ref", "--format=%(if)%(then)%(end)%(end)" }, + &.{ "for-each-ref", "--format=%(if:foo)x%(then)y%(end)" }, + &.{ "for-each-ref", "--format=%(then)", "refs/nomatch" }, &.{ "symbolic-ref", "HEAD" }, &.{ "symbolic-ref", "--short", "HEAD" }, &.{ "symbolic-ref", "-q", "refs/heads/main" }, @@ -1045,6 +1211,50 @@ test "zig-git lists refs as git does, packed and loose, in both ref formats" { } } +test "zig-git writes the subjects of commits and tags as for-each-ref does" { + const gpa = testing.allocator; + var repo = try Repo.init(gpa, testing.io); + defer repo.deinit(); + try repo.commit("one"); + const tree_out = try repo.output(&.{ "rev-parse", "HEAD^{tree}" }); + defer gpa.free(tree_out); + const tree = std.mem.trimEnd(u8, tree_out, "\n"); + // Messages whose first paragraph ends, or does not, in every way + // git was seen to tell apart, and signatures after them. + const messages = [_][]const u8{ + "a\r\n\nb", "a\n\r\nb", "a\rb\nc", "\r\n\r\na", + "\n\n\na\n", "a \n\nb", "a", "", + "a\n \n\nb", "\ta\n\tb", "a\r\r\nb\r\n", "\r\na", + "x\n\r\n\r\ny", " \t \nreal\n \nbody", "\r\n\na", "caf\xc3\xa9 \xe9", + "a\n-----BEGIN PGP SIGNATURE-----\nxx\n-----END PGP SIGNATURE-----\n", "-----BEGIN PGP SIGNATURE-----\nxx\n", "a\nb\n-----BEGIN SSH SIGNATURE-----\nxx\n", "a\nb\n-----BEGIN SIGNED MESSAGE-----\nxx\n", + "a\nb\n-----BEGIN PGP MESSAGE-----\nxx\n", "a\nb\n -----BEGIN PGP SIGNATURE-----\nxx\n", "a\nb\n-----BEGIN PGP SIGNATURE-----\nx\n-----BEGIN PGP SIGNATURE-----\ny\n", "a\nb\n-----BEGIN PGP SIGNATURE----\nxx\n", + }; + for (messages, 0..) |m, k| { + const commit_text = try std.fmt.allocPrint(gpa, "tree {s}\nauthor A 1 +0000\ncommitter C 1 +0000\n\n{s}", .{ tree, m }); + defer gpa.free(commit_text); + const c = try repo.hashObject("commit", commit_text); + defer gpa.free(c); + const tag_text = try std.fmt.allocPrint(gpa, "object {s}\ntype commit\ntag t{d}\ntagger T 1 +0000\n\n{s}", .{ c, k, m }); + defer gpa.free(tag_text); + const t = try repo.hashObject("tag", tag_text); + defer gpa.free(t); + var name_buf: [32]u8 = undefined; + try repo.git(&.{ "update-ref", try std.fmt.bufPrint(&name_buf, "refs/c/{d:0>2}", .{k}), c }); + try repo.git(&.{ "update-ref", try std.fmt.bufPrint(&name_buf, "refs/t/{d:0>2}", .{k}), t }); + } + // A commit without a message at all, and objects that have none. + const bare = try std.fmt.allocPrint(gpa, "tree {s}\nauthor A 1 +0000\ncommitter C 1 +0000\n", .{tree}); + defer gpa.free(bare); + const bare_id = try repo.hashObject("commit", bare); + defer gpa.free(bare_id); + try repo.git(&.{ "update-ref", "refs/c/bare", bare_id }); + try repo.git(&.{ "update-ref", "refs/c/tree", tree }); + try expectAllSameAsGit(&repo, ".", &.{ + &.{ "for-each-ref", "--format=[%(contents:subject)]", "refs/c", "refs/t" }, + &.{ "for-each-ref", "--format=%(refname) %(objecttype) [%(subject)]" }, + }); +} + test "zig-git lists refs after git refs migrate as git does" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); @@ -1062,7 +1272,9 @@ test "zig-git refuses what it does not do, by name" { for ([_][]const []const u8{ &.{ "check-ref-format", "--branch", "main" }, &.{ "show-ref", "--exclude-existing" }, - &.{ "for-each-ref", "--format=%(subject)" }, + &.{ "for-each-ref", "--format=%(contents)" }, + &.{ "for-each-ref", "--format=%(subject:sanitize)" }, + &.{ "for-each-ref", "--format=%(*subject)" }, &.{ "for-each-ref", "--format=%(*refname)" }, &.{ "for-each-ref", "--sort=objectname" }, &.{ "for-each-ref", "--include-root-refs" }, diff --git a/tools/repo_diff.zig b/tools/repo_diff.zig index 0a4b025..d8636a8 100644 --- a/tools/repo_diff.zig +++ b/tools/repo_diff.zig @@ -112,6 +112,8 @@ pub const Settings = struct { stat_name_width: ?usize = null, stat_graph_width: ?usize = null, stat_count: ?usize = null, + /// `--compact-summary`. + compact_summary: bool = false, /// `-s`, `--name-only`, `--name-status` and `--check` as given, which /// git refuses together. exclusive: u4 = 0, @@ -390,7 +392,6 @@ const refused = [_][]const u8{ "--output-indicator-new", "--output-indicator-old", "--output-indicator-context", - "--compact-summary", "--patience", "--histogram", "--ita-invisible-in-index", @@ -592,6 +593,12 @@ pub fn parseOption(gpa: Allocator, s: *Settings, arg: []const u8, next: ?[]const s.format_given = true; if (std.mem.eql(u8, name, "--stat-width")) s.stat_width = n else if (std.mem.eql(u8, name, "--stat-name-width")) s.stat_name_width = n else if (std.mem.eql(u8, name, "--stat-graph-width")) s.stat_graph_width = n else s.stat_count = n; return if (took) .took_next else .yes; + } else if (std.mem.eql(u8, arg, "--compact-summary")) { + // It implies --stat, and says beside each name what --summary + // would. + f.stat = true; + s.format_given = true; + s.compact_summary = true; } else if (std.mem.eql(u8, arg, "-z")) { s.out.nul = true; } else if (std.mem.eql(u8, arg, "--minimal")) { @@ -943,6 +950,7 @@ pub fn finish(ctx: Context, s: *Settings, mnemonic: ?[2][]const u8, names: ?*obj .name_width = s.stat_name_width orelse s.config_name_width, .graph_width = s.stat_graph_width orelse s.config_graph_width, .count = s.stat_count, + .compact_summary = s.compact_summary, }; if (s.out.stat.name_width) |n| if (n == 0) { s.out.stat.name_width = null; diff --git a/tools/rev_list.zig b/tools/rev_list.zig index 6112040..e63ddfd 100644 --- a/tools/rev_list.zig +++ b/tools/rev_list.zig @@ -184,7 +184,7 @@ fn runPrefixed(ctx: Context, args: []const []const u8, stdout: *Io.Writer, stder const took = try diff_args.take(gpa, .log, 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, "-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) { @@ -193,7 +193,10 @@ fn runPrefixed(ctx: Context, args: []const []const u8, stdout: *Io.Writer, stder } } } - if (isUnsupported(arg) or (command == .log and describe.isUnsupported(arg))) + // -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", .{ commandName(command), 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"); @@ -477,7 +480,8 @@ fn runPrefixed(ctx: Context, args: []const []const u8, stdout: *Io.Writer, stder unreachable; } try revs.flushMessages(stderr, 1, false); - if (text[0] == '^') return tool.fatalMessage(stderr, "bad revision '{s}'", .{text}); + // 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. @@ -488,6 +492,8 @@ fn runPrefixed(ctx: Context, args: []const []const u8, stdout: *Io.Writer, stder 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: @@ -617,8 +623,10 @@ fn runPrefixed(ctx: Context, args: []const []const u8, stdout: *Io.Writer, stder const excluding = for (starts.items) |st| { if (st.excluded) break true; } else false; - const refusal: ?[]const u8 = if (excluding) - "an excluded commit" + // 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) @@ -635,8 +643,6 @@ fn runPrefixed(ctx: Context, args: []const []const u8, stdout: *Io.Writer, stder "a parent count" else if (out.children) "--children" - else if (h.graph.layers.len != 0) - "a commit-graph" else null; if (refusal) |r| return tool.fatalMessage(stderr, "zig-git {s}: --simplify-by-decoration with {s} is not supported", .{ commandName(command), r }); @@ -886,8 +892,12 @@ const Filter = struct { } fn fail(f: *Filter, stderr: *Io.Writer) error{Fatal} { - // What the diff writer refused, it has said already. - if (f.failure.? == error.Fatal) return 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}: {t}", .{ f.failed_id.?, f.failure.? }); } }; diff --git a/tools/rev_parse.zig b/tools/rev_parse.zig index 9eea45b..7377f4e 100644 --- a/tools/rev_parse.zig +++ b/tools/rev_parse.zig @@ -31,9 +31,13 @@ //! and each after it must be there too. Ranges and the other sets of //! commits (`A..B`, `A...B`, `^A`, `A^@`, `A^!`, `A^-`) are printed as //! the revisions they stand for, each excluded one with `^`, as git -//! prints them (`history.zig` takes them apart). The options git has and -//! this does not (`--sq`, `--not`, `--revs-only` and the like) are refused -//! by name before anything is printed. Only +//! prints them (`history.zig` takes them apart). `--not` turns the `^` +//! of everything printed after it about, and `--revs-only` prints only +//! revisions and the options git keeps for `rev-list` (`revListFlag`), +//! passing over anything else, paths and what is neither path nor +//! revision included, without a word. `-n` takes the next argument with +//! it. The options git has and this does not (`--sq`, `--no-revs` and +//! the like) are refused by name before anything is printed. Only //! a command that reads objects stops at a bad `core.abbrev` or //! `core.disambiguate`; git stops at one for the discovery queries too. //! @@ -142,11 +146,11 @@ fn idOption(arg: []const u8) ?IdOption { /// The options git's rev-parse has and this does not, refused by name. const unsupported_options = [_][]const u8{ - "--parseopt", "--sq-quote", "--keep-dashdash", "--stop-at-non-option", "--stuck-long", - "--revs-only", "--no-revs", "--flags", "--no-flags", "--prefix", - "--sq", "--not", "--since", "--after", "--until", - "--before", "--bisect", "--resolve-git-dir", "--git-path", "--show-superproject-working-tree", - "--shared-index-path", "--output-object-format", "--exclude-hidden", + "--parseopt", "--sq-quote", "--keep-dashdash", "--stop-at-non-option", "--stuck-long", + "--no-revs", "--flags", "--no-flags", "--prefix", "--sq", + "--since", "--after", "--until", "--before", "--bisect", + "--resolve-git-dir", "--git-path", "--show-superproject-working-tree", "--shared-index-path", "--output-object-format", + "--exclude-hidden", }; fn isUnsupported(arg: []const u8) bool { @@ -156,6 +160,24 @@ fn isUnsupported(arg: []const u8) bool { return false; } +/// Whether `--revs-only` prints the flag `arg`: one of those `rev-list` +/// takes that git was seen to keep, among every option git-rev-list(1) +/// and git-log(1) name. The others are dropped, `--first-parent` and +/// `--reverse` among them. +fn revListFlag(arg: []const u8) bool { + const exact = [_][]const u8{ + "--date-order", "--topo-order", "--dense", "--sparse", "--header", "--parents", + "--pretty", "--no-merges", "--no-min-parents", "--no-max-parents", "--objects", "--objects-edge", + "--unpacked", "--ignore-missing", + }; + for (exact) |e| if (std.mem.eql(u8, arg, e)) return true; + const prefixes = [_][]const u8{ "--max-count=", "--max-age=", "--min-age=", "--max-parents=", "--min-parents=", "-n" }; + for (prefixes) |p| if (std.mem.startsWith(u8, arg, p)) return true; + // `-`, as git counts it: any argument a digit begins after the + // dash. + return arg.len >= 2 and arg[0] == '-' and std.ascii.isDigit(arg[1]); +} + /// Runs `rev-parse` with `args`, the arguments after its name. pub fn run(ctx: Context, args: []const []const u8, stdout: *Io.Writer, stderr: *Io.Writer) !void { // What this does not answer is refused before anything is said. @@ -182,6 +204,14 @@ pub fn run(ctx: Context, args: []const []const u8, stdout: *Io.Writer, stderr: * state.default = args[i]; continue; } + // `-n` takes the next argument with it, whatever it is, and both + // are printed as they are. + if (std.mem.eql(u8, arg, "-n") and !state.options_ended and !state.paths_only and !state.path_phase) { + if (i + 1 == args.len) return tool.fatalMessage(stderr, "-n requires an argument", .{}); + i += 1; + if (!state.verify) try stdout.print("-n\n{s}\n", .{args[i]}); + continue; + } try state.answer(arg); } try state.finish(); @@ -239,6 +269,12 @@ const State = struct { paths_only: bool = false, /// After the first path, everything is one. path_phase: bool = false, + /// `--revs-only`: only revisions and the options `rev-list` is + /// given are printed. + revs_only: bool = false, + /// `--not`, an odd number of times: each revision printed after it is + /// printed with `^` if it would be without, and without if with. + not: bool = false, /// `--exclude` patterns waiting for the next listing option. excludes: std.ArrayList([]const u8) = .empty, /// The commits, for the sets of them that need commits read. @@ -288,7 +324,7 @@ const State = struct { fn answer(s: *State, arg: []const u8) !void { if (s.paths_only) { - if (!s.verify) try s.stdout.print("{s}\n", .{arg}); + if (!s.verify and !s.revs_only) try s.stdout.print("{s}\n", .{arg}); return; } const is_option = !s.options_ended and !s.path_phase and arg.len > 0 and arg[0] == '-'; @@ -296,18 +332,27 @@ const State = struct { if (std.mem.eql(u8, arg, "--")) { try s.applyDefault(); s.paths_only = true; - if (!s.verify) try s.stdout.writeAll("--\n"); + if (!s.verify and !s.revs_only) try s.stdout.writeAll("--\n"); return; } if (std.mem.eql(u8, arg, "--end-of-options")) { s.options_ended = true; - if (!s.verify) try s.stdout.print("{s}\n", .{arg}); + if (!s.verify and !s.revs_only) try s.stdout.print("{s}\n", .{arg}); + return; + } + if (std.mem.eql(u8, arg, "--revs-only")) { + s.revs_only = true; + return; + } + if (std.mem.eql(u8, arg, "--not")) { + s.not = !s.not; return; } if (query(arg) != null) return s.answerQuery(arg); if (idOption(arg)) |o| return s.answerOption(o, arg); - // Anything else is a flag for rev-list, which git passes on. - if (!s.verify) try s.stdout.print("{s}\n", .{arg}); + // Anything else is a flag for rev-list, which git passes on; + // under --revs-only, only those git knows rev-list takes. + if (!s.verify and (!s.revs_only or revListFlag(arg))) try s.stdout.print("{s}\n", .{arg}); return; } if (s.path_phase) return s.asPath(arg, null, false); @@ -464,6 +509,7 @@ const State = struct { .direct => |d| d.id, else => continue, }; + if (s.not) try s.stdout.writeByte('^'); switch (s.output) { .id => { const length: ?usize = if (s.short) |n| s.shortLength(names, n, id) else null; @@ -540,7 +586,7 @@ const State = struct { fn printPart(s: *State, p: history.Part) !void { const names = try s.objectNames(); - const caret: []const u8 = if (p.excluded) "^" else ""; + const caret: []const u8 = if (p.excluded != s.not) "^" else ""; switch (s.output) { .id => { try s.stdout.writeAll(caret); @@ -615,13 +661,15 @@ const State = struct { fn print(s: *State, arg: []const u8, id: git.ObjectId) !void { const names = try s.objectNames(); + const caret: []const u8 = if (s.not) "^" else ""; switch (s.output) { .id => { + try s.stdout.writeAll(caret); const length: ?usize = if (s.short) |n| s.shortLength(names, n, id) else null; try names.write(s.stdout, id, length); try s.stdout.writeByte('\n'); }, - .symbolic => try s.stdout.print("{s}\n", .{arg}), + .symbolic => try s.stdout.print("{s}{s}\n", .{ caret, arg }), .full_name, .abbrev_strict, .abbrev_loose => { const revs = try s.resolver(); var diag: Diagnostics = .{}; @@ -633,10 +681,10 @@ const State = struct { .none => {}, .ambiguous => |t| try s.stderr.print("error: refname '{s}' is ambiguous\n", .{t}), .name => |n| if (s.output == .full_name) { - try s.stdout.print("{s}\n", .{n}); + try s.stdout.print("{s}{s}\n", .{ caret, n }); } else { const short = s.repo.?.refs().shorten(s.ctx.io, s.ctx.gpa, n, s.output == .abbrev_strict, &diag) catch n; - try s.stdout.print("{s}\n", .{short}); + try s.stdout.print("{s}{s}\n", .{ caret, short }); }, } }, @@ -648,6 +696,12 @@ const State = struct { /// `first` whether it is the first argument taken for a path. fn asPath(s: *State, arg: []const u8, explained: ?[]const u8, first: bool) !void { if (first) try s.applyDefault(); + // Under --revs-only an argument that is no revision is passed + // over, checked or not, and so is everything after it. + if (s.revs_only) { + s.path_phase = true; + return; + } try s.stdout.print("{s}\n", .{arg}); if (try s.pathExists(arg)) { s.path_phase = true; @@ -686,7 +740,10 @@ const State = struct { try s.applyDefault(); if (!s.verify) return; const id = s.verified orelse return s.needOne(); - if (s.verified_caret) try s.stdout.writeByte('^'); + // `^` keeps its `^`, which --not takes away. + const saved = s.not; + defer s.not = saved; + s.not = s.verified_caret != s.not; try s.print(s.verified_text, id); } diff --git a/tools/revision_tests.zig b/tools/revision_tests.zig index a4b8e17..07e0ef0 100644 --- a/tools/revision_tests.zig +++ b/tools/revision_tests.zig @@ -607,3 +607,59 @@ test "rev-parse: options, paths and listing refs" { } } } + +test "rev-parse: --revs-only, --not and -n" { + const gpa = testing.allocator; + var repo = try Repo.init(gpa, testing.io); + defer repo.deinit(); + try writeFile(&repo, "top", "x\n"); + try repo.git(&.{ "add", "." }); + try repo.commit("one"); + try repo.git(&.{ "branch", "side" }); + try repo.commit("two"); + try repo.git(&.{ "tag", "-a", "-m", "m", "v1" }); + try repo.commit("three"); + // Every option git-rev-list(1) and git-log(1) name that --revs-only + // was seen to keep, some it drops, and the revisions and paths + // around them. + const args = [_][]const []const u8{ + &.{}, &.{"--all"}, &.{"--date-order"}, + &.{"--topo-order"}, &.{"--first-parent"}, &.{ "-n", "3" }, + &.{"-n3"}, &.{"--reverse"}, &.{"--no-merges"}, + &.{ "--", "top" }, &.{"top"}, &.{"nothere"}, + &.{"HEAD~1..HEAD"}, &.{"HEAD...side"}, &.{ "^side", "HEAD" }, + &.{"--branches"}, &.{"--tags=v*"}, &.{"--max-count=2"}, + &.{"--foo"}, &.{"-x"}, &.{"--glob=refs/heads/*"}, + &.{ "--exclude=main", "--branches" }, &.{ "top", "nothere" }, &.{"HEAD:nothere"}, + &.{ "--default", "HEAD" }, &.{ "--verify", "HEAD" }, &.{ "--symbolic", "HEAD" }, + &.{ "--end-of-options", "HEAD" }, &.{ "--short", "HEAD" }, &.{"--parents"}, + &.{"3"}, &.{"HEAD^@"}, &.{"HEAD^!"}, + &.{"side^-1"}, &.{"--show-toplevel"}, &.{ "-q", "nothere" }, + &.{"-3"}, &.{"-nx"}, &.{"--max-count=x"}, + &.{"--topo-order=1"}, &.{ "-n", "-3" }, &.{ "-n", "--all" }, + &.{ "--all", "-n" }, &.{"-n"}, &.{ "--", "--topo-order" }, + &.{ "--end-of-options", "--topo-order" }, &.{ "--end-of-options", "-n", "3" }, &.{ "--verify", "--topo-order" }, + &.{ "--verify", "-n", "3", "HEAD" }, &.{ "--objects", "HEAD" }, &.{"--pretty"}, + &.{"--pretty=x"}, &.{ "nothere", "HEAD" }, &.{ "top", "HEAD" }, + &.{ "HEAD", "top", "HEAD~1" }, &.{ "--", "HEAD" }, &.{ "--verify", "nothere" }, + &.{ "--symbolic-full-name", "main" }, &.{ "HEAD", "--topo-order", "nothere", "--date-order" }, &.{"main^{tree}"}, + &.{"v1^{}"}, &.{ "-n", "3", "--default", "main" }, &.{"nothere@{1}"}, + &.{ "HEAD", "--revs-only", "--topo-order" }, &.{ "--dense", "--sparse", "--header", "--ignore-missing", "--objects-edge", "--unpacked", "--no-min-parents", "--no-max-parents" }, &.{ "--author-date-order", "--simplify-merges", "--boundary", "--left-right", "--min-parents=2", "--max-age=5" }, + &.{ "--not", "HEAD" }, &.{ "--not", "^HEAD" }, &.{ "--not", "HEAD~1..HEAD" }, + &.{ "--not", "--all" }, &.{ "--not", "--not", "HEAD" }, &.{ "HEAD", "--not", "side", "main" }, + &.{ "--not", "--symbolic", "HEAD", "^side" }, &.{ "--not", "HEAD^!" }, &.{ "--not", "HEAD...side" }, + &.{ "--not", "--branches" }, &.{ "--verify", "--not", "HEAD" }, &.{ "--verify", "--not", "^HEAD" }, + &.{ "--not", "HEAD^@" }, &.{ "--not", "--", "top" }, &.{ "--not", "--default", "HEAD" }, + &.{ "--not", "--symbolic-full-name", "main" }, &.{ "--not", "--short", "HEAD" }, &.{ "--not", "--abbrev-ref", "HEAD" }, + &.{ "--not", "--symbolic", "--branches" }, + }; + var argv: std.ArrayList([]const u8) = .empty; + defer argv.deinit(gpa); + for (args) |a| for ([_]bool{ false, true }) |revs_only| { + argv.clearRetainingCapacity(); + try argv.append(gpa, "rev-parse"); + if (revs_only) try argv.append(gpa, "--revs-only"); + try argv.appendSlice(gpa, a); + try same(&repo, argv.items); + }; +} diff --git a/tools/walk_tests.zig b/tools/walk_tests.zig index 2edde88..5ea6ac9 100644 --- a/tools/walk_tests.zig +++ b/tools/walk_tests.zig @@ -168,15 +168,6 @@ fn keep(repo: *Repo) !void { repo.gpa.free(r.stderr); } -/// Whether a range excludes commits. -fn excludes(range: []const []const u8) bool { - for (range) |r| { - if (std.mem.find(u8, r, "..") != null or r[0] == '^' or std.mem.eql(u8, r, "--not") or - std.mem.endsWith(u8, r, "^!") or std.mem.find(u8, r, "^-") != null) return true; - } - return false; -} - const orders = [_][]const []const u8{ &.{}, &.{"--topo-order"}, &.{"--date-order"}, &.{"--author-date-order"} }; /// Every extra option with every range in the default order, and a third @@ -192,12 +183,12 @@ fn walkEverything(repo: *Repo, graph: bool) !void { try args.appendSlice(gpa, order); try args.appendSlice(gpa, extra); try args.appendSlice(gpa, range); - // With a commit-graph git orders these by generation number, which - // is not matched, and they are refused. - const graph_order = graph and o != 0 and (excludes(range) or for (extra) |x| { + // With a commit-graph git orders these by generation number, + // which is matched for exclusions and refused for the rest. + const graph_order = graph and o != 0 and for (extra) |x| { if (std.mem.eql(u8, x, "--first-parent") or std.mem.startsWith(u8, x, "--max-age") or std.mem.startsWith(u8, x, "--min-age") or std.mem.startsWith(u8, x, "--since-as-filter")) break true; - } else false); + } else false; var has_children = false; var has_boundary = false; for (extra) |x| { diff --git a/tools/zig_git.zig b/tools/zig_git.zig index 0cd5f73..daa2d37 100644 --- a/tools/zig_git.zig +++ b/tools/zig_git.zig @@ -2108,4 +2108,5 @@ test { _ = @import("log.zig"); _ = @import("line_prefix.zig"); _ = @import("diffcore_tests.zig"); + _ = @import("grebe_tests.zig"); }