use std::path::Path;
use std::sync::Arc;
use wsb::peer::model::{PeerId, RepositoryId, WorkspaceId};
use wsb::peer::protocol::{RefusalCode, Request, Response};
use wsb::peer::scan;
use crate::machine::MachineState;
const MAX_BYTES: u64 = 4 * 1024 * 1024;
#[derive(Debug, Clone, serde::Serialize)]
pub struct Side {
pub present: bool,
pub size: u64,
pub text: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Versions {
pub repository: String,
pub path: String,
pub here: Side,
pub there: Side,
pub diff: Option<String>,
pub reason: Option<String>,
}
pub fn versions(
machine: &Arc<MachineState>,
peer: &PeerId,
workspace: &WorkspaceId,
repository: &RepositoryId,
path: &str,
) -> Result<Versions, String> {
let (_, local) = futures::executor::block_on(machine.continuity(workspace))?
.ok_or_else(|| format!("no worktree open on this machine is {workspace}"))?;
let repo = local
.repository(repository)
.ok_or_else(|| format!("this machine does not have {repository}"))?;
let here = read_here(&repo.workdir, path)?;
let there = read_there(machine, peer, workspace, repository, path)?;
Ok(compare(repository, path, here, there))
}
fn read_here(workdir: &Path, path: &str) -> Result<Side, String> {
let absolute = scan::local_path(workdir, path)?;
let metadata = match std::fs::symlink_metadata(&absolute) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(Side {
present: false,
size: 0,
text: None,
})
}
Err(error) => return Err(format!("cannot read {path}: {error}")),
};
let size = metadata.len();
if metadata.file_type().is_symlink() {
let target = std::fs::read_link(&absolute)
.map_err(|error| format!("cannot read the link {path}: {error}"))?;
return Ok(Side {
present: true,
size,
text: Some(scan::to_slash(&target)),
});
}
if size > MAX_BYTES {
return Ok(Side {
present: true,
size,
text: None,
});
}
let bytes = std::fs::read(&absolute).map_err(|error| format!("cannot read {path}: {error}"))?;
Ok(as_side(bytes))
}
fn read_there(
machine: &Arc<MachineState>,
peer: &PeerId,
workspace: &WorkspaceId,
repository: &RepositoryId,
path: &str,
) -> Result<Side, String> {
let mut session = machine.peers.connect(peer)?;
let response: Response = session.exchange(&Request::Report {
workspace: workspace.clone(),
repositories: vec![repository.clone()],
})?;
let report = match response {
Response::Report { repositories, .. } => repositories
.into_iter()
.next()
.ok_or_else(|| "the peer reported no repositories".to_string())?,
Response::Refused { code, detail } => {
session.shutdown();
return Err(format!("the peer refused a report ({}): {detail}", code.as_str()));
}
other => {
session.shutdown();
return Err(format!("the peer answered a report with {other:?}"));
}
};
if !report.facts.present {
session.shutdown();
return Ok(Side {
present: false,
size: 0,
text: None,
});
}
let entry = report.entries.get(path).cloned();
let Some(entry) = entry else {
session.shutdown();
return Ok(Side {
present: false,
size: 0,
text: None,
});
};
if entry.size > MAX_BYTES {
session.shutdown();
return Ok(Side {
present: true,
size: entry.size,
text: None,
});
}
let response: Response = session.exchange(&Request::Fetch {
workspace: workspace.clone(),
repository: repository.clone(),
epoch: report.facts.epoch(),
paths: vec![path.to_string()],
})?;
session.shutdown();
match response {
Response::Fetched { writes } => match writes.into_iter().next() {
Some(write) => Ok(as_side(write.decoded()?)),
None => Ok(Side {
present: false,
size: 0,
text: None,
}),
},
Response::Refused { code, detail } => Err(match code {
RefusalCode::EpochMoved => {
format!("the peer's repository changed while reading {path}; try again")
}
_ => format!("the peer refused ({}): {detail}", code.as_str()),
}),
other => Err(format!("the peer answered a fetch with {other:?}")),
}
}
fn as_side(bytes: Vec<u8>) -> Side {
let size = bytes.len() as u64;
match String::from_utf8(bytes) {
Ok(text) => Side {
present: true,
size,
text: Some(text),
},
Err(_) => Side {
present: true,
size,
text: None,
},
}
}
fn compare(repository: &RepositoryId, path: &str, here: Side, there: Side) -> Versions {
let reason = match (&here, &there) {
(h, _) if !h.present => Some("deleted_here"),
(_, t) if !t.present => Some("deleted_there"),
(h, t) if h.text.is_none() || t.text.is_none() => {
if h.size > MAX_BYTES || t.size > MAX_BYTES {
Some("too_large")
} else {
Some("binary")
}
}
(h, t) if h.text == t.text => Some("identical"),
_ => None,
};
let diff = match (reason, here.text.as_deref(), there.text.as_deref()) {
(None, Some(mine), Some(theirs)) => unified(path, mine, theirs).ok(),
_ => None,
};
Versions {
repository: repository.as_str().to_string(),
path: path.to_string(),
here,
there,
reason: match (&diff, reason) {
(Some(_), _) => None,
(None, Some(reason)) => Some(reason.to_string()),
(None, None) => Some("undiffable".to_string()),
},
diff,
}
}
fn unified(path: &str, here: &str, there: &str) -> Result<String, String> {
let dir = tempfile::Builder::new()
.prefix("wsb-peer-diff-")
.tempdir()
.map_err(|error| format!("cannot make a scratch directory: {error}"))?;
let mine = dir.path().join("here");
let theirs = dir.path().join("there");
std::fs::write(&mine, here).map_err(|error| format!("cannot stage a version: {error}"))?;
std::fs::write(&theirs, there).map_err(|error| format!("cannot stage a version: {error}"))?;
let output = wsb::git_backend::git_command()
.args(["diff", "--no-index", "--unified=3", "--no-color"])
.arg(&mine)
.arg(&theirs)
.output()
.map_err(|error| format!("cannot run git diff: {error}"))?;
match output.status.code() {
Some(0) | Some(1) => {}
_ => {
return Err(format!(
"git diff failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
))
}
}
let text = String::from_utf8(output.stdout)
.map_err(|error| format!("git diff produced something that is not text: {error}"))?;
Ok(relabel(&text, path))
}
fn relabel(diff: &str, path: &str) -> String {
let mut out = Vec::new();
let mut in_body = false;
for line in diff.lines() {
if line.starts_with("@@") {
in_body = true;
}
if in_body {
out.push(line.to_string());
continue;
}
out.push(if line.starts_with("--- ") {
format!("--- a/{path}\t(this machine)")
} else if line.starts_with("+++ ") {
format!("+++ b/{path}\t(the peer)")
} else if line.starts_with("diff --git ") {
format!("diff --git a/{path} b/{path}")
} else {
line.to_string()
});
}
out.join("\n")
}
#[cfg(test)]
#[path = "versions_tests.rs"]
mod tests;