Skip to content
zit Artifact [091947c6b6]
Login

Artifact [091947c6b6]

Artifact 091947c6b6ad48fc3bd53b992bfa0eca57767387b80c1a0cb7b68f68a12e33ee:


//! `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, 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 email domain is remembered too, as the repository's Git
//! email domain, which IS synced: a commit's ID hashes its author's address,
//! so every clone must name its exported commits alike.
//!
//! The export itself is `export-git`'s incremental export, through the one
//! path both verbs share, `exportOnce`, and the one identity rule
//! (`git_export.IdentityRule`): `login@DOMAIN` from the check-in's U card, at
//! the D card's whole second.
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 repocache = @import("zit").repocache;

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,
    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")) {
            // Accepted for compatibility with existing scripts; has no effect.
        } 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;
        }
    }
    if (parsed.email_domain) |d| if (!emailDomainOk(d, "git export", streams)) 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);

    // A sparse store (path cone or horizon) elides blobs; a git export of it
    // would be a repository with holes where files should be. Refuse by the
    // class, never by alleging a missing file.
    if (repocache.readStoreCone(gpa, io, root_dir)) |cone_const| {
        var cone = cone_const;
        defer cone.deinit(gpa);
        if (cone.horizon) |h| {
            streams.err.print("zit git export: this store holds history only from the horizon at {s} ({s}); a git mirror needs every blob\n", .{ h.hash, h.rule.name() }) catch {};
        } else {
            streams.err.print("zit git export: this store is sparse (cone of {d} member{s}); a git mirror needs every blob\n", .{ cone.members.len, if (cone.members.len == 1) "" else "s" }) catch {};
        }
        streams.err.writeAll("zit git export: export from a dense clone instead\n") catch {};
        return .failure;
    }

    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 push_branch: []const u8 = parsed.push_branch orelse
        (if (saved.git_push_branch.len != 0) saved.git_push_branch else default_push_branch);

    const emitted = exportOnce(gpa, io, &repo, mirror, false, parsed.email_domain, "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. A domain a store saved
    // locally before the domain was synced becomes the repository's synced
    // domain here, unless one is already set, and the local copy is dropped.
    const adopt_local = saved.git_email_domain.len == 0 and saved.git_email_domain_local.len != 0;
    const domain: []const u8 = parsed.email_domain orelse (if (adopt_local) saved.git_email_domain_local else saved.git_email_domain);
    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 (!std.mem.eql(u8, domain, saved.git_email_domain)) domain else null,
        .git_email_domain_local = if (saved.git_email_domain_local.len != 0) "" 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_email_domain_local != 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;
}

/// The repository's Git email domain: the synced setting, else a domain the
/// store saved locally before the domain was synced, else the default.
pub fn repositoryEmailDomain(cfg: logstore.Config) []const u8 {
    if (cfg.git_email_domain.len != 0) return cfg.git_email_domain;
    if (cfg.git_email_domain_local.len != 0) return cfg.git_email_domain_local;
    return git_export.default_email_domain;
}

/// Whether `--email-domain DOMAIN` names a domain an identity line can carry;
/// says why not when it cannot.
pub fn emailDomainOk(domain: []const u8, verb: []const u8, streams: cli.Streams) bool {
    if (git_export.validEmailDomain(domain)) return true;
    streams.err.print("zit {s}: '{s}' is not an email domain (letters, digits, '.', '-' and '_', at most {d})\n", .{ verb, domain, git_export.max_email_domain }) catch {};
    return false;
}

/// One incremental export of `repo` into the Git repository at `dest_path`:
/// the work behind both `export-git` and `git export`, and so the one place a
/// commit's identity rule is chosen. A mirror with history keeps the rule its
/// state records; one without takes `requested_domain`, else the
/// repository's Git email domain. 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,
    requested_domain: ?[]const u8,
    verb: []const u8,
    streams: cli.Streams,
) ?usize {
    // A Git mirror is the whole history, and a horizon store holds content
    // (or, structurally, manifests) only from C forward: refuse before DEST
    // is touched rather than fail midway through translation.
    if (repocache.readStoreCone(gpa, io, repo.root_dir)) |stored| {
        var c = stored;
        defer c.deinit(gpa);
        if (c.horizon) |h| {
            streams.err.print(
                "zit {s}: this store holds history only from its horizon {s} (rule '{s}'); a Git mirror needs all of it\n" ++
                    "zit {s}: export from a dense clone\n",
                .{ verb, cli.shortHash(h.hash), h.rule.name(), verb },
            ) catch {};
            return null;
        }
    }
    const log = repo.logStore(gpa) catch {
        streams.err.print("zit {s}: cannot read the source log\n", .{verb}) catch {};
        return null;
    };
    const source_key = log.logKey() catch {
        streams.err.print("zit {s}: cannot key the source log\n", .{verb}) catch {};
        return null;
    };
    var owned_config = log.readConfig(gpa) catch {
        streams.err.print("zit {s}: cannot read the repository's config\n", .{verb}) catch {};
        return null;
    };
    defer if (owned_config) |*o| o.deinit(gpa);
    const domain = if (owned_config) |o| repositoryEmailDomain(o.config) else git_export.default_email_domain;

    // 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,
        .rule = .{ .domain = domain },
        .requested_domain = requested_domain,
    };
    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;
    };
    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", .{repositoryEmailDomain(cfg)}) 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 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"));
}