A file-based task manager
0
fork

Configure Feed

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

Rebase id collisions on tsk git-pull

When two clones offline both pick the same id and create different
tasks at it, pull now resolves the collision automatically by
renumbering the loser past the highest known id on either side and
rewriting every reference.

Detection: at the start of the pull reconcile pass, scan the post-fetch
shadow for tasks/<id> or archive/<id> refs that exist on both sides at
different OIDs. Compare the `created` log line byte-wise — if equal
it's the same task edited in two places (regular conflict path); if
different it's an id collision. Earlier `created` timestamp keeps the
id; tie-break on lexicographically-smaller blob OID.

Renumber actions:
- LocalLoser: move our blobs (tasks/archive/attrs/backlinks/log) from
<old> to <new>; rewrite [[tsk-<old>]] across our task content, attrs,
log details, backlinks, and index titles; rewrite [[<our-ns>/tsk-<old>]]
across every other namespace's blobs; bump our `next` past <new>.
After this, our <old> is vacated and the regular reconcile pass takes
remote's <old> normally.
- RemoteLoser: copy remote's <old> blobs from the shadow to our local
<new> (preserving bucket); add to the index if it was active on the
remote; suppress reconcile for the remote's <old> blobs so they don't
clobber our local winner.

In both cases a `renumbered\tfrom tsk-<old>...` log entry is appended
on <new> via the existing per-task log so history is recoverable.

Also: `next` is now mergeable (take max of either side) so two clones
calling next_id offline don't deadlock on a non-FF push of `next`.

Test simulates both cases: A creates tsk-1, B sleeps 2s and creates
tsk-1 with a referencing tsk-2, B pulls. Asserts tsk-1 = A's content,
B's original moved past tsk-2, B's body link rewritten, and a
renumbered log entry exists on the new id.

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

+524 -3
+1 -1
Cargo.lock
··· 839 839 840 840 [[package]] 841 841 name = "tsk-cli" 842 - version = "0.4.0" 842 + version = "0.5.0" 843 843 dependencies = [ 844 844 "clap", 845 845 "clap_complete",
+4
src/backend.rs
··· 338 338 } 339 339 340 340 /// Read+lossy-decode a blob, returning empty string when absent. 341 + pub fn read_text_blob(store: &dyn Store, key: &str) -> Result<String> { 342 + read_text(store, key) 343 + } 344 + 341 345 fn read_text(store: &dyn Store, key: &str) -> Result<String> { 342 346 Ok(store 343 347 .read(key)?
+519 -2
src/workspace.rs
··· 13 13 use std::str::FromStr; 14 14 15 15 /// A unique identifier for a task. When referenced in text, it is prefixed with `tsk-`. 16 - #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] 16 + #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] 17 17 pub struct Id(pub u32); 18 18 19 19 impl FromStr for Id { ··· 114 114 .join(",") 115 115 } 116 116 117 + /// One id-collision resolution decision computed during `tsk git-pull`. 118 + /// `local_loses` true means we vacate `old_id` locally (renumbering our 119 + /// content to `new_id`) so the remote's blob can take `old_id` cleanly. 120 + /// false means the remote's blob is the loser; we import it at `new_id` 121 + /// while keeping our local `old_id` intact. 122 + struct Renumber { 123 + old_id: Id, 124 + new_id: Id, 125 + local_loses: bool, 126 + } 127 + 117 128 enum PullAction { 118 129 /// Local already matches remote — nothing to do. 119 130 Skip, ··· 126 137 } 127 138 128 139 fn is_mergeable_key(rel: &str) -> bool { 129 - rel.starts_with("log/") || rel.ends_with("/log") || rel == "index" || rel.ends_with("/index") 140 + rel.starts_with("log/") 141 + || rel.ends_with("/log") 142 + || rel == "index" 143 + || rel.ends_with("/index") 144 + || rel == "next" 145 + || rel.ends_with("/next") 130 146 } 131 147 132 148 fn resolve_pull( ··· 1020 1036 1021 1037 let repo = git2::Repository::open(self.require_git_dir()?)?; 1022 1038 let mut conflicts: Vec<String> = Vec::new(); 1039 + // Refs marked "handled" by the rebase pass below — the per-ref 1040 + // reconcile skips these because they don't represent a real conflict. 1041 + let mut rebased_handled: HashSet<String> = HashSet::new(); 1042 + // First pass: detect id collisions in our current namespace and 1043 + // resolve them by renumbering the loser locally. 1044 + let renames = self.detect_id_collisions(&repo, &post_fetch)?; 1045 + for r in &renames { 1046 + self.apply_renumber(&repo, r, &post_fetch, &mut rebased_handled)?; 1047 + } 1023 1048 for (rel, &new_remote) in &post_fetch { 1049 + if rebased_handled.contains(rel) { 1050 + continue; 1051 + } 1024 1052 let local_refname = format!("refs/tsk/{rel}"); 1025 1053 let local_oid = repo 1026 1054 .find_reference(&local_refname) ··· 1102 1130 Ok(blob.content().to_vec()) 1103 1131 } 1104 1132 1133 + /// Walk the post-fetch shadow looking for ids that exist on both sides 1134 + /// with different content. For each such id, decide which side keeps the 1135 + /// id (winner = earlier `created` timestamp; tie-break = lexicographically 1136 + /// smaller blob OID) and queue a [`Renumber`] for the loser. Renumbered 1137 + /// ids are allocated past the highest known id on either side. 1138 + fn detect_id_collisions( 1139 + &self, 1140 + repo: &git2::Repository, 1141 + post_fetch: &BTreeMap<String, git2::Oid>, 1142 + ) -> Result<Vec<Renumber>> { 1143 + let our_ns = self.namespace(); 1144 + // Candidate ids: any tasks/<id> or archive/<id> in the shadow under 1145 + // our namespace. 1146 + let mut candidates: std::collections::BTreeSet<Id> = Default::default(); 1147 + for bucket in ["tasks", "archive"] { 1148 + let prefix = format!("{our_ns}/{bucket}/"); 1149 + for rel in post_fetch.keys() { 1150 + if let Some(rest) = rel.strip_prefix(&prefix) 1151 + && let Ok(n) = rest.parse::<u32>() 1152 + { 1153 + candidates.insert(Id(n)); 1154 + } 1155 + } 1156 + } 1157 + if candidates.is_empty() { 1158 + return Ok(Vec::new()); 1159 + } 1160 + 1161 + // Allocate fresh ids past the highest known on either side. 1162 + let mut next_free = self.highest_known_id(post_fetch)? + 1; 1163 + let local_next: u32 = backend::read_text_blob(self.store(), "next")? 1164 + .trim() 1165 + .parse() 1166 + .unwrap_or(1); 1167 + next_free = next_free.max(local_next); 1168 + 1169 + let mut out = Vec::new(); 1170 + for id in candidates { 1171 + let local_oid = self.local_task_oid(id)?; 1172 + let remote_oid = self.remote_task_oid(post_fetch, &our_ns, id); 1173 + let (Some(local_oid), Some(remote_oid)) = (local_oid, remote_oid) else { 1174 + continue; 1175 + }; 1176 + if local_oid == remote_oid { 1177 + continue; 1178 + } 1179 + let local_create = self.read_local_create_line(id)?; 1180 + let remote_create = self.read_remote_create_line(repo, post_fetch, &our_ns, id)?; 1181 + let (Some(lc), Some(rc)) = (local_create, remote_create) else { 1182 + continue; 1183 + }; 1184 + // Same `created` line on both sides means it's the same logical 1185 + // task being edited in two places — not an id collision. Let the 1186 + // regular reconcile pass handle it. 1187 + if lc == rc { 1188 + continue; 1189 + } 1190 + // Pull out the timestamp from the `created` line for ordering. 1191 + let lc_ts: u64 = lc 1192 + .split('\t') 1193 + .next() 1194 + .and_then(|t| t.parse().ok()) 1195 + .unwrap_or(0); 1196 + let rc_ts: u64 = rc 1197 + .split('\t') 1198 + .next() 1199 + .and_then(|t| t.parse().ok()) 1200 + .unwrap_or(0); 1201 + let local_loses = match lc_ts.cmp(&rc_ts) { 1202 + std::cmp::Ordering::Greater => true, 1203 + std::cmp::Ordering::Less => false, 1204 + std::cmp::Ordering::Equal => local_oid > remote_oid, 1205 + }; 1206 + out.push(Renumber { 1207 + old_id: id, 1208 + new_id: Id(next_free), 1209 + local_loses, 1210 + }); 1211 + next_free += 1; 1212 + } 1213 + Ok(out) 1214 + } 1215 + 1216 + fn highest_known_id(&self, post_fetch: &BTreeMap<String, git2::Oid>) -> Result<u32> { 1217 + let our_ns = self.namespace(); 1218 + let mut max_id = 0u32; 1219 + for id in backend::list_active(self.store())? { 1220 + max_id = max_id.max(id.0); 1221 + } 1222 + for id in backend::list_archive(self.store())? { 1223 + max_id = max_id.max(id.0); 1224 + } 1225 + for bucket in ["tasks", "archive"] { 1226 + let prefix = format!("{our_ns}/{bucket}/"); 1227 + for rel in post_fetch.keys() { 1228 + if let Some(rest) = rel.strip_prefix(&prefix) 1229 + && let Ok(n) = rest.parse::<u32>() 1230 + { 1231 + max_id = max_id.max(n); 1232 + } 1233 + } 1234 + } 1235 + Ok(max_id) 1236 + } 1237 + 1238 + fn local_task_oid(&self, id: Id) -> Result<Option<git2::Oid>> { 1239 + let repo = git2::Repository::open(self.require_git_dir()?)?; 1240 + for bucket in ["tasks", "archive"] { 1241 + let refname = format!("refs/tsk/{}/{bucket}/{}", self.namespace(), id.0); 1242 + if let Ok(r) = repo.find_reference(&refname) 1243 + && let Some(oid) = r.target() 1244 + { 1245 + return Ok(Some(oid)); 1246 + } 1247 + } 1248 + Ok(None) 1249 + } 1250 + 1251 + fn remote_task_oid( 1252 + &self, 1253 + post_fetch: &BTreeMap<String, git2::Oid>, 1254 + our_ns: &str, 1255 + id: Id, 1256 + ) -> Option<git2::Oid> { 1257 + for bucket in ["tasks", "archive"] { 1258 + if let Some(&oid) = post_fetch.get(&format!("{our_ns}/{bucket}/{}", id.0)) { 1259 + return Some(oid); 1260 + } 1261 + } 1262 + None 1263 + } 1264 + 1265 + fn read_local_create_line(&self, id: Id) -> Result<Option<String>> { 1266 + let raw = backend::read_text_blob(self.store(), &format!("log/{}", id.0))?; 1267 + Ok(raw 1268 + .lines() 1269 + .find(|l| l.split('\t').nth(1) == Some("created")) 1270 + .map(str::to_string)) 1271 + } 1272 + 1273 + fn read_remote_create_line( 1274 + &self, 1275 + repo: &git2::Repository, 1276 + post_fetch: &BTreeMap<String, git2::Oid>, 1277 + our_ns: &str, 1278 + id: Id, 1279 + ) -> Result<Option<String>> { 1280 + let Some(&oid) = post_fetch.get(&format!("{our_ns}/log/{}", id.0)) else { 1281 + return Ok(None); 1282 + }; 1283 + let bytes = self.read_oid(repo, oid)?; 1284 + Ok(String::from_utf8_lossy(&bytes) 1285 + .lines() 1286 + .find(|l| l.split('\t').nth(1) == Some("created")) 1287 + .map(str::to_string)) 1288 + } 1289 + 1290 + /// Apply one renumber decision. See [`Renumber`] for the two flavours. 1291 + fn apply_renumber( 1292 + &self, 1293 + repo: &git2::Repository, 1294 + r: &Renumber, 1295 + post_fetch: &BTreeMap<String, git2::Oid>, 1296 + handled: &mut HashSet<String>, 1297 + ) -> Result<()> { 1298 + let our_ns = self.namespace(); 1299 + if r.local_loses { 1300 + self.rename_local(r.old_id, r.new_id)?; 1301 + self.rewrite_intra_ns_links(r.old_id, r.new_id)?; 1302 + self.rewrite_cross_ns_links(repo, &our_ns, r.old_id, r.new_id)?; 1303 + self.bump_next_past(r.new_id)?; 1304 + // Don't mark anything handled — reconcile should now Take remote's 1305 + // <old> blobs (we vacated those keys) and merge log/<old>. 1306 + } else { 1307 + self.import_remote_at_new_id(repo, post_fetch, r.old_id, r.new_id, &our_ns)?; 1308 + self.bump_next_past(r.new_id)?; 1309 + // Suppress reconcile for the remote's loser blobs at <old>; we 1310 + // keep our local <old> intact. 1311 + for kind in ["tasks", "archive", "attrs", "backlinks", "log"] { 1312 + handled.insert(format!("{our_ns}/{kind}/{}", r.old_id.0)); 1313 + } 1314 + } 1315 + // Either way, append a renumbered event to the new id's log so the 1316 + // history is recoverable. 1317 + backend::append_log( 1318 + self.store(), 1319 + r.new_id, 1320 + "renumbered", 1321 + Some(&format!("from tsk-{} (collision rebase)", r.old_id.0)), 1322 + &self.git_author().unwrap_or_default(), 1323 + )?; 1324 + Ok(()) 1325 + } 1326 + 1327 + /// Rename local blobs from `old` → `new` within our namespace. 1328 + fn rename_local(&self, old: Id, new: Id) -> Result<()> { 1329 + for kind in ["tasks", "archive", "attrs", "backlinks", "log"] { 1330 + let from = format!("{kind}/{}", old.0); 1331 + let to = format!("{kind}/{}", new.0); 1332 + if let Some(data) = self.store().read(&from)? { 1333 + self.store().write(&to, &data)?; 1334 + self.store().delete(&from)?; 1335 + } 1336 + } 1337 + // Index: rewrite the row for old → new. 1338 + let raw = backend::read_text_blob(self.store(), "index")?; 1339 + let mut out = String::with_capacity(raw.len()); 1340 + for line in raw.lines() { 1341 + let mut parts = line.splitn(2, '\t'); 1342 + let id_field = parts.next().unwrap_or(""); 1343 + let rest = parts.next().unwrap_or(""); 1344 + if id_field.parse::<Id>().ok() == Some(old) { 1345 + out.push_str(&format!("{new}\t{rest}\n")); 1346 + } else { 1347 + out.push_str(line); 1348 + out.push('\n'); 1349 + } 1350 + } 1351 + if !raw.is_empty() { 1352 + self.store().write("index", out.as_bytes())?; 1353 + } 1354 + Ok(()) 1355 + } 1356 + 1357 + /// Rewrite every reference to `[[tsk-<old>]]` in our namespace to 1358 + /// `[[tsk-<new>]]` across task content, attrs values, log details, 1359 + /// backlinks, and index titles. 1360 + fn rewrite_intra_ns_links(&self, old: Id, new: Id) -> Result<()> { 1361 + let from_link = format!("[[tsk-{}]]", old.0); 1362 + let to_link = format!("[[tsk-{}]]", new.0); 1363 + for bucket in ["tasks", "archive"] { 1364 + for key in self.store().list(bucket)? { 1365 + if let Some(data) = self.store().read(&key)? { 1366 + let text = String::from_utf8_lossy(&data); 1367 + let new_text = text.replace(&from_link, &to_link); 1368 + if new_text != text { 1369 + self.store().write(&key, new_text.as_bytes())?; 1370 + } 1371 + } 1372 + } 1373 + } 1374 + for key in self.store().list("attrs")? { 1375 + if let Some(data) = self.store().read(&key)? { 1376 + let text = String::from_utf8_lossy(&data); 1377 + let new_text = text.replace(&from_link, &to_link); 1378 + if new_text != text { 1379 + self.store().write(&key, new_text.as_bytes())?; 1380 + } 1381 + } 1382 + } 1383 + for key in self.store().list("log")? { 1384 + if let Some(data) = self.store().read(&key)? { 1385 + let text = String::from_utf8_lossy(&data); 1386 + let new_text = text.replace(&from_link, &to_link); 1387 + if new_text != text { 1388 + self.store().write(&key, new_text.as_bytes())?; 1389 + } 1390 + } 1391 + } 1392 + // Backlinks: stored as comma-separated `tsk-N` (no brackets). 1393 + for key in self.store().list("backlinks")? { 1394 + if let Some(data) = self.store().read(&key)? { 1395 + let text = String::from_utf8_lossy(&data); 1396 + let mapped: Vec<String> = text 1397 + .split(',') 1398 + .map(|t| { 1399 + if t.trim().parse::<Id>().ok() == Some(old) { 1400 + format!("{new}") 1401 + } else { 1402 + t.to_string() 1403 + } 1404 + }) 1405 + .collect(); 1406 + let new_text = mapped.join(","); 1407 + if new_text != text { 1408 + self.store().write(&key, new_text.as_bytes())?; 1409 + } 1410 + } 1411 + } 1412 + // Index titles can also contain links. 1413 + let raw = backend::read_text_blob(self.store(), "index")?; 1414 + let new_index = raw.replace(&from_link, &to_link); 1415 + if new_index != raw { 1416 + self.store().write("index", new_index.as_bytes())?; 1417 + } 1418 + Ok(()) 1419 + } 1420 + 1421 + /// Rewrite cross-namespace references `[[<our_ns>/tsk-<old>]]` → 1422 + /// `[[<our_ns>/tsk-<new>]]` in every other namespace's blobs. 1423 + fn rewrite_cross_ns_links( 1424 + &self, 1425 + repo: &git2::Repository, 1426 + our_ns: &str, 1427 + old: Id, 1428 + new: Id, 1429 + ) -> Result<()> { 1430 + let from_link = format!("[[{our_ns}/tsk-{}]]", old.0); 1431 + let to_link = format!("[[{our_ns}/tsk-{}]]", new.0); 1432 + let prefix = "refs/tsk/"; 1433 + let our_prefix = format!("refs/tsk/{our_ns}/"); 1434 + let mut updates: Vec<(String, Vec<u8>)> = Vec::new(); 1435 + for r in repo.references()? { 1436 + let r = r?; 1437 + let Some(name) = r.name() else { continue }; 1438 + if !name.starts_with(prefix) || name.starts_with(&our_prefix) { 1439 + continue; 1440 + } 1441 + let Some(oid) = r.target() else { continue }; 1442 + let Ok(blob) = repo.find_blob(oid) else { 1443 + continue; 1444 + }; 1445 + let text = String::from_utf8_lossy(blob.content()); 1446 + let new_text = text.replace(&from_link, &to_link); 1447 + if new_text != text { 1448 + updates.push((name.to_string(), new_text.into_bytes())); 1449 + } 1450 + } 1451 + for (refname, bytes) in updates { 1452 + let new_oid = repo.blob(&bytes)?; 1453 + repo.reference(&refname, new_oid, true, "tsk renumber cross-ns")?; 1454 + } 1455 + Ok(()) 1456 + } 1457 + 1458 + /// Import remote's <old> blobs (winner stays at <old> locally; remote's 1459 + /// loser content lands at <new>). 1460 + fn import_remote_at_new_id( 1461 + &self, 1462 + repo: &git2::Repository, 1463 + post_fetch: &BTreeMap<String, git2::Oid>, 1464 + old: Id, 1465 + new: Id, 1466 + our_ns: &str, 1467 + ) -> Result<()> { 1468 + // Determine which bucket the remote had it in. 1469 + let bucket = if post_fetch.contains_key(&format!("{our_ns}/tasks/{}", old.0)) { 1470 + "tasks" 1471 + } else if post_fetch.contains_key(&format!("{our_ns}/archive/{}", old.0)) { 1472 + "archive" 1473 + } else { 1474 + return Ok(()); 1475 + }; 1476 + for kind in ["tasks", "archive", "attrs", "backlinks", "log"] { 1477 + let key = format!("{our_ns}/{kind}/{}", old.0); 1478 + if let Some(&oid) = post_fetch.get(&key) { 1479 + let bytes = self.read_oid(repo, oid)?; 1480 + let local_kind = if kind == "tasks" || kind == "archive" { 1481 + bucket 1482 + } else { 1483 + kind 1484 + }; 1485 + self.store() 1486 + .write(&format!("{local_kind}/{}", new.0), &bytes)?; 1487 + } 1488 + } 1489 + // If the imported task was active on remote, add it to our index too. 1490 + if bucket == "tasks" 1491 + && let Some((title, _, _)) = backend::read_task(self.store(), new)? 1492 + { 1493 + let mut stack = self.read_stack()?; 1494 + stack.push(StackItem { 1495 + id: new, 1496 + title: title.replace('\t', " "), 1497 + modify_time: std::time::SystemTime::now(), 1498 + }); 1499 + stack.save(self.store())?; 1500 + } 1501 + Ok(()) 1502 + } 1503 + 1504 + fn bump_next_past(&self, id: Id) -> Result<()> { 1505 + let cur: u32 = backend::read_text_blob(self.store(), "next")? 1506 + .trim() 1507 + .parse() 1508 + .unwrap_or(1); 1509 + let target = id.0 + 1; 1510 + if target > cur { 1511 + self.store() 1512 + .write("next", format!("{target}\n").as_bytes())?; 1513 + } 1514 + Ok(()) 1515 + } 1516 + 1105 1517 /// Three-way merge for union-mergeable refs (`log/*` and `index`). 1106 1518 fn merge_blob( 1107 1519 &self, ··· 1119 1531 let remote_text = String::from_utf8_lossy(&remote_bytes); 1120 1532 let merged = if rel.starts_with("log/") || rel.ends_with("/log") { 1121 1533 merge_log(&local_text, &remote_text) 1534 + } else if rel == "next" || rel.ends_with("/next") { 1535 + // next counter: always take the max so neither clone reissues an id. 1536 + let l: u32 = local_text.trim().parse().unwrap_or(1); 1537 + let r: u32 = remote_text.trim().parse().unwrap_or(1); 1538 + format!("{}\n", l.max(r)) 1122 1539 } else { 1123 1540 // index: union of stack item lines, preserving local order then 1124 1541 // appending remote-only items in their relative order. ··· 1886 2303 Workspace::init(dir.path().to_path_buf()).unwrap(); 1887 2304 let ws = Workspace::from_path(dir.path().to_path_buf()).unwrap(); 1888 2305 run_every_command(&ws); 2306 + } 2307 + 2308 + /// Two clones independently create tsk-1 offline. After B pulls, the 2309 + /// later-created (B's) is renumbered locally; A's content takes tsk-1, 2310 + /// and B's body link to tsk-1 from another task is rewritten to the new 2311 + /// id. 2312 + #[test] 2313 + fn test_pull_rebases_id_collisions() { 2314 + let dir = tempfile::tempdir().unwrap(); 2315 + let remote_dir = dir.path().join("remote.git"); 2316 + let a_dir = dir.path().join("a"); 2317 + let b_dir = dir.path().join("b"); 2318 + std::fs::create_dir_all(&remote_dir).unwrap(); 2319 + std::fs::create_dir_all(&a_dir).unwrap(); 2320 + std::fs::create_dir_all(&b_dir).unwrap(); 2321 + assert!( 2322 + std::process::Command::new("git") 2323 + .args(["init", "--bare", "-q"]) 2324 + .current_dir(&remote_dir) 2325 + .status() 2326 + .unwrap() 2327 + .success() 2328 + ); 2329 + let init_clone = |path: &std::path::Path| { 2330 + run_git_init(path); 2331 + std::process::Command::new("git") 2332 + .args(["remote", "add", "origin"]) 2333 + .arg(&remote_dir) 2334 + .current_dir(path) 2335 + .status() 2336 + .unwrap(); 2337 + Workspace::init(path.to_path_buf()).unwrap(); 2338 + Workspace::from_path(path.to_path_buf()).unwrap() 2339 + }; 2340 + let a = init_clone(&a_dir); 2341 + let b = init_clone(&b_dir); 2342 + 2343 + // A creates tsk-1 first. 2344 + let ta = a.new_task("a-task".into(), "from A".into()).unwrap(); 2345 + let id_a = ta.id; 2346 + a.push_task(ta).unwrap(); 2347 + // Sleep so B's created timestamp is strictly later. The rebase keys 2348 + // off seconds-resolution unix timestamps in the log. 2349 + std::thread::sleep(std::time::Duration::from_secs(2)); 2350 + // B creates tsk-1 too (offline; doesn't see A's push). B also has 2351 + // another task (tsk-2) whose body links to tsk-1 — that link must be 2352 + // rewritten to the new id post-rebase. 2353 + let tb = b.new_task("b-task".into(), "from B".into()).unwrap(); 2354 + let id_b = tb.id; 2355 + b.push_task(tb).unwrap(); 2356 + assert_eq!(id_a.0, 1); 2357 + assert_eq!(id_b.0, 1); 2358 + let tb2 = b 2359 + .new_task("b-other".into(), format!("see [[tsk-{}]]", id_b.0)) 2360 + .unwrap(); 2361 + let id_b2 = tb2.id; 2362 + b.handle_metadata(&tb2, None).unwrap(); 2363 + b.push_task(tb2).unwrap(); 2364 + 2365 + // A pushes; B pulls. 2366 + a.git_push_refs("origin").unwrap(); 2367 + b.git_pull_refs("origin").unwrap(); 2368 + 2369 + // Tsk-1 should now contain A's content. 2370 + let one = b.task(TaskIdentifier::Id(id_a)).unwrap(); 2371 + assert_eq!(one.title, "a-task", "tsk-1 should be A's after rebase"); 2372 + 2373 + // B's original tsk-1 should have been moved to a fresh id past 2. 2374 + let stack = b.read_stack().unwrap(); 2375 + let renumbered_id = stack 2376 + .iter() 2377 + .map(|i| i.id) 2378 + .find(|id| { 2379 + id.0 != id_a.0 2380 + && id.0 != id_b2.0 2381 + && b.task(TaskIdentifier::Id(*id)) 2382 + .map(|t| t.title == "b-task") 2383 + .unwrap_or(false) 2384 + }) 2385 + .expect("renumbered b-task in stack"); 2386 + assert!( 2387 + renumbered_id.0 >= 3, 2388 + "renumbered past collisions: {renumbered_id}" 2389 + ); 2390 + 2391 + // B's other task's body link should now point at the renumbered id. 2392 + let other = b.task(TaskIdentifier::Id(id_b2)).unwrap(); 2393 + assert!( 2394 + other.body.contains(&format!("[[tsk-{}]]", renumbered_id.0)), 2395 + "expected body to reference new id, got: {}", 2396 + other.body 2397 + ); 2398 + 2399 + // A `renumbered` log entry should exist on the new id. 2400 + let log = b.read_log(renumbered_id).unwrap(); 2401 + assert!( 2402 + log.iter().any(|e| e.event == "renumbered"), 2403 + "renumbered event missing: {:?}", 2404 + log 2405 + ); 1889 2406 } 1890 2407 1891 2408 /// Two clones diverge: clone A pushes, clone B edits locally, then B