A file-based task manager
0
fork

Configure Feed

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

Reconcile divergent task histories on git-pull (merge / rebase)

git-pull now fetches into a non-clobbering shadow namespace
(refs/tsk-fetched/<remote>/*) and reconciles each task ref in two-way
git2 merge_trees against the common ancestor:

- merge (default): clean 3-way merge → single merge commit with two
parents. True conflicts abort that one task; local ref unchanged.
- --rebase: replay each local-only commit on top of the remote tip
via merge_trees, preserving each commit's original author and
setting committer to the local user (mirrors git rebase).

The --refmap= flag on the underlying git fetch is essential: without
it the remote's configured fetch refspec also runs, clobbering local
refs before reconciliation gets a chance.

Non-task refs (namespaces / queues / property indices) still
fast-forward from the remote — better merging for those is tracked
separately (queue merge driver, namespace renumber, etc.).

Tests: 5 unit tests in merge.rs covering merge / rebase / conflict /
fast-forward / new-remote, plus 2 multi-clone integration tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+605 -7
+2 -1
AGENTS.md
··· 92 92 93 93 ``` 94 94 tsk git-push # push refs/tsk/* to origin (also runs after assign/reject) 95 - tsk git-pull # fetch + force-update local refs/tsk/* 95 + tsk git-pull # fetch + reconcile divergent task histories 96 + tsk git-pull --rebase # replay local commits on the remote tip instead of merging 96 97 tsk inbox # auto-pulls then lists pending inbox items 97 98 ``` 98 99
+20 -3
src/lib.rs
··· 1 1 pub mod errors; 2 2 mod fzf; 3 3 mod namespace; 4 + mod merge; 4 5 mod object; 5 6 mod patch; 6 7 mod properties; ··· 144 145 GitPush { 145 146 remote: Option<String>, 146 147 }, 147 - /// Fetch tsk refs from a git remote (default: origin). 148 + /// Fetch tsk refs from a git remote (default: origin) and reconcile 149 + /// divergent task histories. Default strategy is merge; pass --rebase 150 + /// to replay local-only commits onto the remote tip instead. 148 151 GitPull { 149 152 remote: Option<String>, 153 + #[arg(long)] 154 + rebase: bool, 150 155 }, 151 156 /// Share a task into another namespace (binds same stable id under that namespace's next human id). 152 157 Share { ··· 372 377 let r = remote.unwrap_or_else(|| "origin".to_string()); 373 378 Workspace::from_path(dir)?.git_push(&r) 374 379 } 375 - Commands::GitPull { remote } => { 380 + Commands::GitPull { remote, rebase } => { 376 381 let r = remote.unwrap_or_else(|| "origin".to_string()); 377 - Workspace::from_path(dir)?.git_pull(&r) 382 + let strategy = if rebase { 383 + merge::Strategy::Rebase 384 + } else { 385 + merge::Strategy::Merge 386 + }; 387 + let recs = Workspace::from_path(dir)?.git_pull_with_strategy(&r, strategy)?; 388 + for rec in &recs { 389 + if !matches!(rec.kind, merge::ReconKind::Unchanged) { 390 + let short = &rec.stable.0[..12.min(rec.stable.0.len())]; 391 + println!("{:?} {short}", rec.kind); 392 + } 393 + } 394 + Ok(()) 378 395 } 379 396 Commands::Share { target, task_id } => command_share(dir, target, task_id), 380 397 Commands::Assign {
+508
src/merge.rs
··· 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 + 24 + use crate::errors::Result; 25 + use crate::object::{StableId, TASK_REF_PREFIX}; 26 + use git2::{Commit, Oid, Repository, Signature}; 27 + use std::collections::BTreeSet; 28 + 29 + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] 30 + pub enum Strategy { 31 + #[default] 32 + Merge, 33 + Rebase, 34 + } 35 + 36 + #[derive(Clone, Copy, Debug, Eq, PartialEq)] 37 + pub enum ReconKind { 38 + /// No work needed (either side strict ancestor or refs identical). 39 + Unchanged, 40 + /// Local was strict ancestor of remote; ref now points at remote tip. 41 + FastForward, 42 + /// Remote ref existed without a local counterpart; copied verbatim. 43 + NewRemote, 44 + /// Wrote a merge commit with two parents. 45 + Merged, 46 + /// Replayed local-only commits onto the remote tip. 47 + Rebased, 48 + /// Reconciliation aborted due to overlapping edits; local ref unchanged. 49 + Conflict, 50 + } 51 + 52 + #[derive(Debug)] 53 + pub struct Reconciliation { 54 + pub stable: StableId, 55 + pub kind: ReconKind, 56 + } 57 + 58 + pub const FETCH_PREFIX: &str = "refs/tsk-fetched/"; 59 + 60 + pub fn fetched_prefix(remote: &str) -> String { 61 + format!("{FETCH_PREFIX}{remote}/") 62 + } 63 + 64 + /// Reconcile every `refs/tsk/tasks/*` against its fetched counterpart at 65 + /// `refs/tsk-fetched/<remote>/tasks/*`. Returns one entry per task that 66 + /// existed in either side. 67 + pub fn reconcile_task_refs( 68 + repo: &Repository, 69 + remote: &str, 70 + strategy: Strategy, 71 + ) -> Result<Vec<Reconciliation>> { 72 + let fetched_tasks = format!("{}tasks/", fetched_prefix(remote)); 73 + let mut stables: BTreeSet<String> = BTreeSet::new(); 74 + for r in repo.references_glob(&format!("{TASK_REF_PREFIX}*"))? { 75 + let r = r?; 76 + if let Some(name) = r.name().and_then(|n| n.strip_prefix(TASK_REF_PREFIX)) { 77 + stables.insert(name.to_string()); 78 + } 79 + } 80 + for r in repo.references_glob(&format!("{fetched_tasks}*"))? { 81 + let r = r?; 82 + if let Some(name) = r.name().and_then(|n| n.strip_prefix(fetched_tasks.as_str())) { 83 + stables.insert(name.to_string()); 84 + } 85 + } 86 + let mut out = Vec::new(); 87 + for s in stables { 88 + let stable = StableId(s.clone()); 89 + let local = repo 90 + .find_reference(&stable.refname()) 91 + .ok() 92 + .and_then(|r| r.target()); 93 + let remote_ref = format!("{fetched_tasks}{s}"); 94 + let remote_tip = repo 95 + .find_reference(&remote_ref) 96 + .ok() 97 + .and_then(|r| r.target()); 98 + let kind = reconcile_one(repo, &stable, local, remote_tip, strategy)?; 99 + out.push(Reconciliation { stable, kind }); 100 + } 101 + Ok(out) 102 + } 103 + 104 + fn reconcile_one( 105 + repo: &Repository, 106 + stable: &StableId, 107 + local: Option<Oid>, 108 + remote: Option<Oid>, 109 + strategy: Strategy, 110 + ) -> Result<ReconKind> { 111 + match (local, remote) { 112 + (None, None) | (Some(_), None) => Ok(ReconKind::Unchanged), 113 + (None, Some(r)) => { 114 + repo.reference(&stable.refname(), r, true, "pull-import")?; 115 + Ok(ReconKind::NewRemote) 116 + } 117 + (Some(l), Some(r)) if l == r => Ok(ReconKind::Unchanged), 118 + (Some(l), Some(r)) => { 119 + // graph_descendant_of(a, b) is true iff a descends from b. 120 + if repo.graph_descendant_of(l, r).unwrap_or(false) { 121 + Ok(ReconKind::Unchanged) 122 + } else if repo.graph_descendant_of(r, l).unwrap_or(false) { 123 + repo.reference(&stable.refname(), r, true, "fast-forward")?; 124 + Ok(ReconKind::FastForward) 125 + } else { 126 + match strategy { 127 + Strategy::Merge => merge_strategy(repo, stable, l, r), 128 + Strategy::Rebase => rebase_strategy(repo, stable, l, r), 129 + } 130 + } 131 + } 132 + } 133 + } 134 + 135 + fn merge_strategy( 136 + repo: &Repository, 137 + stable: &StableId, 138 + local: Oid, 139 + remote: Oid, 140 + ) -> Result<ReconKind> { 141 + let base_oid = repo.merge_base(local, remote)?; 142 + let base_tree = repo.find_commit(base_oid)?.tree()?; 143 + let our_tree = repo.find_commit(local)?.tree()?; 144 + let their_tree = repo.find_commit(remote)?.tree()?; 145 + let mut idx = repo.merge_trees(&base_tree, &our_tree, &their_tree, None)?; 146 + if idx.has_conflicts() { 147 + return Ok(ReconKind::Conflict); 148 + } 149 + let tree_oid = idx.write_tree_to(repo)?; 150 + let sig = signature(repo); 151 + let local_commit = repo.find_commit(local)?; 152 + let remote_commit = repo.find_commit(remote)?; 153 + let parents: Vec<&Commit> = vec![&local_commit, &remote_commit]; 154 + let short = &stable.0[..12.min(stable.0.len())]; 155 + let merge_oid = repo.commit( 156 + None, 157 + &sig, 158 + &sig, 159 + &format!("merge tsk-{short}"), 160 + &repo.find_tree(tree_oid)?, 161 + &parents, 162 + )?; 163 + repo.reference(&stable.refname(), merge_oid, true, "merge")?; 164 + Ok(ReconKind::Merged) 165 + } 166 + 167 + fn rebase_strategy( 168 + repo: &Repository, 169 + stable: &StableId, 170 + local: Oid, 171 + remote: Oid, 172 + ) -> Result<ReconKind> { 173 + let base_oid = repo.merge_base(local, remote)?; 174 + // Walk local from tip back to (but not including) base, then reverse so 175 + // we replay oldest-first. 176 + let mut to_replay: Vec<Oid> = Vec::new(); 177 + let mut cur = repo.find_commit(local)?; 178 + while cur.id() != base_oid { 179 + to_replay.push(cur.id()); 180 + let Ok(parent) = cur.parent(0) else { break }; 181 + cur = parent; 182 + } 183 + to_replay.reverse(); 184 + let committer = signature(repo); 185 + let mut current = remote; 186 + for c_oid in to_replay { 187 + let c = repo.find_commit(c_oid)?; 188 + let parent_tree = c.parent(0)?.tree()?; 189 + let c_tree = c.tree()?; 190 + let cur_commit = repo.find_commit(current)?; 191 + let cur_tree = cur_commit.tree()?; 192 + let mut idx = repo.merge_trees(&parent_tree, &cur_tree, &c_tree, None)?; 193 + if idx.has_conflicts() { 194 + return Ok(ReconKind::Conflict); 195 + } 196 + let tree_oid = idx.write_tree_to(repo)?; 197 + let new_oid = repo.commit( 198 + None, 199 + &c.author(), 200 + &committer, 201 + c.message().unwrap_or(""), 202 + &repo.find_tree(tree_oid)?, 203 + &[&cur_commit], 204 + )?; 205 + current = new_oid; 206 + } 207 + repo.reference(&stable.refname(), current, true, "rebase")?; 208 + Ok(ReconKind::Rebased) 209 + } 210 + 211 + fn signature(repo: &Repository) -> Signature<'static> { 212 + repo.signature() 213 + .map(|s| s.to_owned()) 214 + .unwrap_or_else(|_| Signature::now("tsk", "tsk@local").unwrap()) 215 + } 216 + 217 + /// After task refs are reconciled, copy every other fetched ref 218 + /// (`refs/tsk-fetched/<remote>/{namespaces,queues,properties}/*`) onto its 219 + /// `refs/tsk/*` counterpart with force-update. Better merging for these is 220 + /// tracked separately (queue merge, namespace renumber, etc.). 221 + pub fn fast_forward_non_task_refs(repo: &Repository, remote: &str) -> Result<()> { 222 + let prefix = fetched_prefix(remote); 223 + let names: Vec<String> = repo 224 + .references_glob(&format!("{prefix}*"))? 225 + .filter_map(|r| r.ok().and_then(|r| r.name().map(String::from))) 226 + .collect(); 227 + for name in names { 228 + let Some(rest) = name.strip_prefix(prefix.as_str()) else { 229 + continue; 230 + }; 231 + if rest.starts_with("tasks/") { 232 + continue; 233 + } 234 + let Some(target) = repo.find_reference(&name).ok().and_then(|r| r.target()) else { 235 + continue; 236 + }; 237 + let local_name = format!("refs/tsk/{rest}"); 238 + repo.reference(&local_name, target, true, "pull")?; 239 + } 240 + Ok(()) 241 + } 242 + 243 + #[cfg(test)] 244 + mod test { 245 + use super::*; 246 + use crate::object::{self, Task}; 247 + use std::path::Path; 248 + 249 + fn init_repo(p: &Path) -> Repository { 250 + let r = Repository::init(p).unwrap(); 251 + let mut cfg = r.config().unwrap(); 252 + cfg.set_str("user.name", "Tester").unwrap(); 253 + cfg.set_str("user.email", "t@e").unwrap(); 254 + r 255 + } 256 + 257 + /// Set up a divergent pair of refs in one repo: local at refs/tsk/tasks/<s> 258 + /// and a "fetched-from-origin" tip at refs/tsk-fetched/origin/tasks/<s>. 259 + /// `local_props` and `remote_props` get applied to the same root content. 260 + fn make_diverged( 261 + repo: &Repository, 262 + content: &str, 263 + local_props: &[(&str, &str)], 264 + remote_props: &[(&str, &str)], 265 + ) -> StableId { 266 + let stable = object::create(repo, &Task::new(content), "create").unwrap(); 267 + let root_oid = repo 268 + .find_reference(&stable.refname()) 269 + .unwrap() 270 + .target() 271 + .unwrap(); 272 + // Local edit. 273 + let mut t_local = Task::new(content); 274 + for (k, v) in local_props { 275 + t_local 276 + .properties 277 + .insert((*k).to_string(), vec![(*v).to_string()]); 278 + } 279 + object::update(repo, &stable, &t_local, "edit-local").unwrap(); 280 + // Build remote commit branching off the root. 281 + let mut t_remote = Task::new(content); 282 + for (k, v) in remote_props { 283 + t_remote 284 + .properties 285 + .insert((*k).to_string(), vec![(*v).to_string()]); 286 + } 287 + let content_oid = repo.blob(t_remote.content.as_bytes()).unwrap(); 288 + let mut tb = repo.treebuilder(None).unwrap(); 289 + tb.insert("content", content_oid, 0o100644).unwrap(); 290 + let title_oid = repo.blob(t_remote.title().as_bytes()).unwrap(); 291 + tb.insert("title", title_oid, 0o100644).unwrap(); 292 + for (k, vs) in &t_remote.properties { 293 + let body: String = vs.iter().map(|v| format!("{v}\n")).collect(); 294 + let oid = repo.blob(body.as_bytes()).unwrap(); 295 + tb.insert(k.as_str(), oid, 0o100644).unwrap(); 296 + } 297 + let tree_oid = tb.write().unwrap(); 298 + let sig = Signature::now("Remote", "r@x").unwrap(); 299 + let parent = repo.find_commit(root_oid).unwrap(); 300 + let remote_oid = repo 301 + .commit( 302 + None, 303 + &sig, 304 + &sig, 305 + "edit-remote", 306 + &repo.find_tree(tree_oid).unwrap(), 307 + &[&parent], 308 + ) 309 + .unwrap(); 310 + repo.reference( 311 + &format!("refs/tsk-fetched/origin/tasks/{}", stable.0), 312 + remote_oid, 313 + true, 314 + "test-setup", 315 + ) 316 + .unwrap(); 317 + stable 318 + } 319 + 320 + #[test] 321 + fn merge_clean_when_edits_dont_overlap() { 322 + let dir = tempfile::tempdir().unwrap(); 323 + let repo = init_repo(dir.path()); 324 + let stable = make_diverged( 325 + &repo, 326 + "shared", 327 + &[("priority", "high")], 328 + &[("status", "urgent")], 329 + ); 330 + let recs = reconcile_task_refs(&repo, "origin", Strategy::Merge).unwrap(); 331 + assert_eq!(recs.len(), 1); 332 + assert_eq!(recs[0].kind, ReconKind::Merged); 333 + // Merge commit has two parents. 334 + let head = repo 335 + .find_reference(&stable.refname()) 336 + .unwrap() 337 + .target() 338 + .unwrap(); 339 + let merge = repo.find_commit(head).unwrap(); 340 + assert_eq!(merge.parent_count(), 2); 341 + // Both property changes survived. 342 + let task = object::read(&repo, &stable).unwrap().unwrap(); 343 + assert_eq!(task.properties.get("priority").unwrap(), &vec!["high"]); 344 + assert_eq!(task.properties.get("status").unwrap(), &vec!["urgent"]); 345 + } 346 + 347 + #[test] 348 + fn rebase_replays_local_on_remote_preserving_authors() { 349 + let dir = tempfile::tempdir().unwrap(); 350 + let repo = init_repo(dir.path()); 351 + let stable = make_diverged( 352 + &repo, 353 + "shared", 354 + &[("priority", "high")], 355 + &[("status", "urgent")], 356 + ); 357 + let recs = reconcile_task_refs(&repo, "origin", Strategy::Rebase).unwrap(); 358 + assert_eq!(recs[0].kind, ReconKind::Rebased); 359 + // Rebased tip should be a single-parent commit whose parent chain 360 + // traces back through the remote's edit. 361 + let head = repo 362 + .find_reference(&stable.refname()) 363 + .unwrap() 364 + .target() 365 + .unwrap(); 366 + let tip = repo.find_commit(head).unwrap(); 367 + assert_eq!(tip.parent_count(), 1); 368 + // Author of the rebased tip preserved (Tester from the local edit). 369 + assert_eq!(tip.author().name().unwrap(), "Tester"); 370 + // Parent is the remote commit, authored by "Remote". 371 + let parent = tip.parent(0).unwrap(); 372 + assert_eq!(parent.author().name().unwrap(), "Remote"); 373 + let task = object::read(&repo, &stable).unwrap().unwrap(); 374 + assert_eq!(task.properties.get("priority").unwrap(), &vec!["high"]); 375 + assert_eq!(task.properties.get("status").unwrap(), &vec!["urgent"]); 376 + } 377 + 378 + #[test] 379 + fn conflict_leaves_local_unchanged() { 380 + let dir = tempfile::tempdir().unwrap(); 381 + let repo = init_repo(dir.path()); 382 + let stable = object::create(&repo, &Task::new("v0"), "create").unwrap(); 383 + let root_oid = repo 384 + .find_reference(&stable.refname()) 385 + .unwrap() 386 + .target() 387 + .unwrap(); 388 + // Local: change content to "v-local". 389 + let mut t_local = Task::new("v-local"); 390 + object::update(&repo, &stable, &t_local, "edit-local").unwrap(); 391 + let local_tip = repo 392 + .find_reference(&stable.refname()) 393 + .unwrap() 394 + .target() 395 + .unwrap(); 396 + // Remote: branch off root with "v-remote". 397 + t_local.content = "v-remote".into(); 398 + let content_oid = repo.blob(t_local.content.as_bytes()).unwrap(); 399 + let mut tb = repo.treebuilder(None).unwrap(); 400 + tb.insert("content", content_oid, 0o100644).unwrap(); 401 + let title_oid = repo.blob(t_local.title().as_bytes()).unwrap(); 402 + tb.insert("title", title_oid, 0o100644).unwrap(); 403 + let tree_oid = tb.write().unwrap(); 404 + let sig = Signature::now("Remote", "r@x").unwrap(); 405 + let parent = repo.find_commit(root_oid).unwrap(); 406 + let remote_oid = repo 407 + .commit( 408 + None, 409 + &sig, 410 + &sig, 411 + "edit-remote", 412 + &repo.find_tree(tree_oid).unwrap(), 413 + &[&parent], 414 + ) 415 + .unwrap(); 416 + repo.reference( 417 + &format!("refs/tsk-fetched/origin/tasks/{}", stable.0), 418 + remote_oid, 419 + true, 420 + "test", 421 + ) 422 + .unwrap(); 423 + let recs = reconcile_task_refs(&repo, "origin", Strategy::Merge).unwrap(); 424 + assert_eq!(recs[0].kind, ReconKind::Conflict); 425 + // Local ref unchanged. 426 + let head = repo 427 + .find_reference(&stable.refname()) 428 + .unwrap() 429 + .target() 430 + .unwrap(); 431 + assert_eq!(head, local_tip); 432 + } 433 + 434 + #[test] 435 + fn fast_forward_when_local_is_ancestor() { 436 + let dir = tempfile::tempdir().unwrap(); 437 + let repo = init_repo(dir.path()); 438 + let stable = object::create(&repo, &Task::new("v0"), "create").unwrap(); 439 + let root_oid = repo 440 + .find_reference(&stable.refname()) 441 + .unwrap() 442 + .target() 443 + .unwrap(); 444 + // Build a remote with one extra commit on top of the root. 445 + let content_oid = repo.blob(b"v0").unwrap(); 446 + let mut tb = repo.treebuilder(None).unwrap(); 447 + tb.insert("content", content_oid, 0o100644).unwrap(); 448 + tb.insert("title", repo.blob(b"v0").unwrap(), 0o100644).unwrap(); 449 + tb.insert("status", repo.blob(b"open\n").unwrap(), 0o100644) 450 + .unwrap(); 451 + let tree_oid = tb.write().unwrap(); 452 + let sig = Signature::now("Remote", "r@x").unwrap(); 453 + let parent = repo.find_commit(root_oid).unwrap(); 454 + let remote_oid = repo 455 + .commit( 456 + None, 457 + &sig, 458 + &sig, 459 + "edit-remote", 460 + &repo.find_tree(tree_oid).unwrap(), 461 + &[&parent], 462 + ) 463 + .unwrap(); 464 + repo.reference( 465 + &format!("refs/tsk-fetched/origin/tasks/{}", stable.0), 466 + remote_oid, 467 + true, 468 + "test", 469 + ) 470 + .unwrap(); 471 + let recs = reconcile_task_refs(&repo, "origin", Strategy::Merge).unwrap(); 472 + assert_eq!(recs[0].kind, ReconKind::FastForward); 473 + let head = repo 474 + .find_reference(&stable.refname()) 475 + .unwrap() 476 + .target() 477 + .unwrap(); 478 + assert_eq!(head, remote_oid); 479 + } 480 + 481 + #[test] 482 + fn new_remote_task_is_imported() { 483 + let dir = tempfile::tempdir().unwrap(); 484 + let repo = init_repo(dir.path()); 485 + // No local task; stash one only at the fetched ref. 486 + let stable = object::create(&repo, &Task::new("foreign"), "create").unwrap(); 487 + let oid = repo 488 + .find_reference(&stable.refname()) 489 + .unwrap() 490 + .target() 491 + .unwrap(); 492 + // Move the local ref away so only fetched exists. 493 + repo.find_reference(&stable.refname()) 494 + .unwrap() 495 + .delete() 496 + .unwrap(); 497 + repo.reference( 498 + &format!("refs/tsk-fetched/origin/tasks/{}", stable.0), 499 + oid, 500 + true, 501 + "test", 502 + ) 503 + .unwrap(); 504 + let recs = reconcile_task_refs(&repo, "origin", Strategy::Merge).unwrap(); 505 + assert_eq!(recs[0].kind, ReconKind::NewRemote); 506 + assert!(repo.find_reference(&stable.refname()).is_ok()); 507 + } 508 + }
+25 -3
src/workspace.rs
··· 8 8 use crate::errors::{Error, Result}; 9 9 use crate::object::{self, StableId, Task as TaskObj}; 10 10 use crate::patch; 11 - use crate::{namespace, properties, queue}; 11 + use crate::{merge, namespace, properties, queue}; 12 12 use git2::Repository; 13 13 use std::collections::BTreeMap; 14 14 use std::fmt::Display; ··· 785 785 } 786 786 787 787 pub fn git_pull(&self, remote: &str) -> Result<()> { 788 + self.git_pull_with_strategy(remote, merge::Strategy::default())?; 789 + Ok(()) 790 + } 791 + 792 + /// Fetch into a non-clobbering shadow namespace, then reconcile each 793 + /// task ref under the chosen strategy (default `merge`). Non-task refs 794 + /// (namespaces/queues/property indices) still force-update from the 795 + /// remote — better merging for those is tracked separately. 796 + pub fn git_pull_with_strategy( 797 + &self, 798 + remote: &str, 799 + strategy: merge::Strategy, 800 + ) -> Result<Vec<merge::Reconciliation>> { 801 + // `--refmap=` disables the remote's configured fetch refspec so our 802 + // explicit refspec is the *only* one applied; otherwise git also 803 + // performs the configured `+refs/tsk/*:refs/tsk/*` mapping and 804 + // clobbers local task refs before we get a chance to reconcile. 805 + let refspec = format!("+refs/tsk/*:{}{remote}/*", merge::FETCH_PREFIX); 788 806 let s = std::process::Command::new("git") 789 807 .arg("--git-dir") 790 808 .arg(&self.git_dir) 791 - .args(["fetch", "--prune", remote, "+refs/tsk/*:refs/tsk/*"]) 809 + .args(["fetch", "--prune", "--refmap=", remote]) 810 + .arg(&refspec) 792 811 .status()?; 793 812 if !s.success() { 794 813 return Err(Error::Parse("git fetch failed".into())); 795 814 } 796 - Ok(()) 815 + let repo = self.repo()?; 816 + let recs = merge::reconcile_task_refs(&repo, remote, strategy)?; 817 + merge::fast_forward_non_task_refs(&repo, remote)?; 818 + Ok(recs) 797 819 } 798 820 } 799 821
+50
tests/multi_user.rs
··· 259 259 assert_eq!(code, 0, "show should succeed: stderr={stderr}"); 260 260 assert!(stdout.contains("to share"), "got {stdout}"); 261 261 } 262 + 263 + #[test] 264 + fn divergent_task_edits_merge_on_pull() { 265 + let (_dir, alice, bob) = setup_two_clones(); 266 + 267 + // Alice creates a task, pushes; Bob pulls so they share the same 268 + // root commit on the task ref. 269 + tsk_ok(&alice, &["push", "shared task"]); 270 + tsk_ok(&alice, &["git-push"]); 271 + tsk_ok(&bob, &["git-pull"]); 272 + 273 + // Both edit the same task, touching different properties (no overlap). 274 + tsk_ok(&alice, &["prop", "add", "-T", "tsk-1", "priority", "high"]); 275 + tsk_ok(&bob, &["prop", "add", "-T", "tsk-1", "owner", "bob"]); 276 + 277 + // Alice pushes first; Bob's push would be non-fast-forward, so he pulls. 278 + tsk_ok(&alice, &["git-push"]); 279 + tsk_ok(&bob, &["git-pull"]); 280 + 281 + // After the merge pull, Bob should see both his and Alice's edits on 282 + // the task. 283 + let listing = tsk_ok(&bob, &["prop", "list", "-T", "tsk-1"]); 284 + eprintln!("LISTING: {listing}"); 285 + assert!(listing.contains("priority\thigh"), "alice's edit lost: {listing}"); 286 + assert!(listing.contains("owner\tbob"), "bob's edit lost: {listing}"); 287 + } 288 + 289 + #[test] 290 + fn divergent_task_edits_rebase_on_pull() { 291 + let (_dir, alice, bob) = setup_two_clones(); 292 + 293 + tsk_ok(&alice, &["push", "shared task"]); 294 + tsk_ok(&alice, &["git-push"]); 295 + tsk_ok(&bob, &["git-pull"]); 296 + 297 + tsk_ok(&alice, &["prop", "add", "-T", "tsk-1", "priority", "high"]); 298 + tsk_ok(&bob, &["prop", "add", "-T", "tsk-1", "owner", "bob"]); 299 + 300 + tsk_ok(&alice, &["git-push"]); 301 + let pull_out = tsk_ok(&bob, &["git-pull", "--rebase"]); 302 + assert!( 303 + pull_out.contains("Rebased") || pull_out.is_empty(), 304 + "expected rebase summary, got {pull_out}" 305 + ); 306 + 307 + // Both edits survived. 308 + let listing = tsk_ok(&bob, &["prop", "list", "-T", "tsk-1"]); 309 + assert!(listing.contains("priority\thigh"), "alice's edit lost: {listing}"); 310 + assert!(listing.contains("owner\tbob"), "bob's edit lost: {listing}"); 311 + }