use crate::blob_batch::load_blob_batch;
use crate::diagnostic_log::{debug, warn};
use crate::path_bytes::{os_from_path_bytes, path_message_text};
use crate::random_id::random_uuid;
use crate::recovery_error::{RecoveryError, io_failure};
use crate::transaction_git::GitContext;
use crate::transaction_journal::AddedPath;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct WorktreeFileIdentity {
pub device: u64,
pub inode: u64,
pub modified: i128,
pub changed: i128,
pub size: u64,
pub mode: u32,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WorktreeFileState {
Original(WorktreeFileIdentity),
Intended,
Conflict,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct InstallResult {
pub rewritten: Vec<Vec<u8>>,
pub conflicted: Vec<Vec<u8>>,
}
pub fn is_conflict_error(error: &std::io::Error) -> bool {
if matches!(
error.kind(),
std::io::ErrorKind::NotFound
| std::io::ErrorKind::NotADirectory
| std::io::ErrorKind::PermissionDenied
) {
return true;
}
#[cfg(unix)]
{
return error.raw_os_error() == Some(libc::ELOOP);
}
#[cfg(not(unix))]
{
return false;
}
}
fn identity_of(metadata: &std::fs::Metadata) -> WorktreeFileIdentity {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
return WorktreeFileIdentity {
device: metadata.dev(),
inode: metadata.ino(),
modified: i128::from(metadata.mtime()) * 1_000_000_000
+ i128::from(metadata.mtime_nsec()),
changed: i128::from(metadata.ctime()) * 1_000_000_000
+ i128::from(metadata.ctime_nsec()),
size: metadata.size(),
mode: metadata.mode() & 0o777,
};
}
#[cfg(not(unix))]
{
let modified: i128 = match metadata.modified() {
Ok(time) => match time.duration_since(std::time::UNIX_EPOCH) {
Ok(elapsed) => i128::try_from(elapsed.as_nanos()).unwrap_or(i128::MAX),
Err(_) => 0,
},
Err(_) => 0,
};
return WorktreeFileIdentity {
device: 0,
inode: 0,
modified,
changed: modified,
size: metadata.len(),
mode: if metadata.permissions().readonly() {
0o444
} else {
0o666
},
};
}
}
fn link_count(metadata: &std::fs::Metadata) -> u64 {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
return metadata.nlink();
}
#[cfg(not(unix))]
{
let _ = metadata;
return 1;
}
}
fn directory_is_canonical(destination: &Path) -> std::io::Result<bool> {
let Some(parent) = destination.parent() else {
return Ok(false);
};
return Ok(std::fs::canonicalize(parent)? == parent);
}
fn open_nonblocking(destination: &Path) -> std::io::Result<std::fs::File> {
let mut options: std::fs::OpenOptions = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
}
return options.open(destination);
}
pub fn inspect_worktree_file(
destination: &Path,
git_mode: &str,
original: &[u8],
intended: &[u8],
) -> std::io::Result<WorktreeFileState> {
match inspect_or_fail(destination, git_mode, original, intended) {
Ok(state) => return Ok(state),
Err(error) if is_conflict_error(&error) => return Ok(WorktreeFileState::Conflict),
Err(error) => return Err(error),
}
}
fn inspect_or_fail(
destination: &Path,
git_mode: &str,
original: &[u8],
intended: &[u8],
) -> std::io::Result<WorktreeFileState> {
if !directory_is_canonical(destination)? {
return Ok(WorktreeFileState::Conflict);
}
let entry: std::fs::Metadata = std::fs::symlink_metadata(destination)?;
if !entry.file_type().is_file() || link_count(&entry) != 1 {
return Ok(WorktreeFileState::Conflict);
}
let mut handle: std::fs::File = open_nonblocking(destination)?;
let opened: std::fs::Metadata = handle.metadata()?;
let opened_identity: WorktreeFileIdentity = identity_of(&opened);
let entry_identity: WorktreeFileIdentity = identity_of(&entry);
if !opened.file_type().is_file()
|| link_count(&opened) != 1
|| entry_identity.device != opened_identity.device
|| entry_identity.inode != opened_identity.inode
{
return Ok(WorktreeFileState::Conflict);
}
if (opened_identity.mode & 0o100 != 0) != (git_mode == "100755") {
return Ok(WorktreeFileState::Conflict);
}
let mut bytes: Vec<u8> = Vec::new();
handle.read_to_end(&mut bytes)?;
let after: WorktreeFileIdentity = identity_of(&std::fs::symlink_metadata(destination)?);
if after.device != opened_identity.device
|| after.inode != opened_identity.inode
|| after.modified != opened_identity.modified
|| after.changed != opened_identity.changed
|| after.size != opened_identity.size
|| !directory_is_canonical(destination)?
{
return Ok(WorktreeFileState::Conflict);
}
if bytes == intended {
return Ok(WorktreeFileState::Intended);
}
if bytes != original {
return Ok(WorktreeFileState::Conflict);
}
return Ok(WorktreeFileState::Original(opened_identity));
}
fn write_prepared(prepared: &Path, bytes: &[u8], mode: u32) -> std::io::Result<()> {
let mut options: std::fs::OpenOptions = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(mode);
}
let mut file: std::fs::File = options.open(prepared)?;
file.write_all(bytes)?;
drop(file);
return crate::private_storage::protect_path(prepared, mode);
}
pub fn replace_worktree_file(
destination: &Path,
bytes: &[u8],
git_mode: &str,
original: &[u8],
identity: &WorktreeFileIdentity,
) -> Result<bool, RecoveryError> {
let unique: String = match random_uuid() {
Ok(id) => id,
Err(error) => return Err(RecoveryError(error.0)),
};
let directory: &Path = destination.parent().unwrap_or(destination);
let prepared: PathBuf = directory.join(format!(".cli-git-added-{unique}"));
let outcome: Result<bool, RecoveryError> = replace_through(
prepared.as_path(),
destination,
bytes,
git_mode,
original,
identity,
);
match &outcome {
Ok(true) => {}
Ok(false) => {
let _ = std::fs::remove_file(&prepared);
}
Err(error) => {
warn(
"replaceWorktreeFile",
format!(
"worktree completion failed for {}: {error}",
destination.display()
)
.as_str(),
);
let _ = std::fs::remove_file(&prepared);
}
}
return outcome;
}
fn replace_through(
prepared: &Path,
destination: &Path,
bytes: &[u8],
git_mode: &str,
original: &[u8],
identity: &WorktreeFileIdentity,
) -> Result<bool, RecoveryError> {
if let Err(error) = write_prepared(prepared, bytes, identity.mode) {
return Err(io_failure(
"preparing the worktree replacement",
prepared,
&error,
));
}
let current: WorktreeFileState =
match inspect_worktree_file(destination, git_mode, original, bytes) {
Ok(state) => state,
Err(error) => {
return Err(io_failure(
"checking the worktree file",
destination,
&error,
));
}
};
let WorktreeFileState::Original(again) = current else {
return Ok(false);
};
if again != *identity {
return Ok(false);
}
if let Err(error) = std::fs::rename(prepared, destination) {
return Err(io_failure(
"installing the worktree replacement",
destination,
&error,
));
}
return Ok(true);
}
pub fn install_added_worktree_files(
context: &GitContext,
cwd: &Path,
repository_root: &Path,
records: &[AddedPath],
object_directory: Option<&Path>,
) -> Result<InstallResult, RecoveryError> {
let mut result: InstallResult = InstallResult::default();
if records.is_empty() {
return Ok(result);
}
let mut oids: Vec<String> = Vec::with_capacity(records.len() * 2);
for record in records {
oids.push(record.original_oid.clone());
oids.push(record.intended_oid.clone());
}
let blobs: HashMap<String, Vec<u8>> =
match load_blob_batch(context, cwd, oids.as_slice(), object_directory) {
Ok(found) => found,
Err(message) => return Err(RecoveryError(message)),
};
for record in records {
let destination: PathBuf = repository_root.join(os_from_path_bytes(record.path.as_slice()));
let shown: String = path_message_text(record.path.as_slice());
let (Some(intended), Some(original)) = (
blobs.get(&record.intended_oid),
blobs.get(&record.original_oid),
) else {
return Err(RecoveryError(format!(
"Git blob batch omitted a worktree completion object for {}.",
shown
)));
};
let state: WorktreeFileState = match inspect_worktree_file(
destination.as_path(),
record.git_mode.as_str(),
original.as_slice(),
intended.as_slice(),
) {
Ok(found) => found,
Err(error) => {
return Err(io_failure(
"checking the worktree file",
destination.as_path(),
&error,
));
}
};
let identity: WorktreeFileIdentity = match state {
WorktreeFileState::Intended => continue,
WorktreeFileState::Conflict => {
warn(
"installAddedWorktreeFiles",
format!(
"Worktree copy of {path} changed or disappeared while cli-git completed the commit; \
the committed bytes remain in HEAD and your worktree state was kept. \
Compare it with HEAD (git diff HEAD -- {path}).",
path = shown
)
.as_str(),
);
result.conflicted.push(record.path.clone());
continue;
}
WorktreeFileState::Original(found) => found,
};
if !replace_worktree_file(
destination.as_path(),
intended.as_slice(),
record.git_mode.as_str(),
original.as_slice(),
&identity,
)? {
warn(
"installAddedWorktreeFiles",
format!(
"Worktree copy of {path} changed while its replacement was prepared; \
the committed bytes remain in HEAD and your edit was kept. \
Compare it with HEAD (git diff HEAD -- {path}).",
path = shown
)
.as_str(),
);
result.conflicted.push(record.path.clone());
continue;
}
debug(
"installAddedWorktreeFiles",
format!("rewrote {shown} with its landed bytes").as_str(),
);
result.rewritten.push(record.path.clone());
}
return Ok(result);
}
#[cfg(test)]
#[path = "worktree_completion_tests.rs"]
mod tests;