//! Working-copy snapshots, commits, and the change builder (spec §12.3). use super::*; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ChangeStatus { Added, Modified, Removed, ModeChanged, /// The file is conflicted in the base state and still contains conflict markers. ConflictEdited, /// The file was conflicted in the base state and the working copy resolves it. Resolved, } /// Lines that mark a conflict region in a materialized file (spec §9.5). pub fn has_conflict_markers(content: &[u8]) -> bool { split_lines(content).iter().any(|l| { l.starts_with(b"<<<<<<< oxy conflict") || l.starts_with(b"======= oxy conflict") || l.starts_with(b">>>>>>> oxy conflict") }) } #[derive(Clone, Debug, PartialEq, Eq)] pub struct PathStatus { pub path: String, pub status: ChangeStatus, } /// Operations of a change built from the working directory against a base. pub(crate) struct BuiltChange { pub tree_ops: Vec, pub edits: Vec, pub contributors: Vec, pub resolves: bool, pub statuses: Vec, } /// Result of snapshotting the working copy. pub struct SnapshotResult { pub wc_hash: ChangeHash, pub wc: Change, /// The base state (closure of the working-copy change's dependencies). pub base: State, pub base_snapshot: Snapshot, pub statuses: Vec, /// Whether the snapshot rewrote the working-copy change. pub changed: bool, /// Whether the working copy resolves conflicts of its base (spec §9.6). pub resolves: bool, /// Changes that contributed to the conflicts being resolved. pub contributors: Vec, } pub fn is_binary(content: &[u8]) -> bool { content[..content.len().min(BINARY_SNIFF)].contains(&0) } impl Repo { /// Diff the files on disk against `base` and build the operations of a /// change from `base` to the working directory. `reuse` maps paths to the /// NodeIds an earlier version of the same change assigned to new paths. pub(crate) fn build_against(&self, base: &State, base_snapshot: &Snapshot, reuse: HashMap) -> Result { let fs_entries = self.walk_working_copy()?; self.build_from_entries(base, base_snapshot, reuse, &fs_entries) } /// Like `build_against`, for files given in memory (imports). pub(crate) fn build_from_entries(&self, base: &State, base_snapshot: &Snapshot, reuse: HashMap, fs_entries: &BTreeMap) -> Result { let mut b = ChangeBuilder::new(base, base_snapshot, reuse); for (path, fs_entry) in fs_entries { match base_snapshot.files.get(path) { None => b.add_path(self, path, fs_entry)?, Some(expected) => b.update_path(self, path, fs_entry, expected)?, } } for (path, expected) in &base_snapshot.files { if fs_entries.contains_key(path) { continue; } let still_needed = expected.kind == NodeKind::Dir && fs_entries.keys().any(|p| p.starts_with(&format!("{path}/"))); if !still_needed { b.remove_path(path, expected); } } let mut statuses = b.statuses; statuses.sort_by(|a, b| a.path.cmp(&b.path)); Ok(BuiltChange { tree_ops: b.tree_ops, edits: b.edits, contributors: b.contributors.into_iter().collect(), resolves: b.resolves, statuses }) } /// Store a change and, when `core.sign = change` and we are its author, /// sign it. A rewritten change by another author is stored unsigned: only /// its author (or a delegate) can vouch for it. pub(crate) fn store_signed(&self, change: &Change) -> Result { let ours = change.author == self.author(); if self.config.sign_changes && ours && self.secret_key()?.is_none() { return fail("core.sign is \"change\" but no secret key is available for author.key"); } let hash = self.store.put_change(change)?; if self.config.sign_changes && ours { self.sign_change(&hash)?; } Ok(hash) } /// Snapshot the working copy into the working-copy change (spec §12.3). pub fn snapshot(&self, command: &str) -> Result { let (op_ref, op) = self.head_op()?; let (old_hash, old_wc) = self.working_copy()?; let base = self.load_state(&old_wc.deps)?; let base_snapshot = base.materialize(); // Reuse the NodeIds the previous working-copy version assigned to new // paths, so an unchanged working copy hashes identically (spec §12.3). let reuse = added_node_paths(&old_wc, &base_snapshot); let built = self.build_against(&base, &base_snapshot, reuse)?; let mut wc = Change { change_id: old_wc.change_id, deps: vec![], author: self.author(), hlc: old_wc.hlc, message: old_wc.message.clone(), tree_ops: built.tree_ops, edits: built.edits, kind: ChangeKind::WorkingCopy, }; let base_heads = base.maximal(&old_wc.deps); let mut deps: BTreeSet = base_heads.into_iter().collect(); deps.extend(referenced_changes(&wc, &base)); deps.extend(built.contributors.iter().copied()); wc.deps = deps.into_iter().collect(); wc.normalize(); let resolves = built.resolves; let contributors = built.contributors; let statuses = built.statuses; // Empty snapshot: hash the candidate before storing; only record an op on change. let new_hash = wc.hash(); let changed = new_hash != old_hash; if changed { self.store.put_change(&wc)?; let mut view = op.view.clone(); view.workspaces.insert(self.workspace, new_hash); self.record_op(&[op_ref], &format!("{command}{SNAPSHOT_SUFFIX}"), view)?; } // The prompt compares against this without recording anything. self.write_manifest(base_snapshot.is_conflicted(), !wc.tree_ops.is_empty() || !wc.edits.is_empty())?; Ok(SnapshotResult { wc_hash: new_hash, wc, base, base_snapshot, statuses, changed, resolves, contributors }) } /// Turn the working-copy change into a normal change on the bookmark (spec §12.3). pub fn commit(&self, message: &str, extra_deps: &[ChangeHash]) -> Result { let snap = self.snapshot("oxy commit")?; if snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty() { return fail("nothing to commit: the working copy matches its base"); } if let Some(s) = snap.statuses.iter().find(|s| s.status == ChangeStatus::ConflictEdited) { return fail(format!("{} still contains conflict markers", s.path)); } if message.trim().is_empty() { return fail("a commit message is required"); } let (op_ref, op) = self.head_op()?; let mut deps: BTreeSet = referenced_changes(&snap.wc, &snap.base); // A resolution depends on every change that contributed to the // conflict (spec §8.1). Tree and opaque conflicts are resolved through // ordinary operations whose references already cover the contributors. deps.extend(snap.contributors.iter().copied()); for d in extra_deps { if !snap.base.contains(d) { return fail(format!("--depends-on {d}: not in the working copy's base state")); } deps.insert(*d); } let mut change = Change { change_id: snap.wc.change_id, deps: deps.into_iter().collect(), author: self.author(), hlc: Hlc::now(), message: message.trim_end().to_string(), tree_ops: snap.wc.tree_ops.clone(), edits: snap.wc.edits.clone(), kind: if snap.resolves { ChangeKind::Resolution } else { ChangeKind::Normal }, }; change.normalize(); let hash = self.store_signed(&change)?; // New bookmark heads: maximal elements of old heads plus the new change. // The working copy continues on its own base plus the new change, which // is exactly what the files on disk contain; the bookmark may hold // other heads (and conflicts) the working copy has not checked out. let old_heads = self.heads_of(&op.view, &self.config.bookmark)?; let old_base = snap.base.maximal(&snap.wc.deps); let mut all: Vec = old_heads.clone(); all.extend(old_base.iter().copied()); all.push(hash); let mut st = self.load_state(&all)?; st.add(change)?; let mut with_new = old_heads.clone(); with_new.push(hash); let new_heads = st.maximal(&with_new); // Automatic checkpoint once enough changes accumulated (spec §11). let since = self.latest_checkpoint(&self.config.bookmark)?.map(|(_, cp)| cp.count).unwrap_or(0); if self.config.checkpoint_interval > 0 && (st.len() as u64).saturating_sub(since) >= self.config.checkpoint_interval { let cp_state = self.load_state_full(&new_heads)?; self.write_checkpoint(&self.config.bookmark, &cp_state, &new_heads)?; } let mut wc_base = old_base.clone(); wc_base.push(hash); let wc_base = st.maximal(&wc_base); let wc = self.new_working_copy(wc_base, ""); let wc_hash = self.store.put_change(&wc)?; let mut view = op.view.clone(); view.bookmarks.insert(self.config.bookmark.clone(), self.store.put_state(&StateObject::new(new_heads))?); view.workspaces.insert(self.workspace, wc_hash); self.record_op(&[op_ref], &format!("oxy commit -m {message:?}"), view)?; self.write_manifest(snap.base_snapshot.is_conflicted() && !snap.resolves, false)?; self.maybe_autopack()?; Ok(hash) } } /// Builds the tree ops and edits of a working-copy change against a base. struct ChangeBuilder<'a> { base: &'a State, base_snapshot: &'a Snapshot, tree_ops: Vec, edits: Vec, statuses: Vec, dir_nodes: HashMap, reuse: HashMap, /// Changes that contributed to conflicts this working copy resolves. contributors: BTreeSet, resolves: bool, } impl<'a> ChangeBuilder<'a> { fn new(base: &'a State, base_snapshot: &'a Snapshot, reuse: HashMap) -> Self { let mut dir_nodes: HashMap = HashMap::new(); dir_nodes.insert(String::new(), NodeId::ROOT); for (p, f) in &base_snapshot.files { if f.kind == NodeKind::Dir { dir_nodes.insert(p.clone(), f.node); } } ChangeBuilder { base, base_snapshot, tree_ops: vec![], edits: vec![], statuses: vec![], dir_nodes, reuse, contributors: BTreeSet::new(), resolves: false } } fn reused_node(&self, path: &str, kind: NodeKind) -> Option { self.reuse.get(path).filter(|(_, k)| *k == kind).map(|(n, _)| *n) } fn ensure_dir(&mut self, path: &str) -> NodeId { if let Some(n) = self.dir_nodes.get(path) { return *n; } let mut parent = NodeId::ROOT; let mut prefix = String::new(); for comp in path.split('/') { if !prefix.is_empty() { prefix.push('/'); } prefix.push_str(comp); parent = match self.dir_nodes.get(&prefix) { Some(n) => *n, None => { let node = self.reused_node(&prefix, NodeKind::Dir).unwrap_or_else(NodeId::random); self.tree_ops.push(TreeOp::AddNode { node, kind: NodeKind::Dir, mode: Mode::default(), parent, name: comp.to_string() }); self.dir_nodes.insert(prefix.clone(), node); node } }; } parent } fn add_path(&mut self, repo: &Repo, path: &str, e: &FsEntry) -> Result<()> { let (dir, name) = split_path(path); let parent = self.ensure_dir(dir); let node = self.reused_node(path, e.kind).unwrap_or_else(NodeId::random); let opaque = e.kind == NodeKind::Symlink || is_binary(&e.content); self.tree_ops.push(TreeOp::AddNode { node, kind: e.kind, mode: Mode { exec: e.exec, opaque }, parent, name: name.to_string() }); if opaque { let blob = repo.store_blob(&e.content)?; self.edits.push(Edit::Replace { node, old: None, new: Some(blob) }); } else if !e.content.is_empty() { self.edits.push(Edit::Insert { node, after: crate::objects::AtomRef::Start, before: crate::objects::AtomRef::End, lines: split_lines(&e.content).iter().map(|l| l.to_vec()).collect(), }); } self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Added }); Ok(()) } fn remove_path(&mut self, path: &str, expected: &crate::state::FileState) { let entry = &self.base_snapshot.tree.entries[path]; self.tree_ops.push(TreeOp::RemoveNode { node: expected.node, placement: op_ref(entry.placement) }); if path.contains(".oxy-conflict-") || expected.is_conflicted() { self.resolves = true; } self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Removed }); } fn update_path(&mut self, repo: &Repo, path: &str, e: &FsEntry, expected: &crate::state::FileState) -> Result<()> { if expected.kind != e.kind { self.remove_path(path, expected); self.statuses.pop(); self.add_path(repo, path, e)?; self.statuses.pop(); self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Modified }); return Ok(()); } let entry = &self.base_snapshot.tree.entries[path]; let node = expected.node; let mode_atom = entry.mode_atom.map(op_ref); let mut status: Option = None; match &expected.content { FileContent::Dir => {} FileContent::Text { bytes, materialized } => { if materialized.is_conflicted() { if bytes == &e.content { return Ok(()); } if has_conflict_markers(&e.content) { self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited }); return Ok(()); } // Resolution (spec §9.6): diff against the unconflicted lines // with each region as a gap, then retire every region atom. // Whatever the author kept from a region is re-inserted as // new atoms, which leaves the graph totally ordered. let regions = self.base.conflict_regions(&node); let mut edits = text_edits(node, ®ions.plain, &e.content); if !regions.region_atoms.is_empty() { edits.insert(0, Edit::Delete { node, atoms: ranges(®ions.region_atoms) }); } self.edits.extend(edits); self.contributors.extend(regions.contributors); self.resolves = true; if e.exec != expected.mode.exec { if let Some(old) = mode_atom { self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } }); } } self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Resolved }); return Ok(()); } if bytes != &e.content { if is_binary(&e.content) { // Text → opaque (spec §7.5). if let Some(old) = mode_atom { self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } }); } let live: Vec<_> = repo_live_keys(self.base, &node); if !live.is_empty() { self.edits.push(Edit::Delete { node, atoms: ranges(&live) }); } let blob = repo.store_blob(&e.content)?; self.edits.push(Edit::Replace { node, old: None, new: Some(blob) }); status = Some(ChangeStatus::Modified); } else { let old = self.base.live_lines(&node); let edits = text_edits(node, &old, &e.content); if !edits.is_empty() { self.edits.extend(edits); status = Some(ChangeStatus::Modified); } } } let switched_to_opaque = status == Some(ChangeStatus::Modified) && is_binary(&e.content); if !switched_to_opaque && e.exec != expected.mode.exec { if let Some(old) = mode_atom { self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } }); status.get_or_insert(ChangeStatus::ModeChanged); } } } FileContent::Opaque { blobs } => { let new_ref = Repo::blob_ref(&e.content); if blobs.len() > 1 { if !blobs.contains(&new_ref) { self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited }); } return Ok(()); } let current = blobs.first().copied(); if current != Some(new_ref) { if e.kind == NodeKind::File && !is_binary(&e.content) { // Opaque → text (spec §7.5). if let Some(old) = mode_atom { self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } }); } self.edits.push(Edit::Replace { node, old: current, new: None }); if !e.content.is_empty() { self.edits.push(Edit::Insert { node, after: crate::objects::AtomRef::Start, before: crate::objects::AtomRef::End, lines: split_lines(&e.content).iter().map(|l| l.to_vec()).collect(), }); } } else { let blob = repo.store_blob(&e.content)?; self.edits.push(Edit::Replace { node, old: current, new: Some(blob) }); } status = Some(ChangeStatus::Modified); } else if e.kind == NodeKind::File && e.exec != expected.mode.exec { if let Some(old) = mode_atom { self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } }); status = Some(ChangeStatus::ModeChanged); } } } } if let Some(s) = status { self.statuses.push(PathStatus { path: path.to_string(), status: s }); } Ok(()) } } fn repo_live_keys(base: &State, node: &NodeId) -> Vec { base.live_lines(node).into_iter().map(|(k, _)| k).collect() } /// The changes a change's scope references (spec §8.1): introducers of every /// atom, placement, and mode it touches, and of every node it operates on. pub fn referenced_changes(change: &Change, base: &State) -> BTreeSet { let mut out = BTreeSet::new(); let opref = |r: &OpRef, out: &mut BTreeSet| { if !r.change.is_zero() { out.insert(r.change); } }; let mut nodes: Vec = Vec::new(); for op in &change.tree_ops { match op { TreeOp::AddNode { parent, .. } => nodes.push(*parent), TreeOp::RemoveNode { node, placement } => { nodes.push(*node); opref(placement, &mut out); } TreeOp::Move { node, old, parent, .. } => { nodes.push(*node); nodes.push(*parent); opref(old, &mut out); } TreeOp::SetMode { node, old, .. } => { nodes.push(*node); opref(old, &mut out); } } } for e in &change.edits { nodes.push(e.node()); match e { Edit::Insert { after, before, .. } => { for r in [after, before] { if let crate::objects::AtomRef::Atom { change, .. } = r { if !change.is_zero() { out.insert(*change); } } } } Edit::Delete { atoms, .. } => { for r in atoms { if !r.change.is_zero() { out.insert(r.change); } } } Edit::Replace { .. } => {} } } for n in nodes { if let Some(h) = base.tree().introducer(&n) { out.insert(h); } } out } /// Paths of the nodes a change adds on top of `base_snapshot`. pub fn added_node_paths(change: &Change, base_snapshot: &Snapshot) -> HashMap { let by_node = base_snapshot.tree.by_node(); let mut new_names: HashMap = HashMap::new(); for op in &change.tree_ops { if let TreeOp::AddNode { node, parent, name, kind, .. } = op { new_names.insert(*node, (*parent, name.clone(), *kind)); } } let mut out = HashMap::new(); for (node, (_, _, kind)) in &new_names { if let Some(path) = path_of_node(node, &by_node, &new_names) { out.insert(path, (*node, *kind)); } } out } fn path_of_node( n: &NodeId, by_node: &HashMap, new_names: &HashMap, ) -> Option { if let Some(e) = by_node.get(n) { return Some(e.path.clone()); } let mut parts = Vec::new(); let mut cur = *n; loop { if cur.is_root() { break; } if let Some(e) = by_node.get(&cur) { if !e.path.is_empty() { parts.push(e.path.clone()); } break; } let (parent, name, _) = new_names.get(&cur)?; parts.push(name.clone()); cur = *parent; } parts.reverse(); Some(parts.join("/")) } /// Render a change's operations with line content, using `base` for deleted lines. pub fn render_change(change: &Change, base: &State, base_snapshot: &Snapshot) -> String { let by_node = base_snapshot.tree.by_node(); let mut new_names: HashMap = HashMap::new(); for op in &change.tree_ops { if let TreeOp::AddNode { node, parent, name, kind, .. } = op { new_names.insert(*node, (*parent, name.clone(), *kind)); } } let path_of = |n: &NodeId| -> String { path_of_node(n, &by_node, &new_names).unwrap_or_else(|| format!("", n.short(8))) }; let mut out = String::new(); for op in &change.tree_ops { match op { TreeOp::AddNode { node, kind, mode, .. } => { out.push_str(&format!("A {} ({:?}{}{})\n", path_of(node), kind, if mode.exec { ", exec" } else { "" }, if mode.opaque { ", opaque" } else { "" })); } TreeOp::RemoveNode { node, .. } => out.push_str(&format!("D {}\n", path_of(node))), TreeOp::Move { node, name, .. } => out.push_str(&format!("R {} -> {}\n", path_of(node), name)), TreeOp::SetMode { node, mode, .. } => { out.push_str(&format!("M {} mode: exec={} opaque={}\n", path_of(node), mode.exec, mode.opaque)); } } } let mut last_node: Option = None; for e in &change.edits { let n = e.node(); if last_node != Some(n) { out.push_str(&format!("--- {}\n", path_of(&n))); last_node = Some(n); } match e { Edit::Insert { lines, .. } => { for l in lines { out.push('+'); out.push_str(&String::from_utf8_lossy(l)); if !l.ends_with(b"\n") { out.push('\n'); } } } Edit::Delete { atoms, .. } => { for r in atoms { for i in r.from..r.to { let content = base.file(&n).and_then(|g| g.content(&(r.change, i))).unwrap_or(b"\n"); out.push('-'); out.push_str(&String::from_utf8_lossy(content)); if !content.ends_with(b"\n") { out.push('\n'); } } } } Edit::Replace { old, new, .. } => { out.push_str(&format!( " opaque content {} -> {}\n", old.map(|o| o.short(12)).unwrap_or_else(|| "none".into()), new.map(|o| o.short(12)).unwrap_or_else(|| "none".into()) )); } } } out }