use crate::fs_beneath::{ObjectId, identity_of, open_file_at, open_root};
use crate::worktree_path_failure_message::subject_failure;
use std::ffi::OsStr;
use std::fs::File;
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Binding {
pub destination: PathBuf,
pub destination_id: ObjectId,
pub admin: PathBuf,
pub admin_id: ObjectId,
pub pointer: Vec<u8>,
}
#[derive(Debug)]
pub struct Bound {
pub binding: Binding,
pub destination: File,
pub admin: File,
}
fn field_of<'a>(text: &'a str, key: &str) -> Option<&'a str> {
for line in text.lines() {
if let Some(rest) = line.strip_prefix(key)
&& let Some(value) = rest.strip_prefix('=')
{
return Some(value);
}
}
return None;
}
fn hex(bytes: &[u8]) -> String {
let mut spelled: String = String::new();
for byte in bytes {
spelled.push_str(format!("{byte:02x}").as_str());
}
return spelled;
}
fn unhex(text: &str) -> Option<Vec<u8>> {
if text.len() % 2 != 0 {
return None;
}
let mut bytes: Vec<u8> = Vec::new();
let mut start: usize = 0;
while start < text.len() {
let pair: &str = text.get(start..start + 2)?;
bytes.push(u8::from_str_radix(pair, 16).ok()?);
start += 2;
}
return Some(bytes);
}
pub fn encode_path(path: &Path) -> String {
return hex(path.as_os_str().as_bytes());
}
pub fn decode_path(text: &str) -> Option<PathBuf> {
use std::os::unix::ffi::OsStringExt;
return Some(PathBuf::from(std::ffi::OsString::from_vec(unhex(text)?)));
}
impl Binding {
pub fn encode(&self) -> String {
return format!(
"destination={}\nphase=registered\ndestination_id={}\nadmin={}\nadmin_id={}\npointer={}\n",
encode_path(self.destination.as_path()),
self.destination_id.encode(),
encode_path(self.admin.as_path()),
self.admin_id.encode(),
hex(self.pointer.as_slice()),
);
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Recorded {
Planned(PathBuf),
Registered(Binding),
}
pub fn decode_record(text: &str) -> Option<Recorded> {
let destination: PathBuf = decode_path(field_of(text, "destination")?)?;
if field_of(text, "phase")? == "planned" {
return Some(Recorded::Planned(destination));
}
if field_of(text, "phase")? != "registered" {
return None;
}
return Some(Recorded::Registered(Binding {
destination,
destination_id: ObjectId::decode(field_of(text, "destination_id")?)?,
admin: decode_path(field_of(text, "admin")?)?,
admin_id: ObjectId::decode(field_of(text, "admin_id")?)?,
pointer: unhex(field_of(text, "pointer")?)?,
}));
}
pub fn planned_record(destination: &Path) -> String {
return format!("destination={}\nphase=planned\n", encode_path(destination));
}
pub fn path_identity(path: &Path) -> Option<ObjectId> {
let parent: File = open_root(path.parent()?).ok()?;
return crate::fs_beneath::identity_at(&parent, path.file_name()?).ok();
}
pub fn bind_registration(common_dir: &Path, destination: &Path) -> Result<Bound, String> {
let shown: String = destination.display().to_string();
let pointer_shown: String = format!("{shown}/.git");
let registration_shown: String = format!("the registration of {shown}");
let held: File = subject_failure(open_root(destination), &shown, "is not a real directory")?;
let pointer: Vec<u8> = subject_failure(read_through(&held, OsStr::new(".git")), &pointer_shown, "is unreadable")?;
let pointer_path: PathBuf = destination.join(".git");
let admin_path: PathBuf = pointer_target(destination, pointer.as_slice())?;
let admin: File = match open_root(admin_path.as_path()) {
Ok(opened) => opened,
Err(error) => return Err(not_canonical(shown.as_str(), admin_path.as_path(), error.to_string().as_str())),
};
let worktrees: PathBuf = subject_failure(std::fs::canonicalize(common_dir.join("worktrees")), &"the worktrees of the repository", "are unreadable")?;
if admin_path.parent() != Some(worktrees.as_path()) {
return Err(format!("{shown} is not registered in this repository"));
}
let recorded: Vec<u8> = subject_failure(read_through(&admin, OsStr::new("gitdir")), ®istration_shown, "is unreadable")?;
let recorded_line: &[u8] = recorded.strip_suffix(b"\n").unwrap_or(recorded.as_slice());
if recorded_destination(admin_path.as_path(), recorded_line)?.as_path() != pointer_path.as_path() {
return Err(format!("the registration {} does not name {shown}", admin_path.display()));
}
let binding: Binding = Binding {
destination: destination.to_path_buf(),
destination_id: subject_failure(identity_of(&held), &shown, "cannot be examined")?,
admin: admin_path,
admin_id: subject_failure(identity_of(&admin), &"the registration", "cannot be examined")?,
pointer,
};
return Ok(Bound { binding, destination: held, admin });
}
fn not_canonical(shown: &str, target: &Path, why: &str) -> String {
return format!("the pointer in {shown}/.git names {}, which cannot be resolved without a symbolic link to the registration ({why}); the add is left as Git made it: make the worktree with a canonical path, or replace the link in the pointer with the real directory", target.display());
}
fn recorded_destination(admin: &Path, recorded: &[u8]) -> Result<PathBuf, String> {
return match resolve_lexically(admin, recorded) {
Ok(resolved) => Ok(resolved),
Err(()) => Err(format!("the registration {} names a place outside the file system root", admin.display())),
};
}
fn resolve_lexically(base: &Path, named: &[u8]) -> Result<PathBuf, ()> {
let mut resolved: PathBuf = PathBuf::new();
for part in base.join(OsStr::from_bytes(named)).components() {
match part {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
if !resolved.pop() {
return Err(());
}
}
other => resolved.push(other.as_os_str()),
}
}
return Ok(resolved);
}
fn pointer_target(destination: &Path, pointer: &[u8]) -> Result<PathBuf, String> {
let shown: String = destination.display().to_string();
let line: &[u8] = pointer.strip_suffix(b"\n").unwrap_or(pointer);
let Some(named) = line.strip_prefix(b"gitdir: ") else {
return Err(format!("{shown}/.git is not a gitdir pointer"));
};
return match resolve_lexically(destination, named) {
Ok(resolved) => Ok(resolved),
Err(()) => Err(format!("the pointer in {shown}/.git climbs out of the file system root")),
};
}
fn read_through(directory: &File, name: &OsStr) -> std::io::Result<Vec<u8>> {
let mut file: File = open_file_at(directory, name)?;
let mut bytes: Vec<u8> = Vec::new();
std::io::Read::read_to_end(&mut file, &mut bytes)?;
return Ok(bytes);
}
pub fn verify_association(bound: &Bound) -> Result<(), String> {
let binding: &Binding = &bound.binding;
let shown: String = binding.destination.display().to_string();
if path_identity(binding.destination.as_path()) != Some(binding.destination_id) {
return Err(format!("{shown} is no longer the directory Git made for this add"));
}
if path_identity(binding.admin.as_path()) != Some(binding.admin_id) {
return Err(format!("the registration {} is no longer the one Git made for this add", binding.admin.display()));
}
let pointer: Vec<u8> = subject_failure(read_through(&bound.destination, OsStr::new(".git")), &format!("{shown}/.git"), "is unreadable")?;
if pointer != binding.pointer {
return Err(format!("{shown}/.git is no longer the pointer Git wrote"));
}
let target: PathBuf = pointer_target(binding.destination.as_path(), pointer.as_slice())?;
let resolved: File = match open_root(target.as_path()) {
Ok(opened) => opened,
Err(error) => return Err(not_canonical(shown.as_str(), target.as_path(), error.to_string().as_str())),
};
if identity_of(&resolved).ok() != Some(binding.admin_id) || identity_of(&bound.admin).ok() != Some(binding.admin_id) {
return Err(format!("the pointer in {shown}/.git now resolves to another registration than the one Git made for this add"));
}
let recorded: Vec<u8> = subject_failure(read_through(&bound.admin, OsStr::new("gitdir")), &format!("the registration of {shown}"), "is unreadable")?;
let line: &[u8] = recorded.strip_suffix(b"\n").unwrap_or(recorded.as_slice());
if recorded_destination(binding.admin.as_path(), line)?.as_path() != binding.destination.join(".git").as_path() {
return Err(format!("the registration {} no longer names {shown}", binding.admin.display()));
}
return Ok(());
}
#[cfg(test)]
#[path = "worktree_add_binding_tests.rs"]
mod tests;