A file-based task manager
0
fork

Configure Feed

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

Auto-renumber on namespace binding conflict during git-pull

git-pull now reconciles namespace refs the same way it does task refs:
fast-forward when one side is an ancestor, three-way merge when they
diverge. The merge is data-aware, not tree-aware:

- conflicting bindings (same human id, different stable id): the
remote keeps the id, and the local binding is auto-renumbered to a
fresh id past max(local.next, remote.next). The pull output prints
`<ns>-<old> → <ns>-<new> (conflict with <remote>)` so the user
sees the move.
- local-only bindings: preserved at their current id (no collision is
possible since the remote lacks that id).
- remote-only bindings: added verbatim.

Result is committed to the namespace ref with two parents (local +
remote tip), so future pulls fast-forward instead of detecting another
divergence.

Queues already store stable ids, not human ids, so the renumber
doesn't require any queue index update.

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

+195 -8
+10 -2
src/lib.rs
··· 392 392 } else { 393 393 merge::Strategy::Merge 394 394 }; 395 - let recs = Workspace::from_path(dir)?.git_pull_with_strategy(&r, strategy)?; 396 - for rec in &recs { 395 + let outcome = Workspace::from_path(dir)?.git_pull_with_strategy(&r, strategy)?; 396 + for rec in &outcome.tasks { 397 397 if !matches!(rec.kind, merge::ReconKind::Unchanged) { 398 398 let short = &rec.stable.0[..12.min(rec.stable.0.len())]; 399 399 println!("{:?} {short}", rec.kind); 400 + } 401 + } 402 + for nr in &outcome.namespaces { 403 + for (old, new) in &nr.renumbers { 404 + println!( 405 + "{}-{} → {}-{} (conflict with {r})", 406 + nr.namespace, old, nr.namespace, new 407 + ); 400 408 } 401 409 } 402 410 Ok(())
+138 -1
src/merge.rs
··· 22 22 //! re-run with the other strategy or hand-resolve. 23 23 24 24 use crate::errors::Result; 25 + use crate::namespace::{self, NS_REF_PREFIX, Namespace}; 25 26 use crate::object::{StableId, TASK_REF_PREFIX}; 26 27 use git2::{Commit, Oid, Repository, Signature}; 27 28 use std::collections::BTreeSet; ··· 53 54 pub struct Reconciliation { 54 55 pub stable: StableId, 55 56 pub kind: ReconKind, 57 + } 58 + 59 + #[derive(Debug)] 60 + pub struct PullOutcome { 61 + pub tasks: Vec<Reconciliation>, 62 + pub namespaces: Vec<NamespaceReconciliation>, 56 63 } 57 64 58 65 pub const FETCH_PREFIX: &str = "refs/tsk-fetched/"; ··· 214 221 .unwrap_or_else(|_| Signature::now("tsk", "tsk@local").unwrap()) 215 222 } 216 223 224 + /// One namespace's reconciliation outcome at pull time. 225 + #[derive(Debug)] 226 + pub struct NamespaceReconciliation { 227 + pub namespace: String, 228 + /// `(old_human_id, new_human_id)` per binding that had to be moved 229 + /// because the remote claimed the same id for a different stable. 230 + pub renumbers: Vec<(u32, u32)>, 231 + } 232 + 233 + /// Three-way merge each namespace ref against its fetched counterpart. 234 + /// 235 + /// On conflict (same human id mapped to different stable ids on each 236 + /// side), the **remote** binding keeps the id and the **local** binding 237 + /// is renumbered to a fresh id past `max(local.next, remote.next)`. 238 + /// Local-only bindings are preserved at their original id; remote-only 239 + /// bindings are added verbatim. The merged tree is written as a commit 240 + /// with two parents so future pulls can fast-forward. 241 + pub fn reconcile_namespace_refs( 242 + repo: &Repository, 243 + remote: &str, 244 + ) -> Result<Vec<NamespaceReconciliation>> { 245 + let fetched_ns = format!("{}namespaces/", fetched_prefix(remote)); 246 + let mut names: BTreeSet<String> = BTreeSet::new(); 247 + for r in repo.references_glob(&format!("{NS_REF_PREFIX}*"))? { 248 + let r = r?; 249 + if let Some(name) = r.name().and_then(|n| n.strip_prefix(NS_REF_PREFIX)) { 250 + names.insert(name.to_string()); 251 + } 252 + } 253 + for r in repo.references_glob(&format!("{fetched_ns}*"))? { 254 + let r = r?; 255 + if let Some(name) = r.name().and_then(|n| n.strip_prefix(fetched_ns.as_str())) { 256 + names.insert(name.to_string()); 257 + } 258 + } 259 + let mut out = Vec::new(); 260 + for name in names { 261 + let local = repo 262 + .find_reference(&namespace::refname(&name)) 263 + .ok() 264 + .and_then(|r| r.target()); 265 + let remote_oid = repo 266 + .find_reference(&format!("{fetched_ns}{name}")) 267 + .ok() 268 + .and_then(|r| r.target()); 269 + if let Some(rec) = reconcile_namespace_one(repo, &name, local, remote_oid)? { 270 + out.push(rec); 271 + } 272 + } 273 + Ok(out) 274 + } 275 + 276 + fn reconcile_namespace_one( 277 + repo: &Repository, 278 + name: &str, 279 + local: Option<Oid>, 280 + remote: Option<Oid>, 281 + ) -> Result<Option<NamespaceReconciliation>> { 282 + match (local, remote) { 283 + (None, None) | (Some(_), None) => Ok(None), 284 + (None, Some(r)) => { 285 + repo.reference(&namespace::refname(name), r, true, "pull-import")?; 286 + Ok(None) 287 + } 288 + (Some(l), Some(r)) if l == r => Ok(None), 289 + (Some(l), Some(r)) => { 290 + if repo.graph_descendant_of(l, r).unwrap_or(false) { 291 + return Ok(None); 292 + } 293 + if repo.graph_descendant_of(r, l).unwrap_or(false) { 294 + repo.reference(&namespace::refname(name), r, true, "fast-forward")?; 295 + return Ok(None); 296 + } 297 + // Diverged: 3-way merge with remote-wins on conflicts. 298 + let local_ns = namespace::read_at_commit(repo, l)?; 299 + let remote_ns = namespace::read_at_commit(repo, r)?; 300 + let mut merged = Namespace { 301 + next: local_ns.next.max(remote_ns.next), 302 + mapping: remote_ns.mapping.clone(), 303 + }; 304 + let mut renumbers: Vec<(u32, u32)> = Vec::new(); 305 + for (lh, lstable) in &local_ns.mapping { 306 + match remote_ns.mapping.get(lh) { 307 + Some(rstable) if rstable == lstable => {} // already in merged 308 + Some(_rstable) => { 309 + // Conflict: same id, different stable. Renumber local. 310 + let new_h = merged.next; 311 + merged.next += 1; 312 + merged.mapping.insert(new_h, lstable.clone()); 313 + renumbers.push((*lh, new_h)); 314 + } 315 + None => { 316 + // Local-only binding; preserve at its current id (it 317 + // can't collide because remote_ns lacks that id). 318 + merged.mapping.insert(*lh, lstable.clone()); 319 + } 320 + } 321 + } 322 + // Bump next past any human id we just placed. 323 + if let Some(max_h) = merged.mapping.keys().max() { 324 + merged.next = merged.next.max(max_h + 1); 325 + } 326 + // Write a merge commit with two parents. 327 + let tree_oid = namespace::build_tree(repo, &merged)?; 328 + let local_commit = repo.find_commit(l)?; 329 + let remote_commit = repo.find_commit(r)?; 330 + let sig = signature(repo); 331 + let msg = if renumbers.is_empty() { 332 + format!("merge-namespace {name}") 333 + } else { 334 + format!("rebase-bind {name}") 335 + }; 336 + let parents: Vec<&Commit> = vec![&local_commit, &remote_commit]; 337 + let new_oid = repo.commit( 338 + None, 339 + &sig, 340 + &sig, 341 + &msg, 342 + &repo.find_tree(tree_oid)?, 343 + &parents, 344 + )?; 345 + repo.reference(&namespace::refname(name), new_oid, true, &msg)?; 346 + Ok(Some(NamespaceReconciliation { 347 + namespace: name.to_string(), 348 + renumbers, 349 + })) 350 + } 351 + } 352 + } 353 + 217 354 /// After task refs are reconciled, copy every other fetched ref 218 355 /// (`refs/tsk-fetched/<remote>/{namespaces,queues,properties}/*`) onto its 219 356 /// `refs/tsk/*` counterpart with force-update. Better merging for these is ··· 228 365 let Some(rest) = name.strip_prefix(prefix.as_str()) else { 229 366 continue; 230 367 }; 231 - if rest.starts_with("tasks/") { 368 + if rest.starts_with("tasks/") || rest.starts_with("namespaces/") { 232 369 continue; 233 370 } 234 371 let Some(target) = repo.find_reference(&name).ok().and_then(|r| r.target()) else {
+9 -2
src/namespace.rs
··· 62 62 mapping: BTreeMap::new(), 63 63 }); 64 64 }; 65 - let tree = repo.find_commit(target)?.tree()?; 65 + read_at_commit(repo, target) 66 + } 67 + 68 + /// Read a namespace from the tree of a specific commit (rather than from 69 + /// the active ref). Used by the namespace merge driver to compare local 70 + /// and fetched-remote tips. 71 + pub fn read_at_commit(repo: &Repository, commit_oid: Oid) -> Result<Namespace> { 72 + let tree = repo.find_commit(commit_oid)?.tree()?; 66 73 let mut ns = Namespace { 67 74 next: 1, 68 75 mapping: BTreeMap::new(), ··· 91 98 Ok(ns) 92 99 } 93 100 94 - fn build_tree(repo: &Repository, ns: &Namespace) -> Result<Oid> { 101 + pub fn build_tree(repo: &Repository, ns: &Namespace) -> Result<Oid> { 95 102 let mut ids_tb = repo.treebuilder(None)?; 96 103 for (human, stable) in &ns.mapping { 97 104 let oid = repo.blob(stable.0.as_bytes())?;
+4 -3
src/workspace.rs
··· 908 908 &self, 909 909 remote: &str, 910 910 strategy: merge::Strategy, 911 - ) -> Result<Vec<merge::Reconciliation>> { 911 + ) -> Result<merge::PullOutcome> { 912 912 // `--refmap=` disables the remote's configured fetch refspec so our 913 913 // explicit refspec is the *only* one applied; otherwise git also 914 914 // performs the configured `+refs/tsk/*:refs/tsk/*` mapping and ··· 924 924 return Err(Error::Parse("git fetch failed".into())); 925 925 } 926 926 let repo = self.repo()?; 927 - let recs = merge::reconcile_task_refs(&repo, remote, strategy)?; 927 + let tasks = merge::reconcile_task_refs(&repo, remote, strategy)?; 928 + let namespaces = merge::reconcile_namespace_refs(&repo, remote)?; 928 929 merge::fast_forward_non_task_refs(&repo, remote)?; 929 - Ok(recs) 930 + Ok(merge::PullOutcome { tasks, namespaces }) 930 931 } 931 932 } 932 933
+34
tests/multi_user.rs
··· 348 348 "namespace must NOT be pushed: {after}" 349 349 ); 350 350 } 351 + 352 + #[test] 353 + fn namespace_collision_renumbers_local_on_pull() { 354 + let (_dir, alice, bob) = setup_two_clones(); 355 + 356 + // Both clones independently allocate tsk-1 to different stable ids. 357 + tsk_ok(&alice, &["push", "alice's task"]); 358 + tsk_ok(&bob, &["push", "bob's task"]); 359 + 360 + // Alice pushes first: origin's namespace now binds tsk-1 → alice-stable. 361 + tsk_ok(&alice, &["git-push"]); 362 + 363 + // Bob pulls — his local namespace had tsk-1 → bob-stable, conflict with 364 + // origin's tsk-1 → alice-stable. Auto-renumber should move bob's binding 365 + // to a fresh id (tsk-2) and let alice's win tsk-1. 366 + let pull_out = tsk_ok(&bob, &["git-pull"]); 367 + assert!( 368 + pull_out.contains("tsk-1 → tsk-2") || pull_out.contains("tsk-1 \u{2192} tsk-2"), 369 + "expected renumber message in pull output, got: {pull_out}" 370 + ); 371 + 372 + // tsk-1 on bob's side now resolves to alice's task. 373 + let show1 = tsk_ok(&bob, &["show", "-T", "tsk-1"]); 374 + assert!( 375 + show1.contains("alice's task"), 376 + "tsk-1 must point at alice's task after pull: {show1}" 377 + ); 378 + // bob's original task is bound at tsk-2. 379 + let show2 = tsk_ok(&bob, &["show", "-T", "tsk-2"]); 380 + assert!( 381 + show2.contains("bob's task"), 382 + "tsk-2 must point at bob's task after renumber: {show2}" 383 + ); 384 + }