char/sorcery

static-files based git repo viewer

git clone https://git.t4t.associates/char/sorcery

Charlotte Somsorcery-ssh: refactor3356bff

main
3.6 KiB82 linesraw
1const std = @import("std");
2const refresh = @import("refresh.zig");
3
4/// `describe USER/REPOSITORY [DESCRIPTION...]`: print or set a repository's description.
5pub fn run(
6    allocator: std.mem.Allocator,
7    io: std.Io,
8    environ: *const std.process.Environ.Map,
9    args: []const []const u8,
10) !void {
11    if (args.len == 0) std.process.fatal("usage: describe USER/REPOSITORY [DESCRIPTION...]", .{});
12    const root = environ.get("SORCERY_REPOSITORIES") orelse
13        std.process.fatal("SORCERY_REPOSITORIES not set", .{});
14    const user, const name = parseId(args[0]) orelse
15        std.process.fatal("repository must be written as USER/REPOSITORY, got {s}", .{args[0]});
16
17    // same precedence as sorceryd's catalog: NAME shadows NAME.git
18    const repo_path, var repo = for ([_][]const u8{ "", ".git" }) |suffix| {
19        const path = try std.fmt.allocPrint(allocator, "{s}/{s}/{s}{s}", .{ root, user, name, suffix });
20        if (std.Io.Dir.cwd().openDir(io, path, .{})) |dir| {
21            if (dir.access(io, "HEAD", .{})) |_| break .{ path, dir } else |_| dir.close(io);
22        } else |_| {}
23        allocator.free(path);
24    } else std.process.fatal("repository {s}/{s} not found", .{ user, name });
25    defer allocator.free(repo_path);
26    defer repo.close(io);
27
28    var buffer: [1024]u8 = undefined;
29    var stdout = std.Io.File.stdout().writer(io, &buffer);
30
31    if (args.len == 1) {
32        const contents = repo.readFileAlloc(io, "description", allocator, .limited(64 * 1024)) catch |err| switch (err) {
33            error.FileNotFound => return,
34            else => return err,
35        };
36        defer allocator.free(contents);
37        const description = std.mem.trim(u8, contents, &std.ascii.whitespace);
38        // placeholder written by git init
39        if (description.len == 0 or std.mem.startsWith(u8, description, "Unnamed repository")) return;
40        try stdout.interface.print("{s}\n", .{description});
41        return stdout.interface.flush();
42    }
43
44    const joined = try std.mem.join(allocator, " ", args[1..]);
45    defer allocator.free(joined);
46    if (std.mem.indexOfAny(u8, joined, "\r\n") != null) {
47        std.process.fatal("descriptions must fit on one line", .{});
48    }
49    const description = std.mem.trim(u8, joined, &std.ascii.whitespace);
50    if (description.len == 0) {
51        repo.deleteFile(io, "description") catch |err| switch (err) {
52            error.FileNotFound => {},
53            else => return err,
54        };
55    } else {
56        const contents = try std.fmt.allocPrint(allocator, "{s}\n", .{description});
57        defer allocator.free(contents);
58        try repo.writeFile(io, .{ .sub_path = "description", .data = contents });
59    }
60
61    refresh.request(io, environ, repo_path);
62    try stdout.interface.print("{s}\n", .{description});
63    try stdout.interface.flush();
64}
65
66/// Split `USER/REPOSITORY[.git]`, refusing anything that could escape the repository root.
67fn parseId(id: []const u8) ?struct { []const u8, []const u8 } {
68    const user, const rest = std.mem.cutScalar(u8, id, '/') orelse return null;
69    const name = std.mem.cutSuffix(u8, rest, ".git") orelse rest;
70    if (user.len == 0 or name.len == 0 or user[0] == '.' or name[0] == '.') return null;
71    if (std.mem.indexOfScalar(u8, name, '/') != null) return null;
72    return .{ user, name };
73}
74
75test "describe only accepts USER/REPOSITORY" {
76    const user, const name = parseId("alice/repo.git").?;
77    try std.testing.expectEqualStrings("alice", user);
78    try std.testing.expectEqualStrings("repo", name);
79    for ([_][]const u8{ "alice", "alice/", "/repo", "../repo", "alice/..", "alice/.git", "alice/a/b", "alice/../../etc" }) |id| {
80        try std.testing.expect(parseId(id) == null);
81    }
82}