//! `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 repocache = @import("zit").repocache;
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);
// 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 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"));
}