A file-based task manager
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at noah/git-backend-2 811 lines 30 kB view raw
1//! Reconcile divergent task histories on `tsk git-pull`. 2//! 3//! `git_pull` fetches the remote's `refs/tsk/*` into a non-clobbering shadow 4//! namespace at `refs/tsk-fetched/<remote>/*` so both the local and remote 5//! tip of every task ref are available in the same repo. We then walk every 6//! task that exists in either, and for each: 7//! 8//! - one side missing → take the side that has it 9//! - one side strictly ancestor of the other → fast-forward (or no-op) 10//! - both diverged → reconcile per [`Strategy`] 11//! 12//! `Strategy::Merge` (default) creates a merge commit using `git2`'s 13//! 3-way `merge_trees` against the common ancestor; clean merges land as a 14//! single commit with two parents and the local user as both author and 15//! committer. `Strategy::Rebase` replays each local-only commit on top of 16//! the remote tip, preserving each commit's original author and updating 17//! the committer to the local user — same shape as `git rebase`. 18//! 19//! True content conflicts (both sides edited the same blob in incompatible 20//! ways) abort that one task's reconciliation and leave the local ref 21//! untouched. The conflict surfaces in the pull summary so the user can 22//! re-run with the other strategy or hand-resolve. 23 24use crate::errors::Result; 25use crate::namespace::{self, NS_REF_PREFIX, Namespace}; 26use crate::object::{self, StableId, TASK_REF_PREFIX}; 27use crate::queue::{self, QUEUE_REF_PREFIX, Queue}; 28use git2::{Commit, Oid, Repository}; 29use std::collections::{BTreeMap, BTreeSet, HashSet}; 30 31#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] 32pub enum Strategy { 33 #[default] 34 Merge, 35 Rebase, 36} 37 38#[derive(Clone, Copy, Debug, Eq, PartialEq)] 39pub enum ReconKind { 40 /// No work needed (either side strict ancestor or refs identical). 41 Unchanged, 42 /// Local was strict ancestor of remote; ref now points at remote tip. 43 FastForward, 44 /// Remote ref existed without a local counterpart; copied verbatim. 45 NewRemote, 46 /// Wrote a merge commit with two parents. 47 Merged, 48 /// Replayed local-only commits onto the remote tip. 49 Rebased, 50 /// Reconciliation aborted due to overlapping edits; local ref unchanged. 51 Conflict, 52} 53 54#[derive(Debug)] 55pub struct Reconciliation { 56 pub stable: StableId, 57 pub kind: ReconKind, 58} 59 60#[derive(Debug)] 61pub struct PullOutcome { 62 pub tasks: Vec<Reconciliation>, 63 pub namespaces: Vec<NamespaceReconciliation>, 64 pub queues: Vec<QueueReconciliation>, 65} 66 67pub const FETCH_PREFIX: &str = "refs/tsk-fetched/"; 68 69pub fn fetched_prefix(remote: &str) -> String { 70 format!("{FETCH_PREFIX}{remote}/") 71} 72 73/// Reconcile every `refs/tsk/tasks/*` against its fetched counterpart at 74/// `refs/tsk-fetched/<remote>/tasks/*`. Returns one entry per task that 75/// existed in either side. 76pub fn reconcile_task_refs( 77 repo: &Repository, 78 remote: &str, 79 strategy: Strategy, 80) -> Result<Vec<Reconciliation>> { 81 let fetched_tasks = format!("{}tasks/", fetched_prefix(remote)); 82 let mut stables: BTreeSet<String> = BTreeSet::new(); 83 for r in repo.references_glob(&format!("{TASK_REF_PREFIX}*"))? { 84 let r = r?; 85 if let Some(name) = r.name().and_then(|n| n.strip_prefix(TASK_REF_PREFIX)) { 86 stables.insert(name.to_string()); 87 } 88 } 89 for r in repo.references_glob(&format!("{fetched_tasks}*"))? { 90 let r = r?; 91 if let Some(name) = r.name().and_then(|n| n.strip_prefix(fetched_tasks.as_str())) { 92 stables.insert(name.to_string()); 93 } 94 } 95 let mut out = Vec::new(); 96 for s in stables { 97 let stable = StableId(s.clone()); 98 let local = repo 99 .find_reference(&stable.refname()) 100 .ok() 101 .and_then(|r| r.target()); 102 let remote_ref = format!("{fetched_tasks}{s}"); 103 let remote_tip = repo 104 .find_reference(&remote_ref) 105 .ok() 106 .and_then(|r| r.target()); 107 let kind = reconcile_one(repo, &stable, local, remote_tip, strategy)?; 108 out.push(Reconciliation { stable, kind }); 109 } 110 Ok(out) 111} 112 113fn reconcile_one( 114 repo: &Repository, 115 stable: &StableId, 116 local: Option<Oid>, 117 remote: Option<Oid>, 118 strategy: Strategy, 119) -> Result<ReconKind> { 120 match (local, remote) { 121 (None, None) | (Some(_), None) => Ok(ReconKind::Unchanged), 122 (None, Some(r)) => { 123 repo.reference(&stable.refname(), r, true, "pull-import")?; 124 Ok(ReconKind::NewRemote) 125 } 126 (Some(l), Some(r)) if l == r => Ok(ReconKind::Unchanged), 127 (Some(l), Some(r)) => { 128 // graph_descendant_of(a, b) is true iff a descends from b. 129 if repo.graph_descendant_of(l, r).unwrap_or(false) { 130 Ok(ReconKind::Unchanged) 131 } else if repo.graph_descendant_of(r, l).unwrap_or(false) { 132 repo.reference(&stable.refname(), r, true, "fast-forward")?; 133 Ok(ReconKind::FastForward) 134 } else { 135 match strategy { 136 Strategy::Merge => merge_strategy(repo, stable, l, r), 137 Strategy::Rebase => rebase_strategy(repo, stable, l, r), 138 } 139 } 140 } 141 } 142} 143 144fn merge_strategy( 145 repo: &Repository, 146 stable: &StableId, 147 local: Oid, 148 remote: Oid, 149) -> Result<ReconKind> { 150 let base_oid = repo.merge_base(local, remote)?; 151 let base_tree = repo.find_commit(base_oid)?.tree()?; 152 let our_tree = repo.find_commit(local)?.tree()?; 153 let their_tree = repo.find_commit(remote)?.tree()?; 154 let mut idx = repo.merge_trees(&base_tree, &our_tree, &their_tree, None)?; 155 if idx.has_conflicts() { 156 return Ok(ReconKind::Conflict); 157 } 158 let tree_oid = idx.write_tree_to(repo)?; 159 let sig = object::signature(repo); 160 let local_commit = repo.find_commit(local)?; 161 let remote_commit = repo.find_commit(remote)?; 162 let parents: Vec<&Commit> = vec![&local_commit, &remote_commit]; 163 let short = &stable.0[..12.min(stable.0.len())]; 164 let merge_oid = repo.commit( 165 None, 166 &sig, 167 &sig, 168 &format!("merge tsk-{short}"), 169 &repo.find_tree(tree_oid)?, 170 &parents, 171 )?; 172 repo.reference(&stable.refname(), merge_oid, true, "merge")?; 173 Ok(ReconKind::Merged) 174} 175 176fn rebase_strategy( 177 repo: &Repository, 178 stable: &StableId, 179 local: Oid, 180 remote: Oid, 181) -> Result<ReconKind> { 182 let base_oid = repo.merge_base(local, remote)?; 183 // Walk local from tip back to (but not including) base, then reverse so 184 // we replay oldest-first. 185 let mut to_replay: Vec<Oid> = Vec::new(); 186 let mut cur = repo.find_commit(local)?; 187 while cur.id() != base_oid { 188 to_replay.push(cur.id()); 189 let Ok(parent) = cur.parent(0) else { break }; 190 cur = parent; 191 } 192 to_replay.reverse(); 193 let committer = object::signature(repo); 194 let mut current = remote; 195 for c_oid in to_replay { 196 let c = repo.find_commit(c_oid)?; 197 let parent_tree = c.parent(0)?.tree()?; 198 let c_tree = c.tree()?; 199 let cur_commit = repo.find_commit(current)?; 200 let cur_tree = cur_commit.tree()?; 201 let mut idx = repo.merge_trees(&parent_tree, &cur_tree, &c_tree, None)?; 202 if idx.has_conflicts() { 203 return Ok(ReconKind::Conflict); 204 } 205 let tree_oid = idx.write_tree_to(repo)?; 206 let new_oid = repo.commit( 207 None, 208 &c.author(), 209 &committer, 210 c.message().unwrap_or(""), 211 &repo.find_tree(tree_oid)?, 212 &[&cur_commit], 213 )?; 214 current = new_oid; 215 } 216 repo.reference(&stable.refname(), current, true, "rebase")?; 217 Ok(ReconKind::Rebased) 218} 219 220/// One namespace's reconciliation outcome at pull time. 221#[derive(Debug)] 222pub struct NamespaceReconciliation { 223 pub namespace: String, 224 /// `(old_human_id, new_human_id)` per binding that had to be moved 225 /// because the remote claimed the same id for a different stable. 226 pub renumbers: Vec<(u32, u32)>, 227} 228 229/// Three-way merge each namespace ref against its fetched counterpart. 230/// 231/// On conflict (same human id mapped to different stable ids on each 232/// side), the **remote** binding keeps the id and the **local** binding 233/// is renumbered to a fresh id past `max(local.next, remote.next)`. 234/// Local-only bindings are preserved at their original id; remote-only 235/// bindings are added verbatim. The merged tree is written as a commit 236/// with two parents so future pulls can fast-forward. 237pub fn reconcile_namespace_refs( 238 repo: &Repository, 239 remote: &str, 240) -> Result<Vec<NamespaceReconciliation>> { 241 let fetched_ns = format!("{}namespaces/", fetched_prefix(remote)); 242 let mut names: BTreeSet<String> = BTreeSet::new(); 243 for r in repo.references_glob(&format!("{NS_REF_PREFIX}*"))? { 244 let r = r?; 245 if let Some(name) = r.name().and_then(|n| n.strip_prefix(NS_REF_PREFIX)) { 246 names.insert(name.to_string()); 247 } 248 } 249 for r in repo.references_glob(&format!("{fetched_ns}*"))? { 250 let r = r?; 251 if let Some(name) = r.name().and_then(|n| n.strip_prefix(fetched_ns.as_str())) { 252 names.insert(name.to_string()); 253 } 254 } 255 let mut out = Vec::new(); 256 for name in names { 257 let local = repo 258 .find_reference(&namespace::refname(&name)) 259 .ok() 260 .and_then(|r| r.target()); 261 let remote_oid = repo 262 .find_reference(&format!("{fetched_ns}{name}")) 263 .ok() 264 .and_then(|r| r.target()); 265 if let Some(rec) = reconcile_namespace_one(repo, &name, local, remote_oid)? { 266 out.push(rec); 267 } 268 } 269 Ok(out) 270} 271 272fn reconcile_namespace_one( 273 repo: &Repository, 274 name: &str, 275 local: Option<Oid>, 276 remote: Option<Oid>, 277) -> Result<Option<NamespaceReconciliation>> { 278 match (local, remote) { 279 (None, None) | (Some(_), None) => Ok(None), 280 (None, Some(r)) => { 281 repo.reference(&namespace::refname(name), r, true, "pull-import")?; 282 Ok(None) 283 } 284 (Some(l), Some(r)) if l == r => Ok(None), 285 (Some(l), Some(r)) => { 286 if repo.graph_descendant_of(l, r).unwrap_or(false) { 287 return Ok(None); 288 } 289 if repo.graph_descendant_of(r, l).unwrap_or(false) { 290 repo.reference(&namespace::refname(name), r, true, "fast-forward")?; 291 return Ok(None); 292 } 293 // Diverged: 3-way merge with remote-wins on conflicts. 294 let local_ns = namespace::read_at_commit(repo, l)?; 295 let remote_ns = namespace::read_at_commit(repo, r)?; 296 let mut merged = Namespace { 297 next: local_ns.next.max(remote_ns.next), 298 mapping: remote_ns.mapping.clone(), 299 }; 300 let mut renumbers: Vec<(u32, u32)> = Vec::new(); 301 for (lh, lstable) in &local_ns.mapping { 302 match remote_ns.mapping.get(lh) { 303 Some(rstable) if rstable == lstable => {} // already in merged 304 Some(_rstable) => { 305 // Conflict: same id, different stable. Renumber local. 306 let new_h = merged.next; 307 merged.next += 1; 308 merged.mapping.insert(new_h, lstable.clone()); 309 renumbers.push((*lh, new_h)); 310 } 311 None => { 312 // Local-only binding; preserve at its current id (it 313 // can't collide because remote_ns lacks that id). 314 merged.mapping.insert(*lh, lstable.clone()); 315 } 316 } 317 } 318 // Bump next past any human id we just placed. 319 if let Some(max_h) = merged.mapping.keys().max() { 320 merged.next = merged.next.max(max_h + 1); 321 } 322 // Write a merge commit with two parents. 323 let tree_oid = namespace::build_tree(repo, &merged)?; 324 let local_commit = repo.find_commit(l)?; 325 let remote_commit = repo.find_commit(r)?; 326 let sig = object::signature(repo); 327 let msg = if renumbers.is_empty() { 328 format!("merge-namespace {name}") 329 } else { 330 format!("rebase-bind {name}") 331 }; 332 let parents: Vec<&Commit> = vec![&local_commit, &remote_commit]; 333 let new_oid = repo.commit( 334 None, 335 &sig, 336 &sig, 337 &msg, 338 &repo.find_tree(tree_oid)?, 339 &parents, 340 )?; 341 repo.reference(&namespace::refname(name), new_oid, true, &msg)?; 342 Ok(Some(NamespaceReconciliation { 343 namespace: name.to_string(), 344 renumbers, 345 })) 346 } 347 } 348} 349 350/// One queue's reconciliation outcome at pull time. Currently we only 351/// surface that a non-trivial merge happened; counts/diffs could be 352/// added later if useful. 353#[derive(Debug)] 354pub struct QueueReconciliation { 355 pub name: String, 356} 357 358/// 3-way merge each queue ref against its fetched counterpart. 359/// 360/// `index`: per-stable-id 3-way set merge — entries present in *base* 361/// stay only if both sides keep them; entries added on either side are 362/// included; entries removed on either side are dropped. Order is 363/// remote-first, then local additions appended. 364/// 365/// `inbox`: per-key 3-way map merge. Removals on either side win; 366/// adds on either side are included; if both sides set the same new key 367/// to different stables (shouldn't happen — keys carry a per-source 368/// sequence), remote wins. 369/// 370/// `can_pull`: 3-way bool. Local change wins if it differs from base; 371/// otherwise take remote. 372pub fn reconcile_queue_refs( 373 repo: &Repository, 374 remote: &str, 375) -> Result<Vec<QueueReconciliation>> { 376 let fetched = format!("{}queues/", fetched_prefix(remote)); 377 let mut names: BTreeSet<String> = BTreeSet::new(); 378 for r in repo.references_glob(&format!("{QUEUE_REF_PREFIX}*"))? { 379 let r = r?; 380 if let Some(n) = r.name().and_then(|n| n.strip_prefix(QUEUE_REF_PREFIX)) { 381 names.insert(n.to_string()); 382 } 383 } 384 for r in repo.references_glob(&format!("{fetched}*"))? { 385 let r = r?; 386 if let Some(n) = r.name().and_then(|n| n.strip_prefix(fetched.as_str())) { 387 names.insert(n.to_string()); 388 } 389 } 390 let mut out = Vec::new(); 391 for name in names { 392 let local = repo 393 .find_reference(&queue::refname(&name)) 394 .ok() 395 .and_then(|r| r.target()); 396 let remote_oid = repo 397 .find_reference(&format!("{fetched}{name}")) 398 .ok() 399 .and_then(|r| r.target()); 400 if let Some(rec) = reconcile_queue_one(repo, &name, local, remote_oid)? { 401 out.push(rec); 402 } 403 } 404 Ok(out) 405} 406 407fn reconcile_queue_one( 408 repo: &Repository, 409 name: &str, 410 local: Option<Oid>, 411 remote: Option<Oid>, 412) -> Result<Option<QueueReconciliation>> { 413 match (local, remote) { 414 (None, None) | (Some(_), None) => Ok(None), 415 (None, Some(r)) => { 416 repo.reference(&queue::refname(name), r, true, "pull-import")?; 417 Ok(None) 418 } 419 (Some(l), Some(r)) if l == r => Ok(None), 420 (Some(l), Some(r)) => { 421 if repo.graph_descendant_of(l, r).unwrap_or(false) { 422 return Ok(None); 423 } 424 if repo.graph_descendant_of(r, l).unwrap_or(false) { 425 repo.reference(&queue::refname(name), r, true, "fast-forward")?; 426 return Ok(None); 427 } 428 // No common ancestor when each clone independently rooted 429 // its queue ref; treat the base as empty in that case. 430 let base_q = match repo.merge_base(l, r).ok() { 431 Some(oid) => queue::read_at_commit(repo, name, oid)?, 432 None => Queue::new(name), 433 }; 434 let local_q = queue::read_at_commit(repo, name, l)?; 435 let remote_q = queue::read_at_commit(repo, name, r)?; 436 let merged = three_way_queue_merge(&base_q, &local_q, &remote_q); 437 let tree_oid = queue::build_tree(repo, &merged)?; 438 let sig = object::signature(repo); 439 let local_commit = repo.find_commit(l)?; 440 let remote_commit = repo.find_commit(r)?; 441 let parents: Vec<&Commit> = vec![&local_commit, &remote_commit]; 442 let new_oid = repo.commit( 443 None, 444 &sig, 445 &sig, 446 &format!("merge-queue {name}"), 447 &repo.find_tree(tree_oid)?, 448 &parents, 449 )?; 450 repo.reference(&queue::refname(name), new_oid, true, "merge")?; 451 Ok(Some(QueueReconciliation { name: name.to_string() })) 452 } 453 } 454} 455 456fn three_way_queue_merge(base: &Queue, local: &Queue, remote: &Queue) -> Queue { 457 let base_set: HashSet<&StableId> = base.index.iter().collect(); 458 let local_set: HashSet<&StableId> = local.index.iter().collect(); 459 let remote_set: HashSet<&StableId> = remote.index.iter().collect(); 460 let keep: HashSet<&StableId> = base_set 461 .iter() 462 .chain(local_set.iter()) 463 .chain(remote_set.iter()) 464 .copied() 465 .filter(|s| { 466 let in_base = base_set.contains(*s); 467 let in_local = local_set.contains(*s); 468 let in_remote = remote_set.contains(*s); 469 // present in base → kept iff neither side removed it. 470 // not in base → added by either side, keep. 471 if in_base { in_local && in_remote } else { in_local || in_remote } 472 }) 473 .collect(); 474 475 let mut index = Vec::new(); 476 let mut seen: HashSet<StableId> = HashSet::new(); 477 for s in remote.index.iter().chain(local.index.iter()) { 478 if keep.contains(s) && seen.insert(s.clone()) { 479 index.push(s.clone()); 480 } 481 } 482 483 let mut inbox: BTreeMap<String, StableId> = BTreeMap::new(); 484 let all_keys: BTreeSet<&String> = base 485 .inbox 486 .keys() 487 .chain(local.inbox.keys()) 488 .chain(remote.inbox.keys()) 489 .collect(); 490 for k in all_keys { 491 let in_base = base.inbox.contains_key(k); 492 let lv = local.inbox.get(k); 493 let rv = remote.inbox.get(k); 494 if in_base { 495 // Removal on either side wins; otherwise prefer remote on conflict. 496 if let (Some(_), Some(rv)) = (lv, rv) { 497 inbox.insert(k.clone(), rv.clone()); 498 } 499 } else { 500 // New on either side; remote wins on simultaneous-add conflict. 501 if let Some(v) = rv.or(lv) { 502 inbox.insert(k.clone(), v.clone()); 503 } 504 } 505 } 506 507 let can_pull = if local.can_pull == base.can_pull { 508 remote.can_pull 509 } else { 510 local.can_pull 511 }; 512 513 Queue { index, can_pull, inbox } 514} 515 516/// After task refs are reconciled, copy every other fetched ref 517/// (`refs/tsk-fetched/<remote>/{namespaces,queues,properties}/*`) onto its 518/// `refs/tsk/*` counterpart with force-update. Better merging for these is 519/// tracked separately (queue merge, namespace renumber, etc.). 520pub fn fast_forward_non_task_refs(repo: &Repository, remote: &str) -> Result<()> { 521 let prefix = fetched_prefix(remote); 522 let names: Vec<String> = repo 523 .references_glob(&format!("{prefix}*"))? 524 .filter_map(|r| r.ok().and_then(|r| r.name().map(String::from))) 525 .collect(); 526 for name in names { 527 let Some(rest) = name.strip_prefix(prefix.as_str()) else { 528 continue; 529 }; 530 if rest.starts_with("tasks/") 531 || rest.starts_with("namespaces/") 532 || rest.starts_with("queues/") 533 { 534 continue; 535 } 536 let Some(target) = repo.find_reference(&name).ok().and_then(|r| r.target()) else { 537 continue; 538 }; 539 let local_name = format!("refs/tsk/{rest}"); 540 repo.reference(&local_name, target, true, "pull")?; 541 } 542 Ok(()) 543} 544 545#[cfg(test)] 546mod test { 547 use super::*; 548 use crate::object::{self, Task}; 549 use git2::Signature; 550 use std::path::Path; 551 552 fn init_repo(p: &Path) -> Repository { 553 let r = Repository::init(p).unwrap(); 554 let mut cfg = r.config().unwrap(); 555 cfg.set_str("user.name", "Tester").unwrap(); 556 cfg.set_str("user.email", "t@e").unwrap(); 557 r 558 } 559 560 /// Set up a divergent pair of refs in one repo: local at refs/tsk/tasks/<s> 561 /// and a "fetched-from-origin" tip at refs/tsk-fetched/origin/tasks/<s>. 562 /// `local_props` and `remote_props` get applied to the same root content. 563 fn make_diverged( 564 repo: &Repository, 565 content: &str, 566 local_props: &[(&str, &str)], 567 remote_props: &[(&str, &str)], 568 ) -> StableId { 569 let stable = object::create(repo, &Task::new(content), "create").unwrap(); 570 let root_oid = repo 571 .find_reference(&stable.refname()) 572 .unwrap() 573 .target() 574 .unwrap(); 575 // Local edit. 576 let mut t_local = Task::new(content); 577 for (k, v) in local_props { 578 t_local 579 .properties 580 .insert((*k).to_string(), vec![(*v).to_string()]); 581 } 582 object::update(repo, &stable, &t_local, "edit-local").unwrap(); 583 // Build remote commit branching off the root. 584 let mut t_remote = Task::new(content); 585 for (k, v) in remote_props { 586 t_remote 587 .properties 588 .insert((*k).to_string(), vec![(*v).to_string()]); 589 } 590 let content_oid = repo.blob(t_remote.content.as_bytes()).unwrap(); 591 let mut tb = repo.treebuilder(None).unwrap(); 592 tb.insert("content", content_oid, 0o100644).unwrap(); 593 let title_oid = repo.blob(t_remote.title().as_bytes()).unwrap(); 594 tb.insert("title", title_oid, 0o100644).unwrap(); 595 for (k, vs) in &t_remote.properties { 596 let body: String = vs.iter().map(|v| format!("{v}\n")).collect(); 597 let oid = repo.blob(body.as_bytes()).unwrap(); 598 tb.insert(k.as_str(), oid, 0o100644).unwrap(); 599 } 600 let tree_oid = tb.write().unwrap(); 601 let sig = Signature::now("Remote", "r@x").unwrap(); 602 let parent = repo.find_commit(root_oid).unwrap(); 603 let remote_oid = repo 604 .commit( 605 None, 606 &sig, 607 &sig, 608 "edit-remote", 609 &repo.find_tree(tree_oid).unwrap(), 610 &[&parent], 611 ) 612 .unwrap(); 613 repo.reference( 614 &format!("refs/tsk-fetched/origin/tasks/{}", stable.0), 615 remote_oid, 616 true, 617 "test-setup", 618 ) 619 .unwrap(); 620 stable 621 } 622 623 #[test] 624 fn merge_clean_when_edits_dont_overlap() { 625 let dir = tempfile::tempdir().unwrap(); 626 let repo = init_repo(dir.path()); 627 let stable = make_diverged( 628 &repo, 629 "shared", 630 &[("priority", "high")], 631 &[("status", "urgent")], 632 ); 633 let recs = reconcile_task_refs(&repo, "origin", Strategy::Merge).unwrap(); 634 assert_eq!(recs.len(), 1); 635 assert_eq!(recs[0].kind, ReconKind::Merged); 636 // Merge commit has two parents. 637 let head = repo 638 .find_reference(&stable.refname()) 639 .unwrap() 640 .target() 641 .unwrap(); 642 let merge = repo.find_commit(head).unwrap(); 643 assert_eq!(merge.parent_count(), 2); 644 // Both property changes survived. 645 let task = object::read(&repo, &stable).unwrap().unwrap(); 646 assert_eq!(task.properties.get("priority").unwrap(), &vec!["high"]); 647 assert_eq!(task.properties.get("status").unwrap(), &vec!["urgent"]); 648 } 649 650 #[test] 651 fn rebase_replays_local_on_remote_preserving_authors() { 652 let dir = tempfile::tempdir().unwrap(); 653 let repo = init_repo(dir.path()); 654 let stable = make_diverged( 655 &repo, 656 "shared", 657 &[("priority", "high")], 658 &[("status", "urgent")], 659 ); 660 let recs = reconcile_task_refs(&repo, "origin", Strategy::Rebase).unwrap(); 661 assert_eq!(recs[0].kind, ReconKind::Rebased); 662 // Rebased tip should be a single-parent commit whose parent chain 663 // traces back through the remote's edit. 664 let head = repo 665 .find_reference(&stable.refname()) 666 .unwrap() 667 .target() 668 .unwrap(); 669 let tip = repo.find_commit(head).unwrap(); 670 assert_eq!(tip.parent_count(), 1); 671 // Author of the rebased tip preserved (Tester from the local edit). 672 assert_eq!(tip.author().name().unwrap(), "Tester"); 673 // Parent is the remote commit, authored by "Remote". 674 let parent = tip.parent(0).unwrap(); 675 assert_eq!(parent.author().name().unwrap(), "Remote"); 676 let task = object::read(&repo, &stable).unwrap().unwrap(); 677 assert_eq!(task.properties.get("priority").unwrap(), &vec!["high"]); 678 assert_eq!(task.properties.get("status").unwrap(), &vec!["urgent"]); 679 } 680 681 #[test] 682 fn conflict_leaves_local_unchanged() { 683 let dir = tempfile::tempdir().unwrap(); 684 let repo = init_repo(dir.path()); 685 let stable = object::create(&repo, &Task::new("v0"), "create").unwrap(); 686 let root_oid = repo 687 .find_reference(&stable.refname()) 688 .unwrap() 689 .target() 690 .unwrap(); 691 // Local: change content to "v-local". 692 let mut t_local = Task::new("v-local"); 693 object::update(&repo, &stable, &t_local, "edit-local").unwrap(); 694 let local_tip = repo 695 .find_reference(&stable.refname()) 696 .unwrap() 697 .target() 698 .unwrap(); 699 // Remote: branch off root with "v-remote". 700 t_local.content = "v-remote".into(); 701 let content_oid = repo.blob(t_local.content.as_bytes()).unwrap(); 702 let mut tb = repo.treebuilder(None).unwrap(); 703 tb.insert("content", content_oid, 0o100644).unwrap(); 704 let title_oid = repo.blob(t_local.title().as_bytes()).unwrap(); 705 tb.insert("title", title_oid, 0o100644).unwrap(); 706 let tree_oid = tb.write().unwrap(); 707 let sig = Signature::now("Remote", "r@x").unwrap(); 708 let parent = repo.find_commit(root_oid).unwrap(); 709 let remote_oid = repo 710 .commit( 711 None, 712 &sig, 713 &sig, 714 "edit-remote", 715 &repo.find_tree(tree_oid).unwrap(), 716 &[&parent], 717 ) 718 .unwrap(); 719 repo.reference( 720 &format!("refs/tsk-fetched/origin/tasks/{}", stable.0), 721 remote_oid, 722 true, 723 "test", 724 ) 725 .unwrap(); 726 let recs = reconcile_task_refs(&repo, "origin", Strategy::Merge).unwrap(); 727 assert_eq!(recs[0].kind, ReconKind::Conflict); 728 // Local ref unchanged. 729 let head = repo 730 .find_reference(&stable.refname()) 731 .unwrap() 732 .target() 733 .unwrap(); 734 assert_eq!(head, local_tip); 735 } 736 737 #[test] 738 fn fast_forward_when_local_is_ancestor() { 739 let dir = tempfile::tempdir().unwrap(); 740 let repo = init_repo(dir.path()); 741 let stable = object::create(&repo, &Task::new("v0"), "create").unwrap(); 742 let root_oid = repo 743 .find_reference(&stable.refname()) 744 .unwrap() 745 .target() 746 .unwrap(); 747 // Build a remote with one extra commit on top of the root. 748 let content_oid = repo.blob(b"v0").unwrap(); 749 let mut tb = repo.treebuilder(None).unwrap(); 750 tb.insert("content", content_oid, 0o100644).unwrap(); 751 tb.insert("title", repo.blob(b"v0").unwrap(), 0o100644).unwrap(); 752 tb.insert("status", repo.blob(b"open\n").unwrap(), 0o100644) 753 .unwrap(); 754 let tree_oid = tb.write().unwrap(); 755 let sig = Signature::now("Remote", "r@x").unwrap(); 756 let parent = repo.find_commit(root_oid).unwrap(); 757 let remote_oid = repo 758 .commit( 759 None, 760 &sig, 761 &sig, 762 "edit-remote", 763 &repo.find_tree(tree_oid).unwrap(), 764 &[&parent], 765 ) 766 .unwrap(); 767 repo.reference( 768 &format!("refs/tsk-fetched/origin/tasks/{}", stable.0), 769 remote_oid, 770 true, 771 "test", 772 ) 773 .unwrap(); 774 let recs = reconcile_task_refs(&repo, "origin", Strategy::Merge).unwrap(); 775 assert_eq!(recs[0].kind, ReconKind::FastForward); 776 let head = repo 777 .find_reference(&stable.refname()) 778 .unwrap() 779 .target() 780 .unwrap(); 781 assert_eq!(head, remote_oid); 782 } 783 784 #[test] 785 fn new_remote_task_is_imported() { 786 let dir = tempfile::tempdir().unwrap(); 787 let repo = init_repo(dir.path()); 788 // No local task; stash one only at the fetched ref. 789 let stable = object::create(&repo, &Task::new("foreign"), "create").unwrap(); 790 let oid = repo 791 .find_reference(&stable.refname()) 792 .unwrap() 793 .target() 794 .unwrap(); 795 // Move the local ref away so only fetched exists. 796 repo.find_reference(&stable.refname()) 797 .unwrap() 798 .delete() 799 .unwrap(); 800 repo.reference( 801 &format!("refs/tsk-fetched/origin/tasks/{}", stable.0), 802 oid, 803 true, 804 "test", 805 ) 806 .unwrap(); 807 let recs = reconcile_task_refs(&repo, "origin", Strategy::Merge).unwrap(); 808 assert_eq!(recs[0].kind, ReconKind::NewRemote); 809 assert!(repo.find_reference(&stable.refname()).is_ok()); 810 } 811}