// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! A branch's upstream and push branches, `@{upstream}` and `@{push}`, //! from the configuration alone: `branch..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..merge`, mapped through //! the first fetch refspec of `branch..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..pushRemote`, else //! `remote.pushDefault`, else `branch..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/`; 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 ''". /// - `UpstreamNotTracked`: "upstream branch '' not stored as a /// remote-tracking branch". /// - `InvalidRefspec`: "invalid refspec ''". /// - `PushNothing`: "push has no destination (push.default is /// 'nothing')". /// - `PushNotSingle`: "cannot resolve 'simple' push to a single /// destination". /// - `PushRefspecsExclude`: "push refspecs for '' do not include /// ''". /// - `PushNoTracking`: "push destination '' on 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..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()); }