//! `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"));
}