const std = @import("std"); const refresh = @import("refresh.zig"); /// `describe USER/REPOSITORY [DESCRIPTION...]`: print or set a repository's description. pub fn run( allocator: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map, args: []const []const u8, ) !void { if (args.len == 0) std.process.fatal("usage: describe USER/REPOSITORY [DESCRIPTION...]", .{}); const root = environ.get("SORCERY_REPOSITORIES") orelse std.process.fatal("SORCERY_REPOSITORIES not set", .{}); const user, const name = parseId(args[0]) orelse std.process.fatal("repository must be written as USER/REPOSITORY, got {s}", .{args[0]}); // same precedence as sorceryd's catalog: NAME shadows NAME.git const repo_path, var repo = for ([_][]const u8{ "", ".git" }) |suffix| { const path = try std.fmt.allocPrint(allocator, "{s}/{s}/{s}{s}", .{ root, user, name, suffix }); if (std.Io.Dir.cwd().openDir(io, path, .{})) |dir| { if (dir.access(io, "HEAD", .{})) |_| break .{ path, dir } else |_| dir.close(io); } else |_| {} allocator.free(path); } else std.process.fatal("repository {s}/{s} not found", .{ user, name }); defer allocator.free(repo_path); defer repo.close(io); var buffer: [1024]u8 = undefined; var stdout = std.Io.File.stdout().writer(io, &buffer); if (args.len == 1) { const contents = repo.readFileAlloc(io, "description", allocator, .limited(64 * 1024)) catch |err| switch (err) { error.FileNotFound => return, else => return err, }; defer allocator.free(contents); const description = std.mem.trim(u8, contents, &std.ascii.whitespace); // placeholder written by git init if (description.len == 0 or std.mem.startsWith(u8, description, "Unnamed repository")) return; try stdout.interface.print("{s}\n", .{description}); return stdout.interface.flush(); } const joined = try std.mem.join(allocator, " ", args[1..]); defer allocator.free(joined); if (std.mem.indexOfAny(u8, joined, "\r\n") != null) { std.process.fatal("descriptions must fit on one line", .{}); } const description = std.mem.trim(u8, joined, &std.ascii.whitespace); if (description.len == 0) { repo.deleteFile(io, "description") catch |err| switch (err) { error.FileNotFound => {}, else => return err, }; } else { const contents = try std.fmt.allocPrint(allocator, "{s}\n", .{description}); defer allocator.free(contents); try repo.writeFile(io, .{ .sub_path = "description", .data = contents }); } refresh.request(io, environ, repo_path); try stdout.interface.print("{s}\n", .{description}); try stdout.interface.flush(); } /// Split `USER/REPOSITORY[.git]`, refusing anything that could escape the repository root. fn parseId(id: []const u8) ?struct { []const u8, []const u8 } { const user, const rest = std.mem.cutScalar(u8, id, '/') orelse return null; const name = std.mem.cutSuffix(u8, rest, ".git") orelse rest; if (user.len == 0 or name.len == 0 or user[0] == '.' or name[0] == '.') return null; if (std.mem.indexOfScalar(u8, name, '/') != null) return null; return .{ user, name }; } test "describe only accepts USER/REPOSITORY" { const user, const name = parseId("alice/repo.git").?; try std.testing.expectEqualStrings("alice", user); try std.testing.expectEqualStrings("repo", name); for ([_][]const u8{ "alice", "alice/", "/repo", "../repo", "alice/..", "alice/.git", "alice/a/b", "alice/../../etc" }) |id| { try std.testing.expect(parseId(id) == null); } }