A client session for the Git protocol, currently limited to an HTTP(S) transport. Only protocol version 2 is supported, as documented in protocol-v2.
pub const Session = struct
pub const Session = struct {
transport: *std.http.Client,
location: Location,
supports_agent: bool,
supports_shallow: bool,
object_format: Oid.Format,
arena: Allocator,
const agent = "zig/" ++ @import("builtin").zig_version_string;
const agent_capability = std.fmt.comptimePrint("agent={s}\n", .{agent});
/// Initializes a client session and discovers the capabilities of the
/// server for optimal transport.
pub fn init(
arena: Allocator,
transport: *std.http.Client,
uri: std.Uri,
/// Asserted to be at least `Packet.max_data_length`
response_buffer: []u8,
) !Session {
assert(response_buffer.len >= Packet.max_data_length);
var session: Session = .{
.transport = transport,
.location = try .init(arena, uri),
.supports_agent = false,
.supports_shallow = false,
.object_format = .sha1,
.arena = arena,
};
var capability_iterator: CapabilityIterator = undefined;
try session.getCapabilities(&capability_iterator, response_buffer);
defer capability_iterator.deinit();
while (try capability_iterator.next()) |capability| {
if (mem.eql(u8, capability.key, "agent")) {
session.supports_agent = true;
} else if (mem.eql(u8, capability.key, "fetch")) {
var feature_iterator = mem.splitScalar(u8, capability.value orelse continue, ' ');
while (feature_iterator.next()) |feature| {
if (mem.eql(u8, feature, "shallow")) {
session.supports_shallow = true;
}
}
} else if (mem.eql(u8, capability.key, "object-format")) {
if (std.meta.stringToEnum(Oid.Format, capability.value orelse continue)) |format| {
session.object_format = format;
}
}
}
return session;
}
/// An owned `std.Uri` representing the location of the server (base URI).
const Location = struct {
uri: std.Uri,
fn init(arena: Allocator, uri: std.Uri) !Location {
const scheme = try arena.dupe(u8, uri.scheme);
const user = if (uri.user) |user| try std.fmt.allocPrint(arena, "{f}", .{
std.fmt.alt(user, .formatUser),
}) else null;
const password = if (uri.password) |password| try std.fmt.allocPrint(arena, "{f}", .{
std.fmt.alt(password, .formatPassword),
}) else null;
const host = if (uri.host) |host| try std.fmt.allocPrint(arena, "{f}", .{
std.fmt.alt(host, .formatHost),
}) else null;
const path = try std.fmt.allocPrint(arena, "{f}", .{
std.fmt.alt(uri.path, .formatPath),
});
// The query and fragment are not used as part of the base server URI.
return .{
.uri = .{
.scheme = scheme,
.user = if (user) |s| .{ .percent_encoded = s } else null,
.password = if (password) |s| .{ .percent_encoded = s } else null,
.host = if (host) |s| .{ .percent_encoded = s } else null,
.port = uri.port,
.path = .{ .percent_encoded = path },
},
};
}
};
/// Returns an iterator over capabilities supported by the server.
///
/// The `session.location` is updated if the server returns a redirect, so
/// that subsequent session functions do not need to handle redirects.
fn getCapabilities(session: *Session, it: *CapabilityIterator, response_buffer: []u8) !void {
const arena = session.arena;
assert(response_buffer.len >= Packet.max_data_length);
var info_refs_uri = session.location.uri;
{
const session_uri_path = try std.fmt.allocPrint(arena, "{f}", .{
std.fmt.alt(session.location.uri.path, .formatPath),
});
info_refs_uri.path = .{ .percent_encoded = try std.fs.path.resolvePosix(arena, &.{
"/", session_uri_path, "info/refs",
}) };
}
info_refs_uri.query = .{ .percent_encoded = "service=git-upload-pack" };
info_refs_uri.fragment = null;
const max_redirects = 3;
it.* = .{
.request = try session.transport.request(.GET, info_refs_uri, .{
.redirect_behavior = .init(max_redirects),
.extra_headers = &.{
.{ .name = "Git-Protocol", .value = "version=2" },
},
}),
.reader = undefined,
.decompress = undefined,
};
errdefer it.deinit();
const request = &it.request;
try request.sendBodiless();
var redirect_buffer: [1024]u8 = undefined;
var response = try request.receiveHead(&redirect_buffer);
if (response.head.status != .ok) return error.ProtocolError;
const any_redirects_occurred = request.redirect_behavior.remaining() < max_redirects;
if (any_redirects_occurred) {
const request_uri_path = try std.fmt.allocPrint(arena, "{f}", .{
std.fmt.alt(request.uri.path, .formatPath),
});
if (!mem.endsWith(u8, request_uri_path, "/info/refs")) return error.UnparseableRedirect;
var new_uri = request.uri;
new_uri.path = .{ .percent_encoded = request_uri_path[0 .. request_uri_path.len - "/info/refs".len] };
session.location = try .init(arena, new_uri);
}
const decompress_buffer = try arena.alloc(u8, response.head.content_encoding.minBufferCapacity());
it.reader = response.readerDecompressing(response_buffer, &it.decompress, decompress_buffer);
var state: enum { response_start, response_content } = .response_start;
while (true) {
// Some Git servers (at least GitHub) include an additional
// '# service=git-upload-pack' informative response before sending
// the expected 'version 2' packet and capability information.
// This is not universal: SourceHut, for example, does not do this.
// Thus, we need to skip any such useless additional responses
// before we get the one we're actually looking for. The responses
// will be delimited by flush packets.
const packet = Packet.read(it.reader) catch |err| switch (err) {
error.EndOfStream => return error.UnsupportedProtocol, // 'version 2' packet not found
else => |e| return e,
};
switch (packet) {
.flush => state = .response_start,
.data => |data| switch (state) {
.response_start => if (mem.eql(u8, Packet.normalizeText(data), "version 2")) {
return;
} else {
state = .response_content;
},
else => {},
},
else => return error.UnexpectedPacket,
}
}
}
const CapabilityIterator = struct {
request: std.http.Client.Request,
reader: *Io.Reader,
decompress: std.http.Decompress,
const Capability = struct {
key: []const u8,
value: ?[]const u8 = null,
fn parse(data: []const u8) Capability {
return if (mem.indexOfScalar(u8, data, '=')) |separator_pos|
.{ .key = data[0..separator_pos], .value = data[separator_pos + 1 ..] }
else
.{ .key = data };
}
};
fn deinit(it: *CapabilityIterator) void {
it.request.deinit();
it.* = undefined;
}
fn next(it: *CapabilityIterator) !?Capability {
switch (try Packet.read(it.reader)) {
.flush => return null,
.data => |data| return Capability.parse(Packet.normalizeText(data)),
else => return error.UnexpectedPacket,
}
}
};
const ListRefsOptions = struct {
/// The ref prefixes (if any) to use to filter the refs available on the
/// server. Note that the client must still check the returned refs
/// against its desired filters itself: the server is not required to
/// respect these prefix filters and may return other refs as well.
ref_prefixes: []const []const u8 = &.{},
/// Whether to include symref targets for returned symbolic refs.
include_symrefs: bool = false,
/// Whether to include the peeled object ID for returned tag refs.
include_peeled: bool = false,
/// Asserted to be at least `Packet.max_data_length`.
buffer: []u8,
};
/// Returns an iterator over refs known to the server.
pub fn listRefs(session: Session, it: *RefIterator, options: ListRefsOptions) !void {
const arena = session.arena;
assert(options.buffer.len >= Packet.max_data_length);
var upload_pack_uri = session.location.uri;
{
const session_uri_path = try std.fmt.allocPrint(arena, "{f}", .{
std.fmt.alt(session.location.uri.path, .formatPath),
});
upload_pack_uri.path = .{ .percent_encoded = try std.fs.path.resolvePosix(arena, &.{ "/", session_uri_path, "git-upload-pack" }) };
}
upload_pack_uri.query = null;
upload_pack_uri.fragment = null;
var body: Io.Writer = .fixed(options.buffer);
try Packet.write(.{ .data = "command=ls-refs\n" }, &body);
if (session.supports_agent) {
try Packet.write(.{ .data = agent_capability }, &body);
}
{
const object_format_packet = try std.fmt.allocPrint(arena, "object-format={t}\n", .{
session.object_format,
});
try Packet.write(.{ .data = object_format_packet }, &body);
}
try Packet.write(.delimiter, &body);
for (options.ref_prefixes) |ref_prefix| {
const ref_prefix_packet = try std.fmt.allocPrint(arena, "ref-prefix {s}\n", .{ref_prefix});
try Packet.write(.{ .data = ref_prefix_packet }, &body);
}
if (options.include_symrefs) {
try Packet.write(.{ .data = "symrefs\n" }, &body);
}
if (options.include_peeled) {
try Packet.write(.{ .data = "peel\n" }, &body);
}
try Packet.write(.flush, &body);
it.* = .{
.request = try session.transport.request(.POST, upload_pack_uri, .{
.redirect_behavior = .unhandled,
.extra_headers = &.{
.{ .name = "Content-Type", .value = "application/x-git-upload-pack-request" },
.{ .name = "Git-Protocol", .value = "version=2" },
},
}),
.reader = undefined,
.format = session.object_format,
.decompress = undefined,
};
const request = &it.request;
errdefer request.deinit();
try request.sendBodyComplete(body.buffered());
var response = try request.receiveHead(options.buffer);
if (response.head.status != .ok) return error.ProtocolError;
const decompress_buffer = try arena.alloc(u8, response.head.content_encoding.minBufferCapacity());
it.reader = response.readerDecompressing(options.buffer, &it.decompress, decompress_buffer);
}
pub const RefIterator = struct {
format: Oid.Format,
request: std.http.Client.Request,
reader: *Io.Reader,
decompress: std.http.Decompress,
pub const Ref = struct {
oid: Oid,
name: []const u8,
symref_target: ?[]const u8,
peeled: ?Oid,
};
pub fn deinit(iterator: *RefIterator) void {
iterator.request.deinit();
iterator.* = undefined;
}
pub fn next(it: *RefIterator) !?Ref {
switch (try Packet.read(it.reader)) {
.flush => return null,
.data => |data| {
const ref_data = Packet.normalizeText(data);
const oid_sep_pos = mem.indexOfScalar(u8, ref_data, ' ') orelse return error.InvalidRefPacket;
const oid = Oid.parse(it.format, data[0..oid_sep_pos]) catch return error.InvalidRefPacket;
const name_sep_pos = mem.indexOfScalarPos(u8, ref_data, oid_sep_pos + 1, ' ') orelse ref_data.len;
const name = ref_data[oid_sep_pos + 1 .. name_sep_pos];
var symref_target: ?[]const u8 = null;
var peeled: ?Oid = null;
var last_sep_pos = name_sep_pos;
while (last_sep_pos < ref_data.len) {
const next_sep_pos = mem.indexOfScalarPos(u8, ref_data, last_sep_pos + 1, ' ') orelse ref_data.len;
const attribute = ref_data[last_sep_pos + 1 .. next_sep_pos];
if (mem.startsWith(u8, attribute, "symref-target:")) {
symref_target = attribute["symref-target:".len..];
} else if (mem.startsWith(u8, attribute, "peeled:")) {
peeled = Oid.parse(it.format, attribute["peeled:".len..]) catch return error.InvalidRefPacket;
}
last_sep_pos = next_sep_pos;
}
return .{ .oid = oid, .name = name, .symref_target = symref_target, .peeled = peeled };
},
else => return error.UnexpectedPacket,
}
}
};
/// Fetches the given refs from the server. A shallow fetch (depth 1) is
/// performed if the server supports it.
pub fn fetch(
session: Session,
fs: *FetchStream,
wants: []const []const u8,
/// Asserted to be at least `Packet.max_data_length`.
response_buffer: []u8,
) !void {
const arena = session.arena;
assert(response_buffer.len >= Packet.max_data_length);
var upload_pack_uri = session.location.uri;
{
const session_uri_path = try std.fmt.allocPrint(arena, "{f}", .{
std.fmt.alt(session.location.uri.path, .formatPath),
});
upload_pack_uri.path = .{ .percent_encoded = try std.fs.path.resolvePosix(arena, &.{ "/", session_uri_path, "git-upload-pack" }) };
}
upload_pack_uri.query = null;
upload_pack_uri.fragment = null;
var body: Io.Writer = .fixed(response_buffer);
try Packet.write(.{ .data = "command=fetch\n" }, &body);
if (session.supports_agent) {
try Packet.write(.{ .data = agent_capability }, &body);
}
{
const object_format_packet = try std.fmt.allocPrint(arena, "object-format={s}\n", .{@tagName(session.object_format)});
try Packet.write(.{ .data = object_format_packet }, &body);
}
try Packet.write(.delimiter, &body);
// Our packfile parser supports the OFS_DELTA object type
try Packet.write(.{ .data = "ofs-delta\n" }, &body);
// We do not currently convey server progress information to the user
try Packet.write(.{ .data = "no-progress\n" }, &body);
if (session.supports_shallow) {
try Packet.write(.{ .data = "deepen 1\n" }, &body);
}
for (wants) |want| {
var buf: [Packet.max_data_length]u8 = undefined;
const arg = std.fmt.bufPrint(&buf, "want {s}\n", .{want}) catch unreachable;
try Packet.write(.{ .data = arg }, &body);
}
try Packet.write(.{ .data = "done\n" }, &body);
try Packet.write(.flush, &body);
fs.* = .{
.request = try session.transport.request(.POST, upload_pack_uri, .{
.redirect_behavior = .not_allowed,
.extra_headers = &.{
.{ .name = "Content-Type", .value = "application/x-git-upload-pack-request" },
.{ .name = "Git-Protocol", .value = "version=2" },
},
}),
.input = undefined,
.reader = undefined,
.remaining_len = undefined,
.decompress = undefined,
};
const request = &fs.request;
errdefer request.deinit();
try request.sendBodyComplete(body.buffered());
var response = try request.receiveHead(&.{});
if (response.head.status != .ok) return error.ProtocolError;
const decompress_buffer = try arena.alloc(u8, response.head.content_encoding.minBufferCapacity());
const reader = response.readerDecompressing(response_buffer, &fs.decompress, decompress_buffer);
// We are not interested in any of the sections of the returned fetch
// data other than the packfile section, since we aren't doing anything
// complex like ref negotiation (this is a fresh clone).
var state: enum { section_start, section_content } = .section_start;
while (true) {
const packet = try Packet.read(reader);
switch (state) {
.section_start => switch (packet) {
.data => |data| if (mem.eql(u8, Packet.normalizeText(data), "packfile")) {
fs.input = reader;
fs.reader = .{
.buffer = &.{},
.vtable = &.{ .stream = FetchStream.stream },
.seek = 0,
.end = 0,
};
fs.remaining_len = 0;
return;
} else {
state = .section_content;
},
else => return error.UnexpectedPacket,
},
.section_content => switch (packet) {
.delimiter => state = .section_start,
.data => {},
else => return error.UnexpectedPacket,
},
}
}
}
pub const FetchStream = struct {
request: std.http.Client.Request,
input: *Io.Reader,
reader: Io.Reader,
err: ?Error = null,
remaining_len: usize,
decompress: std.http.Decompress,
pub fn deinit(fs: *FetchStream) void {
fs.request.deinit();
}
pub const Error = error{
InvalidPacket,
ProtocolError,
UnexpectedPacket,
WriteFailed,
ReadFailed,
EndOfStream,
};
const StreamCode = enum(u8) {
pack_data = 1,
progress = 2,
fatal_error = 3,
_,
};
pub fn stream(r: *Io.Reader, w: *Io.Writer, limit: Io.Limit) Io.Reader.StreamError!usize {
const fs: *FetchStream = @alignCast(@fieldParentPtr("reader", r));
const input = fs.input;
if (fs.remaining_len == 0) {
while (true) {
switch (Packet.peek(input) catch |err| {
fs.err = err;
return error.ReadFailed;
}) {
.flush => return error.EndOfStream,
.data => |data| switch (@as(StreamCode, @fromBackingInt(@intCast(data[0])))) {
.pack_data => {
input.toss(1);
fs.remaining_len = data.len - 1;
break;
},
.fatal_error => {
fs.err = error.ProtocolError;
return error.ReadFailed;
},
else => {
input.toss(data.len);
},
},
else => {
fs.err = error.UnexpectedPacket;
return error.ReadFailed;
},
}
}
}
const buf = limit.slice(try w.writableSliceGreedy(1));
const n = @min(buf.len, fs.remaining_len);
try input.readSliceAll(buf[0..n]);
w.advance(n);
fs.remaining_len -= n;
return n;
}
};
}