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