const std = @import("std"); const refresh = @import("refresh.zig"); pub const Service = enum { @"git-upload-pack", @"git-receive-pack" }; /// Run `service` on a repository beneath HOME, creating it on first push. pub fn serve( allocator: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map, service: Service, path: []const u8, ) !void { const home = environ.get("HOME") orelse std.process.fatal("HOME not set", .{}); const repo_path = try expandPath(allocator, home, path); defer allocator.free(repo_path); if (!isUnder(repo_path, home)) std.process.fatal("repo path must be inside HOME: {s}", .{path}); if (service == .@"git-upload-pack") { return std.process.replace(io, .{ .argv = &.{ "git-upload-pack", repo_path }, }); } var created_repo = false; std.Io.Dir.accessAbsolute(io, repo_path, .{}) catch { std.log.info("auto-initializing bare repo at {s}", .{repo_path}); std.Io.Dir.createDirPath(.cwd(), io, repo_path) catch |err| std.process.fatal("failed to create repo directory: {}", .{err}); const result = try std.process.run(allocator, io, .{ .argv = &.{ "git", "init", "--bare", repo_path }, }); defer allocator.free(result.stdout); defer allocator.free(result.stderr); if (result.term != .exited or result.term.exited != 0) { std.process.fatal("git init --bare failed: {s}", .{result.stderr}); } created_repo = true; }; var receive_pack = try std.process.spawn(io, .{ .argv = &.{ "git-receive-pack", repo_path }, }); const term = try receive_pack.wait(io); if (term != .exited) std.process.exit(1); if (term.exited != 0) std.process.exit(term.exited); if (created_repo) try fixHead(allocator, io, repo_path); refresh.request(io, environ, repo_path); } /// Whether `path` is strictly below `root`; `root` itself does not count. fn isUnder(path: []const u8, root: []const u8) bool { if (std.mem.eql(u8, root, &.{std.fs.path.sep})) { return path.len > 1 and path[0] == std.fs.path.sep; } return std.mem.startsWith(u8, path, root) and path.len > root.len and path[root.len] == std.fs.path.sep; } fn fixHead(allocator: std.mem.Allocator, io: std.Io, repo_path: []const u8) !void { const head_result = try std.process.run(allocator, io, .{ .argv = &.{ "git", "--git-dir", repo_path, "rev-parse", "--verify", "--quiet", "HEAD" }, }); defer allocator.free(head_result.stdout); defer allocator.free(head_result.stderr); if (head_result.term == .exited and head_result.term.exited == 0) return; if (head_result.term != .exited or head_result.term.exited != 1) { std.log.err("failed to resolve repository HEAD: {s}", .{head_result.stderr}); return error.GitCommandFailed; } const refs_result = try std.process.run(allocator, io, .{ .argv = &.{ "git", "--git-dir", repo_path, "for-each-ref", "--format=%(refname)", "refs/heads/" }, }); defer allocator.free(refs_result.stdout); defer allocator.free(refs_result.stderr); if (refs_result.term != .exited or refs_result.term.exited != 0) { std.log.err("failed to list repository branches: {s}", .{refs_result.stderr}); return error.GitCommandFailed; } var refs = std.mem.tokenizeScalar(u8, refs_result.stdout, '\n'); var sole_ref: ?[]const u8 = null; var main_ref: ?[]const u8 = null; var ref_count: usize = 0; while (refs.next()) |ref| { sole_ref = ref; ref_count += 1; if (std.mem.eql(u8, ref, "refs/heads/main")) main_ref = ref; } const head_ref = main_ref orelse if (ref_count == 1) sole_ref else null; if (head_ref) |ref| { const set_head_result = try std.process.run(allocator, io, .{ .argv = &.{ "git", "--git-dir", repo_path, "symbolic-ref", "HEAD", ref }, }); defer allocator.free(set_head_result.stdout); defer allocator.free(set_head_result.stderr); if (set_head_result.term != .exited or set_head_result.term.exited != 0) { std.log.err("failed to set repository HEAD: {s}", .{set_head_result.stderr}); return error.GitCommandFailed; } } else if (ref_count > 1) { std.log.warn("HEAD remains unresolved because the repository has multiple branches and no main branch", .{}); } } // im pretty sure this is what git expand user path does. wordexp from posix is overkill fn expandPath(allocator: std.mem.Allocator, home: []const u8, path: []const u8) ![]const u8 { if (std.mem.startsWith(u8, path, "~/")) { return std.fs.path.resolve(allocator, &.{ home, path[2..] }); } else if (std.mem.eql(u8, path, "~")) { return allocator.dupe(u8, home); } else { return std.fs.path.resolve(allocator, &.{ home, path }); } } test "pushed paths normalise into HOME or are rejected" { const home = "/home/git"; for ([_][]const u8{ "x", "~/x", "/home/git/x", "~/a/../x", "/home/git/../git/x" }) |path| { const expanded = try expandPath(std.testing.allocator, home, path); defer std.testing.allocator.free(expanded); try std.testing.expectEqualStrings("/home/git/x", expanded); try std.testing.expect(isUnder(expanded, home)); } for ([_][]const u8{ "~", ".", "../x", "~/../x", "/etc/passwd", "/home/gitolite/x", "/home" }) |path| { const expanded = try expandPath(std.testing.allocator, home, path); defer std.testing.allocator.free(expanded); try std.testing.expect(!isUnder(expanded, home)); } }