|
|
… |
1 unchanged lines |
| 2 |
2 |
|
//! log, and the working-copy change (spec §12, §13.1). |
| 3 |
3 |
|
//! |
| 4 |
4 |
|
//! Not yet implemented from the spec: seals, attestations and trust policy, |
| 5 |
|
- |
//! packs, checkpoints, multiple workspaces, remotes, and resolving conflicts |
| 6 |
|
- |
//! by editing markers. Changes are signed individually when `core.sign` is |
|
5 |
+ |
//! packs, checkpoints, multiple workspaces, and remotes. Changes are signed individually when `core.sign` is |
| 7 |
6 |
|
//! `change`; the default `seal` leaves them unsigned until push exists. |
| 8 |
7 |
|
|
| 9 |
8 |
|
use crate::diff::{ranges, split_lines, text_edits}; |
|
|
… |
128 unchanged lines |
| 138 |
137 |
|
Modified, |
| 139 |
138 |
|
Removed, |
| 140 |
139 |
|
ModeChanged, |
| 141 |
|
- |
/// The file is conflicted in the base state and was edited in the working copy. |
|
140 |
+ |
/// The file is conflicted in the base state and still contains conflict markers. |
| 142 |
141 |
|
ConflictEdited, |
|
142 |
+ |
/// The file was conflicted in the base state and the working copy resolves it. |
|
143 |
+ |
Resolved, |
|
144 |
+ |
} |
|
145 |
+ |
|
|
146 |
+ |
/// Lines that mark a conflict region in a materialized file (spec §9.5). |
|
147 |
+ |
pub fn has_conflict_markers(content: &[u8]) -> bool { |
|
148 |
+ |
split_lines(content).iter().any(|l| { |
|
149 |
+ |
l.starts_with(b"<<<<<<< oxy conflict") || l.starts_with(b"======= oxy conflict") || l.starts_with(b">>>>>>> oxy conflict") |
|
150 |
+ |
}) |
| 143 |
151 |
|
} |
| 144 |
152 |
|
|
| 145 |
153 |
|
#[derive(Clone, Debug, PartialEq, Eq)] |
|
|
… |
12 unchanged lines |
| 158 |
166 |
|
pub statuses: Vec<PathStatus>, |
| 159 |
167 |
|
/// Whether the snapshot rewrote the working-copy change. |
| 160 |
168 |
|
pub changed: bool, |
|
169 |
+ |
/// Whether the working copy resolves conflicts of its base (spec §9.6). |
|
170 |
+ |
pub resolves: bool, |
| 161 |
171 |
|
} |
| 162 |
172 |
|
|
| 163 |
173 |
|
pub struct LogEntry { |
|
|
… |
417 unchanged lines |
| 581 |
591 |
|
let base_heads = base.maximal(&old_wc.deps); |
| 582 |
592 |
|
let mut deps: BTreeSet<ChangeHash> = base_heads.into_iter().collect(); |
| 583 |
593 |
|
deps.extend(referenced_changes(&wc, &base)); |
|
594 |
+ |
deps.extend(b.contributors.iter().copied()); |
| 584 |
595 |
|
wc.deps = deps.into_iter().collect(); |
| 585 |
596 |
|
wc.normalize(); |
|
597 |
+ |
let resolves = b.resolves; |
| 586 |
598 |
|
|
| 587 |
599 |
|
let mut statuses = b.statuses; |
| 588 |
600 |
|
statuses.sort_by(|a, b| a.path.cmp(&b.path)); |
|
|
… |
6 unchanged lines |
| 595 |
607 |
|
view.workspaces.insert(self.workspace, new_hash); |
| 596 |
608 |
|
self.record_op(&[op_ref], &format!("{command}{SNAPSHOT_SUFFIX}"), view)?; |
| 597 |
609 |
|
} |
| 598 |
|
- |
Ok(SnapshotResult { wc_hash: new_hash, wc, base, base_snapshot, statuses, changed }) |
|
610 |
+ |
Ok(SnapshotResult { wc_hash: new_hash, wc, base, base_snapshot, statuses, changed, resolves }) |
| 599 |
611 |
|
} |
| 600 |
612 |
|
|
| 601 |
613 |
|
/// Turn the working-copy change into a normal change on the bookmark (spec §12.3). |
|
|
… |
2 unchanged lines |
| 604 |
616 |
|
if snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty() { |
| 605 |
617 |
|
return fail("nothing to commit: the working copy matches its base"); |
| 606 |
618 |
|
} |
| 607 |
|
- |
if snap.statuses.iter().any(|s| s.status == ChangeStatus::ConflictEdited) { |
| 608 |
|
- |
return fail("a conflicted file was edited; resolving conflicts is not implemented yet"); |
|
619 |
+ |
if let Some(s) = snap.statuses.iter().find(|s| s.status == ChangeStatus::ConflictEdited) { |
|
620 |
+ |
return fail(format!("{} still contains conflict markers", s.path)); |
| 609 |
621 |
|
} |
| 610 |
622 |
|
if message.trim().is_empty() { |
| 611 |
623 |
|
return fail("a commit message is required"); |
| 612 |
624 |
|
} |
| 613 |
625 |
|
let (op_ref, op) = self.head_op()?; |
| 614 |
626 |
|
let mut deps: BTreeSet<ChangeHash> = referenced_changes(&snap.wc, &snap.base); |
|
627 |
+ |
if snap.resolves { |
|
628 |
+ |
// A resolution depends on every change that contributed to the |
|
629 |
+ |
// conflict (spec §8.1); those are already among the working copy's |
|
630 |
+ |
// dependencies, so keep every one that is not merely a base head. |
|
631 |
+ |
let heads: BTreeSet<ChangeHash> = snap.base.maximal(&snap.wc.deps).into_iter().collect(); |
|
632 |
+ |
deps.extend(snap.wc.deps.iter().filter(|d| !heads.contains(d) || touches_conflict(&snap)).copied()); |
|
633 |
+ |
} |
| 615 |
634 |
|
for d in extra_deps { |
| 616 |
635 |
|
if !snap.base.contains(d) { |
| 617 |
636 |
|
return fail(format!("--depends-on {d}: not in the working copy's base state")); |
|
|
… |
11 unchanged lines |
| 629 |
648 |
|
message: message.trim_end().to_string(), |
| 630 |
649 |
|
tree_ops: snap.wc.tree_ops.clone(), |
| 631 |
650 |
|
edits: snap.wc.edits.clone(), |
| 632 |
|
- |
kind: ChangeKind::Normal, |
|
651 |
+ |
kind: if snap.resolves { ChangeKind::Resolution } else { ChangeKind::Normal }, |
| 633 |
652 |
|
}; |
| 634 |
653 |
|
change.normalize(); |
| 635 |
654 |
|
let hash = self.store.put_change(&change)?; |
|
|
… |
2 unchanged lines |
| 638 |
657 |
|
} |
| 639 |
658 |
|
|
| 640 |
659 |
|
// New bookmark heads: maximal elements of old heads plus the new change. |
|
660 |
+ |
// The working copy continues on its own base plus the new change, which |
|
661 |
+ |
// is exactly what the files on disk contain; the bookmark may hold |
|
662 |
+ |
// other heads (and conflicts) the working copy has not checked out. |
| 641 |
663 |
|
let old_heads = op.view.bookmarks.get(&self.config.bookmark).cloned().unwrap_or_default(); |
| 642 |
|
- |
let mut with_new: Vec<ChangeHash> = old_heads.clone(); |
| 643 |
|
- |
with_new.push(hash); |
| 644 |
|
- |
let mut st = self.load_state(&with_new)?; |
|
664 |
+ |
let old_base = snap.base.maximal(&snap.wc.deps); |
|
665 |
+ |
let mut all: Vec<ChangeHash> = old_heads.clone(); |
|
666 |
+ |
all.extend(old_base.iter().copied()); |
|
667 |
+ |
all.push(hash); |
|
668 |
+ |
let mut st = self.load_state(&all)?; |
| 645 |
669 |
|
st.add(change)?; |
|
670 |
+ |
let mut with_new = old_heads.clone(); |
|
671 |
+ |
with_new.push(hash); |
| 646 |
672 |
|
let new_heads = st.maximal(&with_new); |
|
673 |
+ |
let mut wc_base = old_base.clone(); |
|
674 |
+ |
wc_base.push(hash); |
|
675 |
+ |
let wc_base = st.maximal(&wc_base); |
| 647 |
676 |
|
|
| 648 |
|
- |
let wc = self.new_working_copy(new_heads.clone(), ""); |
|
677 |
+ |
let wc = self.new_working_copy(wc_base, ""); |
| 649 |
678 |
|
let wc_hash = self.store.put_change(&wc)?; |
| 650 |
679 |
|
let mut view = op.view.clone(); |
| 651 |
680 |
|
view.bookmarks.insert(self.config.bookmark.clone(), new_heads); |
|
|
… |
254 unchanged lines |
| 906 |
935 |
|
statuses: Vec<PathStatus>, |
| 907 |
936 |
|
dir_nodes: HashMap<String, NodeId>, |
| 908 |
937 |
|
reuse: HashMap<String, (NodeId, NodeKind)>, |
|
938 |
+ |
/// Changes that contributed to conflicts this working copy resolves. |
|
939 |
+ |
contributors: BTreeSet<ChangeHash>, |
|
940 |
+ |
resolves: bool, |
| 909 |
941 |
|
} |
| 910 |
942 |
|
|
| 911 |
943 |
|
impl<'a> ChangeBuilder<'a> { |
|
|
… |
5 unchanged lines |
| 917 |
949 |
|
dir_nodes.insert(p.clone(), f.node); |
| 918 |
950 |
|
} |
| 919 |
951 |
|
} |
| 920 |
|
- |
ChangeBuilder { base, base_snapshot, tree_ops: vec![], edits: vec![], statuses: vec![], dir_nodes, reuse } |
|
952 |
+ |
ChangeBuilder { base, base_snapshot, tree_ops: vec![], edits: vec![], statuses: vec![], dir_nodes, reuse, contributors: BTreeSet::new(), resolves: false } |
| 921 |
953 |
|
} |
| 922 |
954 |
|
|
| 923 |
955 |
|
fn reused_node(&self, path: &str, kind: NodeKind) -> Option<NodeId> { |
|
|
… |
48 unchanged lines |
| 972 |
1004 |
|
fn remove_path(&mut self, path: &str, expected: &crate::state::FileState) { |
| 973 |
1005 |
|
let entry = &self.base_snapshot.tree.entries[path]; |
| 974 |
1006 |
|
self.tree_ops.push(TreeOp::RemoveNode { node: expected.node, placement: op_ref(entry.placement) }); |
|
1007 |
+ |
if path.contains(".oxy-conflict-") || expected.is_conflicted() { |
|
1008 |
+ |
self.resolves = true; |
|
1009 |
+ |
} |
| 975 |
1010 |
|
self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Removed }); |
| 976 |
1011 |
|
} |
| 977 |
1012 |
|
|
|
|
… |
14 unchanged lines |
| 992 |
1027 |
|
FileContent::Dir => {} |
| 993 |
1028 |
|
FileContent::Text { bytes, materialized } => { |
| 994 |
1029 |
|
if materialized.is_conflicted() { |
| 995 |
|
- |
if bytes != &e.content { |
|
1030 |
+ |
if bytes == &e.content { |
|
1031 |
+ |
return Ok(()); |
|
1032 |
+ |
} |
|
1033 |
+ |
if has_conflict_markers(&e.content) { |
| 996 |
1034 |
|
self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited }); |
|
1035 |
+ |
return Ok(()); |
| 997 |
1036 |
|
} |
|
1037 |
+ |
// Resolution (spec §9.6): diff against the unconflicted lines |
|
1038 |
+ |
// with each region as a gap, then retire every region atom. |
|
1039 |
+ |
// Whatever the author kept from a region is re-inserted as |
|
1040 |
+ |
// new atoms, which leaves the graph totally ordered. |
|
1041 |
+ |
let regions = self.base.conflict_regions(&node); |
|
1042 |
+ |
let mut edits = text_edits(node, ®ions.plain, &e.content); |
|
1043 |
+ |
if !regions.region_atoms.is_empty() { |
|
1044 |
+ |
edits.insert(0, Edit::Delete { node, atoms: ranges(®ions.region_atoms) }); |
|
1045 |
+ |
} |
|
1046 |
+ |
self.edits.extend(edits); |
|
1047 |
+ |
self.contributors.extend(regions.contributors); |
|
1048 |
+ |
self.resolves = true; |
|
1049 |
+ |
if e.exec != expected.mode.exec { |
|
1050 |
+ |
if let Some(old) = mode_atom { |
|
1051 |
+ |
self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } }); |
|
1052 |
+ |
} |
|
1053 |
+ |
} |
|
1054 |
+ |
self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Resolved }); |
| 998 |
1055 |
|
return Ok(()); |
| 999 |
1056 |
|
} |
| 1000 |
1057 |
|
if bytes != &e.content { |
|
|
… |
70 unchanged lines |
| 1071 |
1128 |
|
} |
| 1072 |
1129 |
|
} |
| 1073 |
1130 |
|
|
|
1131 |
+ |
/// Whether a working copy touches a node that is conflicted in its base. |
|
1132 |
+ |
fn touches_conflict(snap: &SnapshotResult) -> bool { |
|
1133 |
+ |
let conflicted: BTreeSet<NodeId> = snap.base_snapshot.files.values().filter(|f| f.is_conflicted()).map(|f| f.node).collect(); |
|
1134 |
+ |
snap.wc.tree_ops.iter().any(|op| conflicted.contains(&op.node())) || snap.wc.edits.iter().any(|e| conflicted.contains(&e.node())) |
|
1135 |
+ |
} |
|
1136 |
+ |
|
| 1074 |
1137 |
|
fn repo_live_keys(base: &State, node: &NodeId) -> Vec<crate::graph::AtomKey> { |
| 1075 |
1138 |
|
base.live_lines(node).into_iter().map(|(k, _)| k).collect() |
| 1076 |
1139 |
|
} |
|
|
… |
288 unchanged lines |
| 1365 |
1428 |
|
assert_eq!(s.statuses.len(), 3); |
| 1366 |
1429 |
|
} |
| 1367 |
1430 |
|
|
|
1431 |
+ |
/// Make `main` conflicted: two commits on the same base that edit the same line. |
|
1432 |
+ |
fn conflicted_repo() -> (tempfile::TempDir, Repo, ChangeHash, ChangeHash, ChangeHash) { |
|
1433 |
+ |
let dir = tempfile::tempdir().unwrap(); |
|
1434 |
+ |
let root = dir.path(); |
|
1435 |
+ |
let repo = Repo::init(root).unwrap(); |
|
1436 |
+ |
write(root, "f.txt", b"a\nb\nc\n"); |
|
1437 |
+ |
let h1 = repo.commit("base", &[]).unwrap(); |
|
1438 |
+ |
write(root, "f.txt", b"a\nB\nc\n"); |
|
1439 |
+ |
let h2 = repo.commit("upper", &[]).unwrap(); |
|
1440 |
+ |
repo.checkout(&[h1], false, "checkout").unwrap(); |
|
1441 |
+ |
write(root, "f.txt", b"a\nX\nc\n"); |
|
1442 |
+ |
let h3 = repo.commit("ex", &[]).unwrap(); |
|
1443 |
+ |
(dir, repo, h1, h2, h3) |
|
1444 |
+ |
} |
|
1445 |
+ |
|
|
1446 |
+ |
#[test] |
|
1447 |
+ |
fn concurrent_edits_conflict_and_can_be_resolved() { |
|
1448 |
+ |
let (dir, repo, h1, h2, h3) = conflicted_repo(); |
|
1449 |
+ |
let root = dir.path(); |
|
1450 |
+ |
let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect(); |
|
1451 |
+ |
assert_eq!(heads, [h2, h3].into_iter().collect()); |
|
1452 |
+ |
let st = repo.load_state(&[h2, h3]).unwrap(); |
|
1453 |
+ |
assert!(st.materialize().is_conflicted()); |
|
1454 |
+ |
|
|
1455 |
+ |
// Checking out main writes markers. |
|
1456 |
+ |
let r = repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main").unwrap(); |
|
1457 |
+ |
assert!(r.conflicted); |
|
1458 |
+ |
let marked = fs::read_to_string(root.join("f.txt")).unwrap(); |
|
1459 |
+ |
assert!(marked.starts_with("a\n<<<<<<< oxy conflict 1 of 1"), "{marked}"); |
|
1460 |
+ |
assert!(marked.contains("B\n") && marked.contains("X\n") && marked.ends_with("end\nc\n"), "{marked}"); |
|
1461 |
+ |
assert!(has_conflict_markers(marked.as_bytes())); |
|
1462 |
+ |
// Untouched markers: clean working copy; commit has nothing to do. |
|
1463 |
+ |
let s = repo.snapshot("status").unwrap(); |
|
1464 |
+ |
assert!(s.statuses.is_empty()); |
|
1465 |
+ |
assert!(!s.resolves); |
|
1466 |
+ |
|
|
1467 |
+ |
// Partially edited file that still has markers cannot be committed. |
|
1468 |
+ |
write(root, "f.txt", marked.replace("B\n", "BB\n").as_bytes()); |
|
1469 |
+ |
let s = repo.snapshot("status").unwrap(); |
|
1470 |
+ |
assert_eq!(statuses(&s), vec![("f.txt".into(), ChangeStatus::ConflictEdited)]); |
|
1471 |
+ |
assert!(repo.commit("no", &[]).is_err()); |
|
1472 |
+ |
|
|
1473 |
+ |
// Resolve by keeping one side and adding a line elsewhere. |
|
1474 |
+ |
write(root, "f.txt", b"a\nX\nc\nd\n"); |
|
1475 |
+ |
let s = repo.snapshot("status").unwrap(); |
|
1476 |
+ |
assert_eq!(statuses(&s), vec![("f.txt".into(), ChangeStatus::Resolved)]); |
|
1477 |
+ |
assert!(s.resolves); |
|
1478 |
+ |
let h4 = repo.commit("resolve", &[]).unwrap(); |
|
1479 |
+ |
let c4 = repo.store().get_change(&h4).unwrap(); |
|
1480 |
+ |
assert_eq!(c4.kind, ChangeKind::Resolution); |
|
1481 |
+ |
assert!(c4.deps.contains(&h2) && c4.deps.contains(&h3) && c4.deps.contains(&h1), "{:?}", c4.deps); |
|
1482 |
+ |
assert_eq!(repo.bookmark_heads().unwrap(), vec![h4]); |
|
1483 |
+ |
let snap = repo.load_state(&[h4]).unwrap().materialize(); |
|
1484 |
+ |
assert!(!snap.is_conflicted()); |
|
1485 |
+ |
assert!(matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nc\nd\n")); |
|
1486 |
+ |
assert!(!repo.snapshot("status").unwrap().changed); |
|
1487 |
+ |
// A peer holding only h2 and h3 and receiving h4 sees the same result, |
|
1488 |
+ |
// whatever order the changes arrive in. |
|
1489 |
+ |
let mut st = State::new(); |
|
1490 |
+ |
for h in [h4, h3, h1, h2] { |
|
1491 |
+ |
st.add_unchecked(repo.store().get_change(&h).unwrap()); |
|
1492 |
+ |
} |
|
1493 |
+ |
st.validate().unwrap(); |
|
1494 |
+ |
assert!(matches!(&st.materialize().files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nc\nd\n")); |
|
1495 |
+ |
} |
|
1496 |
+ |
|
|
1497 |
+ |
#[test] |
|
1498 |
+ |
fn resolution_may_keep_both_sides_or_neither() { |
|
1499 |
+ |
let (dir, repo, _h1, _h2, _h3) = conflicted_repo(); |
|
1500 |
+ |
let root = dir.path(); |
|
1501 |
+ |
repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main").unwrap(); |
|
1502 |
+ |
// Keep both sides in a chosen order. |
|
1503 |
+ |
write(root, "f.txt", b"a\nX\nB\nc\n"); |
|
1504 |
+ |
let h = repo.commit("both", &[]).unwrap(); |
|
1505 |
+ |
let snap = repo.load_state(&[h]).unwrap().materialize(); |
|
1506 |
+ |
assert!(!snap.is_conflicted()); |
|
1507 |
+ |
assert!(matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nB\nc\n")); |
|
1508 |
+ |
// Undo and instead drop the whole region. |
|
1509 |
+ |
repo.undo().unwrap(); |
|
1510 |
+ |
write(root, "f.txt", b"a\nc\n"); |
|
1511 |
+ |
let h = repo.commit("neither", &[]).unwrap(); |
|
1512 |
+ |
let snap = repo.load_state(&[h]).unwrap().materialize(); |
|
1513 |
+ |
assert!(!snap.is_conflicted()); |
|
1514 |
+ |
assert!(matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nc\n")); |
|
1515 |
+ |
} |
|
1516 |
+ |
|
|
1517 |
+ |
#[test] |
|
1518 |
+ |
fn name_conflict_resolved_by_removing_a_candidate() { |
|
1519 |
+ |
let dir = tempfile::tempdir().unwrap(); |
|
1520 |
+ |
let root = dir.path(); |
|
1521 |
+ |
let repo = Repo::init(root).unwrap(); |
|
1522 |
+ |
write(root, "keep.txt", b"k\n"); |
|
1523 |
+ |
let h1 = repo.commit("base", &[]).unwrap(); |
|
1524 |
+ |
write(root, "new.txt", b"one\n"); |
|
1525 |
+ |
repo.commit("one", &[]).unwrap(); |
|
1526 |
+ |
repo.checkout(&[h1], false, "checkout").unwrap(); |
|
1527 |
+ |
write(root, "new.txt", b"two\n"); |
|
1528 |
+ |
repo.commit("two", &[]).unwrap(); |
|
1529 |
+ |
let r = repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main").unwrap(); |
|
1530 |
+ |
assert!(r.conflicted); |
|
1531 |
+ |
assert!(root.join("new.txt.oxy-conflict-1").exists() && root.join("new.txt.oxy-conflict-2").exists()); |
|
1532 |
+ |
assert!(!root.join("new.txt").exists()); |
|
1533 |
+ |
// Keep candidate 2 under the real name. |
|
1534 |
+ |
fs::remove_file(root.join("new.txt.oxy-conflict-1")).unwrap(); |
|
1535 |
+ |
fs::rename(root.join("new.txt.oxy-conflict-2"), root.join("new.txt")).unwrap(); |
|
1536 |
+ |
let h = repo.commit("pick two", &[]).unwrap(); |
|
1537 |
+ |
assert_eq!(repo.store().get_change(&h).unwrap().kind, ChangeKind::Resolution); |
|
1538 |
+ |
// The resolution does not touch keep.txt, so it does not depend on h1; |
|
1539 |
+ |
// the bookmark keeps both as heads and their union is the state. |
|
1540 |
+ |
let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect(); |
|
1541 |
+ |
assert_eq!(heads, [h1, h].into_iter().collect()); |
|
1542 |
+ |
let snap = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap().materialize(); |
|
1543 |
+ |
assert!(!snap.is_conflicted(), "{:?}", snap.tree.conflicts); |
|
1544 |
+ |
assert_eq!(snap.files.keys().cloned().collect::<Vec<_>>(), ["keep.txt", "new.txt"]); |
|
1545 |
+ |
assert!(matches!(&snap.files["new.txt"].content, FileContent::Text { bytes, .. } if bytes == b"two\n")); |
|
1546 |
+ |
} |
|
1547 |
+ |
|
| 1368 |
1548 |
|
#[test] |
| 1369 |
1549 |
|
fn identities_and_signed_commits() { |
| 1370 |
1550 |
|
let dir = tempfile::tempdir().unwrap(); |
|
|
… |
191 unchanged lines |