Something went wrong. Try again.
Reading and writing git repositories in Zig, without running git.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! A branch's upstream and push branches, `@{upstream}` and `@{push}`,//! from the configuration alone: `branch.<name>.remote`, `.merge` and//! `.pushRemote`, `remote.pushDefault`, `push.default`, and each remote's//! fetch and push refspecs. What is found is the name of the ref in this//! repository that keeps track of the other side; whether that ref//! exists is the caller's question.//!//! The rules are git's observed ones, found by setting up each workflow//! and asking `git rev-parse --symbolic-full-name`://!//! - **The upstream** is the first `branch.<name>.merge`, mapped through//! the first fetch refspec of `branch.<name>.remote` whose source names//! it (negative refspecs play no part). With neither set there is no//! upstream; a remote of `.` is this repository, whose ref the merge//! value names as a revision would (`Upstream.local`).//! - **The push remote** is `branch.<name>.pushRemote`, else//! `remote.pushDefault`, else `branch.<name>.remote`, else `origin`. A//! URL that exactly one configured remote fetches or pushes to stands//! for that remote.//! - **The push branch**: with push refspecs, the destination of the//! first that maps `refs/heads/<name>`; otherwise as `push.default`//! says — `nothing` has none, `current` and `matching` the branch of//! the same name, `upstream` the upstream, and `simple` (the default)//! the upstream only when that is also the branch of the same name on//! the push remote. Either way the remote's fetch refspecs then say//! where it is tracked here.
const std = @import("std");const Allocator = std.mem.Allocator;
const Config = @import("Config.zig");const Diagnostics = @import("Diagnostics.zig");const refspec = @import("refspec.zig");const Refspec = refspec.Refspec;
/// Why there is no upstream or push branch, each with git's message in/// the diagnostic's detail:////// - `NoUpstream`: "no upstream configured for branch '<name>'"./// - `UpstreamNotTracked`: "upstream branch '<merge>' not stored as a/// remote-tracking branch"./// - `InvalidRefspec`: "invalid refspec '<refspec>'"./// - `PushNothing`: "push has no destination (push.default is/// 'nothing')"./// - `PushNotSingle`: "cannot resolve 'simple' push to a single/// destination"./// - `PushRefspecsExclude`: "push refspecs for '<remote>' do not include/// '<name>'"./// - `PushNoTracking`: "push destination '<ref>' on remote '<remote>' has/// no local tracking branch"./// - `BadPushDefault`: `push.default` is none of git's values; the/// diagnostic's detail is the value, its path the file and its line/// the line, or no path for the command line.pub const Error = error{ NoUpstream, UpstreamNotTracked, InvalidRefspec, PushNothing, PushNotSingle, PushRefspecsExclude, PushNoTracking, BadPushDefault,} || Config.LookupError;
/// What `push.default` says to push when nothing else does.pub const PushDefault = enum { nothing, current, upstream, simple, matching,
/// `push.default`, `simple` when unset; `tracking` is `upstream`. pub fn read(config: *const Config, diag: ?*Diagnostics) Error!PushDefault { const e = try config.get("push.default") orelse return .simple; const v = e.value orelse ""; if (std.mem.eql(u8, v, "tracking")) return .upstream; inline for (@typeInfo(PushDefault).@"enum".field_names) |n| { if (std.mem.eql(u8, v, n)) return @field(PushDefault, n); } if (diag) |d| { d.reset(); if (e.origin.kind == .file) { d.setPath(&.{e.origin.name}); d.line = e.line; } d.setDetail("{s}", .{v}); } return error.BadPushDefault; }};
/// A branch's upstream.pub const Upstream = union(enum) { /// A ref here that tracks the remote's branch; owned. tracking: []u8, /// The remote is this repository: the merge value, to be looked up as /// a revision names a ref; a slice of the configuration. local: []const u8,
/// Frees what it owns. pub fn deinit(u: Upstream, gpa: Allocator) void { switch (u) { .tracking => |t| gpa.free(t), .local => {}, } }};
fn branchValue(config: *const Config, gpa: Allocator, branch: []const u8, name: []const u8, first: bool) Config.LookupError!?[]const u8 { const key = try std.mem.concat(gpa, u8, &.{ "branch.", branch, ".", name }); defer gpa.free(key); if (!first) { const e = try config.get(key) orelse return null; return e.value; } var it = try config.all(key); while (it.next()) |e| if (e.value) |v| return v; return null;}
/// A remote's refspecs of one side, `remote.<name>.fetch` or `.push`,/// which the caller frees; their strings are the configuration's.pub fn remoteRefspecs(config: *const Config, gpa: Allocator, remote: []const u8, side: refspec.Side, diag: ?*Diagnostics) Error![]Refspec { const key = try std.mem.concat(gpa, u8, &.{ "remote.", remote, if (side == .fetch) ".fetch" else ".push" }); defer gpa.free(key); var specs: std.ArrayList(Refspec) = .empty; errdefer specs.deinit(gpa); var it = try config.all(key); while (it.next()) |e| { const text = e.value orelse ""; const s = Refspec.parse(text, side) catch { if (diag) |d| { d.reset(); d.setDetail("invalid refspec '{s}'", .{text}); } return error.InvalidRefspec; }; try specs.append(gpa, s); } return specs.toOwnedSlice(gpa);}
/// Checks every remote's fetch and push refspecs, as git does whenever it/// reads its remotes, failing at the first it cannot read.pub fn checkRemotes(config: *const Config, diag: ?*Diagnostics) Error!void { for (config.entries.items) |*e| { const parts = e.parts(); if (!std.mem.eql(u8, parts.section, "remote") or parts.subsection == null) continue; const side: refspec.Side = if (std.mem.eql(u8, parts.name, "fetch")) .fetch else if (std.mem.eql(u8, parts.name, "push")) .push else continue; const text = e.value orelse ""; _ = Refspec.parse(text, side) catch { if (diag) |d| { d.reset(); d.setDetail("invalid refspec '{s}'", .{text}); } return error.InvalidRefspec; }; }}
/// Where `remote`'s fetch refspecs keep its ref `name` here, which the/// caller frees; null if they keep it nowhere.pub fn trackingRef(config: *const Config, gpa: Allocator, remote: []const u8, name: []const u8, diag: ?*Diagnostics) Error!?[]u8 { const specs = try remoteRefspecs(config, gpa, remote, .fetch, diag); defer gpa.free(specs); return refspec.mapFirst(gpa, specs, name);}
/// The upstream of the branch `branch` (without `refs/heads/`), which/// must exist.pub fn upstream(config: *const Config, gpa: Allocator, branch: []const u8, diag: ?*Diagnostics) Error!Upstream { const merge = try branchValue(config, gpa, branch, "merge", true); const remote = try branchValue(config, gpa, branch, "remote", false); if (merge == null or remote == null) { if (diag) |d| { d.reset(); d.setDetail("no upstream configured for branch '{s}'", .{branch}); } return error.NoUpstream; } if (std.mem.eql(u8, remote.?, ".")) return .{ .local = merge.? }; const t = try trackingRef(config, gpa, remote.?, merge.?, diag) orelse { if (diag) |d| { d.reset(); d.setDetail("upstream branch '{s}' not stored as a remote-tracking branch", .{merge.?}); } return error.UpstreamNotTracked; }; return .{ .tracking = t };}
/// The remote `branch` pushes to, a slice of the configuration or of/// `"origin"`.pub fn pushRemote(config: *const Config, gpa: Allocator, branch: []const u8) Config.LookupError![]const u8 { const chosen: []const u8 = blk: { if (try branchValue(config, gpa, branch, "pushRemote", false)) |v| break :blk v; if (try config.get("remote.pushDefault")) |e| if (e.value) |v| break :blk v; if (try branchValue(config, gpa, branch, "remote", false)) |v| break :blk v; break :blk "origin"; }; return remoteForUrl(config, chosen) orelse chosen;}
/// The one configured remote whose URLs include `url`, if exactly one/// does: its push URLs if it has any, and otherwise its URLs.fn remoteForUrl(config: *const Config, url: []const u8) ?[]const u8 { var found: ?[]const u8 = null; var count: usize = 0; for (config.entries.items) |*e| { const parts = e.parts(); if (!std.mem.eql(u8, parts.section, "remote")) continue; const sub = parts.subsection orelse continue; const is_url = std.mem.eql(u8, parts.name, "url"); const is_push = std.mem.eql(u8, parts.name, "pushurl"); if (!is_url and !is_push) continue; if (!std.mem.eql(u8, e.value orelse "", url)) continue; // A remote with push URLs pushes only to those. if (is_url and remoteHasPushUrl(config, sub)) continue; if (found) |f| if (std.mem.eql(u8, f, sub)) continue; found = sub; count += 1; } return if (count == 1) found else null;}
fn remoteHasPushUrl(config: *const Config, remote: []const u8) bool { for (config.entries.items) |*e| { const parts = e.parts(); if (!std.mem.eql(u8, parts.section, "remote") or !std.mem.eql(u8, parts.name, "pushurl")) continue; if (std.mem.eql(u8, parts.subsection orelse continue, remote)) return true; } return false;}
/// Whether finding where `branch` pushes to needs the branch to exist, as/// git looks for it: only when `push.default` (`upstream` or `simple`)/// asks for its upstream. Push refspecs, `nothing`, `current` and/// `matching` need only its name.pub fn pushNeedsBranch(config: *const Config, gpa: Allocator, branch: []const u8, diag: ?*Diagnostics) Error!bool { const remote = try pushRemote(config, gpa, branch); const specs = try remoteRefspecs(config, gpa, remote, .push, diag); defer gpa.free(specs); if (specs.len != 0) return false; return switch (try PushDefault.read(config, diag)) { .upstream, .simple => true, .nothing, .current, .matching => false, };}
/// The ref here that tracks where `branch` pushes to, which the caller/// frees. `upstream_of` answers the branch's upstream as a full ref name/// (which the caller frees), for `upstream` and `simple`, so that a local/// upstream can be looked up as the caller looks up refs; it fails with/// errors of `E`.pub fn push( comptime E: type, config: *const Config, gpa: Allocator, branch: []const u8, context: anytype, comptime upstream_of: fn (@TypeOf(context), []const u8) E![]u8, diag: ?*Diagnostics,) (E || Error)![]u8 { const remote = try pushRemote(config, gpa, branch); const local = try std.mem.concat(gpa, u8, &.{ "refs/heads/", branch }); defer gpa.free(local);
const push_specs = try remoteRefspecs(config, gpa, remote, .push, diag); defer gpa.free(push_specs); if (push_specs.len != 0) { const dst = for (push_specs) |*s| { if (s.negative or s.matching) continue; if (try s.mapToDestination(gpa, local)) |d| break d; } else { if (diag) |d| { d.reset(); d.setDetail("push refspecs for '{s}' do not include '{s}'", .{ remote, branch }); } return error.PushRefspecsExclude; }; defer gpa.free(dst); return trackedOrFail(config, gpa, remote, dst, diag); }
switch (try PushDefault.read(config, diag)) { .nothing => { if (diag) |d| { d.reset(); d.setDetail("push has no destination (push.default is 'nothing')", .{}); } return error.PushNothing; }, .current, .matching => return trackedOrFail(config, gpa, remote, local, diag), .upstream => return upstream_of(context, branch), .simple => { const up = try upstream_of(context, branch); defer gpa.free(up); const same = try trackedOrFail(config, gpa, remote, local, diag); if (!std.mem.eql(u8, up, same)) { gpa.free(same); if (diag) |d| { d.reset(); d.setDetail("cannot resolve 'simple' push to a single destination", .{}); } return error.PushNotSingle; } return same; }, }}
fn trackedOrFail(config: *const Config, gpa: Allocator, remote: []const u8, dst: []const u8, diag: ?*Diagnostics) Error![]u8 { return try trackingRef(config, gpa, remote, dst, diag) orelse { if (diag) |d| { d.reset(); d.setDetail("push destination '{s}' on remote '{s}' has no local tracking branch", .{ dst, remote }); } return error.PushNoTracking; };}
// -- tests -----------------------------------------------------------------------
const testing = std.testing;
fn configFrom(text: []const u8) !Config { var c: Config = .init(testing.allocator); errdefer c.deinit(); try c.addContents(testing.io, std.Io.Dir.cwd(), text, .{ .kind = .file, .name = "test" }, .local, .{}, null); return c;}
fn upstreamOf(c: *const Config, branch: []const u8) Error![]u8 { const u = try upstream(c, testing.allocator, branch, null); return switch (u) { .tracking => |t| t, .local => |l| testing.allocator.dupe(u8, l), };}
test "upstreams and push branches follow the configuration" { var c = try configFrom( \\[remote "origin"] \\ url = /x/origin \\ fetch = +refs/heads/*:refs/remotes/origin/* \\[remote "fork"] \\ url = /x/fork \\ fetch = +refs/heads/*:refs/remotes/fork/* \\[branch "topic"] \\ remote = origin \\ merge = refs/heads/other \\[branch "local"] \\ remote = . \\ merge = main \\ ); defer c.deinit(); const gpa = testing.allocator; const up = try upstreamOf(&c, "topic"); defer gpa.free(up); try testing.expectEqualStrings("refs/remotes/origin/other", up); try testing.expectEqualStrings("main", (try upstream(&c, gpa, "local", null)).local); var diag: Diagnostics = .{}; try testing.expectError(error.NoUpstream, upstream(&c, gpa, "main", &diag)); try testing.expectEqualStrings("no upstream configured for branch 'main'", diag.detail()); // simple: the upstream is not the branch of the same name. try testing.expectError(error.PushNotSingle, push(Error, &c, gpa, "topic", &c, upstreamOf, &diag)); try testing.expectEqualStrings("origin", try pushRemote(&c, gpa, "x"));}
test "push.default and push refspecs" { var c = try configFrom( \\[push] \\ default = current \\[remote] \\ pushDefault = /x/fork \\[remote "origin"] \\ url = /x/origin \\ fetch = +refs/heads/*:refs/remotes/origin/* \\[remote "fork"] \\ url = /x/fork \\ fetch = +refs/heads/*:refs/remotes/fork/* \\ push = refs/heads/topic:refs/heads/pushed \\ ); defer c.deinit(); const gpa = testing.allocator; const p = try push(Error, &c, gpa, "topic", &c, upstreamOf, null); defer gpa.free(p); try testing.expectEqualStrings("refs/remotes/fork/pushed", p); var diag: Diagnostics = .{}; try testing.expectError(error.PushRefspecsExclude, push(Error, &c, gpa, "main", &c, upstreamOf, &diag)); try testing.expectEqualStrings("push refspecs for 'fork' do not include 'main'", diag.detail());}