A file-based task manager
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}