History editing: oxy amend --into folds the working copy into an older change with dependents rewritten; oxy split; graph: delete/insert conflicts only between commuting changes (spec 0.6l) 23b217d75848
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-26 23:00 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
23b217d75848570eb0753a85385da746cc57077444160127927890fec64b1607- Depends on
- 614541c3cebd 6d3eefbce2f0 982864068a59 c07e0e7d2d85 cdb5beba7f05 f77698833f9f fce44ae9ad29 ff0af1ee299d
- Depended on by
- 69953b507188 530a0fc650a2 2c9ddac3c387 631fc8970bd0 57eaac9107df 6caacfff1d4f 6f8aeb996558 6a81dbab471d d42369abf180 75167672ebcd 728da0f92161 7b9809e67c3d b837d2b55853 b7f102fcbb17 6706bdcfb63e 94084087a876 28fc23d7706a
- In bookmarks
- agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
- In tags
- v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 20 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified crates/oxy-core/src/graph.rs +29 −3
| … | 139 unchanged lines |
||
| 140 | 140 | } |
|
| 141 | 141 | ||
| 142 | 142 | fn is_ghost(&self, k: &AtomKey, info: &AtomInfo, deps: &dyn DepOracle) -> bool { |
|
| 143 | - | // A deleted atom is a ghost when a live direct successor was inserted by |
|
| 144 | - | // a change that no deleter depends on (spec §9.1, delete/insert). |
|
| 143 | + | // A deleted atom is a ghost when a live direct successor was inserted |
|
| 144 | + | // by a change that commutes with every deleter: neither depends on |
|
| 145 | + | // the other (spec §9.1, delete/insert). If the inserter came after the |
|
| 146 | + | // deletion, or the deleter saw the insertion, there is no conflict. |
|
| 145 | 147 | self.succ.get(&Vertex::Atom(*k)).is_some_and(|ss| { |
|
| 146 | 148 | ss.iter().any(|s| match s { |
|
| 147 | - | Vertex::Atom(y) if self.is_live(y) => !info.deleted_by.iter().any(|d| deps.depends_on(d, &y.0)), |
|
| 149 | + | Vertex::Atom(y) if self.is_live(y) => !info.deleted_by.iter().any(|d| deps.depends_on(d, &y.0) || deps.depends_on(&y.0, d)), |
|
| 148 | 150 | _ => false, |
|
| 149 | 151 | }) |
|
| 150 | 152 | }) |
|
| … | 443 unchanged lines |
||
| 594 | 596 | } |
|
| 595 | 597 | ||
| 596 | 598 | #[test] |
|
| 599 | + | fn insert_after_a_line_deleted_by_a_dependency_is_not_a_conflict() { |
|
| 600 | + | // 1 inserts a,b; 2 deletes b (depends on 1); 3 depends on 2 yet anchors |
|
| 601 | + | // after the dead b (a rewritten change may do this): no conflict. |
|
| 602 | + | let mut g = FileGraph::new(); |
|
| 603 | + | g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["a\n", "b\n"])); |
|
| 604 | + | g.delete(h(2), &AtomRange { change: h(1), from: 1, to: 2 }); |
|
| 605 | + | g.insert(h(3), 0, Vertex::Atom((h(1), 1)), Vertex::End, &lines(&["c\n"])); |
|
| 606 | + | let deps = Deps([(h(2), vec![h(1)]), (h(3), vec![h(2)])].into_iter().collect()); |
|
| 607 | + | let m = g.materialize(&deps); |
|
| 608 | + | assert!(!m.is_conflicted(), "{:?}", m.segments); |
|
| 609 | + | assert_eq!(text(&g, &m), "a\nc\n"); |
|
| 610 | + | // A change that deletes a line it introduced itself, with a dependent |
|
| 611 | + | // anchoring after it, is likewise fine. |
|
| 612 | + | let mut g = FileGraph::new(); |
|
| 613 | + | g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["x\n"])); |
|
| 614 | + | g.delete(h(1), &AtomRange { change: h(1), from: 0, to: 1 }); |
|
| 615 | + | g.insert(h(2), 0, Vertex::Atom((h(1), 0)), Vertex::End, &lines(&["y\n"])); |
|
| 616 | + | let deps = Deps([(h(2), vec![h(1)])].into_iter().collect()); |
|
| 617 | + | let m = g.materialize(&deps); |
|
| 618 | + | assert!(!m.is_conflicted()); |
|
| 619 | + | assert_eq!(text(&g, &m), "y\n"); |
|
| 620 | + | } |
|
| 621 | + | ||
| 622 | + | #[test] |
|
| 597 | 623 | fn resolution_collapses_a_conflict() { |
|
| 598 | 624 | let mut g = FileGraph::new(); |
|
| 599 | 625 | g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["a\n"])); |
|
| … | 77 unchanged lines |
modified crates/oxy-core/src/repo/history.rs +265 −0
| … | 7 unchanged lines |
||
| 8 | 8 | //! other authors lose their signatures, as with any rewrite of others' work. |
|
| 9 | 9 | ||
| 10 | 10 | use super::*; |
|
| 11 | + | use crate::objects::{AtomRange, AtomRef}; |
|
| 11 | 12 | use crate::rewrite::{propagate, Correspondence}; |
|
| 12 | 13 | ||
| 13 | 14 | impl Repo { |
|
| … | 196 unchanged lines |
||
| 210 | 211 | Ok(()) |
|
| 211 | 212 | } |
|
| 212 | 213 | } |
|
| 214 | + | ||
| 215 | + | /// Translate a reference to change `target`'s own atom or op into a self |
|
| 216 | + | /// reference, and a working-copy self reference into one offset past the |
|
| 217 | + | /// target's own count; other references pass through. |
|
| 218 | + | fn fold_atom(r: AtomRef, target: &ChangeHash, wc_offset: &HashMap<NodeId, u32>, node: &NodeId) -> Option<AtomRef> { |
|
| 219 | + | match r { |
|
| 220 | + | AtomRef::Atom { change, index } if change == *target => Some(AtomRef::Atom { change: ObjectRef::ZERO, index }), |
|
| 221 | + | AtomRef::Atom { change, index } if change.is_zero() => Some(AtomRef::Atom { change: ObjectRef::ZERO, index: index + wc_offset.get(node).copied().unwrap_or(0) }), |
|
| 222 | + | // References into the target's closure were validated by the caller and stay as they are. |
|
| 223 | + | other => Some(other), |
|
| 224 | + | } |
|
| 225 | + | } |
|
| 226 | + | ||
| 227 | + | impl Repo { |
|
| 228 | + | /// Fold the working copy's uncommitted edits into any change of the |
|
| 229 | + | /// bookmark (spec §8.4). The edits must reference only atoms, placements |
|
| 230 | + | /// and nodes that exist in the target's own closure; dependents are |
|
| 231 | + | /// rewritten with an identity correspondence, since the target's own |
|
| 232 | + | /// atoms keep their indices. |
|
| 233 | + | pub fn amend_into(&self, target: &ChangeHash, message: Option<&str>) -> Result<ChangeHash> { |
|
| 234 | + | let snap = self.snapshot("oxy amend")?; |
|
| 235 | + | if snap.statuses.iter().any(|s| s.status == ChangeStatus::ConflictEdited) { |
|
| 236 | + | return fail("a file still contains conflict markers"); |
|
| 237 | + | } |
|
| 238 | + | let wc = &snap.wc; |
|
| 239 | + | if wc.tree_ops.is_empty() && wc.edits.is_empty() && message.is_none() { |
|
| 240 | + | return fail("nothing to amend: the working copy is clean and no new message was given"); |
|
| 241 | + | } |
|
| 242 | + | let old = self.store.get_change(target)?; |
|
| 243 | + | if old.kind == ChangeKind::WorkingCopy { |
|
| 244 | + | return fail("that is the working copy; commit it instead"); |
|
| 245 | + | } |
|
| 246 | + | // Every reference must live in the target's closure (or be the target). |
|
| 247 | + | let closure = self.load_state(&[*target])?; |
|
| 248 | + | let ok_change = |h: &ChangeHash| *h == *target || closure.contains(h); |
|
| 249 | + | let ok_node = |n: &NodeId| n.is_root() || closure.tree().introducer(n).is_some(); |
|
| 250 | + | let too_new = |what: &str| RepoError::Msg(format!("{what} was introduced after change #{}; amend into a later change instead", target.short(12))); |
|
| 251 | + | for op in &wc.tree_ops { |
|
| 252 | + | match op { |
|
| 253 | + | TreeOp::AddNode { parent, .. } => { |
|
| 254 | + | if !ok_node(parent) { |
|
| 255 | + | return Err(too_new("the parent directory")); |
|
| 256 | + | } |
|
| 257 | + | } |
|
| 258 | + | TreeOp::RemoveNode { node, placement } => { |
|
| 259 | + | if !ok_node(node) || !ok_change(&placement.change) { |
|
| 260 | + | return Err(too_new("a removed file")); |
|
| 261 | + | } |
|
| 262 | + | } |
|
| 263 | + | TreeOp::Move { node, old, parent, .. } => { |
|
| 264 | + | if !ok_node(node) || !ok_change(&old.change) || !ok_node(parent) { |
|
| 265 | + | return Err(too_new("a moved file")); |
|
| 266 | + | } |
|
| 267 | + | } |
|
| 268 | + | TreeOp::SetMode { node, old, .. } => { |
|
| 269 | + | if !ok_node(node) || !ok_change(&old.change) { |
|
| 270 | + | return Err(too_new("a file whose mode changed")); |
|
| 271 | + | } |
|
| 272 | + | } |
|
| 273 | + | } |
|
| 274 | + | } |
|
| 275 | + | let wc_added: BTreeSet<NodeId> = wc.tree_ops.iter().filter_map(|o| if let TreeOp::AddNode { node, .. } = o { Some(*node) } else { None }).collect(); |
|
| 276 | + | for e in &wc.edits { |
|
| 277 | + | let n = e.node(); |
|
| 278 | + | if !wc_added.contains(&n) && !ok_node(&n) { |
|
| 279 | + | return Err(too_new("an edited file")); |
|
| 280 | + | } |
|
| 281 | + | let refs: Vec<ChangeHash> = match e { |
|
| 282 | + | Edit::Insert { after, before, .. } => [after, before].iter().filter_map(|r| if let AtomRef::Atom { change, .. } = r { Some(*change) } else { None }).collect(), |
|
| 283 | + | Edit::Delete { atoms, .. } => atoms.iter().map(|r| r.change).collect(), |
|
| 284 | + | Edit::Replace { .. } => vec![], |
|
| 285 | + | }; |
|
| 286 | + | if refs.iter().any(|h| !h.is_zero() && !ok_change(h)) { |
|
| 287 | + | return Err(too_new("an edited line")); |
|
| 288 | + | } |
|
| 289 | + | } |
|
| 290 | + | ||
| 291 | + | // Compose: the target's operations first, then the working copy's |
|
| 292 | + | // with references to the target turned into self references and its |
|
| 293 | + | // own atom indices shifted past the target's. |
|
| 294 | + | let mut per_node: HashMap<NodeId, u32> = HashMap::new(); |
|
| 295 | + | for e in &old.edits { |
|
| 296 | + | if let Edit::Insert { node, lines, .. } = e { |
|
| 297 | + | *per_node.entry(*node).or_default() += lines.len() as u32; |
|
| 298 | + | } |
|
| 299 | + | } |
|
| 300 | + | let op_offset = old.tree_ops.len() as u32; |
|
| 301 | + | let fold_op = |r: OpRef| -> OpRef { |
|
| 302 | + | if r.change == *target { |
|
| 303 | + | OpRef { change: ObjectRef::ZERO, index: r.index } |
|
| 304 | + | } else if r.change.is_zero() { |
|
| 305 | + | OpRef { change: ObjectRef::ZERO, index: r.index + op_offset } |
|
| 306 | + | } else { |
|
| 307 | + | r |
|
| 308 | + | } |
|
| 309 | + | }; |
|
| 310 | + | let mut tree_ops = old.tree_ops.clone(); |
|
| 311 | + | for op in &wc.tree_ops { |
|
| 312 | + | tree_ops.push(match op { |
|
| 313 | + | TreeOp::RemoveNode { node, placement } => TreeOp::RemoveNode { node: *node, placement: fold_op(*placement) }, |
|
| 314 | + | TreeOp::Move { node, old, parent, name } => TreeOp::Move { node: *node, old: fold_op(*old), parent: *parent, name: name.clone() }, |
|
| 315 | + | TreeOp::SetMode { node, old, mode } => TreeOp::SetMode { node: *node, old: fold_op(*old), mode: *mode }, |
|
| 316 | + | add => add.clone(), |
|
| 317 | + | }); |
|
| 318 | + | } |
|
| 319 | + | let mut edits = old.edits.clone(); |
|
| 320 | + | for e in &wc.edits { |
|
| 321 | + | edits.push(match e { |
|
| 322 | + | Edit::Insert { node, after, before, lines } => Edit::Insert { |
|
| 323 | + | node: *node, |
|
| 324 | + | after: fold_atom(*after, target, &per_node, node).ok_or_else(|| too_new("an anchor line"))?, |
|
| 325 | + | before: fold_atom(*before, target, &per_node, node).ok_or_else(|| too_new("an anchor line"))?, |
|
| 326 | + | lines: lines.clone(), |
|
| 327 | + | }, |
|
| 328 | + | Edit::Delete { node, atoms } => Edit::Delete { |
|
| 329 | + | node: *node, |
|
| 330 | + | atoms: atoms |
|
| 331 | + | .iter() |
|
| 332 | + | .map(|r| { |
|
| 333 | + | if r.change == *target { |
|
| 334 | + | AtomRange { change: ObjectRef::ZERO, from: r.from, to: r.to } |
|
| 335 | + | } else if r.change.is_zero() { |
|
| 336 | + | let off = per_node.get(node).copied().unwrap_or(0); |
|
| 337 | + | AtomRange { change: ObjectRef::ZERO, from: r.from + off, to: r.to + off } |
|
| 338 | + | } else { |
|
| 339 | + | *r |
|
| 340 | + | } |
|
| 341 | + | }) |
|
| 342 | + | .collect(), |
|
| 343 | + | }, |
|
| 344 | + | other => other.clone(), |
|
| 345 | + | }); |
|
| 346 | + | } |
|
| 347 | + | let mut new = Change { |
|
| 348 | + | change_id: old.change_id, |
|
| 349 | + | deps: vec![], |
|
| 350 | + | author: self.author(), |
|
| 351 | + | hlc: Hlc::now(), |
|
| 352 | + | message: message.map(|m| m.trim_end().to_string()).unwrap_or(old.message.clone()), |
|
| 353 | + | tree_ops, |
|
| 354 | + | edits, |
|
| 355 | + | kind: old.kind, |
|
| 356 | + | }; |
|
| 357 | + | let beneath = self.load_state(&old.deps)?; |
|
| 358 | + | let mut deps: BTreeSet<ChangeHash> = old.deps.iter().copied().collect(); |
|
| 359 | + | deps.extend(referenced_changes(&new, &beneath).into_iter().filter(|h| *h != *target)); |
|
| 360 | + | deps.extend(wc.deps.iter().filter(|d| closure.contains(d) && **d != *target).copied()); |
|
| 361 | + | new.deps = deps.into_iter().collect(); |
|
| 362 | + | new.normalize(); |
|
| 363 | + | ||
| 364 | + | // Rewrite dependents (identity correspondence), then start a fresh, |
|
| 365 | + | // empty working copy: its edits now live in the amended change. |
|
| 366 | + | let corr = Correspondence::identity(&old); |
|
| 367 | + | let new_hash = self.rewrite_change(target, Some(&new), &corr, &format!("oxy amend --into {}", old.change_id.short(8)))?.unwrap(); |
|
| 368 | + | let (op_ref, op) = self.head_op()?; |
|
| 369 | + | let heads = self.heads_of(&op.view, &self.config.bookmark)?; |
|
| 370 | + | let fresh = self.new_working_copy(heads, ""); |
|
| 371 | + | let wc_hash = self.store.put_change(&fresh)?; |
|
| 372 | + | let mut view = op.view.clone(); |
|
| 373 | + | view.workspaces.insert(self.workspace, wc_hash); |
|
| 374 | + | self.record_op(&[op_ref], "oxy amend (working copy)", view)?; |
|
| 375 | + | self.drop_manifest(); |
|
| 376 | + | Ok(new_hash) |
|
| 377 | + | } |
|
| 378 | + | ||
| 379 | + | /// Split a head into two changes: one holding the head's effect on |
|
| 380 | + | /// `paths`, the other the rest (keeping the head's ChangeId). The |
|
| 381 | + | /// working copy is unaffected. |
|
| 382 | + | pub fn split(&self, hash: &ChangeHash, paths: &[String]) -> Result<(ChangeHash, ChangeHash)> { |
|
| 383 | + | let old = self.rewritable_head(hash)?; |
|
| 384 | + | if paths.is_empty() { |
|
| 385 | + | return fail("name at least one path to split out"); |
|
| 386 | + | } |
|
| 387 | + | let beneath = self.load_state(&old.deps)?; |
|
| 388 | + | let beneath_snapshot = beneath.materialize(); |
|
| 389 | + | let full = self.load_state(&[*hash])?; |
|
| 390 | + | let full_snapshot = full.materialize(); |
|
| 391 | + | let selected = |p: &str| paths.iter().any(|x| p == x || p.starts_with(&format!("{}/", x.trim_end_matches('/')))); |
|
| 392 | + | // Entries of the intermediate state: selected paths as the head |
|
| 393 | + | // leaves them, everything else as beneath. |
|
| 394 | + | let entries_of = |snap: &Snapshot, keep_from_full: &dyn Fn(&str) -> bool| -> Result<BTreeMap<String, FsEntry>> { |
|
| 395 | + | let mut out = BTreeMap::new(); |
|
| 396 | + | for (p, _) in snap.files.iter().chain(full_snapshot.files.iter()) { |
|
| 397 | + | let take_full = keep_from_full(p); |
|
| 398 | + | let src = if take_full { full_snapshot.files.get(p) } else { snap.files.get(p) }; |
|
| 399 | + | let Some(src) = src else { continue }; |
|
| 400 | + | if out.contains_key(p) { |
|
| 401 | + | continue; |
|
| 402 | + | } |
|
| 403 | + | let content = match &src.content { |
|
| 404 | + | FileContent::Dir => continue, |
|
| 405 | + | FileContent::Text { bytes, .. } => bytes.clone(), |
|
| 406 | + | FileContent::Opaque { blobs } => match blobs.first() { |
|
| 407 | + | Some(b) => self.read_blob(b)?, |
|
| 408 | + | None => continue, |
|
| 409 | + | }, |
|
| 410 | + | }; |
|
| 411 | + | out.insert(p.clone(), FsEntry { kind: src.kind, exec: src.mode.exec, content }); |
|
| 412 | + | } |
|
| 413 | + | Ok(out) |
|
| 414 | + | }; |
|
| 415 | + | let mid_entries = entries_of(&beneath_snapshot, &|p| selected(p))?; |
|
| 416 | + | let first = self.build_from_entries(&beneath, &beneath_snapshot, HashMap::new(), &mid_entries)?; |
|
| 417 | + | if first.tree_ops.is_empty() && first.edits.is_empty() { |
|
| 418 | + | return fail("the named paths are not touched by that change"); |
|
| 419 | + | } |
|
| 420 | + | let mut c1 = Change { |
|
| 421 | + | change_id: ChangeId::random(), |
|
| 422 | + | deps: old.deps.clone(), |
|
| 423 | + | author: old.author, |
|
| 424 | + | hlc: Hlc::now(), |
|
| 425 | + | message: format!("{} (part 1: {})", old.message.lines().next().unwrap_or(""), paths.join(", ")), |
|
| 426 | + | tree_ops: first.tree_ops, |
|
| 427 | + | edits: first.edits, |
|
| 428 | + | kind: ChangeKind::Normal, |
|
| 429 | + | }; |
|
| 430 | + | let mut d1: BTreeSet<ChangeHash> = referenced_changes(&c1, &beneath); |
|
| 431 | + | d1.extend(old.deps.iter().copied()); |
|
| 432 | + | c1.deps = d1.into_iter().collect(); |
|
| 433 | + | c1.normalize(); |
|
| 434 | + | let h1 = self.store_signed(&c1)?; |
|
| 435 | + | let mut mid = beneath.clone(); |
|
| 436 | + | mid.add(c1)?; |
|
| 437 | + | let mid_snapshot = mid.materialize(); |
|
| 438 | + | let rest_entries = entries_of(&full_snapshot, &|_| true)?; |
|
| 439 | + | let rest = self.build_from_entries(&mid, &mid_snapshot, HashMap::new(), &rest_entries)?; |
|
| 440 | + | if rest.tree_ops.is_empty() && rest.edits.is_empty() { |
|
| 441 | + | return fail("everything in that change is under the named paths; nothing would remain"); |
|
| 442 | + | } |
|
| 443 | + | let mut c2 = Change { |
|
| 444 | + | change_id: old.change_id, |
|
| 445 | + | deps: vec![], |
|
| 446 | + | author: old.author, |
|
| 447 | + | hlc: Hlc::now(), |
|
| 448 | + | message: old.message.clone(), |
|
| 449 | + | tree_ops: rest.tree_ops, |
|
| 450 | + | edits: rest.edits, |
|
| 451 | + | kind: ChangeKind::Normal, |
|
| 452 | + | }; |
|
| 453 | + | let mut d2: BTreeSet<ChangeHash> = referenced_changes(&c2, &mid); |
|
| 454 | + | d2.extend(old.deps.iter().copied()); |
|
| 455 | + | c2.deps = d2.into_iter().collect(); |
|
| 456 | + | c2.normalize(); |
|
| 457 | + | let h2 = self.replace_head(hash, Some(&c2), &format!("oxy split {} {}", old.change_id.short(8), paths.join(" ")))?.unwrap(); |
|
| 458 | + | // c1 may be a head of its own if c2 does not depend on it. |
|
| 459 | + | let (op_ref, op) = self.head_op()?; |
|
| 460 | + | let bookmark = self.config.bookmark.clone(); |
|
| 461 | + | let mut heads = self.heads_of(&op.view, &bookmark)?; |
|
| 462 | + | heads.push(h1); |
|
| 463 | + | let st = self.load_state(&heads)?; |
|
| 464 | + | let heads = st.maximal(&heads); |
|
| 465 | + | let mut view = op.view.clone(); |
|
| 466 | + | view.bookmarks.insert(bookmark, self.store.put_state(&StateObject::new(heads.clone()))?); |
|
| 467 | + | let (_, wc) = self.working_copy()?; |
|
| 468 | + | let mut base = wc.deps.clone(); |
|
| 469 | + | base.push(h1); |
|
| 470 | + | let wc_base = self.load_state(&base)?.maximal(&base); |
|
| 471 | + | let fresh = self.new_working_copy(wc_base, &wc.message); |
|
| 472 | + | view.workspaces.insert(self.workspace, self.store.put_change(&fresh)?); |
|
| 473 | + | self.record_op(&[op_ref], "oxy split (heads)", view)?; |
|
| 474 | + | self.drop_manifest(); |
|
| 475 | + | Ok((h1, h2)) |
|
| 476 | + | } |
|
| 477 | + | } |
modified crates/oxy-core/src/repo/tests.rs +65 −0
| … | 1523 unchanged lines |
||
| 1524 | 1524 | assert!(sem.contains("\"touched_paths\":[\"c.txt\"]") && sem.contains("candidate_deps"), "{sem}"); |
|
| 1525 | 1525 | assert_eq!(json_str("a\"b\\c\n"), "\"a\\\"b\\\\c\\n\""); |
|
| 1526 | 1526 | } |
|
| 1527 | + | ||
| 1528 | + | #[test] |
|
| 1529 | + | fn amend_into_older_change_and_split() { |
|
| 1530 | + | hermetic(); |
|
| 1531 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 1532 | + | let root = dir.path(); |
|
| 1533 | + | let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); |
|
| 1534 | + | repo.create_identity("Ada", "a@b", IdentityScope::User).unwrap(); |
|
| 1535 | + | repo.set_user_config("core.sign", "change").unwrap(); |
|
| 1536 | + | write(root, "a.txt", b"one\ntwo\n"); |
|
| 1537 | + | let h0 = repo.commit("base", &[]).unwrap(); |
|
| 1538 | + | write(root, "a.txt", b"one\nTWO\n"); |
|
| 1539 | + | let h1 = repo.commit("edit two", &[]).unwrap(); |
|
| 1540 | + | write(root, "a.txt", b"one\nTWO\nthree\n"); |
|
| 1541 | + | let h2 = repo.commit("add three", &[]).unwrap(); |
|
| 1542 | + | let id1 = repo.store().get_change(&h1).unwrap().change_id; |
|
| 1543 | + | ||
| 1544 | + | // Fixing the line h1 introduced folds into h1; h2 is rewritten on top. |
|
| 1545 | + | write(root, "a.txt", b"one\nTWO!\nthree\n"); |
|
| 1546 | + | let h1b = repo.amend_into(&h1, None).unwrap(); |
|
| 1547 | + | assert_ne!(h1b, h1); |
|
| 1548 | + | let c1b = repo.store().get_change(&h1b).unwrap(); |
|
| 1549 | + | assert_eq!(c1b.change_id, id1); |
|
| 1550 | + | assert_eq!(c1b.deps, vec![h0]); |
|
| 1551 | + | assert!(matches!(repo.verify_change(&h1b).unwrap(), SignatureStatus::Valid { .. })); |
|
| 1552 | + | let heads = repo.bookmark_heads().unwrap(); |
|
| 1553 | + | assert_eq!(heads.len(), 1); |
|
| 1554 | + | let top = repo.store().get_change(&heads[0]).unwrap(); |
|
| 1555 | + | assert_eq!(top.message, "add three"); |
|
| 1556 | + | assert_eq!(top.deps.iter().copied().collect::<BTreeSet<_>>(), [h0, h1b].into_iter().collect(), "depends on the file's creator and the rewritten line"); |
|
| 1557 | + | let snap = repo.load_state(&heads).unwrap().materialize(); |
|
| 1558 | + | assert!(matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\nTWO!\nthree\n")); |
|
| 1559 | + | assert!(!repo.snapshot("status").unwrap().changed, "the working copy is clean after folding"); |
|
| 1560 | + | // Only h1b's own state shows the fix without "three". |
|
| 1561 | + | let only = repo.load_state(&[h1b]).unwrap().materialize(); |
|
| 1562 | + | assert!(matches!(&only.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\nTWO!\n")); |
|
| 1563 | + | assert_eq!(repo.log().unwrap().len(), 4); |
|
| 1564 | + | ||
| 1565 | + | // An edit to a line introduced by h2 cannot fold into h1. |
|
| 1566 | + | write(root, "a.txt", b"one\nTWO!\nthree!\n"); |
|
| 1567 | + | let err = repo.amend_into(&h1b, None).unwrap_err().to_string(); |
|
| 1568 | + | assert!(err.contains("introduced after"), "{err}"); |
|
| 1569 | + | assert!(repo.snapshot("status").unwrap().statuses.len() == 1, "nothing was changed by the refusal"); |
|
| 1570 | + | write(root, "a.txt", b"one\nTWO!\nthree\n"); |
|
| 1571 | + | ||
| 1572 | + | // Split the head that touches two files. |
|
| 1573 | + | write(root, "b.txt", b"b\n"); |
|
| 1574 | + | write(root, "c.txt", b"c\n"); |
|
| 1575 | + | let h3 = repo.commit("b and c", &[]).unwrap(); |
|
| 1576 | + | let id3 = repo.store().get_change(&h3).unwrap().change_id; |
|
| 1577 | + | let (p1, p2) = repo.split(&h3, &["b.txt".into()]).unwrap(); |
|
| 1578 | + | let c_1 = repo.store().get_change(&p1).unwrap(); |
|
| 1579 | + | let c_2 = repo.store().get_change(&p2).unwrap(); |
|
| 1580 | + | assert!(c_1.message.contains("part 1: b.txt")); |
|
| 1581 | + | assert_eq!(c_2.change_id, id3); |
|
| 1582 | + | let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect(); |
|
| 1583 | + | assert!(heads.contains(&p1) && heads.contains(&p2), "independent parts are both heads"); |
|
| 1584 | + | let snap = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap().materialize(); |
|
| 1585 | + | assert!(snap.files.contains_key("b.txt") && snap.files.contains_key("c.txt")); |
|
| 1586 | + | let only1 = repo.load_state(&[p1]).unwrap().materialize(); |
|
| 1587 | + | assert!(only1.files.contains_key("b.txt") && !only1.files.contains_key("c.txt")); |
|
| 1588 | + | assert!(!repo.snapshot("status").unwrap().changed); |
|
| 1589 | + | assert!(repo.split(&p2, &["nope.txt".into()]).is_err()); |
|
| 1590 | + | let _ = h2; |
|
| 1591 | + | } |
modified docs/spec.md +5 −3
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | | | | |
|
| 4 | 4 | |---|---| |
|
| 5 | - | | **Status** | Draft 0.6k | |
|
| 5 | + | | **Status** | Draft 0.6l | |
|
| 6 | 6 | | **Format version** | 1 | |
|
| 7 | 7 | | **Date** | 2026-09-26 | |
|
| 8 | 8 | | **Audience** | Implementers, security reviewers, and contributors | |
|
| … | 308 unchanged lines |
||
| 317 | 317 | ||
| 318 | 318 | ### 8.4 Rewriting |
|
| 319 | 319 | ||
| 320 | - | Rewriting a change `C` to `C'` (amend, rebase, split, squash) produces a new object with the same ChangeId. Any change `D` with `hash(C) ∈ deps(D)` is orphaned relative to `C'`. `oxy` MUST automatically rewrite orphaned descendants by translating their AtomRefs and PlacementRefs from `C` to `C'` where a correspondence exists, and MUST record a conflict (§9) where it does not. Until the shape of that conflict is specified, an implementation MAY instead refuse the rewrite, naming the orphaned reference. Removing `C` altogether rewires each dependent to depend on `deps(C)`, and is refused where a dependent references `C`'s atoms or placements. Changes that do not depend on `C` are untouched, and a rewritten change by another author is stored without that author's signature, which only they can renew. Rewriting is recorded in the operation log (§12) and is undoable. |
|
| 320 | + | Rewriting a change `C` to `C'` (amend, rebase, split, squash) produces a new object with the same ChangeId. Amending an older change with the working copy's edits (`oxy amend --into C`) is allowed when every atom, placement, and node those edits reference exists in `C`'s closure; the edits are appended to `C` with references to `C` turned into self references, so `C`'s own atoms keep their indices and dependents are rewritten with an identity correspondence. Splitting a head (`oxy split C PATH..`) rebuilds it as a first change holding its effect on the named paths and a second holding the rest under the original ChangeId. Any change `D` with `hash(C) ∈ deps(D)` is orphaned relative to `C'`. `oxy` MUST automatically rewrite orphaned descendants by translating their AtomRefs and PlacementRefs from `C` to `C'` where a correspondence exists, and MUST record a conflict (§9) where it does not. Until the shape of that conflict is specified, an implementation MAY instead refuse the rewrite, naming the orphaned reference. Removing `C` altogether rewires each dependent to depend on `deps(C)`, and is refused where a dependent references `C`'s atoms or placements. Changes that do not depend on `C` are untouched, and a rewritten change by another author is stored without that author's signature, which only they can renew. Rewriting is recorded in the operation log (§12) and is undoable. |
|
| 321 | 321 | ||
| 322 | 322 | ## 9. Conflicts |
|
| 323 | 323 | ||
| … | 518 unchanged lines |
||
| 842 | 842 | ||
| 843 | 843 | --- |
|
| 844 | 844 | ||
| 845 | - | *Changelog.* Draft 0.6k (2026-09-27): hook execution model and JSON payloads (§20). |
|
| 845 | + | *Changelog.* Draft 0.6l (2026-09-27): amend into older changes and split (§8.4). |
|
| 846 | + | ||
| 847 | + | Draft 0.6k (2026-09-27): hook execution model and JSON payloads (§20). |
|
| 846 | 848 | ||
| 847 | 849 | Draft 0.6j (2026-09-27): `.oxyattributes` semantics (§19). |
|
| 848 | 850 | ||
| … | 35 unchanged lines |
modified src/cli/mod.rs +11 −2
| … | 73 unchanged lines |
||
| 74 | 74 | message: String, |
|
| 75 | 75 | change: Option<String>, |
|
| 76 | 76 | }, |
|
| 77 | - | /// Fold the working copy into the head it sits on |
|
| 77 | + | /// Fold the working copy into the head it sits on, or into CHANGE with --into |
|
| 78 | 78 | Amend { |
|
| 79 | 79 | #[arg(short, long)] |
|
| 80 | 80 | message: Option<String>, |
|
| 81 | + | /// Amend an older change; the edits must touch only lines it already knows |
|
| 82 | + | #[arg(long)] |
|
| 83 | + | into: Option<String>, |
|
| 81 | 84 | }, |
|
| 85 | + | /// Split a head: the named paths become a separate first change |
|
| 86 | + | Split { |
|
| 87 | + | change: String, |
|
| 88 | + | paths: Vec<String>, |
|
| 89 | + | }, |
|
| 82 | 90 | /// Drop a head from the bookmark; its content stays on disk as uncommitted work |
|
| 83 | 91 | Abandon { change: String }, |
|
| 84 | 92 | /// Show one change (ChangeId prefix, #hash prefix, or @) |
|
| … | 195 unchanged lines |
||
| 280 | 288 | Cmd::Log => workspace::log(), |
|
| 281 | 289 | Cmd::Diff => workspace::diff(), |
|
| 282 | 290 | Cmd::Describe { message, change } => workspace::describe(&message, change.as_deref()), |
|
| 283 | - | Cmd::Amend { message } => workspace::amend(message.as_deref()), |
|
| 291 | + | Cmd::Amend { message, into } => workspace::amend(message.as_deref(), into.as_deref()), |
|
| 292 | + | Cmd::Split { change, paths } => workspace::split(&change, &paths), |
|
| 284 | 293 | Cmd::Abandon { change } => workspace::abandon(&change), |
|
| 285 | 294 | Cmd::Show { change } => workspace::show(&change), |
|
| 286 | 295 | Cmd::Checkout { changes, discard } => workspace::checkout(&changes, discard), |
|
| … | 45 unchanged lines |
modified src/cli/workspace.rs +18 −2
| … | 246 unchanged lines |
||
| 247 | 247 | Ok(0) |
|
| 248 | 248 | } |
|
| 249 | 249 | ||
| 250 | - | pub fn amend(message: Option<&str>) -> Result<i32> { |
|
| 250 | + | pub fn amend(message: Option<&str>, into: Option<&str>) -> Result<i32> { |
|
| 251 | 251 | let repo = open()?; |
|
| 252 | - | let new = repo.amend(message)?; |
|
| 252 | + | let new = match into { |
|
| 253 | + | Some(spec) => { |
|
| 254 | + | let target = repo.resolve_change(spec)?; |
|
| 255 | + | repo.amend_into(&target, message)? |
|
| 256 | + | } |
|
| 257 | + | None => repo.amend(message)?, |
|
| 258 | + | }; |
|
| 253 | 259 | let c = repo.store().get_change(&new)?; |
|
| 254 | 260 | println!("{} {} {} {}", ui::ok("Amended"), ui::id(short_id(&c)), ui::hash(format!("#{}", new.short(12))), c.message.lines().next().unwrap_or("")); |
|
| 255 | 261 | Ok(0) |
|
| … | 152 unchanged lines |
||
| 408 | 414 | println!("{} {}", ui::ok("Unlocked"), ui::path(path)); |
|
| 409 | 415 | Ok(0) |
|
| 410 | 416 | } |
|
| 417 | + | ||
| 418 | + | pub fn split(change: &str, paths: &[String]) -> Result<i32> { |
|
| 419 | + | let repo = open()?; |
|
| 420 | + | let h = repo.resolve_change(change)?; |
|
| 421 | + | let (h1, h2) = repo.split(&h, paths)?; |
|
| 422 | + | let c1 = repo.store().get_change(&h1)?; |
|
| 423 | + | let c2 = repo.store().get_change(&h2)?; |
|
| 424 | + | println!("{} into {} {} and {} {}", ui::ok("Split"), ui::id(short_id(&c1)), ui::hash(format!("#{}", h1.short(12))), ui::id(short_id(&c2)), ui::hash(format!("#{}", h2.short(12)))); |
|
| 425 | + | Ok(0) |
|
| 426 | + | } |