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