// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! `zig-git branch` and `zig-git tag`, listing only, as git lists them: //! //! zig-git branch [--list | -l] [-a | -r] [--show-current] //! [(--contains | --no-contains | --merged | --no-merged) []] //! [--points-at ] [...] //! zig-git tag [--list | -l] [(--contains | --no-contains | --merged | --no-merged) []] //! [--points-at ] [...] //! //! Branches are listed below `refs/heads/`, and with `-r` or `-a` the //! remote-tracking refs below `refs/remotes/`, symbolic ones as ` -> //! `, the current branch marked `*`; a detached `HEAD` comes first, //! as `(HEAD detached at )` while it is where the last checkout put //! it and `from` once it has moved, the name read from `HEAD`'s reflog. //! Patterns are globs matched against the name below the namespace. //! //! Neither creates, renames or deletes anything: those, verbose and //! formatted output, sorting, columns, colors and case-insensitive //! patterns are refused by name, as is listing branches in a repository //! with linked worktrees (whose branches git marks `+`) or in the middle //! of a rebase or bisect (which git describes in place of a detached //! `HEAD`). const std = @import("std"); const Allocator = std.mem.Allocator; const Io = std.Io; const git = @import("git"); const ObjectId = git.ObjectId; const Diagnostics = git.Diagnostics; const RefStore = git.RefStore; const tool = @import("zig_git.zig"); const Context = tool.Context; const discovery = @import("discovery.zig"); const object_names = @import("object_names.zig"); const revisions = @import("revisions.zig"); const history = @import("history.zig"); const ref_filters = @import("ref_filters.zig"); const Which = enum { branch, tag }; /// The options both share. const Listing = struct { list: bool = false, local: bool = true, remote: bool = false, show_current: bool = false, filters: ref_filters.Filters = .{}, patterns: std.ArrayList([]const u8) = .empty, }; /// Runs `branch` with `args`, the arguments after its name. pub fn branch(ctx: Context, args: []const []const u8, stdout: *Io.Writer, stderr: *Io.Writer) !void { return run(ctx, .branch, args, stdout, stderr); } /// Runs `tag` with `args`, the arguments after its name. pub fn tag(ctx: Context, args: []const []const u8, stdout: *Io.Writer, stderr: *Io.Writer) !void { return run(ctx, .tag, args, stdout, stderr); } fn run(ctx: Context, which: Which, args: []const []const u8, stdout: *Io.Writer, stderr: *Io.Writer) !void { const gpa = ctx.gpa; const name = @tagName(which); var l: Listing = .{}; defer l.filters.deinit(gpa); defer l.patterns.deinit(gpa); var i: usize = 0; var only_operands = false; while (i < args.len) : (i += 1) { const arg = args[i]; if (only_operands or arg.len < 2 or arg[0] != '-') { try l.patterns.append(gpa, arg); continue; } if (std.mem.eql(u8, arg, "--")) { only_operands = true; continue; } if (l.filters.parse(gpa, arg, if (i + 1 < args.len) args[i + 1] else null) catch { try stderr.print("error: option `points-at' requires a value\n", .{}); return error.UsageQuiet; }) |took| { i += took - 1; continue; } if (std.mem.eql(u8, arg, "--list") or std.mem.eql(u8, arg, "-l")) { l.list = true; } else if (std.mem.eql(u8, arg, "--no-color") or std.mem.eql(u8, arg, "--color=never") or std.mem.eql(u8, arg, "--no-column")) { // Already so. } else if (which == .branch and (std.mem.eql(u8, arg, "-a") or std.mem.eql(u8, arg, "--all"))) { l.local = true; l.remote = true; } else if (which == .branch and (std.mem.eql(u8, arg, "-r") or std.mem.eql(u8, arg, "--remotes"))) { l.local = false; l.remote = true; } else if (which == .branch and std.mem.eql(u8, arg, "--show-current")) { l.show_current = true; } else { return tool.fatalMessage(stderr, "zig-git {s}: '{s}' is not supported; only listing is", .{ name, arg }); } } const listing = l.list or l.filters.active() or (which == .branch and l.remote); if (l.patterns.items.len != 0 and !listing) { if (which == .branch and l.remote) {} return tool.fatalMessage(stderr, "zig-git {s}: creating a {s} is not supported; only listing is", .{ name, name }); } if (which == .branch and l.remote and !l.list and l.patterns.items.len != 0) return tool.fatalMessage(stderr, "the -a, and -r, options to 'git branch' do not take a branch name.\nDid you mean to use: -a|-r --list ?", .{}); var repo = try discovery.open(ctx, stderr); defer repo.close(ctx.io); const config = &repo.config; // What would change git's output and is not matched. const color_key: []const u8 = if (which == .branch) "color.branch" else "color.ui"; for ([_][]const u8{ color_key, "color.ui" }) |key| if (config.getString(key) catch null) |v| { if (std.mem.eql(u8, v, "always")) return tool.fatalMessage(stderr, "zig-git {s}: {s}=always is not supported", .{ name, key }); }; for ([_][]const u8{ if (which == .branch) "column.branch" else "column.tag", "column.ui" }) |key| if (config.getString(key) catch null) |v| { if (std.mem.indexOf(u8, v, "always") != null) return tool.fatalMessage(stderr, "zig-git {s}: {s}={s} is not supported", .{ name, key, v }); }; if (config.getString(if (which == .branch) "branch.sort" else "tag.sort") catch null) |_| return tool.fatalMessage(stderr, "zig-git {s}: {s}.sort is not supported", .{ name, name }); const store = repo.refs(); var diag: Diagnostics = .{}; var head = store.resolve(ctx.io, gpa, "HEAD", &diag) catch return tool.fatalMessage(stderr, "zig-git: cannot read HEAD", .{}); defer head.deinit(gpa); const current: ?[]const u8 = if (head.isSymbolic() and std.mem.startsWith(u8, head.last(), "refs/heads/")) head.last()["refs/heads/".len..] else null; if (l.show_current) { if (current) |c| try stdout.print("{s}\n", .{c}); return; } const settings = try object_names.settings(config, stderr); var names: object_names.Names = .init(ctx, &repo, settings); defer names.deinit(); var revs = try revisions.Revisions.init(ctx, &repo, settings, .rev_parse, stderr); defer revs.deinit(); var h = try history.History.open(ctx, &repo, stderr); defer h.close(); var prepared = try ref_filters.prepare(&l.filters, ctx, &repo, &revs, &h, stderr); defer prepared.deinit(); if (which == .branch) { // Linked worktrees mark their branches, and a rebase or bisect // describes HEAD, as is not matched. const worktrees = try std.fs.path.join(gpa, &.{ repo.location.common_dir, "worktrees" }); defer gpa.free(worktrees); if (repo.work_dir.openDir(ctx.io, worktrees, .{ .iterate = true })) |d| { var dir = d; defer dir.close(ctx.io); var it = dir.iterate(); if (it.next(ctx.io) catch null) |_| return tool.fatalMessage(stderr, "zig-git branch: listing branches with linked worktrees is not supported", .{}); } else |_| {} for ([_][]const u8{ "rebase-merge", "rebase-apply", "BISECT_LOG" }) |f| { const path = try std.fs.path.join(gpa, &.{ repo.location.git_dir, f }); defer gpa.free(path); if (repo.work_dir.statFile(ctx.io, path, .{})) |_| { return tool.fatalMessage(stderr, "zig-git branch: listing branches during a rebase or bisect is not supported", .{}); } else |_| {} } // A detached HEAD comes first. if (l.local and !head.isSymbolic()) switch (head.outcome) { .direct => |d| if (l.patterns.items.len == 0 and try prepared.keeps(d.id, stderr)) { try stdout.writeAll("* (HEAD detached "); try writeDetached(ctx, &repo, &revs, &names, d.id, stdout); try stdout.writeAll(")\n"); }, else => {}, }; } const Space = struct { prefix: []const u8, shown_prefix: []const u8, on: bool }; const spaces: []const Space = switch (which) { .branch => &.{ .{ .prefix = "refs/heads/", .shown_prefix = "", .on = l.local }, .{ .prefix = "refs/remotes/", .shown_prefix = if (l.local) "remotes/" else "", .on = l.remote }, }, .tag => &.{.{ .prefix = "refs/tags/", .shown_prefix = "", .on = true }}, }; for (spaces) |space| { if (!space.on) continue; var list = store.list(ctx.io, gpa, space.prefix, &diag) catch |err| return tool.fatalMessage(stderr, "zig-git: cannot list refs: {f}", .{tool.errorWords(err)}); defer list.deinit(); for (list.entries) |e| { const short = e.name[space.prefix.len..]; if (l.patterns.items.len != 0) { const any = for (l.patterns.items) |p| { if (git.wildmatch.match(p, short, .{})) break true; } else false; if (!any) continue; } var target: ?[]const u8 = null; const id: ObjectId = switch (e.value) { .direct => |d| d.id, .symbolic => |t| blk: { target = t; var resolved = store.resolve(ctx.io, gpa, e.name, null) catch continue; defer resolved.deinit(gpa); break :blk switch (resolved.outcome) { .direct => |d| d.id, else => continue, }; }, .broken => continue, }; if (!try prepared.keeps(id, stderr)) continue; switch (which) { .tag => try stdout.print("{s}\n", .{short}), .branch => { const is_current = space.prefix.len == "refs/heads/".len and current != null and std.mem.eql(u8, current.?, short); try stdout.print("{s}{s}{s}", .{ if (is_current) "* " else " ", space.shown_prefix, short }); if (target) |t| try stdout.print(" -> {s}", .{git.Decorations.shortName(t, false)}); try stdout.writeByte('\n'); }, } } } } /// How `git branch` names a detached HEAD: `at` or `from`, and the name /// the last checkout recorded in `HEAD`'s reflog. fn writeDetached(ctx: Context, repo: *git.Repository, revs: *revisions.Revisions, names: *object_names.Names, id: ObjectId, w: *Io.Writer) !void { const gpa = ctx.gpa; var log = (repo.refs().reflog(ctx.io, gpa, "HEAD", null) catch null) orelse return writeAbbreviated(names, id, "at ", w); defer log.deinit(); var k = log.entries.len; while (k > 0) { k -= 1; const e = log.entries[k]; const rest = std.mem.cutPrefix(u8, e.message, "checkout: moving from ") orelse continue; const to = rest[(std.mem.find(u8, rest, " to ") orelse continue) + 4 ..]; const at = e.new.eql(id); try w.writeAll(if (at) "at " else "from "); // A ref is named as the checkout named it, a branch whole; anything // else by the commit it reached. var diag: Diagnostics = .{}; const full = revs.resolver.fullName(to, &diag) catch .none; revs.resolver.clearMessages(); switch (full) { .name => |n| { if (std.mem.startsWith(u8, n, "refs/heads/")) return w.writeAll(n); return w.writeAll(git.Decorations.shortName(n, false)); }, else => return names.write(w, e.new, names.defaultLength()), } } return writeAbbreviated(names, id, "at ", w); } fn writeAbbreviated(names: *object_names.Names, id: ObjectId, prefix: []const u8, w: *Io.Writer) !void { try w.writeAll(prefix); try names.write(w, id, names.defaultLength()); }