wsb-daemon 0.248.6265

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
//! Both versions of one conflicted path, and the diff between them.
//!
//! A conflict is a decision, and a decision needs the thing being decided
//! about. "Both machines changed this" tells a person what happened; it does
//! not tell them which of the two versions they want to keep, and choosing
//! blind is how somebody loses an afternoon's work to a button.
//!
//! This is deliberately read-only. Nothing here writes a file, moves a
//! baseline or resolves anything: it reads this machine's version off disk,
//! asks the peer for its version over the same authenticated session every
//! transfer uses, and produces a diff. Resolving stays what it was — an
//! explicit choice of a side.

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;

/// How much of a file is worth carrying to a page.
///
/// A diff of a hundred-megabyte file is not something anybody reads, and
/// holding two of them in memory to produce it is a way to take the daemon
/// down while somebody is deciding about a conflict.
const MAX_BYTES: u64 = 4 * 1024 * 1024;

/// One side's version of a path.
#[derive(Debug, Clone, serde::Serialize)]
pub struct Side {
    /// False when this side deleted the path — one half of a delete-vs-modify
    /// conflict, which has nothing to show.
    pub present: bool,
    pub size: u64,
    /// The bytes as text. Absent when the file is binary or too large, which
    /// `reason` names.
    pub text: Option<String>,
}

/// What a page needs to decide one conflict.
#[derive(Debug, Clone, serde::Serialize)]
pub struct Versions {
    pub repository: String,
    pub path: String,
    pub here: Side,
    pub there: Side,
    /// A unified diff, `here` against `there`. Absent when there is nothing to
    /// diff — see `reason`.
    pub diff: Option<String>,
    /// Why there is no diff: `binary`, `too_large`, `deleted_here`,
    /// `deleted_there`, `identical`. Absent when there is one.
    pub reason: Option<String>,
}

/// Read this machine's version, ask the peer for its own, and diff them.
///
/// Blocking throughout — it talks to the peer over the blocking session and
/// reads files — so callers run it off the runtime.
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))
}

/// This machine's version, off its own disk.
fn read_here(workdir: &Path, path: &str) -> Result<Side, String> {
    // Through the same path check every transfer uses: a path that escapes the
    // repository is refused here too, whoever asked.
    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))
}

/// The peer's version, over the session every transfer already uses.
fn read_there(
    machine: &Arc<MachineState>,
    peer: &PeerId,
    workspace: &WorkspaceId,
    repository: &RepositoryId,
    path: &str,
) -> Result<Side, String> {
    let mut session = machine.peers.connect(peer)?;

    // The peer validates a fetch against ITS OWN epoch, so the epoch has to be
    // the one it reports — this machine's describes a different repository on
    // a different disk.
    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,
        });
    }

    // What the peer says it holds decides whether there is anything to ask
    // for, and how big it is before it is asked for.
    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() {
            // Verified against the hash it was sent with, exactly as a
            // transfer would: a page showing bytes nobody checked is a page
            // showing something other than the peer's file.
            Some(write) => Ok(as_side(write.decoded()?)),
            None => Ok(Side {
                present: false,
                size: 0,
                text: None,
            }),
        },
        Response::Refused { code, detail } => Err(match code {
            // The repository moved while this was asked. Ordinary, and worth
            // saying plainly rather than as a protocol error.
            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:?}")),
    }
}

/// Bytes as a side, deciding text or binary the only way that is true: whether
/// they are text.
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,
        },
    }
}

/// Put the two sides together, with a diff when there is one to make.
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,
        // A diff that could not be produced is not a diff that says nothing
        // differs: say which it is.
        reason: match (&diff, reason) {
            (Some(_), _) => None,
            (None, Some(reason)) => Some(reason.to_string()),
            (None, None) => Some("undiffable".to_string()),
        },
        diff,
    }
}

/// A unified diff of two texts, via git — which is the diff everything else in
/// this workspace shows, and the one the page already knows how to render.
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}"))?;
    // `--no-index` answers 1 when the files differ, which is the whole point
    // of asking. Anything above that is a real failure.
    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))
}

/// Give the diff the names a person recognises.
///
/// git names the two scratch files it was handed. What the reader is looking
/// at is one path, on two machines.
///
/// Only the HEADER is rewritten, and the header is everything before the first
/// hunk. Inside a hunk, a line beginning `---` is a removed line whose content
/// begins `--`, and rewriting that would show the reader a line their file
/// does not contain.
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;