wsb-daemon 0.258.6441

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
//! Deciding what happens to a difference — always a person's decision.
//!
//! Two kinds of difference reach this module and they are settled the same
//! way. A CONFLICT is one both machines changed from the same base, and it
//! never moves without a decision whatever the mode. An ordinary difference is
//! one only one side changed; in auto mode it crosses by itself, and in manual
//! mode it waits here with the conflicts, because "which version wins" is the
//! same question and deserves the same answer.
//!
//! A conflict means both machines changed the same base differently. There is
//! no correct automatic answer — "newest wins" is a clock comparison dressed
//! as a judgement — so nothing moves until somebody chooses a side.
//!
//! # Both choices are destructive, and are treated as such
//!
//! Keeping this machine's version overwrites the peer's; keeping the peer's
//! overwrites this machine's. Either way the losing version stops being
//! reachable through continuity, so a choice is an explicit act — never a
//! click-through, never a default, and never something a sweep does on its own.
//!
//! # How a choice takes effect
//!
//! By moving the BASELINE, not by copying a file. Setting the baseline to the
//! chosen side makes the next reconciliation see exactly one change — the
//! other side's — and carry it the ordinary way. That means the choice goes
//! through the same transfer, the same epoch check and the same verification
//! as every other change, rather than through a second path that would have to
//! be safe all over again.

use std::sync::Arc;

use wsb::peer::model::{PeerId, RepositoryId, WorkspaceId};

use crate::machine::MachineState;

/// Which side the operator chose.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Keep {
    Here,
    There,
}

/// Record a decision about one conflicted path.
///
/// Refuses anything that is not actually in conflict — see [`decide`] for the
/// general case, which is what manual mode uses.
///
/// Nothing is written to the working tree and nothing is copied here: the next
/// reconciliation turns the decision into an ordinary transfer, so it goes
/// through the same epoch check, the same content verification and the same
/// acknowledgement as every other change — and both machines' baselines move
/// together, which is what keeps them able to agree afterwards.
pub async fn resolve(
    machine: &Arc<MachineState>,
    peer: &PeerId,
    workspace: &WorkspaceId,
    repository: &RepositoryId,
    path: &str,
    keep: Keep,
) -> Result<(), String> {
    let (_, local) = machine
        .continuity(workspace)
        .await?
        .ok_or_else(|| format!("no worktree open on this machine is {workspace}"))?;
    local
        .repository(repository)
        .ok_or_else(|| format!("{repository} is not a repository of this workspace"))?;

    // Only a path that IS in conflict may be resolved: a decision about
    // anything else would be an instruction to overwrite a file nobody was
    // asked about.
    let conflicts = machine.store.conflicts(peer, workspace)?;
    if !conflicts
        .iter()
        .any(|row| row.repository == repository.as_str() && row.path == path)
    {
        return Err(format!("{path} is not in conflict with {peer}"));
    }

    machine.store.set_resolution(
        peer,
        workspace,
        repository,
        path,
        match keep {
            Keep::Here => "here",
            Keep::There => "there",
        },
        super::service::now(),
    )
}

/// Record a decision about any path that differs.
///
/// The general case, and the one manual mode is built on: a path appears in
/// the last plan because the two machines hold different bytes for it, and
/// somebody says which of them wins. A conflict is included — it is a
/// difference like any other from the operator's side — so one route settles
/// both and the workbench does not have to know which kind it is looking at.
///
/// What it refuses is a decision about a path that does not differ. That is
/// not a no-op: it would sit in the resolutions table and fire the first time
/// the path DID differ, carrying a version somebody chose about a file they
/// have not seen since.
pub async fn decide(
    machine: &Arc<MachineState>,
    peer: &PeerId,
    workspace: &WorkspaceId,
    repository: &RepositoryId,
    path: &str,
    keep: Keep,
) -> Result<(), String> {
    difference(machine, peer, workspace, repository, path).await?;
    machine.store.set_resolution(
        peer,
        workspace,
        repository,
        path,
        match keep {
            Keep::Here => "here",
            Keep::There => "there",
        },
        super::service::now(),
    )?;
    // A decision supersedes leaving it alone. Keeping both would mean a path
    // that is about to move is also being hidden from the list it is moving
    // out of.
    machine.store.clear_dismissal(peer, workspace, repository, path)
}

/// Leave one difference alone, as it stands right now.
///
/// Recorded against the two versions the operator was looking at, which are
/// the ones in the plan they clicked. Either machine changing the path makes
/// the dismissal stop matching, and the difference comes back by itself —
/// that is the whole difference between this and an ignore list, and it is
/// what stops "not now" from quietly becoming "not ever".
pub async fn dismiss(
    machine: &Arc<MachineState>,
    peer: &PeerId,
    workspace: &WorkspaceId,
    repository: &RepositoryId,
    path: &str,
) -> Result<(), String> {
    let difference = difference(machine, peer, workspace, repository, path).await?;
    machine.store.dismiss(
        peer,
        workspace,
        repository,
        path,
        difference.here_hash.as_deref(),
        difference.there_hash.as_deref(),
        super::service::now(),
    )?;
    // Leaving it alone undoes a decision to move it, for the same reason the
    // reverse does: the two statements contradict each other, and the one
    // somebody just made is the one they mean.
    machine.store.clear_resolution(peer, workspace, repository, path)
}

/// Stop leaving a difference alone, or stop intending to move it.
///
/// The undo for both of the above, and it takes back nothing else: a
/// difference with neither a decision nor a dismissal is simply one waiting to
/// be looked at, which is where every difference starts.
pub async fn reconsider(
    machine: &Arc<MachineState>,
    peer: &PeerId,
    workspace: &WorkspaceId,
    repository: &RepositoryId,
    path: &str,
) -> Result<(), String> {
    machine.store.clear_dismissal(peer, workspace, repository, path)?;
    machine.store.clear_resolution(peer, workspace, repository, path)
}

/// The difference at one path, from the last plan — or an error saying there
/// is none.
///
/// The last plan rather than a fresh comparison: a fresh one would be a second
/// scan of two working trees per click, and the operator is acting on what
/// they were shown. A plan that has moved on since is caught later anyway —
/// every transfer re-validates the epoch before it writes.
async fn difference(
    machine: &Arc<MachineState>,
    peer: &PeerId,
    workspace: &WorkspaceId,
    repository: &RepositoryId,
    path: &str,
) -> Result<crate::machine::PlannedMove, String> {
    let (_, local) = machine
        .continuity(workspace)
        .await?
        .ok_or_else(|| format!("no worktree open on this machine is {workspace}"))?;
    local
        .repository(repository)
        .ok_or_else(|| format!("{repository} is not a repository of this workspace"))?;

    machine
        .plans(peer, workspace)
        .get(repository.as_str())
        .and_then(|moves| moves.iter().find(|planned| planned.path == path).cloned())
        .ok_or_else(|| {
            format!(
                "nothing differs at {path} between this machine and {peer} — \
                 there is no difference there to decide about"
            )
        })
}