///| Upload-pack over git-upload-pack (native targets)
///|
priv struct ProcessSpec {
cmd : String
args : Array[String]
extra_env : Map[String, String]?
}
///|
fn fs_error_message(err : @fs.IOError) -> String {
match err {
@fs.IOError::IOError(message) => message
}
}
///|
priv enum SshVariant {
Ssh
Plink
TortoisePlink
Simple
} derive(Eq)
///|
priv struct SshCommandSpec {
cmd : String
args : Array[String]
}
///|
priv struct SshEndpoint {
host : String
path : String
port : String?
}
///|
fn is_space_char(c : Char) -> Bool {
c == ' ' || c == '\t' || c == '\n' || c == '\r'
}
///|
fn split_shell_words(cmdline : String) -> Array[String] raise @bit.GitError {
let out : Array[String] = []
let current = StringBuilder::new()
let mut in_single = false
let mut in_double = false
let mut escaped = false
let mut token_active = false
for c in cmdline {
if escaped {
current.write_char(c)
token_active = true
escaped = false
continue
}
if in_single {
if c == '\'' {
in_single = false
} else {
current.write_char(c)
token_active = true
}
continue
}
if in_double {
if c == '"' {
in_double = false
} else if c == '\\' {
escaped = true
} else {
current.write_char(c)
token_active = true
}
continue
}
if c == '\\' {
escaped = true
continue
}
if c == '\'' {
in_single = true
continue
}
if c == '"' {
in_double = true
continue
}
if is_space_char(c) {
if token_active {
out.push(current.to_string())
current.reset()
token_active = false
}
continue
}
current.write_char(c)
token_active = true
}
if escaped || in_single || in_double {
raise @bit.GitError::ProtocolError("Malformed GIT_SSH_COMMAND")
}
if token_active {
out.push(current.to_string())
}
if out.length() == 0 {
raise @bit.GitError::ProtocolError("Malformed GIT_SSH_COMMAND")
}
out
}
///|
fn command_basename(path : String) -> String {
match path.rev_find("/") {
Some(idx) =>
String::unsafe_substring(path, start=idx + 1, end=path.length())
None => path
}
}
///|
fn is_digits(s : String) -> Bool {
if s.length() == 0 {
return false
}
for c in s {
if c < '0' || c > '9' {
return false
}
}
true
}
///|
fn normalize_ssh_path(path : String) -> String {
if path.has_prefix("/~") {
String::unsafe_substring(path, start=1, end=path.length())
} else {
path
}
}
///|
fn split_bracket_host_port(value : String) -> (String, String?) {
match (value.find(":"), value.rev_find(":")) {
(Some(first), Some(last)) if first == last => {
let host = String::unsafe_substring(value, start=0, end=first)
let port = String::unsafe_substring(
value,
start=first + 1,
end=value.length(),
)
if host.length() > 0 && is_digits(port) {
(host, Some(port))
} else {
(value, None)
}
}
_ => (value, None)
}
}
///|
fn parse_scp_endpoint(raw : String) -> SshEndpoint? {
if raw.has_prefix("[") {
match raw.find("]:") {
Some(idx) => {
let inside = String::unsafe_substring(raw, start=1, end=idx)
let path = String::unsafe_substring(
raw,
start=idx + 2,
end=raw.length(),
)
if inside.length() == 0 || path.length() == 0 || inside.contains("/") {
None
} else {
let (host, port) = split_bracket_host_port(inside)
Some({ host, path: normalize_ssh_path(path), port })
}
}
None => None
}
} else {
match raw.find(":") {
Some(idx) => {
let host = String::unsafe_substring(raw, start=0, end=idx)
let path = String::unsafe_substring(
raw,
start=idx + 1,
end=raw.length(),
)
if host.length() == 0 || path.length() == 0 || host.contains("/") {
None
} else {
Some({ host, path: normalize_ssh_path(path), port: None })
}
}
None => None
}
}
}
///|
fn parse_ssh_authority(authority : String) -> (String, String?) {
if authority.has_prefix("[") {
match authority.find("]") {
Some(end_idx) => {
let host = String::unsafe_substring(authority, start=1, end=end_idx)
let rest = String::unsafe_substring(
authority,
start=end_idx + 1,
end=authority.length(),
)
if rest.has_prefix(":") && rest.length() > 1 {
let port = String::unsafe_substring(rest, start=1, end=rest.length())
if is_digits(port) {
return (host, Some(port))
}
}
(host, None)
}
None => (authority, None)
}
} else {
let mut user_prefix = ""
let mut host_part = authority
match authority.find("@") {
Some(at_idx) => {
user_prefix = String::unsafe_substring(
authority,
start=0,
end=at_idx + 1,
)
host_part = String::unsafe_substring(
authority,
start=at_idx + 1,
end=authority.length(),
)
}
None => ()
}
if host_part.has_prefix("[") {
match host_part.find("]") {
Some(end_idx) => {
let host = String::unsafe_substring(host_part, start=1, end=end_idx)
let rest = String::unsafe_substring(
host_part,
start=end_idx + 1,
end=host_part.length(),
)
if rest.has_prefix(":") && rest.length() > 1 {
let port = String::unsafe_substring(
rest,
start=1,
end=rest.length(),
)
if is_digits(port) {
return (user_prefix + host, Some(port))
}
}
return (user_prefix + host, None)
}
None => ()
}
}
let host_clean = if host_part.has_suffix(":") {
String::unsafe_substring(host_part, start=0, end=host_part.length() - 1)
} else {
host_part
}
match (host_clean.find(":"), host_clean.rev_find(":")) {
(Some(first), Some(last)) if first == last => {
let host = String::unsafe_substring(host_clean, start=0, end=first)
let port = String::unsafe_substring(
host_clean,
start=first + 1,
end=host_clean.length(),
)
if host.length() > 0 && is_digits(port) {
(user_prefix + host, Some(port))
} else {
(user_prefix + host_clean, None)
}
}
_ => (user_prefix + host_clean, None)
}
}
}
///|
fn parse_ssh_url_endpoint(raw : String) -> SshEndpoint? {
if !raw.has_prefix("ssh://") {
return None
}
let rest = String::unsafe_substring(raw, start=6, end=raw.length())
let (authority, path) = match rest.find("/") {
Some(idx) => {
let auth = String::unsafe_substring(rest, start=0, end=idx)
let tail = String::unsafe_substring(
rest,
start=idx + 1,
end=rest.length(),
)
(auth, "/" + tail)
}
None => (rest, "")
}
let (host, port) = parse_ssh_authority(authority)
if host.length() == 0 || path.length() == 0 {
return None
}
Some({ host, path: normalize_ssh_path(path), port })
}
///|
fn resolve_ssh_endpoint(spec : @protocol.RemoteSpec) -> SshEndpoint? {
if spec.raw.has_prefix("ssh://") {
return parse_ssh_url_endpoint(spec.raw)
}
match parse_scp_endpoint(spec.raw) {
Some(endpoint) => Some(endpoint)
None =>
match spec.host {
Some(host) =>
if spec.path.length() > 0 {
Some({ host, path: normalize_ssh_path(spec.path), port: None })
} else {
None
}
None => None
}
}
}
///|
fn detect_named_ssh_variant(base_name : String) -> SshVariant? {
let name = base_name.to_lower()
if name == "ssh" || name == "ssh.exe" {
Some(Ssh)
} else if name == "plink" ||
name == "plink.exe" ||
name == "putty" ||
name == "putty.exe" {
Some(Plink)
} else if name == "tortoiseplink" || name == "tortoiseplink.exe" {
Some(TortoisePlink)
} else if name == "simple" {
Some(Simple)
} else {
None
}
}
///|
fn command_looks_like_script(cmd : String) -> Bool {
if cmd.find("/") is None {
return false
}
let data = @fs.read_file_to_bytes(cmd) catch { _ => return false }
data.length() >= 2 && data[0] == b'#' && data[1] == b'!'
}
///|
fn read_ssh_variant_override() -> String? {
match @sys.get_env_var("GIT_SSH_VARIANT") {
Some(value) => Some(value.to_lower())
None =>
match @sys.get_env_var("GIT_CONFIG_OVERRIDES") {
None => None
Some(overrides) => {
for line_view in overrides.split("\n") {
let line = line_view.to_owned()
if line.length() == 0 {
continue
}
match line.find("=") {
Some(eq_idx) => {
let key = String::unsafe_substring(line, start=0, end=eq_idx).to_lower()
if key == "ssh.variant" {
return Some(
String::unsafe_substring(
line,
start=eq_idx + 1,
end=line.length(),
).to_lower(),
)
}
}
None => ()
}
}
None
}
}
}
}
///|
fn resolve_ssh_variant(
cmd : String,
needs_special_options : Bool,
) -> SshVariant {
let base_name = command_basename(cmd)
let auto_variant = match detect_named_ssh_variant(base_name) {
Some(v) => v
None =>
if needs_special_options && command_looks_like_script(cmd) {
Ssh
} else {
Simple
}
}
match read_ssh_variant_override() {
Some("ssh") => Ssh
Some("plink") | Some("putty") => Plink
Some("tortoiseplink") => TortoisePlink
Some("simple") => Simple
Some("auto") => auto_variant
Some(_) => auto_variant
None => auto_variant
}
}
///|
fn resolve_ssh_command_spec() -> SshCommandSpec raise @bit.GitError {
match @sys.get_env_var("GIT_SSH_COMMAND") {
Some(cmdline) => {
let words = split_shell_words(cmdline)
let cmd = words[0]
let args : Array[String] = []
for i in 1..
match @sys.get_env_var("GIT_SSH") {
Some(cmd) => { cmd, args: [] }
None => { cmd: "ssh", args: [] }
}
}
}
///|
fn read_ssh_ip_option() -> String? {
match @sys.get_env_var("BIT_SSH_IP_VERSION") {
Some("-4") => Some("-4")
Some("-6") => Some("-6")
_ => None
}
}
///|
/// Build the remote command for SSH transport.
/// SSH uses the native interactive git protocol (no --stateless-rpc).
/// The server sends ref advertisement first, then reads wants, then sends pack.
fn build_upload_pack_remote_command(path : String) -> String {
let quoted_path = quote_shell_arg_single(path)
"git-upload-pack " + quoted_path
}
///|
fn resolve_upload_pack_local_command() -> String {
match @sys.get_env_var("SHIM_EXEC_PATH") {
Some(exec_path) =>
if exec_path.length() > 0 {
return exec_path + "/git-upload-pack"
}
None => ()
}
match @sys.get_env_var("GIT_EXEC_PATH") {
Some(exec_path) =>
if exec_path.length() > 0 {
return exec_path + "/git-upload-pack"
}
None => ()
}
"git-upload-pack"
}
///|
fn quote_shell_arg_single(value : String) -> String {
let b = StringBuilder::new()
b.write_char('\'')
for c in value {
if c == '\'' {
b.write_string("'\\''")
} else {
b.write_char(c)
}
}
b.write_char('\'')
b.to_string()
}
///|
fn build_upload_pack_file_spec(
path : String,
advertise_refs : Bool,
prefer_v2 : Bool,
) -> ProcessSpec raise @bit.GitError {
if path.length() == 0 {
raise @bit.GitError::ProtocolError("Remote path is empty")
}
let args : Array[String] = []
args.push("--stateless-rpc")
if advertise_refs {
args.push("--advertise-refs")
}
args.push(path)
let extra_env = if prefer_v2 {
Some({ "GIT_PROTOCOL": "version=2" })
} else {
None
}
{ cmd: resolve_upload_pack_local_command(), args, extra_env }
}
///|
fn build_upload_pack_process_spec(
spec : @protocol.RemoteSpec,
advertise_refs~ : Bool,
prefer_v2~ : Bool,
) -> ProcessSpec raise @bit.GitError {
match spec.kind {
@protocol.RemoteKind::Http =>
raise @bit.GitError::ProtocolError(
"HTTP transport is not supported by process client",
)
@protocol.RemoteKind::File =>
build_upload_pack_file_spec(spec.path, advertise_refs, prefer_v2)
@protocol.RemoteKind::Ssh => {
let endpoint = match resolve_ssh_endpoint(spec) {
Some(v) => v
None => raise @bit.GitError::ProtocolError("SSH remote missing host")
}
let ip_option = read_ssh_ip_option()
let needs_special_options = endpoint.port is Some(_) ||
ip_option is Some(_)
let ssh = resolve_ssh_command_spec()
let variant = resolve_ssh_variant(ssh.cmd, needs_special_options)
let args : Array[String] = []
for arg in ssh.args {
args.push(arg)
}
if variant == TortoisePlink {
args.push("-batch")
}
match ip_option {
Some(flag) =>
if variant == Simple {
raise @bit.GitError::ProtocolError(
"ssh variant 'simple' does not support setting address family",
)
} else {
args.push(flag)
}
None => ()
}
match endpoint.port {
Some(port) =>
match variant {
Ssh => {
args.push("-p")
args.push(port)
}
Plink | TortoisePlink => {
args.push("-P")
args.push(port)
}
Simple =>
raise @bit.GitError::ProtocolError(
"ssh variant 'simple' does not support setting port",
)
}
None => ()
}
args.push(endpoint.host)
args.push(build_upload_pack_remote_command(endpoint.path))
let extra_env = if prefer_v2 {
Some({ "GIT_PROTOCOL": "version=2" })
} else {
None
}
{ cmd: ssh.cmd, args, extra_env }
}
}
}
///|
fn bytes_to_string_lossy(data : Bytes) -> String {
@utf8.decode_lossy(data[:])
}
///|
fn cleanup_temp_files(paths : Array[String]) -> Unit {
for path in paths {
@fs.remove_file(path) catch {
_ => ()
}
}
}
///|
fn resolve_process_cwd() -> String? {
match @sys.get_env_var("GIT_SHIM_CWD") {
Some(dir) => Some(dir)
None => @sys.get_env_var("GIT_SHIM_PWD")
}
}
///|
fn maybe_trace_packfile(pack : Bytes) -> Unit {
match @sys.get_env_var("GIT_TRACE_PACKFILE") {
Some(path) => ignore(@fs.write_bytes_to_file(path, pack) catch { _ => () })
None => ()
}
}
///|
async fn run_process_with_files(
spec : ProcessSpec,
stdin_data : Bytes?,
) -> (Int, Bytes, Bytes) raise @bit.GitError {
let nonce = @async.now()
let base = ".moonbit-git-upload-pack-\{nonce}"
let stdout_path = "\{base}.out"
let stderr_path = "\{base}.err"
let stdin_path = "\{base}.in"
let cleanup_paths = [stdout_path, stderr_path, stdin_path]
let stdout = @process.redirect_to_file(
stdout_path,
create_mode=CreateOrTruncate,
permission=420,
) catch {
e => raise @bit.GitError::IoError("failed to open stdout file: \{e}")
}
let stderr = @process.redirect_to_file(
stderr_path,
create_mode=CreateOrTruncate,
permission=420,
) catch {
e => raise @bit.GitError::IoError("failed to open stderr file: \{e}")
}
let stdin_bytes = match stdin_data {
Some(data) => data
None => Default::default()
}
@fs.write_bytes_to_file(stdin_path, stdin_bytes) catch {
e =>
raise @bit.GitError::IoError(
"failed to write stdin temp file: \{fs_error_message(e)}",
)
}
let stdin = @process.redirect_from_file(stdin_path) catch {
e => raise @bit.GitError::IoError("process stdin failed: \{e}")
}
let run_cwd = resolve_process_cwd()
let code = match (spec.extra_env, run_cwd) {
(None, None) =>
@process.run(
spec.cmd,
spec.args,
inherit_env=true,
stdin~,
stdout~,
stderr~,
) catch {
e => {
cleanup_temp_files(cleanup_paths)
raise @bit.GitError::IoError("process failed: \{e}")
}
}
(Some(env), None) =>
@process.run(
spec.cmd,
spec.args,
extra_env=env,
inherit_env=true,
stdin~,
stdout~,
stderr~,
) catch {
e => {
cleanup_temp_files(cleanup_paths)
raise @bit.GitError::IoError("process failed: \{e}")
}
}
(None, Some(cwd)) =>
@process.run(
spec.cmd,
spec.args,
inherit_env=true,
stdin~,
stdout~,
stderr~,
cwd~,
) catch {
e => {
cleanup_temp_files(cleanup_paths)
raise @bit.GitError::IoError("process failed: \{e}")
}
}
(Some(env), Some(cwd)) =>
@process.run(
spec.cmd,
spec.args,
extra_env=env,
inherit_env=true,
stdin~,
stdout~,
stderr~,
cwd~,
) catch {
e => {
cleanup_temp_files(cleanup_paths)
raise @bit.GitError::IoError("process failed: \{e}")
}
}
}
let out = @fs.read_file_to_bytes(stdout_path) catch {
e => {
cleanup_temp_files(cleanup_paths)
raise @bit.GitError::IoError(
"failed to read stdout: \{fs_error_message(e)}",
)
}
}
let err = @fs.read_file_to_bytes(stderr_path) catch {
e => {
cleanup_temp_files(cleanup_paths)
raise @bit.GitError::IoError(
"failed to read stderr: \{fs_error_message(e)}",
)
}
}
cleanup_temp_files(cleanup_paths)
(code, out, err)
}
///|
async fn run_upload_pack_process(
spec : ProcessSpec,
stdin_data : Bytes?,
) -> Bytes raise @bit.GitError {
let (code, stdout, stderr) = run_process_with_files(spec, stdin_data)
if code != 0 {
let args_joined = spec.args.join(" ")
let cmdline = "\{spec.cmd} \{args_joined}"
let err = bytes_to_string_lossy(stderr)
let detail = if err.length() > 0 { ": \{err}" } else { "" }
raise @bit.GitError::IoError(
"git-upload-pack failed (exit=\{code}): \{cmdline}\{detail}",
)
}
stdout
}
///|
fn build_upload_pack_info_refs_process_spec(
remote : String,
prefer_v2 : Bool,
) -> ProcessSpec raise @bit.GitError {
let remote_spec = @protocol.parse_remote(remote)
build_upload_pack_process_spec(remote_spec, advertise_refs=true, prefer_v2~)
}
///|
fn build_upload_pack_request_process_spec(
remote : String,
prefer_v2 : Bool,
) -> ProcessSpec raise @bit.GitError {
let remote_spec = @protocol.parse_remote(remote)
build_upload_pack_process_spec(remote_spec, advertise_refs=false, prefer_v2~)
}
///|
/// Run SSH git-upload-pack and read only the ref advertisement.
///
/// SSH git-upload-pack exits with code 128 when stdin is closed without
/// sending wants. File-based stdin (empty file) triggers this immediately,
/// so we use pipe-based I/O: spawn → read stdout → close stdin → ignore
/// non-zero exit as long as we got data.
async fn run_ssh_upload_pack_info_refs(
spec : ProcessSpec,
) -> Bytes raise @bit.GitError {
let (stdin_input, stdin_writer) = @process.write_to_process() catch {
err => raise @bit.GitError::IoError("SSH stdin pipe failed: \{err}")
}
let (stdout_reader, stdout_output) = @process.read_from_process() catch {
err => raise @bit.GitError::IoError("SSH stdout pipe failed: \{err}")
}
let nonce = @async.now()
let stderr_path = ".moonbit-ssh-info-refs-\{nonce}.err"
let stderr = @process.redirect_to_file(
stderr_path,
create_mode=CreateOrTruncate,
permission=420,
) catch {
err => raise @bit.GitError::IoError("SSH stderr file failed: \{err}")
}
let run_cwd = resolve_process_cwd()
let pid = match (spec.extra_env, run_cwd) {
(None, None) =>
@process.spawn_orphan(
spec.cmd,
spec.args,
inherit_env=true,
stdin=stdin_input,
stdout=stdout_output,
stderr~,
) catch {
e => {
cleanup_temp_files([stderr_path])
raise @bit.GitError::IoError("SSH spawn failed: \{e}")
}
}
(Some(env), None) =>
@process.spawn_orphan(
spec.cmd,
spec.args,
extra_env=env,
inherit_env=true,
stdin=stdin_input,
stdout=stdout_output,
stderr~,
) catch {
e => {
cleanup_temp_files([stderr_path])
raise @bit.GitError::IoError("SSH spawn failed: \{e}")
}
}
(None, Some(cwd)) =>
@process.spawn_orphan(
spec.cmd,
spec.args,
inherit_env=true,
stdin=stdin_input,
stdout=stdout_output,
stderr~,
cwd~,
) catch {
e => {
cleanup_temp_files([stderr_path])
raise @bit.GitError::IoError("SSH spawn failed: \{e}")
}
}
(Some(env), Some(cwd)) =>
@process.spawn_orphan(
spec.cmd,
spec.args,
extra_env=env,
inherit_env=true,
stdin=stdin_input,
stdout=stdout_output,
stderr~,
cwd~,
) catch {
e => {
cleanup_temp_files([stderr_path])
raise @bit.GitError::IoError("SSH spawn failed: \{e}")
}
}
}
// Close stdin first — git-upload-pack sends refs then waits for input.
// Closing stdin causes it to exit (code 128), which unblocks read_all().
// Without this, we deadlock: read_all waits for EOF on stdout while
// git-upload-pack waits for data on stdin.
stdin_writer.close()
// Now read all ref advertisement from stdout
let response = stdout_reader.read_all() catch {
e => {
cleanup_temp_files([stderr_path])
raise @bit.GitError::IoError("SSH stdout read failed: \{e}")
}
}
stdout_reader.close()
let _code : Int = @process.wait_pid(pid) catch { _ => -1 }
cleanup_temp_files([stderr_path])
if response.binary().length() == 0 {
raise @bit.GitError::ProtocolError("SSH ref advertisement was empty")
}
response.binary()
}
///|
pub async fn upload_pack_info_refs_process(
remote : String,
prefer_v2 : Bool,
) -> Bytes raise @bit.GitError {
let remote_spec = @protocol.parse_remote(remote)
if remote_spec.kind == @protocol.RemoteKind::Ssh {
let spec = build_upload_pack_process_spec(
remote_spec,
advertise_refs=true,
prefer_v2~,
)
return run_ssh_upload_pack_info_refs(spec)
}
let proc = build_upload_pack_info_refs_process_spec(remote, prefer_v2)
run_upload_pack_process(proc, None)
}
///|
pub async fn upload_pack_request_process(
remote : String,
body : Bytes,
prefer_v2 : Bool,
) -> Bytes raise @bit.GitError {
let remote_spec = @protocol.parse_remote(remote)
if remote_spec.kind == @protocol.RemoteKind::Ssh {
let spec = build_upload_pack_process_spec(
remote_spec,
advertise_refs=false,
prefer_v2~,
)
return run_ssh_upload_pack_interactive(spec, body)
}
let proc = build_upload_pack_request_process_spec(remote, prefer_v2)
run_upload_pack_process(proc, Some(body))
}
///|
/// Parse 4-byte hex pkt-line length from raw bytes.
fn parse_pkt_hex_bytes(header : Bytes) -> Int {
let mut result = 0
for i in 0..<4 {
let b = header[i].to_int()
let digit = if b >= 0x30 && b <= 0x39 {
b - 0x30
} else if b >= 0x61 && b <= 0x66 {
b - 0x61 + 10
} else if b >= 0x41 && b <= 0x46 {
b - 0x41 + 10
} else {
0
}
result = result * 16 + digit
}
result
}
///|
/// Skip pkt-lines from an SSH process stdout until flush (0000).
/// Discards the ref advertisement that the server sends before reading wants.
async fn skip_ssh_ref_advertisement(
reader : @process.ReadFromProcess,
) -> Unit raise @bit.GitError {
while true {
let header = reader.read_exactly(4) catch { _ => break }
let len = parse_pkt_hex_bytes(header)
if len == 0 {
break // flush packet
}
if len <= 4 {
continue // delimiter or empty
}
let _discard = reader.read_exactly(len - 4) catch {
err =>
raise @bit.GitError::ProtocolError("SSH pkt-line read failed: \{err}")
}
}
}
///|
/// Run SSH git-upload-pack with pipe-based interactive protocol.
///
/// SSH interactive protocol requires that the client reads the ref
/// advertisement BEFORE sending wants. File-based stdin doesn't work
/// because SSH sends EOF immediately, causing the server to exit before
/// processing wants.
///
/// This function:
/// 1. Spawns SSH process with pipe-based stdin/stdout
/// 2. Reads and discards the ref advertisement from stdout
/// 3. Writes the fetch request (wants) to stdin
/// 4. Closes stdin (signals EOF to server)
/// 5. Reads the pack response (NAK + sideband pack data) from stdout
async fn run_ssh_upload_pack_interactive(
spec : ProcessSpec,
wants_data : Bytes,
) -> Bytes raise @bit.GitError {
let (stdin_input, stdin_writer) = @process.write_to_process() catch {
err => raise @bit.GitError::IoError("SSH stdin pipe failed: \{err}")
}
let (stdout_reader, stdout_output) = @process.read_from_process() catch {
err => raise @bit.GitError::IoError("SSH stdout pipe failed: \{err}")
}
let nonce = @async.now()
let stderr_path = ".moonbit-ssh-upload-pack-\{nonce}.err"
let stderr = @process.redirect_to_file(
stderr_path,
create_mode=CreateOrTruncate,
permission=420,
) catch {
err => raise @bit.GitError::IoError("SSH stderr file failed: \{err}")
}
let run_cwd = resolve_process_cwd()
let pid = match (spec.extra_env, run_cwd) {
(None, None) =>
@process.spawn_orphan(
spec.cmd,
spec.args,
inherit_env=true,
stdin=stdin_input,
stdout=stdout_output,
stderr~,
) catch {
e => {
cleanup_temp_files([stderr_path])
raise @bit.GitError::IoError("SSH spawn failed: \{e}")
}
}
(Some(env), None) =>
@process.spawn_orphan(
spec.cmd,
spec.args,
extra_env=env,
inherit_env=true,
stdin=stdin_input,
stdout=stdout_output,
stderr~,
) catch {
e => {
cleanup_temp_files([stderr_path])
raise @bit.GitError::IoError("SSH spawn failed: \{e}")
}
}
(None, Some(cwd)) =>
@process.spawn_orphan(
spec.cmd,
spec.args,
inherit_env=true,
stdin=stdin_input,
stdout=stdout_output,
stderr~,
cwd~,
) catch {
e => {
cleanup_temp_files([stderr_path])
raise @bit.GitError::IoError("SSH spawn failed: \{e}")
}
}
(Some(env), Some(cwd)) =>
@process.spawn_orphan(
spec.cmd,
spec.args,
extra_env=env,
inherit_env=true,
stdin=stdin_input,
stdout=stdout_output,
stderr~,
cwd~,
) catch {
e => {
cleanup_temp_files([stderr_path])
raise @bit.GitError::IoError("SSH spawn failed: \{e}")
}
}
}
// Step 1: Read and discard ref advertisement (pkt-lines until flush)
skip_ssh_ref_advertisement(stdout_reader)
// Step 2: Write fetch request (wants) to stdin, then close to signal EOF
stdin_writer.write(wants_data) catch {
e => {
cleanup_temp_files([stderr_path])
raise @bit.GitError::IoError("SSH stdin write failed: \{e}")
}
}
stdin_writer.close()
// Step 3: Read remaining response (NAK + sideband pack data)
let response = stdout_reader.read_all() catch {
e => {
cleanup_temp_files([stderr_path])
raise @bit.GitError::IoError("SSH stdout read failed: \{e}")
}
}
stdout_reader.close()
// Step 4: Wait for SSH process to exit
let code = @process.wait_pid(pid) catch {
e => {
cleanup_temp_files([stderr_path])
raise @bit.GitError::IoError("SSH wait failed: \{e}")
}
}
if code != 0 {
let err_bytes = @fs.read_file_to_bytes(stderr_path) catch {
_ => Default::default()
}
let err_text = bytes_to_string_lossy(err_bytes)
cleanup_temp_files([stderr_path])
let args_joined = spec.args.join(" ")
raise @bit.GitError::IoError(
"git-upload-pack failed (exit=\{code}): \{spec.cmd} \{args_joined}: \{err_text}",
)
}
cleanup_temp_files([stderr_path])
response.binary()
}
///|
pub async fn discover_upload_refs_process(
remote : String,
prefer_v2 : Bool,
) -> (
Array[(@bit.ObjectId, String)],
Array[String],
@protocol.ProtocolVersion,
Map[String, String],
) raise @bit.GitError {
let adv = upload_pack_info_refs_process(remote, prefer_v2)
let version = @protocol.detect_protocol_version(adv)
if version == @protocol.ProtocolVersion::V2 {
let caps = @protocol.parse_v2_advertised_caps(adv)
let req = @protocol.build_ls_refs_request("git/moonbit", [
"refs/heads/", "refs/tags/", "HEAD",
])
let resp = upload_pack_request_process(remote, req, true)
let parsed = @protocol.parse_ls_refs_response(resp)
return (parsed.refs, caps, version, parsed.symrefs)
}
let refs = @protocol.parse_refs(adv)
let caps = @protocol.parse_v0_advertised_caps(adv)
let symrefs = @protocol.parse_symrefs_from_capabilities(caps)
(refs, caps, @protocol.ProtocolVersion::V0, symrefs)
}
///|
pub async fn fetch_pack_process_result(
remote : String,
wants : Array[@bit.ObjectId],
prefer_v2 : Bool,
depth? : Int = 0,
filter? : @protocol.FilterSpec = @protocol.FilterSpec::NoFilter,
haves? : Array[@bit.ObjectId] = [],
shallow? : Array[@bit.ObjectId] = [],
) -> @protocol.FetchPackResult raise @bit.GitError {
let (refs, caps, version, _symrefs) = discover_upload_refs_process(
remote, prefer_v2,
)
let wants = if wants.length() == 0 {
let ids : Array[@bit.ObjectId] = []
for item in refs {
let (id, _) = item
ids.push(id)
}
ids
} else {
wants
}
if version == @protocol.ProtocolVersion::V2 {
let req = @protocol.build_fetch_request_v2(
"git/moonbit",
wants,
depth,
filter~,
haves~,
shallow~,
)
let resp = upload_pack_request_process(remote, req, true)
let result = @protocol.parse_fetch_response_v2(resp)
maybe_trace_packfile(result.pack)
return result
}
let req = @protocol.build_fetch_request_v0(
"git/moonbit",
wants,
caps,
depth~,
filter~,
haves~,
shallow~,
)
let resp = upload_pack_request_process(remote, req, false)
let result = @protocol.parse_fetch_response_v0(resp)
maybe_trace_packfile(result.pack)
result
}
///|
pub async fn fetch_pack_process(
remote : String,
wants : Array[@bit.ObjectId],
prefer_v2 : Bool,
depth? : Int = 0,
filter? : @protocol.FilterSpec = @protocol.FilterSpec::NoFilter,
haves? : Array[@bit.ObjectId] = [],
shallow? : Array[@bit.ObjectId] = [],
) -> Bytes raise @bit.GitError {
fetch_pack_process_result(
remote,
wants,
prefer_v2,
depth~,
filter~,
haves~,
shallow~,
).pack
}
///|
pub async fn fetch_objects_process(
remote : String,
wants : Array[@bit.ObjectId],
prefer_v2 : Bool,
depth? : Int = 0,
filter? : @protocol.FilterSpec = @protocol.FilterSpec::NoFilter,
haves? : Array[@bit.ObjectId] = [],
shallow? : Array[@bit.ObjectId] = [],
) -> Array[@bit.PackObject] raise @bit.GitError {
let pack = fetch_pack_process(
remote,
wants,
prefer_v2,
depth~,
filter~,
haves~,
shallow~,
)
@pack.parse_packfile(pack)
}
///|
pub async fn clone_process(
remote : String,
prefer_v2 : Bool,
depth? : Int = 0,
filter? : @protocol.FilterSpec = @protocol.FilterSpec::NoFilter,
) -> (Array[(@bit.ObjectId, String)], Array[@bit.PackObject]) raise @bit.GitError {
let (refs, _caps, _version, symrefs) = discover_upload_refs_process(
remote, prefer_v2,
)
let wants = @protocol.select_default_wants(refs, symrefs)
let pack = fetch_pack_process(remote, wants, prefer_v2, depth~, filter~)
let objects = @pack.parse_packfile(pack)
(refs, objects)
}
///|
/// Clone and checkout into filesystem under `root`.
pub async fn clone_process_to_fs(
remote : String,
prefer_v2 : Bool,
fs : &@bit.FileSystem,
root : String,
rfs : &@bit.RepoFileSystem,
depth? : Int = 0,
filter? : @protocol.FilterSpec = @protocol.FilterSpec::NoFilter,
no_checkout? : Bool = false,
) -> Array[(@bit.ObjectId, String)] raise @bit.GitError {
let (refs, _caps, _version, symrefs) = discover_upload_refs_process(
remote, prefer_v2,
)
let wants = @protocol.select_default_wants(refs, symrefs)
let default_ref = @protocol.select_default_ref(refs, symrefs)
match default_ref {
None => return refs
Some(_) => ()
}
if wants.length() == 0 {
return refs
}
let pack = fetch_pack_process(remote, wants, prefer_v2, depth~, filter~)
let objects = @pack.parse_packfile(pack)
let git_dir = @bit.join_path(root, ".git")
let pack_id = read_pack_trailer_id(pack)
fs.mkdir_p(@bit.join_path(git_dir, "objects/pack"))
@pack.write_packfile_with_index(fs, git_dir, pack, objects)
let effective_no_checkout = no_checkout || filter.is_partial()
if filter.is_partial() {
write_promisor_remote_file(fs, git_dir, remote)
write_pack_promisor_marker(fs, git_dir, pack_id, refs, default_ref)
}
let store = @bit.ObjectStore::from_pack(objects)
match default_ref {
None => ()
Some((refname, commit_id)) =>
if filter.is_partial() {
write_partial_clone_metadata(
fs, root, refname, commit_id, remote, filter,
)
} else if effective_no_checkout {
@bit.write_git_metadata(
store, commit_id, refname, remote, fs, root, rfs,
)
} else {
@bit.materialize_clone_to_fs(
store, commit_id, refname, remote, fs, root, rfs,
)
}
}
if depth > 0 {
let shallow_ids : Array[String] = []
let seen : Map[String, Bool] = Map([])
for id in wants {
let hex = id.to_hex()
if seen.contains(hex) {
continue
}
seen[hex] = true
shallow_ids.push(hex)
}
if shallow_ids.length() > 0 {
fs.write_string(
@bit.join_path(git_dir, "shallow"),
shallow_ids.join("\n") + "\n",
)
}
}
refs
}
///|
fn read_pack_trailer_id(pack : Bytes) -> @bit.ObjectId raise @bit.GitError {
if pack.length() < 20 {
raise @bit.GitError::PackfileError("Packfile too short")
}
let trailer_start = pack.length() - 20
let bytes = FixedArray::makei(20, i => pack[trailer_start + i])
@bit.ObjectId::new(bytes)
}
///|
fn write_promisor_remote_file(
fs : &@bit.FileSystem,
git_dir : String,
remote : String,
) -> Unit raise @bit.GitError {
let info_dir = @bit.join_path(git_dir, "objects/info")
fs.mkdir_p(info_dir)
fs.write_string(@bit.join_path(info_dir, "promisor"), remote + "\n")
}
///|
fn write_pack_promisor_marker(
fs : &@bit.FileSystem,
git_dir : String,
pack_id : @bit.ObjectId,
refs : Array[(@bit.ObjectId, String)],
default_ref : (String, @bit.ObjectId)?,
) -> Unit raise @bit.GitError {
let pack_dir = @bit.join_path(git_dir, "objects/pack")
let marker_lines : Array[String] = []
let seen : Map[String, Bool] = Map([])
for item in refs {
let (id, name) = item
let line = id.to_hex() + " " + name
if seen.contains(line) {
continue
}
seen[line] = true
marker_lines.push(line)
}
match default_ref {
Some((_, head_id)) => {
let head_line = head_id.to_hex() + " HEAD"
if !seen.contains(head_line) {
seen[head_line] = true
marker_lines.push(head_line)
}
}
None => ()
}
if marker_lines.length() == 0 {
return ()
}
let marker_content = marker_lines.join("\n") + "\n"
let base = "pack-\{pack_id.to_hex()}"
let marker_path = @bit.join_path(pack_dir, base + ".promisor")
fs.write_string(marker_path, marker_content)
}
///|
fn write_partial_clone_metadata(
fs : &@bit.FileSystem,
root : String,
refname : String,
commit_id : @bit.ObjectId,
remote : String,
filter : @protocol.FilterSpec,
) -> Unit raise @bit.GitError {
let git_dir = @bit.join_path(root, ".git")
fs.mkdir_p(@bit.join_path(git_dir, "objects/info"))
fs.mkdir_p(@bit.join_path(git_dir, "objects/pack"))
fs.mkdir_p(@bit.join_path(git_dir, "refs/heads"))
fs.mkdir_p(@bit.join_path(git_dir, "refs/tags"))
if refname == "HEAD" {
// Detached HEAD: write the commit SHA directly
fs.write_string(@bit.join_path(git_dir, "HEAD"), commit_id.to_hex() + "\n")
} else {
fs.write_string(@bit.join_path(git_dir, "HEAD"), "ref: \{refname}\n")
let ref_path = @bit.join_path(git_dir, refname)
let ref_dir = match ref_path.rev_find("/") {
Some(idx) => String::unsafe_substring(ref_path, start=0, end=idx)
None => git_dir
}
fs.mkdir_p(ref_dir)
fs.write_string(ref_path, commit_id.to_hex() + "\n")
}
let filter_value = filter.to_string()
let config_path = @bit.join_path(git_dir, "config")
let config =
$|[core]
$| repositoryformatversion = 1
$| filemode = true
$| bare = false
$| logallrefupdates = true
$|[extensions]
$| partialclone = origin
$|[remote "origin"]
$| url = \{remote}
$| fetch = +refs/heads/*:refs/remotes/origin/*
$| promisor = true
$| partialclonefilter = \{filter_value}
$|
fs.write_string(config_path, config)
let packed_refs_path = @bit.join_path(git_dir, "packed-refs")
fs.write_string(packed_refs_path, "# pack-refs with: peeled fully-peeled\n")
}