git
Source: crates/lvcs/src/git/mod.rs.
/// The trailer every checkpoint commit carries, naming the frontier it
/// holds in `ID_ENCODING`: `LVCS-Frontier: <frontier id>`.
pub(crate) const FRONTIER_TRAILER: &str = "LVCS-Frontier";
/// The prefix of the refs checkpoints advance: `refs/lvcs/checkpoints/main`
/// for `main`, and `refs/lvcs/checkpoints/<workspace id>` for a workspace.
/// `lvcs init` creates `refs/lvcs/checkpoints/main` at `HEAD`, a git ref and
/// never an LVCS document.
pub(crate) const CHECKPOINT_REF_PREFIX: &str = "refs/lvcs/checkpoints/";
/// Where the last commit imported on each branch is kept (row 331):
/// `refs/lvcs/imported/` and the branch's ref below `refs/`, as
/// `refs/lvcs/imported/remotes/origin/main`.
pub(crate) const IMPORTED_REF_PREFIX: &str = "refs/lvcs/imported/";
/// `lvcs.toml` `[git]`, read only from main's `lvcs.toml` at its approved
/// heads (decision 127): `branches` maps a channel name, a change
/// action's or a `[channels]` combination's (decision 297), to the git ref
/// it moves, a branch under `refs/heads/`. Empty by default, so no branch
/// moves. `import` names `<remote>/<branch>` refs LVCS only reads, never
/// moves, with no channel (decision 318): the owner's daemon fetches each
/// into `refs/remotes/<remote>/<branch>` and imports its new heads, as a
/// mapped branch's (decision 309). Empty by default.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub(crate) struct GitConfig {
/// The git branch each channel moves, by channel name.
pub(crate) branches: BTreeMap<String, String>,
/// The `<remote>/<branch>` refs only read, never moved.
pub(crate) import: Vec<String>,
}
/// One `[git] import` entry (decision 318): the remote, the branch on it,
/// and the remote-tracking ref the fetch writes and the import reads.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ImportRef {
pub(crate) remote: String,
pub(crate) branch: String,
pub(crate) reference: String,
}
impl GitConfig {
/// Each `import` entry that names `<remote>/<branch>`, split at its
/// first `/`, with a remote that can't be read as an option, a URL, or
/// a path; the others are skipped (a malformed ref fails its fetch).
pub(crate) fn import_refs(&self) -> Vec<ImportRef> {
self.import
.iter()
.filter_map(|entry| {
let (remote, branch) = entry.split_once('/')?;
let sound = !remote.is_empty()
&& !branch.is_empty()
&& !remote.starts_with(['-', '.'])
&& !remote.contains([':', '\\'])
&& !branch.starts_with('-');
sound.then(|| ImportRef {
remote: remote.to_owned(),
branch: branch.to_owned(),
reference: format!("refs/remotes/{remote}/{branch}"),
})
})
.collect()
}
}
/// A commit a ref names: its SHA-1 object id.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct GitHead {
pub(crate) commit: [u8; 20],
}
/// Why git refused what LVCS asked.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum GitRefusal {
/// The project root is not a git repository.
NotARepository,
/// Tracked files differ from `HEAD`, or an untracked file isn't
/// ignored; the text says which.
Dirty(String),
/// `git` could not run, or answered with an error; the text says
/// which. Also a repository whose work tree is not the project root or
/// whose object format is not SHA-1, a `git` older than 2.31 (it can't
/// be kept from running the repository's hooks), a malformed ref name,
/// a branch outside `refs/heads/`, and a ref that moved concurrently
/// (every ref move is a compare-and-swap).
Failed(String),
/// A mapped branch's head is a commit LVCS neither wrote nor imported
/// (decision 309): the branch stays until the head is imported.
Moved,
}
/// One file of a tree: its bytes and executable bit (decision 85), as a
/// checkpoint's tree holds it or a run materializes it (row 307).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TreeFile {
pub(crate) bytes: Vec<u8>,
pub(crate) executable: bool,
}
/// Whether the git repository at `root` is clean, before `lvcs init`
/// imports anything: `Ok(None)` when `root` holds no `.git` (then no `git`
/// runs), `git` finds no repository there, or the repository has no commit yet, `Ok(Some(HEAD))` when no
/// tracked file differs from `HEAD` and every untracked file is ignored,
/// and `Dirty` otherwise, so `lvcs init` exits 1 with nothing written. It
/// takes no lock, so it writes nothing to the repository either.
pub(crate) fn check_clean(root: &Path) -> Result<Option<GitHead>, GitRefusal> {
let span = tracing::info_span!("git_clean", outcome = tracing::field::Empty);
traced(&span, || setup::check_clean(root))
}
/// Add `.lvcs/` to the repository's `.git/info/exclude` (a linked
/// worktree's common one), once, so git never tracks LVCS's own state.
/// Does nothing when `root` holds no `.git` or `git` finds no repository
/// there, or when a line of the file
/// already excludes `.lvcs`.
pub(crate) fn exclude_lvcs(root: &Path) -> Result<(), GitRefusal> {
let span = tracing::info_span!("git_exclude", outcome = tracing::field::Empty);
traced(&span, || setup::exclude_lvcs(root))
}
/// Record `head`, the `HEAD` [`check_clean`] answered, as `lvcs init`'s
/// starting state (design §2): set `refs/lvcs/checkpoints/main` to it,
/// keeping any earlier value (from an LVCS state removed before this init)
/// in that ref's reflog. Does nothing when `root` holds no `.git` or `git`
/// finds no repository there.
pub(crate) fn record_head(root: &Path, head: &GitHead) -> Result<(), GitRefusal> {
let span = tracing::info_span!("git_record_head", outcome = tracing::field::Empty);
traced(&span, || setup::record_head(root, head))
}
/// Write one checkpoint commit of `tree`, with the `FRONTIER_TRAILER`
/// naming `frontier`, and advance
/// `CHECKPOINT_REF_PREFIX` + `name` to it. Its parent is that ref, else
/// `HEAD`, else none. `tree` holds every path the caller read (decision
/// 136): the frontier's `file` documents, and every protected path at its
/// approved heads, each `Some` with its bytes and executable bit, or
/// `None` when it is absent at the heads read (a deleted file, or a
/// protected path deleted at its approved heads), which the commit leaves
/// out. A protected path with no approved heads is not in `tree`. The
/// commit's tree also keeps the parent tree's gitlinks, and its entries at
/// paths `tree` lacks that are protected or that LVCS never syncs: symbolic links, never-sync paths (by the root's
/// reserved and disk matchers), names that are not UTF-8 or break the
/// address rules, and case and normalization collisions with a path of
/// `tree`. A path of `tree` at, beneath, or above a kept gitlink or
/// protected entry is left out, and one colliding with another kept entry
/// replaces it. `NotARepository` when `root` holds no `.git`, or `git`
/// finds no repository there.
pub(crate) fn checkpoint(
root: &Path,
name: &str,
frontier: &FrontierId,
tree: &BTreeMap<String, Option<TreeFile>>,
) -> Result<GitHead, GitRefusal> {
let span = tracing::info_span!("git_checkpoint", outcome = tracing::field::Empty);
traced(&span, || commits::checkpoint(root, name, frontier, tree))
}
/// Whether `root` is a git repository (decision 239).
///
/// False only where every operation here refuses `NotARepository`: `root`
/// holds no `.git`, or `git` finds no repository there. A `.git` that
/// `git` can't use counts as one, so its operations fail (`Failed`).
pub(crate) fn is_repository(root: &Path) -> bool {
!matches!(repo::Repo::open(root), Ok(None))
}
/// Move the mapped `branch` (a ref under `refs/heads/`) to a commit of
/// `tree`, the tree of `frontier` (a channel value of exactly one
/// frontier, every protected path at its approved heads; each path read
/// `Some` or `None` as for [`checkpoint`]), built as [`checkpoint`] builds
/// one and with its trailer, whose parent is the
/// branch's current commit, or none when the branch doesn't exist yet; a
/// branch checked out in the root gets `.git/index` rewritten to the new
/// tree before it moves, and its working tree, the mirror, untouched, so
/// an index git can't write leaves the branch where it was (`Failed`).
/// `Ok(None)` when the branch stays: its commit already has this tree, and
/// the index, which may hold the user's staged changes, is not touched.
/// `NotARepository` when `root` holds no `.git`, or `git` finds no
/// repository there. `Moved` (decision 309) when the branch's head is a
/// commit LVCS neither wrote nor imported: not `known` (the last commit
/// this daemon wrote or imported there), carrying no `LVCS-Frontier`
/// trailer, and no ancestor of `refs/lvcs/checkpoints/main` (`lvcs init`'s
/// HEAD's line), so LVCS never writes a commit that reverts it.
pub(crate) fn update_branch(
root: &Path,
branch: &str,
frontier: &FrontierId,
tree: &BTreeMap<String, Option<TreeFile>>,
known: Option<&GitHead>,
) -> Result<Option<GitHead>, GitRefusal> {
let span = tracing::info_span!("git_branch", outcome = tracing::field::Empty);
traced(&span, || {
commits::update_branch(root, branch, frontier, tree, known)
})
}
/// The commit `branch` (a ref under `refs/heads/`, or an import ref under
/// `refs/remotes/`) names, or `None` when
/// it names none (decision 309).
pub(crate) fn branch_head(root: &Path, branch: &str) -> Result<Option<GitHead>, GitRefusal> {
let repo = repo::Repo::open(root)?.ok_or(GitRefusal::NotARepository)?;
repo.check_ref(branch)?;
Ok(repo.resolve(branch)?.map(|commit| GitHead { commit }))
}
/// Fetch `import`'s branch from its remote into its ref (decision 318).
pub(crate) fn fetch(root: &Path, import: &ImportRef) -> Result<(), GitRefusal> {
let span = tracing::info_span!("git_fetch", outcome = tracing::field::Empty);
traced(&span, || {
let repo = repo::Repo::open(root)?.ok_or(GitRefusal::NotARepository)?;
repo.check_ref(&import.reference)?;
repo.fetch(&import.remote, &import.branch)
})
}
/// Whether `head` equals or descends from `ancestor` (decision 309).
pub(crate) fn descends(
root: &Path,
ancestor: &GitHead,
head: &GitHead,
) -> Result<bool, GitRefusal> {
let repo = repo::Repo::open(root)?.ok_or(GitRefusal::NotARepository)?;
Ok(repo
.ask([
"merge-base",
"--is-ancestor",
&crate::ids::hex(&ancestor.commit),
&crate::ids::hex(&head.commit),
])?
.is_some())
}
/// Whether LVCS wrote `head`: it carries the frontier trailer, or is
/// `lvcs init`'s HEAD's line (decision 309).
pub(crate) fn lvcs_written(root: &Path, head: &GitHead) -> Result<bool, GitRefusal> {
let repo = repo::Repo::open(root)?.ok_or(GitRefusal::NotARepository)?;
commits::lvcs_written(&repo, &head.commit)
}
/// Merge what `head`'s commits changed since their import base onto the
/// main mirror's disk (decision 309, `import`'s module docs): the commits,
/// the paths written, and the paths skipped. `known`, the last commit LVCS
/// wrote or imported on the branch, bounds the base when it is an
/// ancestor of `head` (row 331).
pub(crate) fn import(
root: &Path,
head: &GitHead,
known: Option<&GitHead>,
) -> Result<ImportReport, GitRefusal> {
let span = tracing::info_span!("git_import", outcome = tracing::field::Empty);
traced(&span, || {
import::import(root, &head.commit, known.map(|known| &known.commit))
})
}
/// The last commit imported on `branch` in any run
/// (`IMPORTED_REF_PREFIX` + the branch's name below `refs/`, row 331).
pub(crate) fn imported(root: &Path, branch: &str) -> Result<Option<GitHead>, GitRefusal> {
let repo = repo::Repo::open(root)?.ok_or(GitRefusal::NotARepository)?;
let name = imported_ref(branch);
repo.check_ref(&name)?;
Ok(repo.resolve(&name)?.map(|commit| GitHead { commit }))
}
/// Keep `head` as the last commit imported on `branch` (row 331).
pub(crate) fn record_imported(root: &Path, branch: &str, head: &GitHead) -> Result<(), GitRefusal> {
let repo = repo::Repo::open(root)?.ok_or(GitRefusal::NotARepository)?;
let name = imported_ref(branch);
repo.check_ref(&name)?;
repo.set_ref(&name, &head.commit, "lvcs: imported")
}