Artifact 2488d81d8c3f4b9f175e24ff6a52d172bf77471a686cbdbca31b0b6ba49aaba9:
- File
src/cmd/gitmirror.zig
— part of check-in
[5fce7e867d]
at
2026-09-28 16:44:53
on branch trunk
— zit git export/status: a remembered Git mirror with autopush
git export exports into MIRROR and remembers it, with an autopush URL, push branch and email domain, in repository config; a bare re-run does the round trip. Authors' addresses come from their user-table contact, set with the new users contact. Autopush URLs carrying a password are refused. The config payload bound is now sized from its key count. Release 0.3.34. (user: dmestas, size: 19773) [annotate] [blame] [check-ins using]
//! `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")); }