use std::fs::{self, File, OpenOptions};
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use super::acquisition::registrations;
use super::{local_head, lock_launch};
use crate::git::Git;
use crate::progress;
#[path = "review_recovery.rs"]
mod review_recovery;
pub(super) use review_recovery::{Publication, recover as recover_review};
pub(super) struct Checkout {
root: Directory,
admin: Directory,
common: Directory,
branch: Option<String>,
}
impl Checkout {
pub(super) fn capture(launch: &Git, path: &Path, branch: Option<&str>) -> Result<Self> {
let git = Git::new(path);
let owner = Self {
root: Directory::open(path.to_path_buf())?,
admin: Directory::open(PathBuf::from(
git.run(&["rev-parse", "--absolute-git-dir"])?,
))?,
common: Directory::open(launch.common_dir()?.canonicalize()?)?,
branch: branch.map(str::to_string),
};
owner.inspect(launch)?;
Ok(owner)
}
pub(super) fn path(&self) -> &Path {
&self.root.path
}
pub(super) fn cleanup(&self, launch: &Git, kept: bool) {
let launch = launch.completion();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
if kept {
self.retain(&launch, "Failed run preservation did not finish", true);
return;
}
progress::step(match &self.branch {
Some(branch) => format!("cleaning up the worktree and local branch {branch}"),
None => "cleaning up the worktree".to_string(),
});
if let Err(error) = self.remove(&launch) {
self.retain(&launch, &format!("{error:#}"), false);
}
}));
if result.is_err() {
use std::io::Write;
let _ = writeln!(
std::io::stderr(),
"thirdshift: warning: retaining checkout {} and {}: cleanup panicked; head unknown",
self.path().display(),
self.branch.as_deref().unwrap_or("detached HEAD"),
);
}
}
fn retain(&self, launch: &Git, reason: &str, kept: bool) {
let head = Git::new(self.path())
.completion()
.run(&["rev-parse", "--verify", "HEAD"])
.ok()
.or_else(|| {
self.branch
.as_deref()
.and_then(|branch| local_head(launch, branch).ok().flatten())
});
progress::step(format_args!(
"{} {} and {} at {}: {reason}",
if kept {
"keeping the worktree"
} else {
"warning: retaining checkout"
},
self.path().display(),
self.branch
.as_ref()
.map(|branch| format!("local branch {branch}"))
.as_deref()
.unwrap_or("detached HEAD"),
head.as_deref().unwrap_or("unknown head"),
));
}
fn remove(&self, launch: &Git) -> Result<()> {
self.common.verify()?;
let _lock = lock_launch(launch).context("cannot establish cleanup worktree lock")?;
self.remove_locked(launch)
}
fn remove_locked(&self, launch: &Git) -> Result<()> {
let _refs_lock = launch.lock_worktree_refs()?;
let head = self.inspect(launch)?;
let config = self
.branch
.as_deref()
.map(|branch| branch_config(launch, branch))
.transpose()?;
launch
.run(&[
"worktree",
"remove",
"--force",
self.path().to_str().context("worktree path is not UTF-8")?,
])
.context("owned checkout removal failed; local branch retained")?;
if let Some(branch) = &self.branch
&& let Err(error) =
self.remove_branch(launch, branch, &head, config.as_deref().unwrap_or_default())
{
let current = local_head(launch, branch).ok().flatten();
progress::step(format_args!(
"warning: retaining local branch {branch} or its configuration for checkout {} at {}: {error:#}",
self.path().display(),
current.as_deref().unwrap_or(&head),
));
}
Ok(())
}
fn remove_branch(
&self,
launch: &Git,
branch: &str,
head: &str,
config: &[String],
) -> Result<()> {
let reference = format!("refs/heads/{branch}");
if let Some(entry) = registrations(launch)?
.iter()
.find(|entry| entry.branch.as_deref() == Some(&reference))
{
bail!("local branch still registered at {}", entry.path.display());
}
launch
.run(&["update-ref", "--no-deref", "-d", &reference, head])
.with_context(|| format!("conditional removal failed; last observed head {head}"))?;
if local_head(launch, branch)?.is_some() {
bail!("local ref was recreated; branch configuration retained");
}
if branch_config(launch, branch)? != config {
bail!("branch configuration changed since cleanup snapshot; configuration retained");
}
if local_head(launch, branch)?.is_some() {
bail!("local ref was recreated; branch configuration retained");
}
if !config.is_empty() {
launch.run(&[
"config",
"--local",
"--no-includes",
"--remove-section",
&format!("branch.{branch}"),
])?;
}
Ok(())
}
fn inspect(&self, launch: &Git) -> Result<String> {
self.root.verify().context("checkout directory identity")?;
self.admin
.verify()
.context("administrative directory identity")?;
self.common.verify().context("common repository identity")?;
if launch.common_dir()?.canonicalize()? != self.common.path {
bail!("launch no longer belongs to the captured common repository");
}
let entries = registrations(launch)?;
let entry = entries
.iter()
.find(|entry| entry.path == self.root.path)
.context("original checkout registration is missing")?;
if entry.locked {
bail!("checkout registration is locked");
}
let reference = self
.branch
.as_ref()
.map(|branch| format!("refs/heads/{branch}"));
if entry.branch != reference || entry.detached != self.branch.is_none() {
bail!("attached/detached checkout identity changed");
}
if fs::symlink_metadata(self.path().join(".git"))?
.file_type()
.is_symlink()
{
bail!("checkout Git link became a symlink");
}
if fs::symlink_metadata(self.admin.path.join("gitdir"))?
.file_type()
.is_symlink()
{
bail!("administrative Git backlink became a symlink");
}
let git = Git::new(self.path()).completion();
let head = git.run(&["rev-parse", "--verify", "HEAD"])?;
let backlink = fs::read_to_string(self.admin.path.join("gitdir"))?;
if Path::new(&git.run(&["rev-parse", "--show-toplevel"])?) != self.root.path
|| Path::new(&git.run(&["rev-parse", "--absolute-git-dir"])?) != self.admin.path
|| git.common_dir()?.canonicalize()? != self.common.path
|| Path::new(backlink.trim_end()).canonicalize()? != self.path().join(".git")
|| git.run(&["rev-parse", "--symbolic-full-name", "HEAD"])?
!= reference.as_deref().unwrap_or("HEAD")
|| entry.head.as_deref() != Some(&head)
{
bail!("checkout and registration ownership evidence is inconsistent");
}
if let Some(branch) = &self.branch
&& local_head(launch, branch)?.as_deref() != Some(&head)
{
bail!("attached Issue ref does not match checkout HEAD");
}
self.root.verify()?;
self.admin.verify()?;
self.common.verify()?;
Ok(head)
}
}
struct Directory {
path: PathBuf,
handle: File,
}
impl Directory {
fn open(path: PathBuf) -> Result<Self> {
let handle = OpenOptions::new()
.read(true)
.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW)
.open(&path)
.with_context(|| format!("cannot pin directory {}", path.display()))?;
let directory = Self { path, handle };
directory.verify()?;
Ok(directory)
}
fn verify(&self) -> Result<()> {
let current = fs::symlink_metadata(&self.path).with_context(|| {
format!("cannot inspect original directory {}", self.path.display())
})?;
let pinned = self.handle.metadata()?;
if !current.is_dir() || current.dev() != pinned.dev() || current.ino() != pinned.ino() {
bail!(
"directory {} was replaced or became a symlink",
self.path.display()
);
}
Ok(())
}
}
fn branch_config(launch: &Git, branch: &str) -> Result<Vec<String>> {
let listing = launch.run(&["config", "--local", "--no-includes", "--null", "--list"])?;
if listing.contains('\u{fffd}') || (!listing.is_empty() && !listing.ends_with('\0')) {
bail!("cannot establish an unambiguous branch configuration snapshot");
}
let section = format!("branch.{branch}");
Ok(listing
.split('\0')
.filter(|record| {
let key = record.split_once('\n').map_or(*record, |(key, _)| key);
key.rsplit_once('.')
.is_some_and(|(prefix, _)| prefix == section)
})
.map(str::to_string)
.collect())
}