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 387 lines 14 kB view raw
1//! End-to-end multi-clone tests. Spins up a bare "origin" repo and two 2//! working clones; exercises the user-visible commands across them via the 3//! compiled `tsk` binary. 4 5use std::path::{Path, PathBuf}; 6use std::process::Command; 7 8fn tsk_bin() -> PathBuf { 9 // Cargo sets CARGO_BIN_EXE_<name> for each [[bin]] when running tests. 10 PathBuf::from(env!("CARGO_BIN_EXE_tsk")) 11} 12 13fn run(cmd: &mut Command) -> (i32, String, String) { 14 let out = cmd.output().expect("spawn failed"); 15 ( 16 out.status.code().unwrap_or(-1), 17 String::from_utf8_lossy(&out.stdout).into_owned(), 18 String::from_utf8_lossy(&out.stderr).into_owned(), 19 ) 20} 21 22fn git(dir: &Path, args: &[&str]) -> String { 23 let out = Command::new("git") 24 .current_dir(dir) 25 .args(args) 26 .output() 27 .expect("git spawn failed"); 28 assert!( 29 out.status.success(), 30 "git {args:?} failed: {}", 31 String::from_utf8_lossy(&out.stderr) 32 ); 33 String::from_utf8_lossy(&out.stdout).into_owned() 34} 35 36fn tsk(dir: &Path, args: &[&str]) -> (i32, String, String) { 37 run(Command::new(tsk_bin()).current_dir(dir).args(args)) 38} 39 40fn tsk_ok(dir: &Path, args: &[&str]) -> String { 41 let (code, stdout, stderr) = tsk(dir, args); 42 assert_eq!( 43 code, 0, 44 "tsk {args:?} failed in {dir:?}: stdout={stdout} stderr={stderr}" 45 ); 46 stdout 47} 48 49fn make_clone(origin: &Path, dest: &Path, name: &str, email: &str) { 50 let _ = Command::new("git") 51 .args(["clone", "-q", origin.to_str().unwrap(), dest.to_str().unwrap()]) 52 .status() 53 .expect("git clone"); 54 git(dest, &["config", "user.name", name]); 55 git(dest, &["config", "user.email", email]); 56 // Configure tsk refspecs so plain `git push`/`git fetch` carries them too. 57 tsk_ok(dest, &["git-setup"]); 58} 59 60fn setup_two_clones() -> (tempfile::TempDir, PathBuf, PathBuf) { 61 let dir = tempfile::tempdir().unwrap(); 62 let origin = dir.path().join("origin.git"); 63 Command::new("git") 64 .args(["init", "-q", "--bare", origin.to_str().unwrap()]) 65 .status() 66 .unwrap(); 67 let alice = dir.path().join("alice"); 68 let bob = dir.path().join("bob"); 69 make_clone(&origin, &alice, "Alice", "a@x"); 70 make_clone(&origin, &bob, "Bob", "b@x"); 71 // Each clone needs at least one commit on the default branch before 72 // tsk can push refs (origin must accept ref updates). 73 std::fs::write(alice.join("README"), b"hi").unwrap(); 74 git(&alice, &["add", "README"]); 75 git(&alice, &["commit", "-q", "-m", "init"]); 76 git(&alice, &["push", "-q", "origin", "HEAD:refs/heads/main"]); 77 git(&bob, &["pull", "-q", "origin", "main"]); 78 (dir, alice, bob) 79} 80 81#[test] 82fn share_and_pull_between_clones() { 83 let (_dir, alice, bob) = setup_two_clones(); 84 85 // Alice creates a task and pushes. 86 tsk_ok(&alice, &["push", "Alice's task"]); 87 tsk_ok(&alice, &["git-push"]); 88 89 // Bob pulls and sees nothing in his stack (different namespace mapping 90 // exists, but the queue index is shared). 91 tsk_ok(&bob, &["git-pull"]); 92 let listed = tsk_ok(&bob, &["list"]); 93 // Bob hasn't bound a human id in his namespace, but he's on the same 94 // default namespace `tsk`, and he pulled Alice's namespace state too — 95 // so the task IS visible. 96 assert!( 97 listed.contains("Alice's task"), 98 "shared default namespace + queue: bob should see alice's task. got: {listed:?}" 99 ); 100} 101 102#[test] 103fn assign_to_other_queue_visible_after_push_pull() { 104 let (_dir, alice, bob) = setup_two_clones(); 105 106 // Bob creates a "review" queue ahead of time. 107 tsk_ok(&bob, &["queue", "create", "review"]); 108 tsk_ok(&bob, &["git-push"]); 109 110 // Alice pulls the queue, creates a task, assigns to review. 111 tsk_ok(&alice, &["git-pull"]); 112 tsk_ok(&alice, &["push", "needs review"]); 113 let assign_out = tsk_ok(&alice, &["assign", "review", "-R", ""]); 114 assert!(assign_out.contains("Assigned to review"), "got {assign_out}"); 115 tsk_ok(&alice, &["git-push"]); 116 117 // Bob switches to review, pulls, sees inbox. 118 tsk_ok(&bob, &["queue", "switch", "review"]); 119 tsk_ok(&bob, &["git-pull"]); 120 let inbox = tsk_ok(&bob, &["inbox", "-R", ""]); 121 assert!( 122 inbox.contains("needs review"), 123 "bob should see assigned task in inbox: {inbox}" 124 ); 125} 126 127#[test] 128fn concurrent_pushes_dont_clobber() { 129 let (_dir, alice, bob) = setup_two_clones(); 130 131 // Both push a task concurrently before either has pulled. 132 tsk_ok(&alice, &["push", "alice work"]); 133 tsk_ok(&bob, &["push", "bob work"]); 134 135 // Alice pushes first. 136 tsk_ok(&alice, &["git-push"]); 137 // Bob's push will be rejected by git's non-fast-forward protection on 138 // the namespace ref (since alice already updated it). Verify it errors 139 // and didn't silently overwrite. 140 let (code, _, stderr) = tsk(&bob, &["git-push"]); 141 assert_ne!( 142 code, 0, 143 "bob's push should fail (non-fast-forward); stderr={stderr}" 144 ); 145 146 // After bob pulls, the queue merge driver merges both sides so neither 147 // task is lost. Namespace conflict (both allocated tsk-1) auto-renumbers 148 // the local binding (tsk-12 + tsk-34). 149 tsk_ok(&bob, &["git-pull"]); 150 let listed = tsk_ok(&bob, &["list"]); 151 assert!( 152 listed.contains("alice work"), 153 "alice's task must survive the merge: {listed}" 154 ); 155 assert!( 156 listed.contains("bob work"), 157 "bob's task must survive the merge: {listed}" 158 ); 159} 160 161#[test] 162fn property_set_find_round_trip_via_binary() { 163 let (_dir, alice, _bob) = setup_two_clones(); 164 165 tsk_ok(&alice, &["push", "first"]); 166 tsk_ok(&alice, &["push", "second"]); 167 // Set priority on tsk-1 (the bottom of stack — first pushed). 168 tsk_ok(&alice, &["prop", "add", "-T", "tsk-1", "priority", "high"]); 169 tsk_ok(&alice, &["prop", "add", "-T", "tsk-1", "tag", "alpha"]); 170 tsk_ok(&alice, &["prop", "add", "-T", "tsk-1", "tag", "beta"]); 171 tsk_ok(&alice, &["prop", "add", "-T", "tsk-2", "priority", "low"]); 172 173 // List on tsk-1: priority=high, tag=alpha, tag=beta. 174 let list = tsk_ok(&alice, &["prop", "list", "-T", "tsk-1"]); 175 assert!(list.contains("priority\thigh"), "got {list}"); 176 assert!(list.contains("tag\talpha"), "got {list}"); 177 assert!(list.contains("tag\tbeta"), "got {list}"); 178 179 // Keys index has both `priority` and `tag`. 180 let keys = tsk_ok(&alice, &["prop", "keys"]); 181 assert!(keys.contains("priority"), "got {keys}"); 182 assert!(keys.contains("tag"), "got {keys}"); 183 184 // Find tasks with priority=high. 185 let found = tsk_ok(&alice, &["prop", "find", "priority", "high"]); 186 assert!(found.contains("tsk-1"), "got {found}"); 187 assert!(!found.contains("tsk-2"), "got {found}"); 188 189 // Unsetting one value on multi-value property. 190 tsk_ok(&alice, &["prop", "unset", "-T", "tsk-1", "tag", "alpha"]); 191 let list = tsk_ok(&alice, &["prop", "list", "-T", "tsk-1"]); 192 assert!(!list.contains("tag\talpha"), "alpha should be gone: {list}"); 193 assert!(list.contains("tag\tbeta"), "beta survives: {list}"); 194 195 // Replace whole property. 196 tsk_ok(&alice, &["prop", "set", "-T", "tsk-1", "priority", "medium"]); 197 let list = tsk_ok(&alice, &["prop", "list", "-T", "tsk-1"]); 198 assert!(list.contains("priority\tmedium"), "got {list}"); 199 assert!(!list.contains("priority\thigh"), "got {list}"); 200} 201 202#[test] 203fn property_index_pushed_and_visible_to_other_clone() { 204 let (_dir, alice, bob) = setup_two_clones(); 205 tsk_ok(&alice, &["push", "shared task"]); 206 tsk_ok(&alice, &["prop", "add", "-T", "tsk-1", "owner", "alice"]); 207 tsk_ok(&alice, &["git-push"]); 208 209 tsk_ok(&bob, &["git-pull"]); 210 let found = tsk_ok(&bob, &["prop", "find", "owner", "alice"]); 211 assert!(found.contains("tsk-1"), "bob sees alice's index: {found}"); 212} 213 214#[test] 215fn show_renders_styled_body() { 216 let (_dir, alice, _bob) = setup_two_clones(); 217 // Push a body that exercises every inline style. 218 tsk_ok( 219 &alice, 220 &[ 221 "push", 222 "rendered\n\nthis is !bold!, *italic*, _under_, ~struck~, =hi=, `code`.", 223 ], 224 ); 225 226 // Force colored output even in the test harness's pipe. 227 let mut cmd = Command::new(tsk_bin()); 228 cmd.current_dir(&alice) 229 .env("CLICOLOR_FORCE", "1") 230 .args(["show", "-r", "0"]); 231 let (code, stdout, stderr) = run(&mut cmd); 232 assert_eq!(code, 0, "stderr={stderr}"); 233 234 // Markup characters must be stripped. 235 assert!(!stdout.contains("!bold!"), "got {stdout:?}"); 236 assert!(!stdout.contains("*italic*"), "got {stdout:?}"); 237 assert!(!stdout.contains("=hi="), "got {stdout:?}"); 238 // ANSI bold escape must be present somewhere. 239 assert!( 240 stdout.contains("\x1b["), 241 "expected ANSI escapes in styled output: {stdout:?}" 242 ); 243 244 // -R bypasses the parser and prints the raw bytes. 245 let raw = tsk_ok(&alice, &["show", "-r", "0", "-R"]); 246 assert!(raw.contains("!bold!"), "raw must keep markup: {raw:?}"); 247} 248 249#[test] 250fn share_into_namespace_round_trip() { 251 let (_dir, alice, _bob) = setup_two_clones(); 252 253 let push_out = tsk_ok(&alice, &["push", "to share"]); 254 drop(push_out); 255 tsk_ok(&alice, &["share", "alpha", "-r", "0"]); 256 257 // Switch to alpha; the task should be visible there. 258 tsk_ok(&alice, &["namespace", "switch", "alpha"]); 259 // The shared task isn't on alpha's queue (queues are per-queue, not 260 // per-namespace), but `tsk show -T tsk-1` should resolve the binding. 261 let (code, stdout, stderr) = tsk(&alice, &["show", "-T", "tsk-1"]); 262 assert_eq!(code, 0, "show should succeed: stderr={stderr}"); 263 assert!(stdout.contains("to share"), "got {stdout}"); 264} 265 266#[test] 267fn divergent_task_edits_merge_on_pull() { 268 let (_dir, alice, bob) = setup_two_clones(); 269 270 // Alice creates a task, pushes; Bob pulls so they share the same 271 // root commit on the task ref. 272 tsk_ok(&alice, &["push", "shared task"]); 273 tsk_ok(&alice, &["git-push"]); 274 tsk_ok(&bob, &["git-pull"]); 275 276 // Both edit the same task, touching different properties (no overlap). 277 tsk_ok(&alice, &["prop", "add", "-T", "tsk-1", "priority", "high"]); 278 tsk_ok(&bob, &["prop", "add", "-T", "tsk-1", "owner", "bob"]); 279 280 // Alice pushes first; Bob's push would be non-fast-forward, so he pulls. 281 tsk_ok(&alice, &["git-push"]); 282 tsk_ok(&bob, &["git-pull"]); 283 284 // After the merge pull, Bob should see both his and Alice's edits on 285 // the task. 286 let listing = tsk_ok(&bob, &["prop", "list", "-T", "tsk-1"]); 287 eprintln!("LISTING: {listing}"); 288 assert!(listing.contains("priority\thigh"), "alice's edit lost: {listing}"); 289 assert!(listing.contains("owner\tbob"), "bob's edit lost: {listing}"); 290} 291 292#[test] 293fn divergent_task_edits_rebase_on_pull() { 294 let (_dir, alice, bob) = setup_two_clones(); 295 296 tsk_ok(&alice, &["push", "shared task"]); 297 tsk_ok(&alice, &["git-push"]); 298 tsk_ok(&bob, &["git-pull"]); 299 300 tsk_ok(&alice, &["prop", "add", "-T", "tsk-1", "priority", "high"]); 301 tsk_ok(&bob, &["prop", "add", "-T", "tsk-1", "owner", "bob"]); 302 303 tsk_ok(&alice, &["git-push"]); 304 let pull_out = tsk_ok(&bob, &["git-pull", "--rebase"]); 305 assert!( 306 pull_out.contains("Rebased") || pull_out.is_empty(), 307 "expected rebase summary, got {pull_out}" 308 ); 309 310 // Both edits survived. 311 let listing = tsk_ok(&bob, &["prop", "list", "-T", "tsk-1"]); 312 assert!(listing.contains("priority\thigh"), "alice's edit lost: {listing}"); 313 assert!(listing.contains("owner\tbob"), "bob's edit lost: {listing}"); 314} 315 316#[test] 317fn assign_auto_push_only_targets_relevant_refs() { 318 let (dir, alice, _bob) = setup_two_clones(); 319 let origin = dir.path().join("origin.git"); 320 321 // Alice creates a review queue and a task, neither pushed yet. 322 tsk_ok(&alice, &["queue", "create", "review"]); 323 tsk_ok(&alice, &["push", "task-to-assign"]); 324 325 // Capture origin's refs before the auto-push. 326 let before = git(&origin, &["for-each-ref", "refs/tsk/"]); 327 assert!( 328 !before.contains("refs/tsk/"), 329 "origin should be empty: {before}" 330 ); 331 332 // Assign auto-pushes to origin (the default). 333 tsk_ok(&alice, &["assign", "review", "-r", "0"]); 334 335 // Origin should have *only* the target queue, the task ref, and any 336 // property indices referencing that task. The active queue (tsk) and 337 // namespace must NOT have been pushed. 338 let after = git(&origin, &["for-each-ref", "refs/tsk/"]); 339 assert!( 340 after.contains("refs/tsk/queues/review"), 341 "review queue must be pushed: {after}" 342 ); 343 let task_ref_present = after.lines().any(|l| l.contains("refs/tsk/tasks/")); 344 assert!(task_ref_present, "task ref must be pushed: {after}"); 345 assert!( 346 !after.contains("refs/tsk/queues/tsk"), 347 "active queue must NOT be pushed: {after}" 348 ); 349 assert!( 350 !after.contains("refs/tsk/namespaces/"), 351 "namespace must NOT be pushed: {after}" 352 ); 353} 354 355#[test] 356fn namespace_collision_renumbers_local_on_pull() { 357 let (_dir, alice, bob) = setup_two_clones(); 358 359 // Both clones independently allocate tsk-1 to different stable ids. 360 tsk_ok(&alice, &["push", "alice's task"]); 361 tsk_ok(&bob, &["push", "bob's task"]); 362 363 // Alice pushes first: origin's namespace now binds tsk-1 → alice-stable. 364 tsk_ok(&alice, &["git-push"]); 365 366 // Bob pulls — his local namespace had tsk-1 → bob-stable, conflict with 367 // origin's tsk-1 → alice-stable. Auto-renumber should move bob's binding 368 // to a fresh id (tsk-2) and let alice's win tsk-1. 369 let pull_out = tsk_ok(&bob, &["git-pull"]); 370 assert!( 371 pull_out.contains("tsk-1 → tsk-2") || pull_out.contains("tsk-1 \u{2192} tsk-2"), 372 "expected renumber message in pull output, got: {pull_out}" 373 ); 374 375 // tsk-1 on bob's side now resolves to alice's task. 376 let show1 = tsk_ok(&bob, &["show", "-T", "tsk-1"]); 377 assert!( 378 show1.contains("alice's task"), 379 "tsk-1 must point at alice's task after pull: {show1}" 380 ); 381 // bob's original task is bound at tsk-2. 382 let show2 = tsk_ok(&bob, &["show", "-T", "tsk-2"]); 383 assert!( 384 show2.contains("bob's task"), 385 "tsk-2 must point at bob's task after renumber: {show2}" 386 ); 387}