openpencil/crates/op-git/src/worktree.rs
2026-05-31 17:04:56 +08:00

165 lines
6.6 KiB
Rust

//! Throwaway git worktrees for isolated merges.
//!
//! A normal `git merge` runs in the live working tree. For a design
//! tool that is dangerous: a conflicting merge writes `<<<<<<<`
//! markers straight into the open `.op` document, corrupting the
//! JSON the editor is rendering, and leaves the repo in a
//! `MERGE_HEAD` state the user did not ask for.
//!
//! [`MergeWorktree`] sidesteps that by running the merge in a
//! detached, throwaway worktree. The live tree is never touched: a
//! clean merge is fast-forwarded back deliberately, and a
//! conflicting one is reported as a [`crate::ConflictBag`] without
//! ever marking up the user's files. The worktree directory and its
//! git registration are removed when the handle drops.
//!
//! The implementation drives libgit2 in-process (`git2`) — no system
//! `git` subprocess. A linked worktree is created with
//! [`git2::Repository::worktree`], detached onto the requested commit
//! by opening the worktree repo and `set_head_detached` + a forced
//! `checkout_head`, and deregistered on drop via
//! [`git2::Worktree::prune`] with `working_tree` enabled so libgit2
//! removes both the `.git/worktrees/<name>` admin files and the
//! worktree directory itself.
use std::path::PathBuf;
use crate::{GitError, GitRepo};
/// A detached, throwaway worktree used to compute a merge in
/// isolation from the user's live working tree.
pub(crate) struct MergeWorktree {
/// `GitRepo` handle rooted at the worktree directory.
repo: GitRepo,
/// The main repository — used to deregister the worktree.
main: GitRepo,
/// The worktree directory.
dir: PathBuf,
/// libgit2's registration name for this linked worktree (the
/// directory basename). Used to look the worktree up again on
/// drop so it can be pruned.
name: String,
}
impl MergeWorktree {
/// Add a detached worktree at `dir` checked out at `commit`.
/// `dir` must not already exist — libgit2 creates it.
pub(crate) fn create(
main: &GitRepo,
dir: PathBuf,
commit: &str,
) -> Result<MergeWorktree, GitError> {
// The worktree registration name. libgit2 keys its admin
// files under `.git/worktrees/<name>`; deriving it from the
// directory basename keeps it unique (the dir is itself a
// unique temp path) and lets `Drop` find the worktree again.
let name = dir
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| GitError::Io("worktree path has no valid basename".to_string()))?
.to_string();
let main_repo = main.open()?;
// Resolve the committish (a full HEAD sha in practice, but
// accept any revspec) to the commit it names. The detached
// HEAD is pinned onto exactly this commit below.
let commit_oid = main_repo
.revparse_single(commit)
.map_err(|e| GitError::Command {
operation: "worktree add".to_string(),
stderr: e.message().to_string(),
})?
.peel_to_commit()
.map_err(|e| GitError::Command {
operation: "worktree add".to_string(),
stderr: e.message().to_string(),
})?
.id();
// Create the linked worktree. With no `reference` set in the
// options libgit2 checks it out at the main repo's HEAD; we
// re-point it onto `commit` (detached) immediately after.
let opts = git2::WorktreeAddOptions::new();
let worktree =
main_repo
.worktree(&name, &dir, Some(&opts))
.map_err(|e| GitError::Command {
operation: "worktree add".to_string(),
stderr: e.message().to_string(),
})?;
// Detach the new worktree's HEAD onto the requested commit and
// force the working tree to match — the libgit2 equivalent of
// `git worktree add --detach <dir> <commit>`.
let wt_repo =
git2::Repository::open_from_worktree(&worktree).map_err(|e| GitError::Command {
operation: "worktree add".to_string(),
stderr: e.message().to_string(),
})?;
wt_repo
.set_head_detached(commit_oid)
.map_err(|e| GitError::Command {
operation: "worktree add".to_string(),
stderr: e.message().to_string(),
})?;
let mut checkout = git2::build::CheckoutBuilder::new();
checkout.force();
wt_repo
.checkout_head(Some(&mut checkout))
.map_err(|e| GitError::Command {
operation: "worktree add".to_string(),
stderr: e.message().to_string(),
})?;
Ok(MergeWorktree {
repo: GitRepo {
workdir: dir.clone(),
auth_env: Vec::new(),
},
main: main.clone(),
dir,
name,
})
}
/// The `GitRepo` handle rooted in the worktree — merge / status
/// operations run against this, never the live tree.
pub(crate) fn repo(&self) -> &GitRepo {
&self.repo
}
}
impl Drop for MergeWorktree {
fn drop(&mut self) {
// Deregister the worktree with libgit2 first so its admin
// files under `.git/worktrees/` are cleaned up. `prune` with
// `working_tree` also recursively removes the worktree
// directory itself.
if let Ok(main_repo) = self.main.open() {
if let Ok(worktree) = main_repo.find_worktree(&self.name) {
let mut opts = git2::WorktreePruneOptions::new();
// Prune even though the worktree is still valid (it
// exists on disk) and recursively remove its working
// directory — this is a throwaway tree by design.
opts.valid(true).working_tree(true);
let _ = worktree.prune(Some(&mut opts));
}
}
// Belt-and-suspenders: if the prune failed or only removed the
// admin files (e.g. a half-created worktree, or a libgit2
// build that left the directory behind), still drop the
// directory so no stale temp tree lingers.
let _ = std::fs::remove_dir_all(&self.dir);
// Final sweep: drop any now-dangling registration whose
// working tree no longer exists on disk.
if let Ok(main_repo) = self.main.open() {
if let Ok(worktree) = main_repo.find_worktree(&self.name) {
if worktree.is_prunable(None).unwrap_or(false) {
let _ = worktree.prune(None);
}
}
}
}
}