Skip to content
zit Artifact [2488d81d8c]
Login

Artifact [2488d81d8c]

Artifact 2488d81d8c3f4b9f175e24ff6a52d172bf77471a686cbdbca31b0b6ba49aaba9:


//! `zit git export` and `zit git status`: a Git mirror as a property of the
//! repository rather than a script around it, in Fossil's shape
//! (`fossil git export MIRROR --autopush URL`, `fossil git status`).
//!
//! The mirror's path, its autopush URL, the email domain for authors with no
//! contact address, and the remote branch the main branch is pushed to are
//! remembered in the repository's config, so a later bare `zit git export`,
//! from any checkout, a hook, a cron job or a forge on a bare store, does the
//! whole round trip. They are local: none of them is ever synced.
//!
//! The export itself is `export-git`'s incremental export, unchanged. An
//! author's address comes from their contact in the user table, the field
//! `zit users` prints last, and falls back to `login@DOMAIN`.
const std = @import("std");
const cli = @import("zit").cli;
const logstore = @import("zit").logstore;
const Repository = @import("zit").Repository;
const main = @import("../main.zig");
const cmd_sync = @import("sync.zig");
const git_export_cli = @import("../git/git_export_cli.zig");
const git_export = @import("zit").git_export;

const default_email_domain = "zit.invalid";
const default_push_branch = "main";

pub fn runGit(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
    amb: cli.Ambient,
) cli.Exit {
    if (verb_args.len == 0) {
        streams.err.writeAll("zit git: name a subcommand: export or status\n") catch {};
        streams.err.writeAll("usage: " ++ cli.git_usage ++ "\n") catch {};
        return .usage;
    }
    const sub = verb_args[0];
    if (std.mem.eql(u8, sub, "-h") or std.mem.eql(u8, sub, "--help")) {
        streams.out.writeAll(main.git_help) catch {};
        return .ok;
    }
    if (std.mem.eql(u8, sub, "export")) return runExport(gpa, io, arena, verb_args[1..], streams, amb);
    if (std.mem.eql(u8, sub, "status")) return runStatus(gpa, io, arena, verb_args[1..], streams, amb);
    streams.err.print("zit git: no subcommand named '{s}'; use export or status\n", .{sub}) catch {};
    return .usage;
}

const ExportArgs = struct {
    mirror: ?[]const u8 = null,
    autopush: ?[]const u8 = null,
    no_autopush: bool = false,
    push_branch: ?[]const u8 = null,
    email_domain: ?[]const u8 = null,
    quiet: bool = false,
    help: bool = false,
};

fn parseExport(args: []const []const u8, streams: cli.Streams) ?ExportArgs {
    var out: ExportArgs = .{};
    var i: usize = 0;
    while (i < args.len) : (i += 1) {
        const tok = args[i];
        const value_flags = [_][]const u8{ "--autopush", "--push-branch", "--email-domain" };
        var took_value = false;
        for (value_flags) |flag| {
            if (!std.mem.eql(u8, tok, flag)) continue;
            i += 1;
            if (i >= args.len) {
                streams.err.print("zit git export: {s} wants a value\n", .{flag}) catch {};
                return null;
            }
            const v = args[i];
            if (std.mem.eql(u8, flag, "--autopush")) out.autopush = v;
            if (std.mem.eql(u8, flag, "--push-branch")) out.push_branch = v;
            if (std.mem.eql(u8, flag, "--email-domain")) out.email_domain = v;
            took_value = true;
        }
        if (took_value) continue;
        if (std.mem.eql(u8, tok, "--no-autopush")) {
            out.no_autopush = true;
        } else if (std.mem.eql(u8, tok, "--quiet")) {
            out.quiet = true;
        } else if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
            out.help = true;
        } else if (tok.len > 0 and tok[0] == '-') {
            streams.err.print("zit git export: unknown option '{s}'\n", .{tok}) catch {};
            return null;
        } else if (out.mirror == null) {
            out.mirror = tok;
        } else {
            streams.err.print("zit git export: unexpected argument '{s}'\n", .{tok}) catch {};
            return null;
        }
    }
    if (out.no_autopush and out.autopush != null) {
        streams.err.writeAll("zit git export: --autopush and --no-autopush cannot be combined\n") catch {};
        return null;
    }
    return out;
}

/// An autopush URL is remembered in the repository's config, so it must not
/// carry a password: authenticate with a Git credential helper or an SSH key.
pub fn urlCarriesPassword(url: []const u8) bool {
    const scheme_end = std.mem.indexOf(u8, url, "://") orelse return false;
    const rest = url[scheme_end + 3 ..];
    const authority_end = std.mem.indexOfAny(u8, rest, "/?#") orelse rest.len;
    const authority = rest[0..authority_end];
    const at = std.mem.lastIndexOfScalar(u8, authority, '@') orelse return false;
    return std.mem.indexOfScalar(u8, authority[0..at], ':') != null;
}

/// A remote branch name Git will accept as one ref component path.
fn validBranch(name: []const u8) bool {
    if (name.len == 0 or name[0] == '-' or name[0] == '/' or name[name.len - 1] == '/') return false;
    if (std.mem.indexOf(u8, name, "..") != null or std.mem.endsWith(u8, name, ".lock")) return false;
    for (name) |c| {
        if (c <= ' ' or c == 0x7f or c == '~' or c == '^' or c == ':' or c == '?' or c == '*' or c == '[' or c == '\\') return false;
    }
    return true;
}

fn runExport(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    args: []const []const u8,
    streams: cli.Streams,
    amb: cli.Ambient,
) cli.Exit {
    const parsed = parseExport(args, streams) orelse {
        streams.err.writeAll("usage: " ++ cli.git_usage ++ "\n") catch {};
        return .usage;
    };
    if (parsed.help) {
        streams.out.writeAll(main.git_help) catch {};
        return .ok;
    }
    if (parsed.autopush) |url| {
        if (urlCarriesPassword(url)) {
            streams.err.writeAll("zit git export: the autopush URL carries a password, and it would be stored in the repository's config; use a Git credential helper or an SSH remote\n") catch {};
            return .usage;
        }
    }
    if (parsed.push_branch) |b| {
        if (!validBranch(b)) {
            streams.err.print("zit git export: '{s}' is not a branch name Git accepts\n", .{b}) catch {};
            return .usage;
        }
    }

    var repo: Repository = undefined;
    var root_dir: std.Io.Dir = undefined;
    const rc = main.openRepo(gpa, io, arena, streams, "git", amb.repo_override, false, false, &repo, &root_dir);
    if (rc != .ok) return rc;
    defer root_dir.close(io);
    defer repo.deinit(gpa);

    const log = repo.logStore(gpa) catch {
        streams.err.writeAll("zit git export: cannot read the repository\n") catch {};
        return .failure;
    };
    var owned = log.readConfig(gpa) catch {
        streams.err.writeAll("zit git export: cannot read the repository's config\n") catch {};
        return .failure;
    };
    defer if (owned) |*o| o.deinit(gpa);
    const saved: logstore.Config = if (owned) |o| o.config else .{ .hash_policy = 0, .default_branch = "", .project_id = "" };

    // The mirror: named now (made absolute, so it means the same thing from
    // any checkout or a hook), or remembered.
    const mirror: []const u8 = if (parsed.mirror) |m| blk: {
        const cwd = (cli.currentPathAlloc(arena, io) catch return main.stateOom(streams, "git")) orelse {
            streams.err.writeAll("zit git export: cannot determine the current directory\n") catch {};
            return .failure;
        };
        break :blk std.fs.path.resolve(arena, &.{ cwd, m }) catch return main.stateOom(streams, "git");
    } else if (saved.git_mirror.len != 0) saved.git_mirror else {
        streams.err.writeAll("zit git export: no mirror is remembered; name one: zit git export MIRROR\n") catch {};
        return .usage;
    };
    if (mirror.len > logstore.max_config_str) {
        streams.err.writeAll("zit git export: the mirror path is too long to remember\n") catch {};
        return .usage;
    }
    const autopush: []const u8 = if (parsed.no_autopush) "" else parsed.autopush orelse saved.git_autopush;
    const email_domain: []const u8 = parsed.email_domain orelse
        (if (saved.git_email_domain.len != 0) saved.git_email_domain else default_email_domain);
    const push_branch: []const u8 = parsed.push_branch orelse
        (if (saved.git_push_branch.len != 0) saved.git_push_branch else default_push_branch);

    var contacts = contactMap(gpa, arena, log) catch return main.stateOom(streams, "git");
    defer contacts.deinit(gpa);

    const emitted = exportOnce(gpa, io, &repo, mirror, false, email_domain, &contacts, parsed.quiet, "git export", streams) orelse
        return .failure;
    streams.out.print("exported {d} commits into {s}\n", .{ emitted, mirror }) catch {};

    // Remember what this run used, once it has worked. Only what changed is
    // written, so a plain re-run appends nothing.
    const patch: cmd_sync.ConfigPatch = .{
        .git_mirror = if (!std.mem.eql(u8, mirror, saved.git_mirror)) mirror else null,
        .git_autopush = if (!std.mem.eql(u8, autopush, saved.git_autopush)) autopush else null,
        .git_email_domain = if (parsed.email_domain) |d| (if (!std.mem.eql(u8, d, saved.git_email_domain)) d else null) else null,
        .git_push_branch = if (parsed.push_branch) |b| (if (!std.mem.eql(u8, b, saved.git_push_branch)) b else null) else null,
    };
    if (patch.git_mirror != null or patch.git_autopush != null or patch.git_email_domain != null or patch.git_push_branch != null) {
        cmd_sync.updateConfig(gpa, io, root_dir, patch) catch {
            streams.err.writeAll("zit git export: exported, but the mirror settings could not be saved\n") catch {};
            return .failure;
        };
    }

    if (autopush.len == 0) return .ok;
    const tip_ref = git_export.refForBranch(arena, repo.mainBranch()) catch return main.stateOom(streams, "git");
    const refspec = std.fmt.allocPrint(arena, "{s}:refs/heads/{s}", .{ tip_ref, push_branch }) catch
        return main.stateOom(streams, "git");
    const pushed = std.process.run(gpa, io, .{
        .argv = &.{ "git", "-C", mirror, "push", "--quiet", autopush, refspec },
    }) catch {
        streams.err.writeAll("zit git export: cannot run git to push\n") catch {};
        return .failure;
    };
    defer gpa.free(pushed.stdout);
    defer gpa.free(pushed.stderr);
    const ok = switch (pushed.term) {
        .exited => |code| code == 0,
        else => false,
    };
    if (!ok) {
        streams.err.print("zit git export: the push to {s} failed:\n{s}", .{ autopush, pushed.stderr }) catch {};
        return .failure;
    }
    streams.out.print("pushed {s} to {s} as {s}\n", .{ repo.mainBranch(), autopush, push_branch }) catch {};
    return .ok;
}

/// Login -> email address, from each user's contact in the user table.
fn contactMap(gpa: std.mem.Allocator, arena: std.mem.Allocator, log: *logstore.LogStore) error{OutOfMemory}!std.StringHashMapUnmanaged([]const u8) {
    var map: std.StringHashMapUnmanaged([]const u8) = .empty;
    errdefer map.deinit(gpa);
    const table = try log.userTable(gpa);
    defer gpa.free(table);
    for (table) |row| {
        const address = contactAddress(row.info) orelse continue;
        try map.put(gpa, try arena.dupe(u8, row.login), try arena.dupe(u8, address));
    }
    return map;
}

/// The email address in a user's contact text, which is free text: the part
/// inside `<...>` when there is one (`Dan <[email protected]>`), otherwise the
/// first word containing an `@`.
pub fn contactAddress(info: []const u8) ?[]const u8 {
    if (std.mem.indexOfScalar(u8, info, '<')) |open| {
        if (std.mem.indexOfScalarPos(u8, info, open + 1, '>')) |close| {
            const inside = std.mem.trim(u8, info[open + 1 .. close], " \t");
            if (std.mem.indexOfScalar(u8, inside, '@') != null and inside.len > 2) return inside;
        }
    }
    var words = std.mem.tokenizeAny(u8, info, " \t\r\n,;");
    while (words.next()) |w| {
        const at = std.mem.indexOfScalar(u8, w, '@') orelse continue;
        if (at > 0 and at + 1 < w.len) return w;
    }
    return null;
}

/// One incremental export of `repo` into the Git repository at `dest_path`:
/// the work behind both `export-git` and `git export`. Returns the commits
/// emitted, or null once it has reported why not.
pub fn exportOnce(
    gpa: std.mem.Allocator,
    io: std.Io,
    repo: *Repository,
    dest_path: []const u8,
    rebuild: bool,
    email_domain: []const u8,
    contacts: ?*const std.StringHashMapUnmanaged([]const u8),
    quiet: bool,
    verb: []const u8,
    streams: cli.Streams,
) ?usize {
    const source_key = blk: {
        const log = repo.logStore(gpa) catch {
            streams.err.print("zit {s}: cannot read the source log\n", .{verb}) catch {};
            return null;
        };
        break :blk log.logKey() catch {
            streams.err.print("zit {s}: cannot key the source log\n", .{verb}) catch {};
            return null;
        };
    };

    // DEST: create the directory when absent; git init happens in preflight.
    std.Io.Dir.cwd().createDir(io, dest_path, .default_dir) catch {};
    var dest_dir = std.Io.Dir.cwd().openDir(io, dest_path, .{}) catch {
        streams.err.print("zit {s}: cannot open DEST '{s}'\n", .{ verb, dest_path }) catch {};
        return null;
    };
    defer dest_dir.close(io);

    var production = git_export_cli.ProductionGit{ .io = io, .dest_dir = dest_dir };
    var session = git_export_cli.Session{
        .gpa = gpa,
        .io = io,
        .repo = repo,
        .source_log_key = source_key,
        .dest_path = dest_path,
        .dest_dir = dest_dir,
        .ops = production.ops(),
        .rebuild = rebuild,
        .email_domain = email_domain,
        .contacts = contacts,
    };
    defer session.deinit();

    const outcome = session.run() catch |err| {
        streams.err.print("zit {s}: {s}: {s}\n", .{ verb, @errorName(err), session.diagnostic.items }) catch {};
        return null;
    };
    if (outcome.provisional_fixture and !quiet) {
        streams.err.writeAll(
            "note: the source holds sub-manifest (S-card) artifacts under a PROVISIONAL\n" ++
                "specification — treat this mirror as disposable and re-export after any\n" ++
                "format revision rather than building on it (see 'zit submanifest --help').\n",
        ) catch {};
    }
    return outcome.commits_emitted;
}

fn runStatus(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    args: []const []const u8,
    streams: cli.Streams,
    amb: cli.Ambient,
) cli.Exit {
    for (args) |tok| {
        if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
            streams.out.writeAll(main.git_help) catch {};
            return .ok;
        }
        streams.err.print("zit git status: unexpected argument '{s}'\n", .{tok}) catch {};
        return .usage;
    }
    var repo: Repository = undefined;
    var root_dir: std.Io.Dir = undefined;
    const rc = main.openRepo(gpa, io, arena, streams, "git", amb.repo_override, false, false, &repo, &root_dir);
    if (rc != .ok) return rc;
    defer root_dir.close(io);
    defer repo.deinit(gpa);

    const log = repo.logStore(gpa) catch {
        streams.err.writeAll("zit git status: cannot read the repository\n") catch {};
        return .failure;
    };
    var owned = log.readConfig(gpa) catch {
        streams.err.writeAll("zit git status: cannot read the repository's config\n") catch {};
        return .failure;
    };
    defer if (owned) |*o| o.deinit(gpa);
    const cfg: logstore.Config = if (owned) |o| o.config else .{ .hash_policy = 0, .default_branch = "", .project_id = "" };

    if (cfg.git_mirror.len == 0) {
        streams.out.writeAll("mirror        none\n") catch {};
        return .ok;
    }
    streams.out.print("mirror        {s}\n", .{cfg.git_mirror}) catch {};
    streams.out.print("autopush      {s}\n", .{if (cfg.git_autopush.len != 0) cfg.git_autopush else "none"}) catch {};
    streams.out.print("push-branch   {s}\n", .{if (cfg.git_push_branch.len != 0) cfg.git_push_branch else default_push_branch}) catch {};
    streams.out.print("email-domain  {s}\n", .{if (cfg.git_email_domain.len != 0) cfg.git_email_domain else default_email_domain}) catch {};

    var mirror_dir = std.Io.Dir.cwd().openDir(io, cfg.git_mirror, .{}) catch {
        streams.out.writeAll("exported      nothing: the mirror does not exist\n") catch {};
        return .ok;
    };
    defer mirror_dir.close(io);
    var state = git_export.loadState(gpa, io, mirror_dir) catch {
        streams.out.writeAll("exported      nothing yet\n") catch {};
        return .ok;
    };
    defer state.deinit();
    var commits: usize = 0;
    for (state.records) |r| commits += @intFromBool(r.kind == .commit);
    const when = blk: {
        const st = mirror_dir.statFile(io, git_export.state_filename, .{}) catch break :blk "";
        break :blk formatTime(arena, st.mtime) catch "";
    };
    streams.out.print("exported      {d} check-ins, last at {s}\n", .{ commits, when }) catch {};
    const tip = repo.resolve(repo.mainBranch(), .checkin) catch {
        streams.out.writeAll("up-to-date    no main branch\n") catch {};
        return .ok;
    };
    const current = state.containsHash(repo.hashOf(tip)) != null;
    streams.out.print("up-to-date    {s}\n", .{if (current) "yes" else "no: run zit git export"}) catch {};
    return .ok;
}

fn formatTime(arena: std.mem.Allocator, t: std.Io.Timestamp) ![]const u8 {
    const secs: u64 = @intCast(@max(0, @divTrunc(t.toNanoseconds(), std.time.ns_per_s)));
    const es: std.time.epoch.EpochSeconds = .{ .secs = secs };
    const day = es.getEpochDay().calculateYearDay();
    const md = day.calculateMonthDay();
    const ds = es.getDaySeconds();
    return std.fmt.allocPrint(arena, "{d:0>4}-{d:0>2}-{d:0>2} {d:0>2}:{d:0>2}:{d:0>2} UTC", .{
        day.year, md.month.numeric(), @as(u32, md.day_index) + 1, ds.getHoursIntoDay(), ds.getMinutesIntoHour(), ds.getSecondsIntoMinute(),
    });
}

test "an autopush URL with a password is recognised, and one without is not" {
    try std.testing.expect(urlCarriesPassword("https://dan:[email protected]/dan/zit.git"));
    try std.testing.expect(!urlCarriesPassword("https://[email protected]/dan/zit.git"));
    try std.testing.expect(!urlCarriesPassword("https://github.com/dan/zit.git"));
    try std.testing.expect(!urlCarriesPassword("[email protected]:dan/zit.git"));
    try std.testing.expect(!urlCarriesPassword("ssh://git@host:2222/dan/zit.git"));
    try std.testing.expect(!urlCarriesPassword("/srv/git/zit.git"));
}

test "a contact's email address is the bracketed part, or else the first word with an @" {
    try std.testing.expectEqualStrings("[email protected]", contactAddress("Dan Mestas <[email protected]>").?);
    try std.testing.expectEqualStrings("[email protected]", contactAddress("[email protected]").?);
    try std.testing.expectEqualStrings("[email protected]", contactAddress("mail [email protected], phone 555").?);
    try std.testing.expect(contactAddress("") == null);
    try std.testing.expect(contactAddress("just a name") == null);
    try std.testing.expect(contactAddress("@ alone") == null);
}

test "a push branch name is refused where Git would refuse it" {
    try std.testing.expect(validBranch("main"));
    try std.testing.expect(validBranch("release/1.0"));
    try std.testing.expect(!validBranch(""));
    try std.testing.expect(!validBranch("-x"));
    try std.testing.expect(!validBranch("a..b"));
    try std.testing.expect(!validBranch("a b"));
    try std.testing.expect(!validBranch("a:b"));
    try std.testing.expect(!validBranch("x.lock"));
}