use std::ffi::OsString;
use std::path::{Path, PathBuf};
use strop_core::worker::CancelToken;
use strop_remote::RemoteEndpoint;
use crate::diff::FileDiff;
use crate::exec::{GitExec, GitExecError, GitRun};
use crate::repo::{gutter_from_contents, hunks_from_buffers};
use crate::ssh::parse_effective_hostname;
use crate::target::RepoTarget;
use crate::{GitContext, Hunk};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RemoteGitError {
Exec(String),
Exit {
op: &'static str,
code: i32,
stderr: String,
},
Parse(&'static str),
Utf8(&'static str),
Truncated { op: &'static str, dropped: u64 },
}
impl std::fmt::Display for RemoteGitError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Exec(message) => write!(f, "{message}"),
Self::Exit { op, code, stderr } => {
write!(f, "{op}: git exited {code}: {}", stderr.trim())
}
Self::Parse(what) => write!(f, "{what}: unparseable git output"),
Self::Utf8(what) => write!(f, "{what} is not UTF-8"),
Self::Truncated { op, dropped } => {
write!(f, "{op}: remote output truncated ({dropped} bytes dropped)")
}
}
}
}
impl std::error::Error for RemoteGitError {}
impl From<GitExecError> for RemoteGitError {
fn from(error: GitExecError) -> Self {
Self::Exec(error.to_string())
}
}
fn records(
exec: &GitExec,
op: &'static str,
argv: &[OsString],
cancel: &CancelToken,
) -> Result<Vec<u8>, RemoteGitError> {
let run = exec.run(argv, cancel)?;
exit_or_bytes(op, &run)
}
fn exit_or_bytes(op: &'static str, run: &GitRun) -> Result<Vec<u8>, RemoteGitError> {
if run.stdout_dropped > 0 {
return Err(RemoteGitError::Truncated {
op,
dropped: run.stdout_dropped,
});
}
match run.code {
Some(0) => Ok(run.stdout.clone()),
Some(code) => Err(RemoteGitError::Exit {
op,
code,
stderr: String::from_utf8_lossy(&run.stderr).trim_end().to_string(),
}),
None => Err(RemoteGitError::Exit {
op,
code: -1,
stderr: String::from_utf8_lossy(&run.stderr).trim_end().to_string(),
}),
}
}
pub fn discover(
endpoint: &RemoteEndpoint,
from: &Path,
cancel: &CancelToken,
) -> Result<Option<PathBuf>, RemoteGitError> {
let exec = GitExec::Remote {
endpoint: endpoint.clone(),
workdir: from,
};
let run = exec.run(&["rev-parse".into(), "--show-toplevel".into()], cancel)?;
if run.success {
let stdout = exit_or_bytes("rev-parse --show-toplevel", &run)?;
return parse_toplevel(&stdout).map(Some);
}
let stderr = String::from_utf8_lossy(&run.stderr);
if run.code == Some(128) && stderr.contains("not a git repository") {
return Ok(None);
}
Err(RemoteGitError::Exit {
op: "rev-parse --show-toplevel",
code: run.code.unwrap_or(-1),
stderr: stderr.trim_end().to_string(),
})
}
pub fn context(
endpoint: &RemoteEndpoint,
workdir: &Path,
cancel: &CancelToken,
) -> Result<GitContext, RemoteGitError> {
let exec = GitExec::Remote {
endpoint: endpoint.clone(),
workdir,
};
let head_sha = match exec.run(
&[
"rev-parse".into(),
"--verify".into(),
"--quiet".into(),
"HEAD".into(),
],
cancel,
)? {
run if run.success => Some(parse_sha(&run.stdout, "rev-parse HEAD")?),
run if run.code == Some(1) => None,
run => {
return Err(RemoteGitError::Exit {
op: "rev-parse HEAD",
code: run.code.unwrap_or(-1),
stderr: String::from_utf8_lossy(&run.stderr).trim_end().to_string(),
})
}
};
let head_branch = match exec.run(
&["symbolic-ref".into(), "--short".into(), "HEAD".into()],
cancel,
)? {
run if run.success => {
let name = String::from_utf8_lossy(&run.stdout).trim().to_string();
(!name.is_empty()).then_some(name)
}
run if run.code == Some(128) => None,
run => {
return Err(RemoteGitError::Exit {
op: "symbolic-ref HEAD",
code: run.code.unwrap_or(-1),
stderr: String::from_utf8_lossy(&run.stderr).trim_end().to_string(),
})
}
};
let config_run = exec.run(
&[
"config".into(),
"-z".into(),
"--get-regexp".into(),
"^remote\\.[^.]+\\.url$".into(),
],
cancel,
)?;
if config_run.code != Some(0) && config_run.code != Some(1) {
return Err(RemoteGitError::Exit {
op: "config --get-regexp remote.*.url",
code: config_run.code.unwrap_or(-1),
stderr: String::from_utf8_lossy(&config_run.stderr)
.trim_end()
.to_string(),
});
}
let stdout = exit_or_bytes("config --get-regexp remote.*.url", &config_run)?;
let remotes = parse_remote_config(&stdout)?;
Ok(GitContext {
repo: RepoTarget::Remote {
endpoint: endpoint.clone(),
workdir: workdir.to_path_buf(),
},
head_sha,
head_branch,
remotes,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileContents {
pub head: Option<Vec<u8>>,
pub index: Option<Vec<u8>>,
}
pub fn gutter(
endpoint: &RemoteEndpoint,
workdir: &Path,
head_sha: Option<&str>,
rel: &Path,
text: &str,
cancel: &CancelToken,
) -> Result<(Vec<Hunk>, Vec<Hunk>, bool), RemoteGitError> {
let contents = file_contents(endpoint, workdir, head_sha, rel, cancel)?;
let head = contents
.head
.as_deref()
.map(|bytes| std::str::from_utf8(bytes).map_err(|_| RemoteGitError::Utf8("HEAD blob")))
.transpose()?;
let index = contents
.index
.as_deref()
.map(|bytes| std::str::from_utf8(bytes).map_err(|_| RemoteGitError::Utf8("index blob")))
.transpose()?;
gutter_from_contents(head, index, text, rel)
.map_err(|error| RemoteGitError::Exec(error.to_string()))
}
pub fn file_contents(
endpoint: &RemoteEndpoint,
workdir: &Path,
head_sha: Option<&str>,
rel: &Path,
cancel: &CancelToken,
) -> Result<FileContents, RemoteGitError> {
let exec = GitExec::Remote {
endpoint: endpoint.clone(),
workdir,
};
let head = match head_sha {
Some(sha) => {
let stdout = records(
&exec,
"ls-tree HEAD",
&[
"ls-tree".into(),
"-z".into(),
sha.into(),
"--".into(),
rel.as_os_str().into(),
],
cancel,
)?;
match parse_tree_entry(&stdout)? {
Some((oid, path)) if path == rel => {
Some(blob_bytes(&exec, &oid, "HEAD blob", cancel)?)
}
Some((_, _)) => return Err(RemoteGitError::Parse("ls-tree HEAD")),
None => None,
}
}
None => None,
};
let stdout = records(
&exec,
"ls-files --stage",
&[
"ls-files".into(),
"-z".into(),
"--stage".into(),
"--".into(),
rel.as_os_str().into(),
],
cancel,
)?;
let index = match parse_index_entry(&stdout)? {
Some((oid, path)) if path == rel => Some(blob_bytes(&exec, &oid, "index blob", cancel)?),
Some((_, _)) => return Err(RemoteGitError::Parse("ls-files --stage")),
None => None,
};
Ok(FileContents { head, index })
}
fn blob_bytes(
exec: &GitExec,
oid: &str,
what: &'static str,
cancel: &CancelToken,
) -> Result<Vec<u8>, RemoteGitError> {
records(
exec,
what,
&["cat-file".into(), "-p".into(), oid.into()],
cancel,
)
}
pub fn commit_file_diff(
endpoint: &RemoteEndpoint,
workdir: &Path,
sha: &str,
rel: &Path,
cancel: &CancelToken,
) -> Result<FileDiff, RemoteGitError> {
let exec = GitExec::Remote {
endpoint: endpoint.clone(),
workdir,
};
let stdout = records(
&exec,
"rev-list --parents",
&[
"rev-list".into(),
"--parents".into(),
"-n".into(),
"1".into(),
sha.into(),
],
cancel,
)?;
let (commit, parent) = parse_commit_parents(&stdout)?;
if commit != sha {
return Err(RemoteGitError::Parse("rev-list --parents"));
}
let parent_blob = match parent.as_deref() {
Some(parent) => {
let stdout = records(
&exec,
"ls-tree parent",
&[
"ls-tree".into(),
"-z".into(),
parent.into(),
"--".into(),
rel.as_os_str().into(),
],
cancel,
)?;
match parse_tree_entry(&stdout)? {
Some((oid, path)) if path == rel => {
Some(blob_bytes(&exec, &oid, "parent blob", cancel)?)
}
Some((_, _)) => return Err(RemoteGitError::Parse("ls-tree parent")),
None => None,
}
}
None => None,
};
let stdout = records(
&exec,
"ls-tree commit",
&[
"ls-tree".into(),
"-z".into(),
commit.clone().into(),
"--".into(),
rel.as_os_str().into(),
],
cancel,
)?;
let commit_blob = match parse_tree_entry(&stdout)? {
Some((oid, path)) if path == rel => blob_bytes(&exec, &oid, "commit blob", cancel)?,
Some((_, _)) => return Err(RemoteGitError::Parse("ls-tree commit")),
None => return Err(RemoteGitError::Exec("no diff for path".into())),
};
let hunks = hunks_from_buffers(parent_blob.as_deref(), &commit_blob, rel)
.map_err(|error| RemoteGitError::Exec(error.to_string()))?;
Ok(FileDiff::from_hunks(rel.to_path_buf(), hunks))
}
pub fn effective_host(
endpoint: &RemoteEndpoint,
workdir: &Path,
remote: &crate::permalink::AliasRemote,
cancel: &CancelToken,
) -> Result<String, crate::ssh::EffectiveHostError> {
use crate::ssh::EffectiveHostError;
let host = remote.host();
if !crate::permalink::is_safe_host(host) {
return Err(EffectiveHostError::InvalidHost);
}
let mut args = vec!["-G".into()];
if let Some(user) = &remote.user {
args.extend(["-l".into(), user.into()]);
}
if let Some(port) = remote.port {
args.extend(["-p".into(), port.to_string().into()]);
}
args.push(host.into());
let command = strop_remote::RemoteCommand::new("ssh", args, workdir)
.map_err(|error| EffectiveHostError::Spawn(error.to_string()))?;
let run = strop_remote::run(endpoint, &command, cancel)
.map_err(|error| EffectiveHostError::Spawn(error.to_string()))?;
if !run.status.success() {
return Err(EffectiveHostError::Failed(
String::from_utf8_lossy(&run.stderr).trim().to_string(),
));
}
if run.stdout_dropped > 0 {
return Err(EffectiveHostError::Failed(
"output truncated before a hostname line".into(),
));
}
let stdout = String::from_utf8_lossy(&run.stdout);
let hostname = parse_effective_hostname(&stdout).ok_or(EffectiveHostError::NoHostname)?;
if hostname == host && !hostname.contains('.') {
return Err(EffectiveHostError::Unresolved);
}
Ok(hostname)
}
fn parse_toplevel(bytes: &[u8]) -> Result<PathBuf, RemoteGitError> {
let trimmed = bytes.strip_suffix(b"\n").unwrap_or(bytes);
if trimmed.is_empty() || trimmed.first() != Some(&b'/') {
return Err(RemoteGitError::Parse("rev-parse --show-toplevel"));
}
Ok(bytes_to_path(trimmed))
}
fn parse_sha(bytes: &[u8], what: &'static str) -> Result<String, RemoteGitError> {
let text = String::from_utf8_lossy(bytes);
let sha = text.trim();
let valid = (40..=64).contains(&sha.len())
&& !sha.is_empty()
&& sha.bytes().all(|b| b.is_ascii_hexdigit());
if !valid {
return Err(RemoteGitError::Parse(what));
}
Ok(sha.to_string())
}
fn parse_remote_config(bytes: &[u8]) -> Result<Vec<(String, String)>, RemoteGitError> {
let mut remotes = Vec::new();
for record in bytes.split(|&b| b == 0) {
if record.is_empty() {
continue;
}
let Some((key, url)) = split_record(record, b'\n') else {
return Err(RemoteGitError::Parse("config --get-regexp remote.*.url"));
};
let key = std::str::from_utf8(key).map_err(|_| RemoteGitError::Utf8("remote name"))?;
let url = std::str::from_utf8(url).map_err(|_| RemoteGitError::Utf8("remote url"))?;
let Some(name) = key
.strip_prefix("remote.")
.and_then(|rest| rest.strip_suffix(".url"))
else {
return Err(RemoteGitError::Parse("config --get-regexp remote.*.url"));
};
if name.is_empty() {
return Err(RemoteGitError::Parse("config --get-regexp remote.*.url"));
}
remotes.push((name.to_string(), url.to_owned()));
}
Ok(remotes)
}
fn parse_tree_entry(bytes: &[u8]) -> Result<Option<(String, PathBuf)>, RemoteGitError> {
let Some(record) = first_record(bytes) else {
return Ok(None);
};
let Some((meta, path)) = split_record(record, b'\t') else {
return Err(RemoteGitError::Parse("ls-tree"));
};
let mut fields = meta.split(|&b| b == b' ');
let oid = fields.nth(2).ok_or(RemoteGitError::Parse("ls-tree"))?;
let oid = parse_sha(oid, "ls-tree oid")?;
Ok(Some((oid, bytes_to_path(path))))
}
fn parse_index_entry(bytes: &[u8]) -> Result<Option<(String, PathBuf)>, RemoteGitError> {
let Some(record) = first_record(bytes) else {
return Ok(None);
};
let Some((meta, path)) = split_record(record, b'\t') else {
return Err(RemoteGitError::Parse("ls-files --stage"));
};
let mut fields = meta.split(|&b| b == b' ');
let oid = fields
.nth(1)
.ok_or(RemoteGitError::Parse("ls-files --stage"))?;
let oid = parse_sha(oid, "ls-files oid")?;
Ok(Some((oid, bytes_to_path(path))))
}
fn parse_commit_parents(bytes: &[u8]) -> Result<(String, Option<String>), RemoteGitError> {
let text = String::from_utf8_lossy(bytes);
let mut shas = text
.split_whitespace()
.map(|token| parse_sha(token.as_bytes(), "rev-list --parents"));
let commit = shas
.next()
.ok_or(RemoteGitError::Parse("rev-list --parents"))??;
let parent = shas.next().transpose()?;
Ok((commit, parent))
}
fn first_record(bytes: &[u8]) -> Option<&[u8]> {
bytes.split(|&b| b == 0).find(|record| !record.is_empty())
}
fn split_record(bytes: &[u8], separator: u8) -> Option<(&[u8], &[u8])> {
let index = bytes.iter().position(|&byte| byte == separator)?;
Some((&bytes[..index], &bytes[index + 1..]))
}
#[cfg(unix)]
fn bytes_to_path(bytes: &[u8]) -> PathBuf {
use std::os::unix::ffi::OsStrExt;
PathBuf::from(std::ffi::OsStr::from_bytes(bytes))
}
#[cfg(not(unix))]
fn bytes_to_path(bytes: &[u8]) -> PathBuf {
PathBuf::from(String::from_utf8_lossy(bytes).into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn toplevel_is_absolute_native_path() {
assert_eq!(
parse_toplevel(b"/srv/proj with space\n").unwrap(),
PathBuf::from("/srv/proj with space")
);
assert!(
parse_toplevel(b"srv/proj\n").is_err(),
"relative is refused"
);
assert!(parse_toplevel(b"").is_err(), "empty is refused");
}
#[test]
fn shas_are_validated_object_names() {
assert_eq!(
parse_sha(b"c59d8ceb7aeb96a1cdccff5646ec485acce32d45\n", "x").unwrap(),
"c59d8ceb7aeb96a1cdccff5646ec485acce32d45"
);
assert!(parse_sha(b"main\n", "x").is_err());
assert!(parse_sha(b"head is at 1234\n", "x").is_err());
assert!(parse_sha(b"\n", "x").is_err());
}
#[test]
fn remote_config_records_parse_native() {
let bytes = b"remote.origin.url\nhttps://example.com/acme/demo.git\0remote.up.url\ngit@gh:acme/other.git\0";
assert_eq!(
parse_remote_config(bytes).unwrap(),
vec![
(
"origin".to_string(),
"https://example.com/acme/demo.git".to_string()
),
("up".to_string(), "git@gh:acme/other.git".to_string()),
]
);
assert_eq!(
parse_remote_config(b"").unwrap(),
Vec::<(String, String)>::new()
);
assert!(parse_remote_config(b"not-a-pair\0").is_err());
assert!(
parse_remote_config(b"remote..url\nx\0").is_err(),
"empty name"
);
assert!(
parse_remote_config(b"remote.o.url\nhttps://a/\xff\xfe\0").is_err(),
"non-UTF-8 url refused"
);
}
#[test]
fn tree_and_index_records_keep_native_paths() {
let tree = b"100644 blob 45b983be36b73c0788dc9cbcb76cbb80fc7bb057\tsrc/a b.rs\0";
let (oid, path) = parse_tree_entry(tree).unwrap().unwrap();
assert_eq!(oid, "45b983be36b73c0788dc9cbcb76cbb80fc7bb057");
assert_eq!(path, PathBuf::from("src/a b.rs"));
assert_eq!(parse_tree_entry(b"").unwrap(), None);
let index = b"100644 45b983be36b73c0788dc9cbcb76cbb80fc7bb057 0\tsrc/a b.rs\0";
let (oid, path) = parse_index_entry(index).unwrap().unwrap();
assert_eq!(oid, "45b983be36b73c0788dc9cbcb76cbb80fc7bb057");
assert_eq!(path, PathBuf::from("src/a b.rs"));
assert_eq!(parse_index_entry(b"").unwrap(), None);
assert!(parse_tree_entry(b"garbage\0").is_err());
assert!(parse_index_entry(b"100644 noshahere 0\tx\0").is_err());
}
#[test]
fn commit_parents_root_and_merged() {
let root = b"60209d7ce72dddfafc1caacd511325c314a083bf\n";
assert_eq!(
parse_commit_parents(root).unwrap(),
("60209d7ce72dddfafc1caacd511325c314a083bf".to_string(), None)
);
let merged = b"c59d8ceb7aeb96a1cdccff5646ec485acce32d45 aaaa1111111111111111111111111111111111111 bbbb2222222222222222222222222222222222222\n";
let (child, parent) = parse_commit_parents(merged).unwrap();
assert_eq!(child, "c59d8ceb7aeb96a1cdccff5646ec485acce32d45");
assert_eq!(
parent.as_deref(),
Some("aaaa1111111111111111111111111111111111111")
);
assert!(parse_commit_parents(b"not-a-sha\n").is_err());
assert!(parse_commit_parents(b"").is_err());
}
#[test]
fn nonzero_exit_is_a_typed_error() {
let run = GitRun {
success: false,
code: Some(128),
stdout: Vec::new(),
stderr: b"fatal: unsafe repository\n".to_vec(),
stdout_dropped: 0,
stderr_dropped: 0,
};
match exit_or_bytes("rev-parse --show-toplevel", &run) {
Err(RemoteGitError::Exit { op, code, stderr }) => {
assert_eq!(op, "rev-parse --show-toplevel");
assert_eq!(code, 128);
assert_eq!(stderr, "fatal: unsafe repository");
}
other => panic!("expected Exit, got {other:?}"),
}
}
#[test]
fn truncation_is_typed() {
let run = GitRun {
success: true,
code: Some(0),
stdout: Vec::new(),
stderr: Vec::new(),
stdout_dropped: 4096,
stderr_dropped: 0,
};
assert_eq!(
exit_or_bytes("ls-tree", &run),
Err(RemoteGitError::Truncated {
op: "ls-tree",
dropped: 4096
})
);
assert_eq!(
RemoteGitError::Truncated {
op: "ls-tree",
dropped: 9
}
.to_string(),
"ls-tree: remote output truncated (9 bytes dropped)"
);
}
}