//! Moving the working copy to another state (spec §12.3, §7.7). use super::*; #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct CheckoutReport { pub written: usize, pub removed: usize, pub conflicted: bool, /// Nested repositories that could not be moved, and other remarks. pub notes: Vec, } impl Repo { /// Move the working copy to the state whose heads are `heads` (spec §12.3): /// the working directory is rewritten to that state and a fresh /// working-copy change is started on it. The bookmark does not move. pub fn checkout( &self, heads: &[ChangeHash], discard: bool, command: &str, ) -> Result { let _wc = self.lock_working_copy()?; let snap = self.snapshot(command)?; if !discard && (!snap.wc.tree_ops.is_empty() || !snap.wc.edits.is_empty()) { let list: Vec = snap .statuses .iter() .take(8) .map(|s| format!("{} {}", status_word(s.status), s.path)) .collect(); return fail(format!( "the working copy has changes ({}{}); commit them, take them along with --carry, or drop them with --discard", list.join(", "), if snap.statuses.len() > 8 { ", …" } else { "" } )); } let target_state = self.load_state(heads)?; let heads = target_state.maximal(heads); let target = target_state.materialize(); let attrs = Repo::state_attributes(&target); let current = self.walk_working_copy()?; let mut report = CheckoutReport::default(); // Paths this file system cannot hold side by side (spec §7.7). report.notes.extend( self.platform .problems(target.files.keys().map(String::as_str)), ); // Remove tracked paths that the target does not have. A nested // repository is never deleted (spec §7.8); it is left in place. let mut dirs_to_prune: BTreeSet = BTreeSet::new(); for (path, entry) in ¤t { if !target.files.contains_key(path) { if entry.kind == NodeKind::Subrepo { report .notes .push(format!("{path}: nested repository left in place")); continue; } let full = self.safe_path(path)?; fs::remove_file(&full)?; report.removed += 1; if let Some(parent) = full.parent() { dirs_to_prune.insert(parent.to_path_buf()); } } } // Write target files that differ (only inside the sparse filter). for (path, f) in &target.files { if !self.in_scope(path) { continue; } match &f.content { FileContent::Dir => continue, FileContent::Text { bytes, .. } => { let a = attrs.lookup(path); let on_disk: std::borrow::Cow<[u8]> = if a.eol == Some(crate::attributes::Eol::Crlf) { crate::attributes::to_crlf(bytes).into() } else { bytes.as_slice().into() }; let same = current.get(path).is_some_and(|c| { c.kind == NodeKind::File && c.content == *on_disk && (!self.platform.exec_bits || c.exec == f.mode.exec) }); if !same { self.write_file(path, &on_disk, f.mode.exec)?; report.written += 1; } if a.lockable && !self.is_locked(path) { let _ = set_readonly(&self.root.join(path), true); } } FileContent::Opaque { blobs } => { let Some(blob) = blobs.first() else { continue }; let bytes = self.read_blob(blob)?; if f.kind == NodeKind::Subrepo { let pin = crate::subrepo::Pin::decode(&bytes)?; match self.sync_subrepo(path, &pin, false)? { SubrepoSync::Updated => report.written += 1, SubrepoSync::UpToDate => {} SubrepoSync::Missing => report.notes.push(format!("{path}: nested repository not present; run oxy submodule update --init")), SubrepoSync::Dirty => report.notes.push(format!("{path}: nested repository has uncommitted changes; left as is")), SubrepoSync::Failed(e) => report.notes.push(format!("{path}: {e}")), } continue; } if f.kind == NodeKind::Symlink { let same = current .get(path) .is_some_and(|c| c.kind == NodeKind::Symlink && c.content == bytes); if !same { self.write_symlink(path, &bytes)?; report.written += 1; } } else { let same = current.get(path).is_some_and(|c| { c.kind == NodeKind::File && c.content == bytes && (!self.platform.exec_bits || c.exec == f.mode.exec) }); if !same { self.write_file(path, &bytes, f.mode.exec)?; report.written += 1; } if attrs.lookup(path).lockable && !self.is_locked(path) { let _ = set_readonly(&self.root.join(path), true); } } if blobs.len() > 1 { for (i, b) in blobs.iter().enumerate().skip(1) { self.write_file( &format!("{path}.oxy-conflict-{}", i + 1), &self.read_blob(b)?, f.mode.exec, )?; } } } } } // Prune directories left empty, deepest first. for d in dirs_to_prune.iter().rev() { let mut d = d.clone(); while d != self.root && fs::read_dir(&d) .map(|mut i| i.next().is_none()) .unwrap_or(false) { fs::remove_dir(&d)?; let Some(p) = d.parent() else { break }; d = p.to_path_buf(); } } let (op_ref, op) = self.head_op()?; let wc = self.new_working_copy(heads.clone(), ""); let wc_hash = self.store.put_change(&wc)?; let mut view = op.view.clone(); view.workspaces.insert(self.workspace, wc_hash); self.record_op(&[op_ref], command, view)?; report.conflicted = target.is_conflicted(); let blobs: HashMap = target .files .iter() .filter_map(|(p, f)| match &f.content { FileContent::Opaque { blobs } if f.kind == NodeKind::File && blobs.len() == 1 => { Some((p.clone(), blobs[0])) } _ => None, }) .collect(); let entries = self.walk_working_copy_with(None, &Self::tracked_kinds(&target), None)?; self.write_manifest_entries(report.conflicted, false, &entries, &blobs)?; let _ = self.run_hook( "post-checkout", &format!( "{{\"event\":\"post-checkout\",\"heads\":{},\"written\":{},\"removed\":{}}}", json_array(heads.iter().map(|h| json_str(&h.to_hex()))), report.written, report.removed ), ); Ok(report) } /// The absolute path for a repository path, refusing anything that would /// escape the workspace or write through a symlink (spec §7.7). fn safe_path(&self, rel: &str) -> Result { let mut full = self.root.clone(); let comps: Vec<&str> = rel.split('/').collect(); for (i, comp) in comps.iter().enumerate() { if comp.is_empty() || *comp == "." || *comp == ".." || comp.contains('\\') { return fail(format!("refusing unsafe path {rel:?}")); } if i == 0 && *comp == OXY_DIR { return fail(format!("refusing to write inside {OXY_DIR}: {rel:?}")); } if i + 1 < comps.len() { full.push(comp); if let Ok(m) = fs::symlink_metadata(&full) { if m.file_type().is_symlink() { return fail(format!( "refusing to write through symlink {}", full.display() )); } } } else { full.push(comp); } } Ok(full) } pub(crate) fn write_file(&self, rel: &str, bytes: &[u8], exec: bool) -> Result<()> { let full = self.safe_path(rel)?; if let Some(parent) = full.parent() { fs::create_dir_all(parent)?; } if fs::symlink_metadata(&full).is_ok_and(|m| m.file_type().is_symlink() || m.is_dir()) { remove_any(&full)?; } if fs::metadata(&full).is_ok_and(|m| m.permissions().readonly()) { set_readonly(&full, false)?; } fs::write(&full, bytes)?; set_executable(&full, exec)?; Ok(()) } pub(crate) fn write_symlink(&self, rel: &str, target: &[u8]) -> Result<()> { let full = self.safe_path(rel)?; if let Some(parent) = full.parent() { fs::create_dir_all(parent)?; } if fs::symlink_metadata(&full).is_ok() { remove_any(&full)?; } #[cfg(unix)] { std::os::unix::fs::symlink(String::from_utf8_lossy(target).as_ref(), &full)?; Ok(()) } #[cfg(not(unix))] { // Spec §7.7: without symlink support, write the target as a plain file. fs::write(&full, target)?; Ok(()) } } } /// How a refusal names a path's change: "removed" is a file of the working /// copy's base that is missing on disk. fn status_word(s: ChangeStatus) -> &'static str { match s { ChangeStatus::Added => "added", ChangeStatus::Modified => "modified", ChangeStatus::Removed => "removed", ChangeStatus::Renamed => "renamed", ChangeStatus::ModeChanged => "mode changed", ChangeStatus::ConflictEdited => "conflict edited", ChangeStatus::Resolved => "resolved", } }