// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! `zig-git diff --no-index`: two files, or two directories compared file //! by file, written as the patch `git diff --no-index` writes, with git's //! exit status. Any other form of `diff` is refused by name, and so is //! every option the library cannot answer as git does. const std = @import("std"); const Allocator = std.mem.Allocator; const Io = std.Io; const git = @import("git"); const LineDiff = git.diff.LineDiff; const hunks = git.diff.hunks; const patch = git.diff.patch; const tool = @import("zig_git.zig"); const Context = tool.Context; const discovery = @import("discovery.zig"); const object_names = @import("object_names.zig"); const diff_porcelain = @import("diff_porcelain.zig"); const repo_diff = @import("repo_diff.zig"); /// What the command line and configuration asked for. const Settings = struct { patch: patch.Options = .{}, quiet: bool = false, reverse: bool = false, no_prefix: bool = false, mnemonic: bool = false, src_prefix: ?[]const u8 = null, dst_prefix: ?[]const u8 = null, output: ?[]const u8 = null, ignore_regexes: std.ArrayList(git.regex.Regex) = .empty, /// `diff.renames`, and `--no-renames` after it: git looks for renames /// between two directories unless it is false, which is not matched; /// see `Comparison.top`. renames: bool = true, /// Every `--anchored=` since the last `--patience`. anchors: std.ArrayList([]const u8) = .empty, }; fn unsupported(stderr: *Io.Writer, what: []const u8) error{Fatal} { return tool.fatalMessage(stderr, "zig-git diff: '{s}' is not supported", .{what}); } /// Runs `zig-git diff`. pub fn run(ctx: Context, args: []const []const u8, stdout: *Io.Writer, stderr: *Io.Writer) !void { const gpa = ctx.gpa; var no_index = false; for (args) |a| { if (std.mem.eql(u8, a, "--")) break; if (std.mem.eql(u8, a, "--no-index")) no_index = true; } if (!no_index) { // Outside a repository, git compares the two paths as --no-index // does; that is not matched here. if (discovery.formatIfAny(ctx) == null) return tool.fatalMessage(stderr, "zig-git diff: diff outside a repository without --no-index is not supported", .{}); return diff_porcelain.run(ctx, args, stdout, stderr); } // The configuration: the repository's if there is one, otherwise the // system, global and command-line files. var repo: ?git.Repository = null; defer if (repo) |*r| r.close(ctx.io); var outside: ?git.Config = null; defer if (outside) |*c| c.deinit(); if (discovery.formatIfAny(ctx) != null) { repo = try discovery.open(ctx, stderr); } else { outside = try discovery.protectedConfig(ctx, stderr); } const config: *const git.Config = if (repo) |*r| &r.config else &outside.?; const names_settings = try object_names.settings(config, stderr); var s: Settings = .{}; defer { for (s.ignore_regexes.items) |*r| r.deinit(); s.ignore_regexes.deinit(gpa); s.anchors.deinit(gpa); } if (repo) |*r| s.patch.object_format = r.location.format.object_format; try readConfig(config, &s, stderr); var paths: std.ArrayList([]const u8) = .empty; defer paths.deinit(gpa); try parseArgs(gpa, args, &s, &paths, stderr); s.patch.lines.anchors = s.anchors.items; if (paths.items.len != 2) { if (paths.items.len > 2) return unsupported(stderr, "pathspecs"); stderr.writeAll(usage) catch {}; return error.UsageQuiet; } for (paths.items) |p| if (std.mem.eql(u8, p, "-")) return unsupported(stderr, "- (standard input)"); // How -W meets the changes these leave out has not been matched. if (s.patch.hunks.function_context and (s.patch.hunks.ignore_blank_lines or s.ignore_regexes.items.len != 0)) return unsupported(stderr, "-W with --ignore-blank-lines or -I"); // Prefixes: the command line, then diff.noprefix, then // diff.mnemonicPrefix, which --no-index spells 1/ and 2/. var src: []const u8 = if (s.mnemonic) "1/" else "a/"; var dst: []const u8 = if (s.mnemonic) "2/" else "b/"; if (s.no_prefix) { src = ""; dst = ""; } if (s.src_prefix) |p| src = p; if (s.dst_prefix) |p| dst = p; s.patch.src_prefix = src; s.patch.dst_prefix = dst; var shortener_state: Short = .{ .ctx = ctx, .names = null }; defer if (shortener_state.names) |*n| n.deinit(); if (repo) |*r| shortener_state.names = .init(ctx, r, names_settings); if (s.patch.abbrev) |*a| { if (a.* == 0) a.* = if (shortener_state.names) |*n| n.defaultLength() else switch (names_settings.length) { .auto => 7, .digits => |f| f, .full => s.patch.object_format.hexLength(), }; } if (shortener_state.names != null) s.patch.shorten = .{ .context = &shortener_state, .length = Short.length }; const matcher: Matcher = .{ .gpa = gpa, .regexes = s.ignore_regexes.items }; if (s.ignore_regexes.items.len != 0) s.patch.hunks.ignore_line = .{ .context = &matcher, .matches = Matcher.matches }; var out_alloc: Io.Writer.Allocating = .init(gpa); defer out_alloc.deinit(); const w: *Io.Writer = if (s.output != null) &out_alloc.writer else stdout; var differs = false; var discarding: Io.Writer.Discarding = .init(&.{}); const sink = if (s.quiet) &discarding.writer else w; // -R swaps each pair as it is written, not the paths before they are // walked, which git was seen to do. const a_path, const b_path = .{ paths.items[0], paths.items[1] }; if (s.reverse) { std.mem.swap([]const u8, &s.patch.src_prefix, &s.patch.dst_prefix); } var base = Io.Dir.cwd().openDir(ctx.io, ctx.cwd, .{}) catch |err| return tool.fatalMessage(stderr, "cannot change to '{s}': {s}", .{ ctx.cwd, git.Config.reason(err) }); defer base.close(ctx.io); var cmp: Comparison = .{ .ctx = ctx, .base = base, .settings = &s, .w = sink, .stderr = stderr, .reverse = s.reverse }; differs = try cmp.top(a_path, b_path); if (s.output) |o| { base.writeFile(ctx.io, .{ .sub_path = o, .data = out_alloc.written() }) catch |err| return tool.fatalMessage(stderr, "could not open '{s}' for writing: {s}", .{ o, git.Config.reason(err) }); } if (differs) return error.No; } const Short = struct { ctx: Context, names: ?object_names.Names, fn length(context: *anyopaque, id: git.ObjectId, min: usize) usize { const self: *Short = @ptrCast(@alignCast(context)); const n = &(self.names orelse return min); return n.lookup.uniqueLength(self.ctx.io, id, min) catch min; } }; const Matcher = struct { gpa: Allocator, regexes: []const git.regex.Regex, fn matches(context: *const anyopaque, line: []const u8) Allocator.Error!bool { const self: *const Matcher = @ptrCast(@alignCast(context)); for (self.regexes) |*r| if (try r.matches(self.gpa, line)) return true; return false; } }; fn readConfig(config: *const git.Config, s: *Settings, stderr: *Io.Writer) !void { for (config.entries.items) |*e| { const key = e.key; if (std.mem.eql(u8, key, "diff.algorithm")) { const v = e.value orelse return badConfig(stderr, e, "missing value for 'diff.algorithm'"); const alg = LineDiff.Algorithm.fromName(v) orelse { stderr.print("error: unknown value for config 'diff.algorithm': {s}\n", .{v}) catch {}; return configFatal(stderr, e); }; s.patch.lines.algorithm = alg; } else if (std.mem.eql(u8, key, "diff.context")) { const v = git.Config.getInt(config, "diff.context") catch return configFatal(stderr, e); if (v) |n| { if (n < 0) return configFatal(stderr, e); s.patch.hunks.context = @intCast(n); } } else if (std.mem.eql(u8, key, "diff.interhunkcontext")) { const v = git.Config.getInt(config, "diff.interhunkcontext") catch return configFatal(stderr, e); if (v) |n| { if (n < 0) return configFatal(stderr, e); s.patch.hunks.inter_hunk_context = @intCast(n); } } else if (std.mem.eql(u8, key, "diff.indentheuristic")) { s.patch.lines.indent_heuristic = try boolValue(e, stderr); } else if (std.mem.eql(u8, key, "diff.noprefix")) { s.no_prefix = try boolValue(e, stderr); } else if (std.mem.eql(u8, key, "diff.mnemonicprefix")) { s.mnemonic = try boolValue(e, stderr); } else if (std.mem.eql(u8, key, "diff.suppressblankempty")) { s.patch.suppress_blank_empty = try boolValue(e, stderr); } else if (std.mem.eql(u8, key, "core.quotepath")) { s.patch.quote_path = try boolValue(e, stderr); } else if (std.mem.eql(u8, key, "diff.renames")) { const v = e.value orelse "true"; s.renames = std.ascii.eqlIgnoreCase(v, "copies") or std.ascii.eqlIgnoreCase(v, "copy") or try boolValue(e, stderr); } else if (std.mem.eql(u8, key, "diff.autorefreshindex")) { // Read, as git reads it, only to be refused when it is not a // boolean: there is no index here to refresh. _ = try boolValue(e, stderr); } else if (std.mem.eql(u8, key, "diff.external")) { return unsupported(stderr, "diff.external"); } else if (std.mem.eql(u8, key, "color.diff") or std.mem.eql(u8, key, "color.ui")) { const v = e.value orelse "true"; if (std.ascii.eqlIgnoreCase(v, "always")) return unsupported(stderr, "color"); } else if (std.mem.eql(u8, key, "diff.relative")) { if (try boolValue(e, stderr)) return unsupported(stderr, "diff.relative"); } } // The last value counts for each, which the loop above gives; but the // ones read through getInt read the last already. } /// A boolean setting, or git's fatal for one that is not: without the /// file and line, which git 2.56 leaves out of it wherever the value /// was set. fn boolValue(e: *const git.Config.Entry, stderr: *Io.Writer) !bool { return git.Config.value.parseBool(e.value) orelse tool.fatalMessage(stderr, "bad boolean config value '{s}' for '{s}'", .{ e.value orelse "", e.key }); } fn badConfig(stderr: *Io.Writer, e: *const git.Config.Entry, msg: []const u8) error{Fatal} { stderr.print("error: {s}\n", .{msg}) catch {}; return configFatal(stderr, e); } fn configFatal(stderr: *Io.Writer, e: *const git.Config.Entry) error{Fatal} { if (e.origin.kind == .file) return tool.fatalMessage(stderr, "bad config variable '{s}' in file '{s}' at line {d}", .{ e.key, e.origin.name, e.line }); return tool.fatalMessage(stderr, "unable to parse '{s}' from command-line config", .{e.key}); } /// Options git's diff takes that are refused here, by name. const refused = [_][]const u8{ "--stat", "--numstat", "--shortstat", "--dirstat", "--cumulative", "--dirstat-by-file", "--summary", "--name-only", "--name-status", "--raw", "--patch-with-raw", "--patch-with-stat", "--check", "--binary", "-z", "--line-prefix", "--word-diff", "--word-diff-regex", "--color-words", "--color-moved", "--color-moved-ws", "-B", "--break-rewrites", "-M", "--find-renames", "-C", "--find-copies", "--find-copies-harder", "-D", "--irreversible-delete", "--follow", "-l", "--relative", "--ext-diff", "--textconv", "--ignore-submodules", "--submodule", "-S", "-G", "--pickaxe-all", "--pickaxe-regex", "-O", "--rotate-to", "--skip-to", "--find-object", "--diff-filter", "--max-depth", "--ws-error-highlight", "--output-indicator-new", "--output-indicator-old", "--output-indicator-context", "-X", "--compact-summary", "--stat-width", "--stat-name-width", "--stat-graph-width", "--stat-count", "--rename-empty", "--ita-invisible-in-index", "--ita-visible-in-index", }; fn parseArgs(gpa: Allocator, args: []const []const u8, s: *Settings, paths: *std.ArrayList([]const u8), stderr: *Io.Writer) !void { var i: usize = 0; var only_paths = false; while (i < args.len) : (i += 1) { const arg = args[i]; if (only_paths or arg.len < 2 or arg[0] != '-') { try paths.append(gpa, arg); continue; } if (std.mem.eql(u8, arg, "--")) { only_paths = true; continue; } const eq = std.mem.findScalar(u8, arg, '='); const name = arg[0 .. eq orelse arg.len]; const value: ?[]const u8 = if (eq) |e| arg[e + 1 ..] else null; if (std.mem.eql(u8, arg, "-W") or std.mem.eql(u8, arg, "--function-context")) { s.patch.hunks.function_context = true; continue; } else if (std.mem.eql(u8, arg, "--no-function-context")) { s.patch.hunks.function_context = false; continue; } else if (std.mem.eql(u8, name, "--function-context") or std.mem.eql(u8, name, "--no-function-context")) { stderr.print("error: option `{s}' takes no value\n", .{name[2..]}) catch {}; return error.UsageQuiet; } for (refused) |r| if (std.mem.eql(u8, name, r)) return unsupported(stderr, name); if (std.mem.startsWith(u8, arg, "--no-") and !std.mem.eql(u8, arg, "--no-index")) { for (refused) |r| if (r.len > 2 and std.mem.eql(u8, arg[5..], r[2..])) return unsupported(stderr, arg); } if (std.mem.eql(u8, arg, "--no-index") or std.mem.eql(u8, arg, "-p") or std.mem.eql(u8, arg, "-u") or std.mem.eql(u8, arg, "--patch")) continue; if (std.mem.eql(u8, arg, "--minimal")) { s.patch.lines.algorithm = .minimal; } else if (std.mem.eql(u8, arg, "--patience")) { // It forgets every --anchored before it, as git was seen to. s.patch.lines.algorithm = .patience; s.anchors.clearRetainingCapacity(); } else if (std.mem.eql(u8, arg, "--histogram")) { s.patch.lines.algorithm = .histogram; } else if (std.mem.eql(u8, name, "--anchored")) { const v = value orelse next: { i += 1; if (i >= args.len) return requiresValue(stderr, "anchored"); break :next args[i]; }; try s.anchors.append(gpa, v); s.patch.lines.algorithm = .patience; } else if (std.mem.eql(u8, name, "--diff-algorithm")) { const v = value orelse next: { i += 1; if (i >= args.len) return requiresValue(stderr, "diff-algorithm"); break :next args[i]; }; s.patch.lines.algorithm = LineDiff.Algorithm.fromName(v) orelse { stderr.writeAll("error: option diff-algorithm accepts \"myers\", \"minimal\", \"patience\" and \"histogram\"\n") catch {}; return error.UsageQuiet; }; } else if (std.mem.eql(u8, arg, "-w") or std.mem.eql(u8, arg, "--ignore-all-space")) { s.patch.lines.whitespace.ignore_all = true; } else if (std.mem.eql(u8, arg, "-b") or std.mem.eql(u8, arg, "--ignore-space-change")) { s.patch.lines.whitespace.ignore_change = true; } else if (std.mem.eql(u8, arg, "--ignore-space-at-eol")) { s.patch.lines.whitespace.ignore_at_eol = true; } else if (std.mem.eql(u8, arg, "--ignore-cr-at-eol")) { s.patch.lines.whitespace.ignore_cr_at_eol = true; } else if (std.mem.eql(u8, arg, "--ignore-blank-lines")) { s.patch.hunks.ignore_blank_lines = true; } else if (std.mem.eql(u8, arg, "--no-ignore-blank-lines")) { s.patch.hunks.ignore_blank_lines = false; } else if (std.mem.startsWith(u8, arg, "-I") or std.mem.eql(u8, name, "--ignore-matching-lines")) { const v = if (std.mem.startsWith(u8, arg, "-I")) (if (arg.len > 2) arg[2..] else next: { i += 1; if (i >= args.len) return requiresValueShort(stderr, 'I'); break :next args[i]; }) else value orelse next: { i += 1; if (i >= args.len) return requiresValue(stderr, "ignore-matching-lines"); break :next args[i]; }; var failure: git.regex.Failure = .{}; const r = git.regex.Regex.compileWith(gpa, v, .{ .syntax = .extended }, &failure) catch |err| switch (err) { error.OutOfMemory => return err, else => return tool.fatalMessage(stderr, "invalid regex given to -I: '{s}'", .{v}), }; try s.ignore_regexes.append(gpa, r); } else if (std.mem.eql(u8, arg, "--no-ignore-matching-lines")) { for (s.ignore_regexes.items) |*r| r.deinit(); s.ignore_regexes.clearRetainingCapacity(); } else if (std.mem.eql(u8, arg, "--indent-heuristic")) { s.patch.lines.indent_heuristic = true; } else if (std.mem.eql(u8, arg, "--no-indent-heuristic")) { s.patch.lines.indent_heuristic = false; } else if (std.mem.startsWith(u8, arg, "-U") or std.mem.eql(u8, name, "--unified")) { const v: ?[]const u8 = if (std.mem.startsWith(u8, arg, "-U")) (if (arg.len > 2) arg[2..] else null) else value; if (v) |text| { const n = object_names.optionNumber(text) orelse { stderr.writeAll("error: --unified expects a numerical value\n") catch {}; return error.UsageQuiet; }; if (n < 0) { stderr.writeAll("error: --unified expects a non-negative integer\n") catch {}; return error.UsageQuiet; } s.patch.hunks.context = @intCast(n); } } else if (std.mem.eql(u8, name, "--inter-hunk-context")) { const v = value orelse next: { i += 1; if (i >= args.len) return requiresValue(stderr, "inter-hunk-context"); break :next args[i]; }; const n = object_names.optionNumber(v) orelse -1; if (n < 0) { stderr.writeAll("error: option `inter-hunk-context' expects a non-negative integer value with an optional k/m/g suffix\n") catch {}; return error.UsageQuiet; } s.patch.hunks.inter_hunk_context = @intCast(n); } else if (std.mem.eql(u8, arg, "-a") or std.mem.eql(u8, arg, "--text")) { s.patch.text = true; } else if (std.mem.eql(u8, arg, "--no-text")) { s.patch.text = false; } else if (std.mem.eql(u8, arg, "--full-index")) { s.patch.abbrev = null; } else if (std.mem.eql(u8, arg, "--no-full-index")) { if (s.patch.abbrev == null) s.patch.abbrev = 0; } else if (std.mem.eql(u8, name, "--abbrev")) { if (value) |v| { const n = object_names.optionNumber(v) orelse { stderr.print("error: option `abbrev' expects a numerical value\n", .{}) catch {}; return error.UsageQuiet; }; if (s.patch.abbrev != null) s.patch.abbrev = if (n <= 0) 4 else @intCast(n); } else if (s.patch.abbrev != null) s.patch.abbrev = 0; } else if (std.mem.eql(u8, arg, "--no-abbrev")) { s.patch.abbrev = null; } else if (std.mem.eql(u8, arg, "--no-prefix")) { s.no_prefix = true; s.src_prefix = null; s.dst_prefix = null; } else if (std.mem.eql(u8, arg, "--default-prefix")) { s.no_prefix = false; s.mnemonic = false; s.src_prefix = "a/"; s.dst_prefix = "b/"; } else if (std.mem.eql(u8, name, "--src-prefix") or std.mem.eql(u8, name, "--dst-prefix")) { const v = value orelse next: { i += 1; if (i >= args.len) return requiresValue(stderr, name[2..]); break :next args[i]; }; if (name[2] == 's') s.src_prefix = v else s.dst_prefix = v; } else if (std.mem.eql(u8, arg, "-R")) { s.reverse = true; } else if (std.mem.eql(u8, arg, "--exit-code") or std.mem.eql(u8, arg, "--no-exit-code")) { // --no-index always exits as --exit-code does. } else if (std.mem.eql(u8, arg, "--quiet")) { s.quiet = true; } else if (std.mem.eql(u8, arg, "--no-quiet")) { s.quiet = false; } else if (std.mem.eql(u8, name, "--output")) { const v = value orelse next: { i += 1; if (i >= args.len) return requiresValue(stderr, "output"); break :next args[i]; }; s.output = v; } else if (std.mem.eql(u8, name, "--color")) { const v = value orelse "always"; if (std.mem.eql(u8, v, "never")) continue; if (std.mem.eql(u8, v, "auto")) continue; // never a terminal return unsupported(stderr, "--color"); } else if (std.mem.eql(u8, arg, "--no-color")) { // The default. } else if (std.mem.eql(u8, arg, "-s") or std.mem.eql(u8, arg, "--no-patch")) { s.quiet = true; return unsupported(stderr, arg); } else if (std.mem.eql(u8, arg, "--no-renames")) { // As diff.renames=false: no renames between two directories, // whatever the configuration says. s.renames = false; } else if (std.mem.eql(u8, name, "--no-renames")) { stderr.writeAll("error: option `no-renames' takes no value\n") catch {}; return error.UsageQuiet; } else { if (arg[1] == '-') { stderr.print("error: unknown option `{s}'\n", .{arg[2..]}) catch {}; } else { stderr.print("error: unknown switch `{c}'\n", .{arg[1]}) catch {}; } stderr.writeAll(usage) catch {}; return error.UsageQuiet; } } } fn requiresValue(stderr: *Io.Writer, name: []const u8) error{UsageQuiet} { stderr.print("error: option `{s}' requires a value\n", .{name}) catch {}; return error.UsageQuiet; } fn requiresValueShort(stderr: *Io.Writer, c: u8) error{UsageQuiet} { stderr.print("error: switch `{c}' requires a value\n", .{c}) catch {}; return error.UsageQuiet; } /// One side found on disk. const Found = struct { kind: enum { file, link, dir }, mode: u32, }; const Comparison = struct { ctx: Context, /// The directory relative paths are taken from. base: Io.Dir, settings: *Settings, w: *Io.Writer, stderr: *Io.Writer, reverse: bool, fn stat(c: *Comparison, path: []const u8) ?Found { if (std.mem.eql(u8, path, "/dev/null")) return null; const st = c.base.statFile(c.ctx.io, path, .{ .follow_symlinks = false }) catch return null; return switch (st.kind) { .directory => .{ .kind = .dir, .mode = 0o40000 }, .sym_link => .{ .kind = .link, .mode = 0o120000 }, else => .{ .kind = .file, .mode = if (executable(st)) 0o100755 else 0o100644 }, }; } fn executable(st: Io.File.Stat) bool { if (@hasDecl(@TypeOf(st.permissions), "toMode")) return st.permissions.toMode() & 0o111 != 0; return false; } fn read(c: *Comparison, path: []const u8, f: Found) ![]u8 { const gpa = c.ctx.gpa; if (f.kind == .link) { var buffer: [4096]u8 = undefined; const n = c.base.readLink(c.ctx.io, path, &buffer) catch { return c.cannotAccess(path); }; return gpa.dupe(u8, buffer[0..n]); } return c.base.readFileAlloc(c.ctx.io, path, gpa, .unlimited) catch c.cannotAccess(path); } fn cannotAccess(c: *Comparison, path: []const u8) error{No} { c.stderr.print("error: Could not access '{s}'\n", .{path}) catch {}; return error.No; } /// Compares the two paths named on the command line. fn top(c: *Comparison, a: []const u8, b: []const u8) !bool { const gpa = c.ctx.gpa; const fa = c.stat(a); const fb = c.stat(b); if (fa == null and !std.mem.eql(u8, a, "/dev/null")) return c.cannotAccess(a); if (fb == null and !std.mem.eql(u8, b, "/dev/null")) return c.cannotAccess(b); const a_dir = fa != null and fa.?.kind == .dir; const b_dir = fb != null and fb.?.kind == .dir; if (a_dir and b_dir) { // git looks for renames among what is only on one side; that // is not matched, so where it could find one the comparison // is refused rather than shown without it. if (c.settings.renames and try c.mayRename(stripSlash(a), stripSlash(b))) return unsupported(c.stderr, "rename detection between directories (set diff.renames=false)"); return c.dirs(stripSlash(a), stripSlash(b)); } if (a_dir and !b_dir) { if (fb == null) return unsupported(c.stderr, "a directory compared with /dev/null"); const joined = try std.fs.path.join(gpa, &.{ stripSlash(a), std.fs.path.basename(b) }); defer gpa.free(joined); const fj = c.stat(joined) orelse return c.cannotAccess(joined); return c.pair(joined, fj, b, fb); } if (b_dir and !a_dir) { if (fa == null) return unsupported(c.stderr, "a directory compared with /dev/null"); const joined = try std.fs.path.join(gpa, &.{ stripSlash(b), std.fs.path.basename(a) }); defer gpa.free(joined); const fj = c.stat(joined) orelse return c.cannotAccess(joined); return c.pair(a, fa, joined, fj); } return c.pair(a, fa, b, fb); } fn stripSlash(p: []const u8) []const u8 { var q = p; while (q.len > 1 and q[q.len - 1] == '/') q = q[0 .. q.len - 1]; return q; } fn displayPath(p: []const u8) []const u8 { return if (p.len > 0 and p[0] == '/') p[1..] else p; } /// Compares one file (or link) with another, either of which may be /// absent. fn pair(c: *Comparison, a_in: []const u8, fa_in: ?Found, b_in: []const u8, fb_in: ?Found) !bool { return c.pairSwapped(a_in, fa_in, b_in, fb_in, c.reverse); } fn pairSwapped(c: *Comparison, a_in: []const u8, fa_in: ?Found, b_in: []const u8, fb_in: ?Found, swap: bool) !bool { const gpa = c.ctx.gpa; const a, const fa, const b, const fb = if (swap) .{ b_in, fb_in, a_in, fa_in } else .{ a_in, fa_in, b_in, fb_in }; if (fa != null and fb != null and fa.?.kind == .dir or fb != null and fb.?.kind == .dir) return unsupported(c.stderr, "a directory compared with a file"); if (fa != null and fb != null and (fa.?.kind == .link) != (fb.?.kind == .link)) { // A change of kind is a deletion and an addition. const gone = try c.pairSwapped(a, fa, a, null, false); const added = try c.pairSwapped(b, null, b, fb, false); return gone or added; } const ca = if (fa) |f| try c.read(a, f) else null; defer if (ca) |x| gpa.free(x); const cb = if (fb) |f| try c.read(b, f) else null; defer if (cb) |x| gpa.free(x); const outcome = patch.write(gpa, c.w, .{ .path = displayPath(a), .contents = ca, .mode = if (fa) |f| f.mode else 0, }, .{ .path = displayPath(b), .contents = cb, .mode = if (fb) |f| f.mode else 0, }, c.settings.patch) catch |err| switch (err) { else => |e| return repo_diff.refuseComparison(c.stderr, .diff, e), }; return outcome.differs; } const Entry = struct { name: []u8, found: Found }; fn list(c: *Comparison, path: []const u8) ![]Entry { const gpa = c.ctx.gpa; var out: std.ArrayList(Entry) = .empty; errdefer { for (out.items) |e| gpa.free(e.name); out.deinit(gpa); } var dir = c.base.openDir(c.ctx.io, path, .{ .iterate = true }) catch return c.cannotAccess(path); defer dir.close(c.ctx.io); var it = dir.iterate(); while (it.next(c.ctx.io) catch return c.cannotAccess(path)) |e| { const full = try std.fs.path.join(gpa, &.{ path, e.name }); defer gpa.free(full); const f = c.stat(full) orelse continue; try out.append(gpa, .{ .name = try gpa.dupe(u8, e.name), .found = f }); } std.mem.sort(Entry, out.items, {}, struct { fn lt(_: void, x: Entry, y: Entry) bool { return std.mem.order(u8, x.name, y.name) == .lt; } }.lt); return out.toOwnedSlice(gpa); } fn freeList(c: *Comparison, l: []const Entry) void { for (l) |e| c.ctx.gpa.free(e.name); c.ctx.gpa.free(l); } /// A file or link only one side of two directories has. const Lone = struct { path: []u8, found: Found }; /// Whether git could find a rename between directories `a` and `b`: /// a file only on the old side and one only on the new with the same /// contents and kind, or two regular files at least half alike by /// `similarity`, the least `-M` takes by default. Where there is no /// such pair git finds no rename and the comparison is written as it /// is. fn mayRename(c: *Comparison, a: []const u8, b: []const u8) !bool { const gpa = c.ctx.gpa; var old: std.ArrayList(Lone) = .empty; var new: std.ArrayList(Lone) = .empty; defer for ([_]*std.ArrayList(Lone){ &old, &new }) |l| { for (l.items) |x| gpa.free(x.path); l.deinit(gpa); }; try c.collectLone(a, b, &old, &new); if (old.items.len == 0 or new.items.len == 0) return false; const sim = git.diff.similarity; var signatures: std.ArrayList(?sim.Signature) = .empty; var contents: std.ArrayList([]u8) = .empty; defer { for (signatures.items) |*x| if (x.*) |*y| y.deinit(gpa); signatures.deinit(gpa); for (contents.items) |x| gpa.free(x); contents.deinit(gpa); } for (old.items) |x| { const data = try c.read(x.path, x.found); try contents.append(gpa, data); try signatures.append(gpa, if (x.found.kind == .file) try sim.signature(gpa, data) else null); } for (new.items) |y| { const data = try c.read(y.path, y.found); defer gpa.free(data); var sig: ?sim.Signature = if (y.found.kind == .file) try sim.signature(gpa, data) else null; defer if (sig) |*z| z.deinit(gpa); for (old.items, 0..) |x, k| { if (x.found.kind == y.found.kind and std.mem.eql(u8, contents.items[k], data)) return true; const xs = signatures.items[k] orelse continue; const ys = sig orelse continue; if (!sim.sizesCanReach(xs.size, ys.size, sim.default_min_score)) continue; if (sim.score(xs.size, ys.size, sim.copied(xs, ys)) >= sim.default_min_score) return true; } } return false; } fn collectLone(c: *Comparison, a: ?[]const u8, b: ?[]const u8, old: *std.ArrayList(Lone), new: *std.ArrayList(Lone)) anyerror!void { const gpa = c.ctx.gpa; const la = if (a) |p| try c.list(p) else &.{}; defer if (a != null) c.freeList(la); const lb = if (b) |p| try c.list(p) else &.{}; defer if (b != null) c.freeList(lb); var i: usize = 0; var j: usize = 0; while (i < la.len or j < lb.len) { const order: std.math.Order = if (i >= la.len) .gt else if (j >= lb.len) .lt else std.mem.order(u8, la[i].name, lb[j].name); const ea: ?Entry = if (order != .gt) la[i] else null; const eb: ?Entry = if (order != .lt) lb[j] else null; if (order != .gt) i += 1; if (order != .lt) j += 1; const a_dir = ea != null and ea.?.found.kind == .dir; const b_dir = eb != null and eb.?.found.kind == .dir; const pa = if (ea != null) try std.fs.path.join(gpa, &.{ a.?, ea.?.name }) else null; defer if (pa) |p| gpa.free(p); const pb = if (eb != null) try std.fs.path.join(gpa, &.{ b.?, eb.?.name }) else null; defer if (pb) |p| gpa.free(p); // A file whose counterpart is missing or a directory, or one // side of a change between a link and a file, which git shows // as a deletion and an addition. const kind_changed = ea != null and eb != null and !a_dir and !b_dir and (ea.?.found.kind == .link) != (eb.?.found.kind == .link); if (ea != null and !a_dir and (eb == null or b_dir or kind_changed)) try old.append(gpa, .{ .path = try gpa.dupe(u8, pa.?), .found = ea.?.found }); if (eb != null and !b_dir and (ea == null or a_dir or kind_changed)) try new.append(gpa, .{ .path = try gpa.dupe(u8, pb.?), .found = eb.?.found }); if (a_dir or b_dir) try c.collectLone(if (a_dir) pa else null, if (b_dir) pb else null, old, new); } } /// Compares two directories, or one with nothing when the other is /// null, entry by entry in name order. fn dirs(c: *Comparison, a: ?[]const u8, b: ?[]const u8) anyerror!bool { const gpa = c.ctx.gpa; const la = if (a) |p| try c.list(p) else &.{}; defer if (a != null) c.freeList(la); const lb = if (b) |p| try c.list(p) else &.{}; defer if (b != null) c.freeList(lb); var differs = false; var i: usize = 0; var j: usize = 0; while (i < la.len or j < lb.len) { const order: std.math.Order = if (i >= la.len) .gt else if (j >= lb.len) .lt else std.mem.order(u8, la[i].name, lb[j].name); const ea: ?Entry = if (order != .gt) la[i] else null; const eb: ?Entry = if (order != .lt) lb[j] else null; if (order != .gt) i += 1; if (order != .lt) j += 1; const pa = if (ea) |e| try std.fs.path.join(gpa, &.{ a.?, e.name }) else null; defer if (pa) |p| gpa.free(p); const pb = if (eb) |e| try std.fs.path.join(gpa, &.{ b.?, e.name }) else null; defer if (pb) |p| gpa.free(p); const a_dir = ea != null and ea.?.found.kind == .dir; const b_dir = eb != null and eb.?.found.kind == .dir; if (a_dir and b_dir) { differs = (try c.dirs(pa, pb)) or differs; } else if (a_dir or b_dir) { // A file on one side: it is shown first, then the // directory's contents as added or deleted. // Under -R, git leaves that file's pair unswapped. if (!a_dir and ea != null) differs = (try c.pairSwapped(pa.?, ea.?.found, pa.?, null, false)) or differs; if (!b_dir and eb != null) differs = (try c.pairSwapped(pb.?, null, pb.?, eb.?.found, false)) or differs; differs = (try c.dirs(if (a_dir) pa else null, if (b_dir) pb else null)) or differs; } else { differs = (try c.pair(pa orelse pb.?, if (ea) |e| e.found else null, pb orelse pa.?, if (eb) |e| e.found else null)) or differs; } } return differs; } }; /// git's usage text for `diff --no-index`, written when it is used wrongly. pub const usage = @embedFile("diff_usage.txt");