wsb 0.258.6441

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! The git guard: when two working trees may be made to agree, and when they
//! may not.
//!
//! Continuity synchronises a working tree, which only means anything against a
//! shared base. Two trees at the same commit differ by exactly the work in
//! progress; two trees at different commits differ by that AND by every change
//! between the commits, and copying files between them would silently mix the
//! two.
//!
//! So the rule is narrow on purpose:
//!
//! ```text
//! same logical repository
//! AND same named branch
//! AND same HEAD commit
//! AND no git operation in progress on either side
//! = eligible
//! ```
//!
//! Anything else blocks. Continuity never reconciles git to make itself
//! possible — the operator does that with the ordinary fleet commands, and
//! this resumes by itself afterwards.
//!
//! # Why an epoch rather than a check
//!
//! Checking once before a long transfer is not enough. A checkout rewrites
//! thousands of files in the seconds between a scan and an apply, and files
//! prepared against one branch applied onto another is the worst thing this
//! feature could do. Every transaction therefore carries the [`Epoch`] it was
//! prepared against; the receiver refuses one that does not match what it has
//! now, and the sender re-validates before promoting a baseline. A transfer
//! prepared against one git state can never land on another.

use std::path::Path;

use serde::{Deserialize, Serialize};

use super::model::{Blocked, RepositoryId};

/// What git is doing in a repository right now.
///
/// A working tree is stable only when git is not moving it. These are the
/// states in which files appear and disappear for reasons that have nothing to
/// do with the person typing, and synchronising through one of them would
/// carry a half-applied checkout to the other machine.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GitState {
    /// Nothing in progress. The only state in which files may move.
    Settled,
    Merge,
    Rebase,
    CherryPick,
    Revert,
    Bisect,
    /// A checkout, switch, reset or submodule update is rewriting the tree.
    /// Unlike the others this has no marker file of its own; it is detected as
    /// a moving index (see [`RepositoryFacts::read`]).
    Rewriting,
}

impl GitState {
    pub fn is_settled(self) -> bool {
        matches!(self, Self::Settled)
    }

    pub fn as_str(self) -> &'static str {
        match self {
            Self::Settled => "settled",
            Self::Merge => "merge",
            Self::Rebase => "rebase",
            Self::CherryPick => "cherry_pick",
            Self::Revert => "revert",
            Self::Bisect => "bisect",
            Self::Rewriting => "rewriting",
        }
    }

    /// Read the in-progress markers git leaves in the repository's git
    /// directory.
    ///
    /// The git DIRECTORY, which for a submodule is `.git/modules/<name>` in
    /// the parent — resolved by the caller, because a submodule's `.git` is a
    /// file pointing elsewhere and reading markers beside that file would find
    /// none, for ever.
    pub fn read(git_dir: &Path) -> Self {
        // Order matters only for reporting; a repository is in at most one of
        // these at a time, and the first match is the one to name.
        for (marker, state) in [
            ("rebase-merge", Self::Rebase),
            ("rebase-apply", Self::Rebase),
            ("MERGE_HEAD", Self::Merge),
            ("CHERRY_PICK_HEAD", Self::CherryPick),
            ("REVERT_HEAD", Self::Revert),
            ("BISECT_LOG", Self::Bisect),
        ] {
            if git_dir.join(marker).exists() {
                return state;
            }
        }
        Self::Settled
    }
}

/// Everything about one repository that decides whether it may synchronise.
///
/// Gathered on each side independently and exchanged. Nothing here is a path:
/// two machines keep the same workspace in different directories, and the only
/// thing they must agree about is what git says.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryFacts {
    pub repository: RepositoryId,
    /// The repository's logical identity: its first-parent root commit.
    ///
    /// Not the remote URL. A remote is renamed, moved between hosts, replaced
    /// by a fork and absent entirely in a repository nobody has pushed — and
    /// two different repositories can be pushed to one URL. The root commit is
    /// the one thing that is the same in every clone of the same history and
    /// different in an unrelated one.
    pub root_commit: String,
    /// The named branch HEAD points at, or `None` when detached.
    pub branch: Option<String>,
    /// The commit HEAD resolves to. `None` for an unborn branch — a repository
    /// with no commits at all, which has no base to synchronise against.
    pub head: Option<String>,
    pub git_state: GitState,
    /// Whether the repository exists and is initialised on this side at all.
    pub present: bool,
}

impl RepositoryFacts {
    /// A repository the peer does not have, or has not initialised.
    pub fn absent(repository: RepositoryId) -> Self {
        Self {
            repository,
            root_commit: String::new(),
            branch: None,
            head: None,
            git_state: GitState::Settled,
            present: false,
        }
    }

    /// Read the facts for one repository from disk.
    ///
    /// `workdir` is the repository's working directory and `git_dir` its git
    /// directory, which are the same thing only for a plain repository — a
    /// submodule's git directory lives in the superproject.
    pub fn read(
        repository: RepositoryId,
        workdir: &Path,
        git_dir: &Path,
    ) -> Result<Self, String> {
        if !workdir.is_dir() || !git_dir.exists() {
            return Ok(Self::absent(repository));
        }
        let repo = crate::git_backend::open_repo(workdir)
            .map_err(|e| format!("{}: {e}", repository.label()))?;

        let branch = crate::git_backend::current_branch(&repo).ok();
        let head = repo.head_id().ok().map(|id| id.detach().to_string());
        let root_commit = root_commit(&repo)?;

        Ok(Self {
            repository,
            root_commit,
            branch,
            head,
            git_state: GitState::read(git_dir),
            present: true,
        })
    }

    /// The epoch a transfer is prepared against and validated by.
    pub fn epoch(&self) -> Epoch {
        Epoch {
            repository: self.repository.clone(),
            root_commit: self.root_commit.clone(),
            branch: self.branch.clone(),
            head: self.head.clone(),
            git_state: self.git_state,
        }
    }
}

/// The identity of one moment of one repository's git state.
///
/// Two epochs that compare equal mean the repository has not moved: same
/// history, same branch, same commit, and nothing in progress. A transfer
/// prepared against one and applied against another is the race this type
/// exists to make impossible.
///
/// The INDEX is deliberately not part of it. It witnessed a real thing —
/// something touched git — but not a thing this guard is about: staging a file
/// does not change the working tree, and `git status` rewrites the index's
/// stat cache while changing nothing whatsoever. With the index in the epoch,
/// any repository being watched (the workbench runs `git status`) could refuse
/// every transfer it prepared, retry, and refuse again — a livelock in which
/// nothing ever crossed and the repository read as "checking" forever. What
/// the epoch must catch is the repository MOVING under a transfer: a commit, a
/// checkout, a branch change, a rebase starting. Those are all here, and a
/// working-tree edit during a transfer is caught where it belongs — by the
/// content hashes, which is what the baseline advance is checked against.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Epoch {
    pub repository: RepositoryId,
    pub root_commit: String,
    pub branch: Option<String>,
    pub head: Option<String>,
    pub git_state: GitState,
}

impl Epoch {
    /// Whether this epoch still describes the repository as it is now.
    pub fn still_current(&self, now: &RepositoryFacts) -> bool {
        now.present && *self == now.epoch()
    }
}

/// Whether two sides may synchronise this repository, and why not when they
/// may not.
///
/// The order of the checks is the order a person would want to be told about
/// them: what a repository IS before which branch it is on, and which branch
/// before which commit.
pub fn eligibility(here: &RepositoryFacts, there: &RepositoryFacts) -> Result<Epoch, Blocked> {
    if !here.present || !there.present {
        return Err(Blocked::SubmoduleMissing {
            here: here.present,
            there: there.present,
        });
    }

    // An unborn branch has no base at all. Reported as a head mismatch only
    // when the two sides differ; two empty repositories have nothing to carry
    // between them but are not in an unsafe state.
    if here.root_commit != there.root_commit {
        return Err(Blocked::RepositoryMismatch {
            here: here.root_commit.clone(),
            there: there.root_commit.clone(),
        });
    }

    match (&here.branch, &there.branch) {
        (Some(a), Some(b)) if a != b => {
            return Err(Blocked::BranchMismatch {
                here: a.clone(),
                there: b.clone(),
            })
        }
        (None, _) | (_, None) => {
            // Detached is never "the same branch", however the commits look.
            // Nothing says the next commit on either side lands where the
            // other can follow it.
            return Err(Blocked::DetachedHead {
                here: here.branch.is_none(),
                there: there.branch.is_none(),
            });
        }
        _ => {}
    }

    if here.head != there.head {
        return Err(Blocked::HeadMismatch {
            here: here.head.clone().unwrap_or_default(),
            there: there.head.clone().unwrap_or_default(),
        });
    }

    if !here.git_state.is_settled() || !there.git_state.is_settled() {
        return Err(Blocked::GitOperation {
            here: !here.git_state.is_settled(),
            there: !there.git_state.is_settled(),
        });
    }

    Ok(here.epoch())
}

/// The repository's first-parent root commit — the identity every clone of the
/// same history shares.
fn root_commit(repo: &gix::Repository) -> Result<String, String> {
    let Ok(head) = repo.head_id() else {
        // No commits yet. An empty repository has an identity nobody can
        // disagree with, and no base to synchronise against; eligibility
        // refuses it at the HEAD check rather than here.
        return Ok(String::new());
    };
    let mut current = head.detach();
    loop {
        let commit = repo
            .find_commit(current)
            .map_err(|e| format!("cannot read commit {current}: {e}"))?;
        let parent = commit.parent_ids().next().map(|id| id.detach());
        match parent {
            Some(parent) => current = parent,
            None => return Ok(current.to_string()),
        }
    }
}

/// A number that changes whenever the index does.
///
/// The index is rewritten by every checkout, reset, merge, rebase step and
/// `git add`, and its modification time plus size is the cheapest honest
/// witness that the tree is not the one a scan looked at. Zero when there is
/// no index — a repository with nothing staged ever — which is a stable state
/// like any other.

#[cfg(test)]
#[path = "compatibility_tests.rs"]
mod tests;