1use super::blob_batch::load_blob_batch;
24use super::diagnostic_log::{debug, warn};
26use super::path_bytes::{os_from_path_bytes, path_message_text};
28use super::random_id::random_uuid;
30use super::recovery_error::{RecoveryError, io_failure};
32use super::transaction_git::GitContext;
34use super::transaction_journal::AddedPath;
36use std::collections::HashMap;
38use std::io::{Read, Write};
40use std::path::{Path, PathBuf};
42
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
53pub struct WorktreeFileIdentity {
54 pub device: u64,
56 pub inode: u64,
58 pub modified: i128,
60 pub changed: i128,
62 pub size: u64,
64 pub mode: u32,
66}
67
68#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78pub enum WorktreeFileState {
79 Original(WorktreeFileIdentity),
82 Intended,
84 Conflict,
89}
90
91#[derive(Clone, Debug, Default, Eq, PartialEq)]
101pub struct InstallResult {
102 pub rewritten: Vec<Vec<u8>>,
104 pub conflicted: Vec<Vec<u8>>,
106}
107
108pub fn is_conflict_error(error: &std::io::Error) -> bool {
122 if matches!(
123 error.kind(),
124 std::io::ErrorKind::NotFound
125 | std::io::ErrorKind::NotADirectory
126 | std::io::ErrorKind::PermissionDenied
127 ) {
128 return true;
129 }
130 #[cfg(unix)]
131 {
132 return error.raw_os_error() == Some(libc::ELOOP);
133 }
134 #[cfg(not(unix))]
136 {
137 return false;
138 }
139}
140
141fn identity_of(metadata: &std::fs::Metadata) -> WorktreeFileIdentity {
152 #[cfg(unix)]
153 {
154 use std::os::unix::fs::MetadataExt;
155 return WorktreeFileIdentity {
156 device: metadata.dev(),
157 inode: metadata.ino(),
158 modified: i128::from(metadata.mtime()) * 1_000_000_000
159 + i128::from(metadata.mtime_nsec()),
160 changed: i128::from(metadata.ctime()) * 1_000_000_000
161 + i128::from(metadata.ctime_nsec()),
162 size: metadata.size(),
163 mode: metadata.mode() & 0o777,
164 };
165 }
166 #[cfg(not(unix))]
168 {
169 let modified: i128 = match metadata.modified() {
170 Ok(time) => match time.duration_since(std::time::UNIX_EPOCH) {
171 Ok(elapsed) => i128::try_from(elapsed.as_nanos()).unwrap_or(i128::MAX),
172 Err(_) => 0,
173 },
174 Err(_) => 0,
175 };
176 return WorktreeFileIdentity {
177 device: 0,
178 inode: 0,
179 modified,
180 changed: modified,
181 size: metadata.len(),
182 mode: if metadata.permissions().readonly() {
183 0o444
184 } else {
185 0o666
186 },
187 };
188 }
189}
190
191fn link_count(metadata: &std::fs::Metadata) -> u64 {
202 #[cfg(unix)]
203 {
204 use std::os::unix::fs::MetadataExt;
205 return metadata.nlink();
206 }
207 #[cfg(not(unix))]
209 {
210 let _ = metadata;
211 return 1;
212 }
213}
214
215fn directory_is_canonical(destination: &Path) -> std::io::Result<bool> {
225 let Some(parent) = destination.parent() else {
226 return Ok(false);
227 };
228 return Ok(std::fs::canonicalize(parent)? == parent);
229}
230
231fn open_nonblocking(destination: &Path) -> std::io::Result<std::fs::File> {
241 let mut options: std::fs::OpenOptions = std::fs::OpenOptions::new();
242 options.read(true);
243 #[cfg(unix)]
244 {
245 use std::os::unix::fs::OpenOptionsExt;
246 options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
247 }
248 return options.open(destination);
249}
250
251pub fn inspect_worktree_file(
265 destination: &Path,
266 git_mode: &str,
267 original: &[u8],
268 intended: &[u8],
269) -> std::io::Result<WorktreeFileState> {
270 match inspect_or_fail(destination, git_mode, original, intended) {
271 Ok(state) => return Ok(state),
272 Err(error) if is_conflict_error(&error) => return Ok(WorktreeFileState::Conflict),
273 Err(error) => return Err(error),
274 }
275}
276
277fn inspect_or_fail(
288 destination: &Path,
289 git_mode: &str,
290 original: &[u8],
291 intended: &[u8],
292) -> std::io::Result<WorktreeFileState> {
293 if !directory_is_canonical(destination)? {
294 return Ok(WorktreeFileState::Conflict);
295 }
296 let entry: std::fs::Metadata = std::fs::symlink_metadata(destination)?;
297 if !entry.file_type().is_file() || link_count(&entry) != 1 {
298 return Ok(WorktreeFileState::Conflict);
299 }
300 let mut handle: std::fs::File = open_nonblocking(destination)?;
301 let opened: std::fs::Metadata = handle.metadata()?;
302 let opened_identity: WorktreeFileIdentity = identity_of(&opened);
303 let entry_identity: WorktreeFileIdentity = identity_of(&entry);
304 if !opened.file_type().is_file()
305 || link_count(&opened) != 1
306 || entry_identity.device != opened_identity.device
307 || entry_identity.inode != opened_identity.inode
308 {
309 return Ok(WorktreeFileState::Conflict);
310 }
311 if (opened_identity.mode & 0o100 != 0) != (git_mode == "100755") {
312 return Ok(WorktreeFileState::Conflict);
313 }
314 let mut bytes: Vec<u8> = Vec::new();
316 handle.read_to_end(&mut bytes)?;
317 let after: WorktreeFileIdentity = identity_of(&std::fs::symlink_metadata(destination)?);
318 if after.device != opened_identity.device
319 || after.inode != opened_identity.inode
320 || after.modified != opened_identity.modified
321 || after.changed != opened_identity.changed
322 || after.size != opened_identity.size
323 || !directory_is_canonical(destination)?
324 {
325 return Ok(WorktreeFileState::Conflict);
326 }
327 if bytes == intended {
328 return Ok(WorktreeFileState::Intended);
329 }
330 if bytes != original {
331 return Ok(WorktreeFileState::Conflict);
332 }
333 return Ok(WorktreeFileState::Original(opened_identity));
334}
335
336fn write_prepared(prepared: &Path, bytes: &[u8], mode: u32) -> std::io::Result<()> {
346 let mut options: std::fs::OpenOptions = std::fs::OpenOptions::new();
347 options.write(true).create_new(true);
348 #[cfg(unix)]
349 {
350 use std::os::unix::fs::OpenOptionsExt;
351 options.mode(mode);
352 }
353 let mut file: std::fs::File = options.open(prepared)?;
354 file.write_all(bytes)?;
355 drop(file);
356 return super::private_storage::protect_path(prepared, mode);
357}
358
359pub fn replace_worktree_file(
372 destination: &Path,
373 bytes: &[u8],
374 git_mode: &str,
375 original: &[u8],
376 identity: &WorktreeFileIdentity,
377) -> Result<bool, RecoveryError> {
378 let unique: String = match random_uuid() {
379 Ok(id) => id,
380 Err(error) => return Err(RecoveryError(error.0)),
381 };
382 let directory: &Path = destination.parent().unwrap_or(destination);
383 let prepared: PathBuf = directory.join(format!(".cli-git-added-{unique}"));
384 let outcome: Result<bool, RecoveryError> = replace_through(
385 prepared.as_path(),
386 destination,
387 bytes,
388 git_mode,
389 original,
390 identity,
391 );
392 match &outcome {
393 Ok(true) => {}
394 Ok(false) => {
395 let _ = std::fs::remove_file(&prepared);
396 }
397 Err(error) => {
398 warn(
399 "replaceWorktreeFile",
400 format!(
401 "worktree completion failed for {}: {error}",
402 destination.display()
403 )
404 .as_str(),
405 );
406 let _ = std::fs::remove_file(&prepared);
407 }
408 }
409 return outcome;
410}
411
412fn replace_through(
422 prepared: &Path,
423 destination: &Path,
424 bytes: &[u8],
425 git_mode: &str,
426 original: &[u8],
427 identity: &WorktreeFileIdentity,
428) -> Result<bool, RecoveryError> {
429 if let Err(error) = write_prepared(prepared, bytes, identity.mode) {
430 return Err(io_failure(
431 "preparing the worktree replacement",
432 prepared,
433 &error,
434 ));
435 }
436 let current: WorktreeFileState =
437 match inspect_worktree_file(destination, git_mode, original, bytes) {
438 Ok(state) => state,
439 Err(error) => {
440 return Err(io_failure(
441 "checking the worktree file",
442 destination,
443 &error,
444 ));
445 }
446 };
447 let WorktreeFileState::Original(again) = current else {
448 return Ok(false);
449 };
450 if again != *identity {
451 return Ok(false);
452 }
453 if let Err(error) = std::fs::rename(prepared, destination) {
454 return Err(io_failure(
455 "installing the worktree replacement",
456 destination,
457 &error,
458 ));
459 }
460 return Ok(true);
461}
462
463pub fn install_added_worktree_files(
476 context: &GitContext,
477 cwd: &Path,
478 repository_root: &Path,
479 records: &[AddedPath],
480 object_directory: Option<&Path>,
481) -> Result<InstallResult, RecoveryError> {
482 let mut result: InstallResult = InstallResult::default();
484 if records.is_empty() {
485 return Ok(result);
486 }
487 let mut oids: Vec<String> = Vec::with_capacity(records.len() * 2);
488 for record in records {
489 oids.push(record.original_oid.clone());
490 oids.push(record.intended_oid.clone());
491 }
492 let blobs: HashMap<String, Vec<u8>> =
493 match load_blob_batch(context, cwd, oids.as_slice(), object_directory) {
494 Ok(found) => found,
495 Err(message) => return Err(RecoveryError(message)),
496 };
497 for record in records {
498 let destination: PathBuf = repository_root.join(os_from_path_bytes(record.path.as_slice()));
499 let shown: String = path_message_text(record.path.as_slice());
500 let (Some(intended), Some(original)) = (
501 blobs.get(&record.intended_oid),
502 blobs.get(&record.original_oid),
503 ) else {
504 return Err(RecoveryError(format!(
505 "Git blob batch omitted a worktree completion object for {}.",
506 shown
507 )));
508 };
509 let state: WorktreeFileState = match inspect_worktree_file(
510 destination.as_path(),
511 record.git_mode.as_str(),
512 original.as_slice(),
513 intended.as_slice(),
514 ) {
515 Ok(found) => found,
516 Err(error) => {
517 return Err(io_failure(
518 "checking the worktree file",
519 destination.as_path(),
520 &error,
521 ));
522 }
523 };
524 let identity: WorktreeFileIdentity = match state {
525 WorktreeFileState::Intended => continue,
526 WorktreeFileState::Conflict => {
527 warn(
528 "installAddedWorktreeFiles",
529 format!(
530 "Worktree copy of {path} changed or disappeared while cli-git completed the commit; \
531 the committed bytes remain in HEAD and your worktree state was kept. \
532 Compare it with HEAD (git diff HEAD -- {path}).",
533 path = shown
534 )
535 .as_str(),
536 );
537 result.conflicted.push(record.path.clone());
538 continue;
539 }
540 WorktreeFileState::Original(found) => found,
541 };
542 if !replace_worktree_file(
543 destination.as_path(),
544 intended.as_slice(),
545 record.git_mode.as_str(),
546 original.as_slice(),
547 &identity,
548 )? {
549 warn(
550 "installAddedWorktreeFiles",
551 format!(
552 "Worktree copy of {path} changed while its replacement was prepared; \
553 the committed bytes remain in HEAD and your edit was kept. \
554 Compare it with HEAD (git diff HEAD -- {path}).",
555 path = shown
556 )
557 .as_str(),
558 );
559 result.conflicted.push(record.path.clone());
560 continue;
561 }
562 debug(
563 "installAddedWorktreeFiles",
564 format!("rewrote {shown} with its landed bytes").as_str(),
565 );
566 result.rewritten.push(record.path.clone());
567 }
568 return Ok(result);
569}
570
571#[cfg(test)]
573#[path = "worktree_completion_tests.rs"]
574mod tests;