on 2fa8129 help sign in
j/tests/cli.rs
4633 lines 210 kB history raw
1//! End-to-end CLI tests: the built binary against real jj repositories (§1,
2//! §7). Each test builds its own repo under a temp dir with its own config.
4use std::path::PathBuf;
5use std::process::{Command, Stdio};
7fn j_bin() -> PathBuf {
8 // cargo sets CARGO_BIN_EXE_<name> for integration tests
9 option_env!("CARGO_BIN_EXE_j")
10 .map(PathBuf::from)
11 .unwrap_or_else(|| {
12 let mut p = std::env::current_exe().unwrap();
13 p.pop(); // deps
14 p.pop(); // debug
15 p.push("j");
16 p
17 })
20struct Repo {
21 dir: PathBuf,
22 cfg: PathBuf,
25fn setup() -> Repo {
26 use std::sync::atomic::{AtomicUsize, Ordering};
27 static N: AtomicUsize = AtomicUsize::new(0);
28 let dir = std::env::temp_dir().join(format!(
29 "j-cli-test-{}-{}-{}",
30 std::process::id(),
31 N.fetch_add(1, Ordering::Relaxed),
32 std::time::SystemTime::now()
33 .duration_since(std::time::UNIX_EPOCH)
34 .unwrap()
35 .as_nanos()
36 ));
37 let _ = std::fs::remove_dir_all(&dir);
38 std::fs::create_dir_all(&dir).unwrap();
39 // the config lives outside the repo: a checkout may remove untracked
40 // files inside the working directory
41 let cfg = dir.join("..").join(format!("{}-cfg", dir.file_name().unwrap().to_string_lossy()));
42 std::fs::create_dir_all(&cfg.join("j")).unwrap();
43 let cfg = cfg.canonicalize().unwrap();
44 let mut config = include_str!("../config.j").to_string();
45 config = config.replace("Your Name", "Test User");
46 config = config.replace("you@example.com", "test@example.com");
47 std::fs::write(cfg.join("j/config.j"), config).unwrap();
48 let r = Repo { dir, cfg };
49 let out = r.j(&["init"]);
50 if out.code != 0 {
51 eprintln!("INIT FAILED in {}: {}", r.dir.display(), out.stderr);
52 eprintln!("config exists: {}", r.cfg.join("j/config.j").exists());
53 for e in std::fs::read_dir(&r.dir).unwrap() {
54 eprintln!(" entry: {:?}", e.unwrap().file_name());
55 }
56 }
57 out.ok();
58 r
61impl Repo {
62 fn j(&self, args: &[&str]) -> Out {
63 let out = Command::new(j_bin())
64 .args(args)
65 .current_dir(&self.dir)
66 .env("XDG_CONFIG_HOME", &self.cfg)
67 .env("NO_COLOR", "1")
68 .output()
69 .unwrap();
70 Out {
71 code: out.status.code().unwrap_or(-1),
72 stdout: String::from_utf8_lossy(&out.stdout).to_string(),
73 stderr: String::from_utf8_lossy(&out.stderr).to_string(),
74 }
75 }
77 fn j_stdin(&self, input: &str, args: &[&str]) -> Out {
78 let mut child = Command::new(j_bin())
79 .args(args)
80 .current_dir(&self.dir)
81 .env("XDG_CONFIG_HOME", &self.cfg)
82 .env("NO_COLOR", "1")
83 .stdin(Stdio::piped())
84 .stdout(Stdio::piped())
85 .stderr(Stdio::piped())
86 .spawn()
87 .unwrap();
88 use std::io::Write;
89 child
90 .stdin
91 .as_mut()
92 .unwrap()
93 .write_all(input.as_bytes())
94 .unwrap();
95 let out = child.wait_with_output().unwrap();
96 Out {
97 code: out.status.code().unwrap_or(-1),
98 stdout: String::from_utf8_lossy(&out.stdout).to_string(),
99 stderr: String::from_utf8_lossy(&out.stderr).to_string(),
100 }
101 }
103 /// Run `j` with stdout sent to `stdout` instead of captured.
104 fn j_to(&self, stdout: impl Into<Stdio>, args: &[&str]) -> Out {
105 let out = Command::new(j_bin())
106 .args(args)
107 .current_dir(&self.dir)
108 .env("XDG_CONFIG_HOME", &self.cfg)
109 .env("NO_COLOR", "1")
110 .stdout(stdout)
111 .output()
112 .unwrap();
113 Out {
114 code: out.status.code().unwrap_or(-1),
115 stdout: String::new(),
116 stderr: String::from_utf8_lossy(&out.stderr).to_string(),
117 }
118 }
120 /// Run `j` with stdout on a pipe whose reader has already exited, as in
121 /// `j tree | head` once `head` has read all it wants.
122 fn j_closed_stdout(&self, args: &[&str]) -> Out {
123 let (reader, writer) = std::io::pipe().unwrap();
124 drop(reader);
125 self.j_to(writer, args)
126 }
128 /// Run `j` with stderr on a pipe whose reader has already exited, as in
129 /// `j … 2>&1 | head` once `head` has read all it wants; only the status
130 /// is left to look at.
131 fn j_closed_stderr(&self, args: &[&str]) -> i32 {
132 let (reader, writer) = std::io::pipe().unwrap();
133 drop(reader);
134 Command::new(j_bin())
135 .args(args)
136 .current_dir(&self.dir)
137 .env("XDG_CONFIG_HOME", &self.cfg)
138 .env("NO_COLOR", "1")
139 .stdin(Stdio::null())
140 .stdout(Stdio::null())
141 .stderr(writer)
142 .status()
143 .unwrap()
144 .code()
145 .unwrap_or(-1)
146 }
148 fn write(&self, path: &str, content: &str) {
149 std::fs::write(self.dir.join(path), content).unwrap();
150 }
152 fn read(&self, path: &str) -> String {
153 std::fs::read_to_string(self.dir.join(path)).unwrap()
154 }
157struct Out {
158 code: i32,
159 stdout: String,
160 stderr: String,
163impl Out {
164 fn ok(self) -> Self {
165 assert_eq!(self.code, 0, "stderr: {}", self.stderr);
166 self
167 }
170impl Drop for Repo {
171 fn drop(&mut self) {
172 let _ = std::fs::remove_dir_all(&self.dir);
173 let _ = std::fs::remove_dir_all(&self.cfg);
174 }
177#[test]
178fn no_args_is_usage_error() {
179 let r = setup();
180 let out = r.j(&[]);
181 assert_eq!(out.code, 2);
182 assert!(out.stderr.contains("usage"), "{}", out.stderr);
185#[test]
186fn both_stdin_and_args_rejected() {
187 let r = setup();
188 let out = r.j_stdin("1 + 1", &["extra"]);
189 assert_eq!(out.code, 2);
190 assert!(out.stderr.contains("both"), "{}", out.stderr);
193#[test]
194fn an_expression_that_is_not_utf8_is_a_usage_error() {
195 // §1: an argument that was not UTF-8 (a path with a Latin-1 byte)
196 // panicked in `std::env::args` and exited 101, and such bytes on stdin
197 // read as no input at all, so the run printed the usage line
198 use std::ffi::OsStr;
199 use std::io::Write;
200 use std::os::unix::ffi::OsStrExt;
201 let r = setup();
202 let run = |args: &[&OsStr], input: &[u8]| {
203 let mut child = Command::new(j_bin())
204 .args(args)
205 .current_dir(&r.dir)
206 .env("XDG_CONFIG_HOME", &r.cfg)
207 .env("NO_COLOR", "1")
208 .stdin(Stdio::piped())
209 .stdout(Stdio::piped())
210 .stderr(Stdio::piped())
211 .spawn()
212 .unwrap();
213 child.stdin.take().unwrap().write_all(input).unwrap();
214 let out = child.wait_with_output().unwrap();
215 Out {
216 code: out.status.code().unwrap_or(-1),
217 stdout: String::from_utf8_lossy(&out.stdout).to_string(),
218 stderr: String::from_utf8_lossy(&out.stderr).to_string(),
219 }
220 };
221 let message = OsStr::from_bytes(b"describe \"\xff\"");
222 for args in [vec![message], vec![OsStr::new("remote"), OsStr::from_bytes(b"../nu\xff/r.git")]] {
223 let out = run(&args, b"");
224 assert_eq!(out.code, 2, "{}", out.stderr);
225 assert!(out.stderr.starts_with("j: argument is not valid UTF-8: "), "{}", out.stderr);
226 assert_eq!(out.stderr.lines().count(), 1, "{}", out.stderr);
227 }
228 let out = run(&[], message.as_bytes());
229 assert_eq!(out.code, 2, "{}", out.stderr);
230 assert_eq!(out.stderr, "j: the expression on stdin is not valid UTF-8\n");
231 // input on stdin is input, whatever its bytes (§1 rule 3)
232 let out = run(&[OsStr::new("1")], message.as_bytes());
233 assert_eq!(out.code, 2, "{}", out.stderr);
234 assert!(out.stderr.contains("both"), "{}", out.stderr);
235 // nothing was described
236 assert_eq!(r.j(&["\\r -> show (focus r).message"]).ok().stdout, "\"\"\n");
239#[test]
240fn expression_from_stdin() {
241 let r = setup();
242 let out = r.j_stdin("1 + 2 * 3", &[]).ok();
243 assert_eq!(out.stdout.trim(), "7");
246#[test]
247fn arguments_joined_verbatim() {
248 let r = setup();
249 let out = r.j(&["1", "+", "2"]).ok();
250 assert_eq!(out.stdout.trim(), "3");
253#[test]
254fn parse_error_is_exit_3() {
255 let r = setup();
256 let out = r.j(&["1 +"]);
257 assert_eq!(out.code, 3);
258 assert!(out.stderr.starts_with("j: "), "{}", out.stderr);
261#[test]
262fn deep_nesting_is_a_parse_error() {
263 // §1.4: however deep the input, the process exits 3 with one `j:` line
264 // rather than aborting on a stack overflow in the parser
265 let r = setup();
266 let n = 100_000;
267 // a left-associative chain too: the parser builds it in a loop, but its
268 // tree is as tall as the chain is long, and resolving its id literals
269 // after the parse recursed that deep
270 for src in [
271 format!("{}1{}", "(".repeat(n), ")".repeat(n)),
272 "[".repeat(n),
273 format!("1{}", " + 1".repeat(1_000_000)),
274 ] {
275 let out = r.j_stdin(&src, &[]);
276 assert_eq!(out.code, 3, "{}", out.stderr);
277 assert!(out.stderr.starts_with("j: ") && out.stderr.lines().count() == 1, "{}", out.stderr);
278 }
281#[test]
282fn lists_nested_as_deep_as_they_parse_display() {
283 // §1.4, §3.4: input within the nesting bound is never a stack overflow,
284 // and the displayed result is no exception. Display recursed once per
285 // level of nested lists, at about 14 KB a level in this debug build, so
286 // a list literal 38,000 deep parsed and then aborted with exit 134 and
287 // no `j:` line, as did a list built that deep at run time
288 let r = setup();
289 let n = 40_000;
290 let out = r.j_stdin(&format!("{}{}", "[".repeat(n), "]".repeat(n)), &[]);
291 assert_eq!(out.code, 0, "{}", out.stderr);
292 // the innermost `[[]]` is a list of one path, the empty one (§5.1)
293 assert_eq!(out.stdout, "\n");
294 // `[[[[] 0] 1] …]`: any other list is one block per item (§5.1), and a
295 // list built at run time is not bounded by the parser
296 let n = 100_000;
297 let out = r.j(&[&format!("foldl (\\acc i -> [acc i]) [] (range 0 {})", n)]);
298 assert_eq!(out.code, 0, "{}", out.stderr);
299 let items: String = (0..n).map(|i| format!("\n{}\n", i)).collect();
300 assert!(out.stdout == format!("none\n{}", items), "{}…", &out.stdout[..40.min(out.stdout.len())]);
303#[test]
304fn crash_is_exit_1_with_trace() {
305 let r = setup();
306 let out = r.j(&["crash \"boom\""]);
307 assert_eq!(out.code, 1);
308 assert!(out.stderr.contains("j: crash: boom"), "{}", out.stderr);
309 assert!(out.stderr.contains("from crash \"boom\""), "{}", out.stderr);
312#[test]
313fn crash_trace_names_the_innermost_definition() {
314 // §1.4, §4.13, §5.1: the second line names the innermost definition that
315 // was executing, as the spec's own examples show. Nothing recorded one
316 // at run time, so every crash printed only `from EXPR`. A builtin is no
317 // definition, so a crash outside every definition names none.
318 let r = setup();
319 for (expr, trace) in [
320 ("goto %nope", " in goto, from goto %nope"),
321 ("describe 3", " in describe, from describe 3"),
322 ("new . goto %nope", " in goto, from new . goto %nope"),
323 ("squash 3", " in squash, from squash 3"),
324 // `prev = goto parents` runs goto's body, which raised the crash
325 ("prev . top", " in goto, from prev . top"),
326 ("crash \"boom\"", " from crash \"boom\""),
327 ] {
328 let out = r.j(&[expr]);
329 assert_eq!(out.code, 1, "{}: {}", expr, out.stderr);
330 assert_eq!(out.stderr.lines().nth(1), Some(trace), "{}: {}", expr, out.stderr);
331 }
334#[test]
335fn full_edit_flow() {
336 let r = setup();
337 r.write("a.txt", "one\n");
338 r.j(&["describe \"first\""]).ok();
339 r.j(&["new"]).ok();
340 r.write("b.txt", "two\n");
341 r.j(&["describe \"second\""]).ok();
342 let out = r.j(&["status"]).ok();
343 assert!(out.stdout.contains("second"), "{}", out.stdout);
344 assert!(out.stdout.contains("b.txt"), "{}", out.stdout);
345 r.j(&["prev"]).ok();
346 let out = r.j(&["status"]).ok();
347 assert!(out.stdout.contains("first"), "{}", out.stdout);
348 r.j(&["next"]).ok();
349 let out = r.j(&["here"]).ok();
350 assert!(!out.stdout.trim().is_empty());
353#[test]
354fn gitignore_is_honored() {
355 // §1.2: the snapshot tracks every file not matched by .gitignore
356 let r = setup();
357 r.write(".gitignore", "*.log\n");
358 r.j(&["new"]).ok();
359 r.write("secret.log", "ignored\n");
360 r.write("normal.txt", "tracked\n");
361 r.j(&["id"]).ok();
362 let out = r.j(&["status"]).ok();
363 assert!(out.stdout.contains("normal.txt"), "{}", out.stdout);
364 assert!(!out.stdout.contains("secret.log"), "{}", out.stdout);
367#[test]
368fn a_directory_replaced_by_a_file_inside_an_ignored_directory_is_recorded() {
369 // `build/a` holds tracked files when `.gitignore` comes to ignore
370 // `build/`, and is then replaced by a file. jj's scan reads an ignored
371 // directory only at the paths it tracks there, and took a path below
372 // the file for one it could not look at rather than one deleted, so
373 // every run that scanned exited 2, `undo` included, until the file was
374 // moved away (§7.4)
375 let r = setup();
376 std::fs::create_dir_all(r.dir.join("build/a/c")).unwrap();
377 r.write("build/a/b", "x\n");
378 r.write("build/a/c/d", "y\n");
379 r.write("build/keep", "k\n");
380 r.j(&["id"]).ok();
381 r.write(".gitignore", "build/\n");
382 r.j(&["id"]).ok();
383 std::fs::remove_dir_all(r.dir.join("build/a")).unwrap();
384 r.write("build/a", "f\n");
385 // the files below it are deleted; it is ignored, as a new file there is
386 let paths = "\\r -> show (map (.path) (files r))";
387 let left = "[[\".gitignore\"] [\"build\" \"keep\"]]";
388 assert_eq!(r.j(&[paths]).ok().stdout.trim(), left);
389 let out = r.j(&["undo"]);
390 assert_eq!(out.code, 1, "{}", out.stderr);
391 assert!(out.stderr.contains("working copy has changes not in @"), "{}", out.stderr);
392 r.j(&["id"]).ok();
393 assert_eq!(r.j(&[paths]).ok().stdout.trim(), left);
394 r.j(&["new"]).ok();
395 assert_eq!(r.j(&[paths]).ok().stdout.trim(), left);
396 assert_eq!(r.read("build/a"), "f\n");
397 assert_eq!(r.read("build/keep"), "k\n");
400#[test]
401fn dry_run_persists_nothing() {
402 let r = setup();
403 r.write("a.txt", "x\n");
404 r.j(&["describe \"work\""]).ok();
405 r.j(&["new"]).ok();
406 r.write("b.txt", "y\n");
407 let before = r.j(&["log"]).ok().stdout;
408 let out = r.j(&["tree . squash"]).ok();
409 assert!(!out.stdout.is_empty());
410 let after = r.j(&["log"]).ok().stdout;
411 assert_eq!(before, after, "dry run changed the repository");
414#[test]
415fn validate_catches_what_persistence_refuses() {
416 let r = setup();
417 r.write("a.txt", "x\n");
418 r.j(&["describe \"work\""]).ok();
419 // tree . validate . squash is an honest dry run
420 let out = r.j(&["tree . validate . squash"]).ok();
421 assert!(!out.stdout.is_empty());
424#[test]
425fn persisting_reads_no_tree_of_a_commit_it_keeps() {
426 // validation checked the paths of every commit's snapshot, and the walk
427 // compared every commit with its stored tree entry by entry, so each
428 // persisting run read the files of the whole history: `describe` took
429 // two minutes (a debug build) on 2,500 commits of 3,000 files. A commit
430 // whose files are still its stored tree's is now compared by tree (§7.5).
431 // Here A's tree is missing from the store, which a run that keeps A
432 // does not notice
433 let r = setup();
434 r.write("a.txt", "only in A\n");
435 r.j(&["describe \"A\""]).ok();
436 r.j(&["new"]).ok();
437 r.write("b.txt", "B\n");
438 r.j(&["describe \"B\""]).ok();
439 r.j(&["new"]).ok();
440 let hash = |kind, bytes: &[u8]| gix::objs::compute_hash(gix::hash::Kind::Sha1, kind, bytes).unwrap();
441 let mut tree = b"100644 a.txt\0".to_vec();
442 tree.extend_from_slice(hash(gix::objs::Kind::Blob, b"only in A\n").as_bytes());
443 let id = hash(gix::objs::Kind::Tree, &tree).to_string();
444 std::fs::remove_file(r.dir.join(".git/objects").join(&id[..2]).join(&id[2..])).unwrap();
445 r.j(&["describe \"C\""]).ok();
446 // a snapshot loads the history afresh
447 r.write("c.txt", "C\n");
448 r.j(&["describe \"C2\""]).ok();
449 r.j(&["new"]).ok();
450 // reading A's files still fails
451 let out = r.j(&[PATHS_BY_COMMIT]);
452 assert_eq!(out.code, 1, "{}", out.stderr);
455#[test]
456fn undo_and_redo() {
457 let r = setup();
458 r.write("a.txt", "x\n");
459 r.j(&["describe \"one\""]).ok();
460 let before = r.j(&["status"]).ok().stdout;
461 assert!(before.contains("one"));
462 r.j(&["undo"]).ok();
463 let after = r.j(&["status"]).ok().stdout;
464 assert!(!after.contains("one"), "{}", after);
465 r.j(&["redo"]).ok();
466 let restored = r.j(&["status"]).ok().stdout;
467 assert!(restored.contains("one"), "{}", restored);
470#[test]
471fn undo_refuses_dirty_working_copy() {
472 let r = setup();
473 r.write("a.txt", "x\n");
474 r.j(&["id"]).ok();
475 r.write("a.txt", "dirty\n");
476 let out = r.j(&["undo"]);
477 assert_eq!(out.code, 1);
478 assert!(out.stderr.contains("working copy has changes"), "{}", out.stderr);
481#[test]
482fn nothing_to_undo_or_redo() {
483 let r = setup();
484 let out = r.j(&["redo"]);
485 assert_eq!(out.code, 1);
486 assert!(out.stderr.contains("nothing to redo"), "{}", out.stderr);
489#[test]
490fn undo_after_init_refuses_cleanly() {
491 // undoing the operation that established the working copy would restore a
492 // view with no checked-out commit, leaving the repository unusable; it is
493 // reported as "nothing to undo", not a confusing error
494 let r = setup();
495 let out = r.j(&["undo"]);
496 assert_eq!(out.code, 1);
497 assert!(out.stderr.contains("nothing to undo"), "{}", out.stderr);
498 // and the repository is still usable afterwards
499 r.write("a.txt", "x\n");
500 r.j(&["describe \"still works\""]).ok();
501 assert!(r.j(&["status"]).ok().stdout.contains("still works"));
504#[test]
505fn ops_lists_operations() {
506 let r = setup();
507 r.write("a.txt", "x\n");
508 r.j(&["describe \"marker\""]).ok();
509 let out = r.j(&["ops"]).ok();
510 assert!(out.stdout.contains("describe \"marker\""), "{}", out.stdout);
511 // the current operation is marked
512 assert!(out.stdout.contains('*'), "{}", out.stdout);
513 // one line per operation, newest first
514 assert!(out.stdout.lines().count() >= 3, "{}", out.stdout);
517#[test]
518fn squash_and_abandon() {
519 let r = setup();
520 r.write("a.txt", "one\n");
521 r.j(&["describe \"base\""]).ok();
522 r.j(&["new"]).ok();
523 r.write("a.txt", "two\n");
524 r.j(&["describe \"fold me\""]).ok();
525 r.j(&["squash"]).ok();
526 let out = r.j(&["log"]).ok().stdout;
527 assert!(!out.contains("fold me"), "{}", out);
528 assert_eq!(r.read("a.txt"), "two\n");
531#[test]
532fn rebase_onto_sibling() {
533 let r = setup();
534 r.write("f.txt", "base\n");
535 r.j(&["describe \"base\""]).ok();
536 // branch from the parent of the focus
537 r.j(&["new"]).ok();
538 r.write("f.txt", "side\n");
539 r.j(&["describe \"side\""]).ok();
540 let out = r.j(&["tree"]).ok();
541 assert!(out.stdout.contains("side"), "{}", out.stdout);
544#[test]
545fn lock_blocks_second_j() {
546 // hold the flock ourselves, then a second j is refused
547 use std::os::unix::io::AsRawFd;
548 let r = setup();
549 let lock = std::fs::OpenOptions::new()
550 .create(true)
551 .write(true)
552 .open(r.dir.join(".jj/j.lock"))
553 .unwrap();
554 let rc = unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
555 assert_eq!(rc, 0);
556 let out = r.j(&["tree"]);
557 assert_eq!(out.code, 2);
558 assert!(out.stderr.contains("another j is running"), "{}", out.stderr);
559 unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_UN) };
560 r.j(&["tree"]).ok();
563#[test]
564fn reserved_words_as_first_word() {
565 let r = setup();
566 // `(push)` is an ordinary expression (unbound name)
567 let out = r.j(&["(push)"]);
568 assert_eq!(out.code, 1);
569 assert!(out.stderr.contains("unbound"), "{}", out.stderr);
570 // `x fetch` too
571 let out = r.j(&["x fetch"]);
572 assert_eq!(out.code, 1);
575#[test]
576fn id_resolution_errors() {
577 let r = setup();
578 let out = r.j(&["by @nope"]);
579 assert_eq!(out.code, 3); // lexical error: @ followed by non-k..z
580 let out = r.j(&["by @kqzz"]);
581 assert_eq!(out.code, 1); // no such commit: crash
582 assert!(out.stderr.contains("matches no commit"), "{}", out.stderr);
585#[test]
586fn goto_trunk_requires_new() {
587 let r = setup();
588 r.write("a", "1\n");
589 r.j(&["describe \"x\""]).ok();
590 // focusing the root commit is refused
591 let out = r.j(&["by @zzzzzzzz"]);
592 assert_eq!(out.code, 1);
593 assert!(out.stderr.contains("mutable"), "{}", out.stderr);
596#[test]
597fn text_result_prints_raw() {
598 let r = setup();
599 let out = r.j(&["\"literal text\""]).ok();
600 assert_eq!(out.stdout, "literal text\n");
601 let out = r.j(&["show [1 \"two\"]"]).ok();
602 assert!(out.stdout.contains("[1 \"two\"]"), "{}", out.stdout);
605#[test]
606fn review_says_no_changes_only_when_nothing_changed() {
607 // `review` caught every crash of `diffs` with `or`, so without difft on
608 // PATH it printed "no changes" over a change it could not render
609 let r = setup();
610 // a PATH holding nothing but what the test puts there (the config
611 // directory is removed with the repository)
612 let bin = r.cfg.join("bin");
613 std::fs::create_dir_all(&bin).unwrap();
614 let review = |path: &std::path::Path| {
615 let out = Command::new(j_bin())
616 .arg("review")
617 .current_dir(&r.dir)
618 .env("XDG_CONFIG_HOME", &r.cfg)
619 .env("NO_COLOR", "1")
620 .env("PATH", path)
621 .output()
622 .unwrap();
623 Out {
624 code: out.status.code().unwrap_or(-1),
625 stdout: String::from_utf8_lossy(&out.stdout).to_string(),
626 stderr: String::from_utf8_lossy(&out.stderr).to_string(),
627 }
628 };
629 // nothing to render: difft is never run
630 assert_eq!(review(&bin).ok().stdout, "no changes\n");
631 r.write("f", "hello\n");
632 let out = review(&bin);
633 assert_eq!(out.code, 1, "stdout: {}", out.stdout);
634 assert!(out.stderr.contains("cannot execute `difft`"), "{}", out.stderr);
635 use std::os::unix::fs::PermissionsExt;
636 // a difft that runs and fails renders what it printed, nothing here,
637 // and its complaint reaches the terminal instead of being dropped (§7.10)
638 let failing = r.cfg.join("failing");
639 std::fs::create_dir_all(&failing).unwrap();
640 let stub = failing.join("difft");
641 std::fs::write(&stub, "#!/bin/sh\necho 'error: invalid value for --color' >&2\nexit 2\n").unwrap();
642 std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
643 let out = review(&failing);
644 assert_eq!(out.code, 0, "{}", out.stderr);
645 assert_eq!(out.stderr, "error: invalid value for --color\n");
646 assert_eq!(out.stdout.trim(), "");
647 // with a difft, each changed path's rendering, in order
648 let stub = bin.join("difft");
649 std::fs::write(&stub, "#!/bin/sh\nprintf '%s -> %s\\n' \"${1##*/}\" \"${2##*/}\"\n").unwrap();
650 std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
651 r.write("g", "world\n");
652 assert_eq!(review(&bin).ok().stdout, "old-f -> new-f\nold-g -> new-g\n");
655#[test]
656fn closed_stdout_ends_the_display_quietly() {
657 // §1.4: a reader that stops early (`j tree | head`) is not a failure;
658 // writing into its closed pipe used to panic and exit 101
659 let r = setup();
660 r.write("a.txt", "x\n");
661 r.j(&["describe \"shown\""]).ok();
662 for expr in ["1 + 1", "tree", "range 0 200000"] {
663 let out = r.j_closed_stdout(&[expr]);
664 assert_eq!(out.code, 0, "{}: {}", expr, out.stderr);
665 assert_eq!(out.stderr, "", "{}", expr);
666 }
667 // the reserved command that prints writes the same way
668 let out = r.j_closed_stdout(&["ops"]);
669 assert_eq!(out.code, 0, "ops: {}", out.stderr);
670 assert_eq!(out.stderr, "");
671 // and the lock was released
672 assert!(r.j(&["ops"]).ok().stdout.contains("describe \"shown\""));
675#[test]
676fn closed_stderr_keeps_the_exit_status() {
677 // §1.4: a message stderr cannot take is dropped, and the run keeps its
678 // status; every error writer used to panic on the closed pipe instead,
679 // and the run exited 101
680 use std::os::unix::io::AsRawFd;
681 let r = setup();
682 for (args, code) in [
683 (&["crash \"boom\""][..], 1), // crash and its `from` line
684 (&["by @kqzz"], 1), // an unresolvable id
685 (&["1 +"], 3), // parse error
686 (&["push"], 2), // a reserved command's usage
687 (&[], 2), // no expression at all
688 ] {
689 assert_eq!(r.j_closed_stderr(args), code, "{:?}", args);
690 }
691 // the lock refusal is written by the backend
692 let lock = std::fs::OpenOptions::new()
693 .create(true)
694 .write(true)
695 .open(r.dir.join(".jj/j.lock"))
696 .unwrap();
697 assert_eq!(unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }, 0);
698 assert_eq!(r.j_closed_stderr(&["tree"]), 2);
699 drop(lock);
700 // the notice for a default config written on first use is not an error
701 std::fs::remove_file(r.cfg.join("j/config.j")).unwrap();
702 assert_eq!(r.j_closed_stderr(&["1 + 1"]), 0);
703 assert!(r.cfg.join("j/config.j").exists());
704 // and a config error is still one
705 std::fs::write(r.cfg.join("j/config.j"), "x =\n").unwrap();
706 assert_eq!(r.j_closed_stderr(&["1 + 1"]), 3);
709#[test]
710fn unwritable_stdout_is_a_one_line_error() {
711 // §1.4: any other write failure is an error line, not a panic
712 let full = std::path::Path::new("/dev/full");
713 if !full.exists() {
714 return;
715 }
716 let r = setup();
717 for args in [["1 + 1"], ["ops"]] {
718 let dev = std::fs::OpenOptions::new().write(true).open(full).unwrap();
719 let out = r.j_to(dev, &args);
720 assert_eq!(out.code, 1, "{:?}: {}", args, out.stderr);
721 assert!(out.stderr.starts_with("j: "), "{:?}: {}", args, out.stderr);
722 assert_eq!(out.stderr.lines().count(), 1, "{:?}: {}", args, out.stderr);
723 }
726#[test]
727fn crash_leaves_repo_untouched() {
728 let r = setup();
729 r.write("a.txt", "x\n");
730 r.j(&["describe \"stable\""]).ok();
731 let before = r.j(&["log"]).ok().stdout;
732 r.write("b.txt", "y\n");
733 let out = r.j(&["describe \"x\" . crash \"no\" . describe \"y\""]);
734 assert_eq!(out.code, 1);
735 let after = r.j(&["log"]).ok().stdout;
736 assert_eq!(before, after);
737 // the working directory is untouched too
738 assert_eq!(r.read("b.txt"), "y\n");
741#[test]
742fn unwritable_path_refused_before_anything_is_recorded() {
743 // these paths were written and the operation published; only the
744 // checkout then refused them, so every later persisting run recorded
745 // another operation and crashed the same way, and undo refused
746 let r = setup();
747 r.write("a.txt", "x\n");
748 r.j(&["describe \"stable\""]).ok();
749 let log = r.j(&["log"]).ok().stdout;
750 let ops = r.j(&["ops"]).ok().stdout.lines().count();
751 // longer than NAME_MAX (255 bytes): the checkout failed to stat it
752 let long = format!("[\"{}\"]", "x".repeat(256));
753 for bad in [
754 r#"[".." "evil"]"#,
755 r#"["x" ".."]"#,
756 r#"[".git" "hooks" "post-checkout"]"#,
757 r#"[".jj" "x"]"#,
758 long.as_str(),
759 ] {
760 let edit = format!(
761 "mapRoot (\\c -> c {{ files = c.files ++ [{{ path = {}, content = blob \"pwn\" }}] }})",
762 bad
763 );
764 // the dry run reports what persisting would
765 let out = r.j(&[&format!("tree . validate . {}", edit)]);
766 assert_eq!(out.code, 1, "validate accepted {}: {}", bad, out.stdout);
767 assert!(out.stderr.contains("path component"), "{}", out.stderr);
768 let out = r.j(&[&edit]);
769 assert_eq!(out.code, 1, "{}: {}", bad, out.stderr);
770 assert!(out.stderr.contains("path component"), "{}", out.stderr);
771 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops, "{} was recorded", bad);
772 assert_eq!(r.j(&["log"]).ok().stdout, log);
773 }
774 // nothing was created in the working directory, and the repo still works
775 assert!(!r.dir.join("x").exists());
776 r.j(&["describe \"after\""]).ok();
779#[test]
780fn names_the_filesystem_holds_are_accepted_at_any_length() {
781 // §7.5 step 1: 255 bytes is ext4's name limit, but exFAT, NTFS and APFS
782 // count 255 UTF-16 units or characters and hold a name of 100 `日`
783 // (300 bytes). With one in the working directory, every persisting run
784 // was refused for it, though the checkout never creates a name the
785 // directory holds, and a name the checkout would create was refused
786 // though the filesystem holds it. Where it does not, the name is still
787 // refused before anything is recorded.
788 let r = setup();
789 r.write("a.txt", "x\n");
790 r.j(&["describe \"stable\""]).ok();
791 let wide = "日".repeat(100);
792 let edit = |name: &str| {
793 format!(
794 "mapRoot (\\c -> c {{ files = c.files ++ [{{ path = [\"d\" \"{}\"], content = blob \"new\\n\" }}] }})",
795 name
796 )
797 };
798 if std::fs::write(r.dir.join(&wide), "on disk\n").is_ok() {
799 r.j(&["describe \"wide\""]).ok();
800 r.j(&["new"]).ok();
801 let wider = "日".repeat(101);
802 r.j(&[&format!("tree . validate . {}", edit(&wider))]).ok();
803 r.j(&[&edit(&wider)]).ok();
804 assert_eq!(r.read(&format!("d/{}", wider)), "new\n");
805 assert_eq!(r.read(&wide), "on disk\n");
806 } else {
807 let ops = r.j(&["ops"]).ok().stdout.lines().count();
808 let out = r.j(&[&edit(&wide)]);
809 assert_eq!(out.code, 1, "{}", out.stderr);
810 assert!(out.stderr.contains("path component"), "{}", out.stderr);
811 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops);
812 assert!(!r.dir.join("d").exists());
813 }
814 r.j(&["describe \"after\""]).ok();
817/// Each commit's message and content of `a`, top-down
818const CONTENT_OF_A: &str =
819 "\\r -> show (map (\\c -> [c.message (contentAt [\"a\"] c.files)]) (commits (top r)))";
821/// An expression showing, for the commit with each of `messages`, the text
822/// at each of `paths` (in the language's syntax), in that order: siblings
823/// made or rewritten by one operation are listed in no order of their own
824fn texts_by_message(paths: &[&str], messages: &[&str]) -> String {
825 let texts: Vec<String> = paths.iter().map(|p| format!("(text (contentAt {} f))", p)).collect();
826 let messages: Vec<String> = messages.iter().map(|m| format!("\"{}\"", m)).collect();
827 format!(
828 "\\r -> show (map (\\m -> let f = (focus (goto (matching (\\c -> c.message == m) all) r)).files in [{}]) [{}])",
829 texts.join(" "),
830 messages.join(" ")
831 )
834#[test]
835fn a_checkout_that_fails_is_recorded_and_undone() {
836 // §7.5 step 7, §7.7: the operation was published only once the checkout
837 // had completed, so a checkout the filesystem refused (a path longer
838 // than PATH_MAX here; a name longer than a smaller NAME_MAX, a full
839 // disk) was put back file by file, and a put-back can fail as well. The
840 // operation is now published first, as jj does, and a checkout that
841 // fails leaves it recorded: the crash says so, the next run takes what
842 // the checkout wrote for the focus's own, and `j undo` goes back.
843 let r = setup();
844 r.write("a", "p\n");
845 r.j(&["describe \"P\""]).ok();
846 r.j(&["new"]).ok();
847 r.write("a", "x1\n");
848 r.j(&["describe \"X\""]).ok();
849 r.write("a", "x2\n");
850 let log = r.j(&["log"]).ok().stdout;
851 let ops = r.j(&["ops"]).ok().stdout.lines().count();
852 // every component is a name a checkout can create (§7.5 step 1)
853 let deep = vec![format!("\"{}\"", "d".repeat(250)); 20].join(" ");
854 let edit = format!(
855 "\\r -> mapRoot (\\c -> c {{ files = c.files ++ [{{ path = [{}], content = blob \"deep\" }}] }}) (prev r)",
856 deep
857 );
858 let out = r.j(&[&edit]);
859 assert_eq!(out.code, 1, "{}", out.stderr);
860 assert!(out.stderr.contains("cannot check out"), "{}", out.stderr);
861 assert!(out.stderr.contains("the operation is recorded"), "{}", out.stderr);
862 assert!(out.stderr.contains("only partly updated"), "{}", out.stderr);
863 assert!(out.stderr.contains("`j undo` goes back"), "{}", out.stderr);
864 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
865 // the checkout wrote P's `a` before it failed
866 assert_eq!(r.read("a"), "p\n");
867 // which is not recorded into P, nor into X, which holds the edit the
868 // run recorded
869 r.j(&["id"]).ok();
870 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
871 let files = r.j(&[CONTENT_OF_A]).ok().stdout;
872 assert_eq!(files.trim(), r#"[["" (blob "")] ["P" (blob "p\n")] ["X" (blob "x2\n")]]"#);
873 r.j(&["undo"]).ok();
874 assert_eq!(r.j(&["log"]).ok().stdout, log);
877#[test]
878#[cfg(target_os = "linux")]
879fn an_undo_whose_checkout_fails_is_recorded_and_redone() {
880 // §7.7: `undo` and `redo` publish their operation before checking the
881 // restored focus out, as every run does (§7.5 step 7); a checkout that
882 // fails leaves it recorded, and `j redo` goes back
883 let r = setup();
884 r.write("a", "one\n");
885 // a file as deep as this workspace can hold (PATH_MAX, 4096 bytes with
886 // the NUL), and no deeper
887 let root = r.dir.canonicalize().unwrap();
888 let n = (4095 - root.as_os_str().len() - "/f".len()) / 251;
889 let deep: PathBuf = (0..n).map(|_| "d".repeat(250)).collect();
890 std::fs::create_dir_all(r.dir.join(&deep)).unwrap();
891 std::fs::write(r.dir.join(&deep).join("f"), "deep\n").unwrap();
892 r.j(&["describe \"deep\""]).ok();
893 r.j(&["new"]).ok();
894 std::fs::remove_dir_all(r.dir.join("d".repeat(250))).unwrap();
895 r.write("a", "two\n");
896 r.j(&["id"]).ok();
897 // 300 bytes further down, the workspace can no longer hold it
898 let aside = r.dir.with_extension("aside");
899 std::fs::rename(&r.dir, &aside).unwrap();
900 let moved = r.dir.join("m".repeat(150)).join("m".repeat(150));
901 std::fs::create_dir_all(moved.parent().unwrap()).unwrap();
902 std::fs::rename(&aside, &moved).unwrap();
903 let r = Repo { dir: moved, cfg: r.cfg.clone() };
904 // `ops` prints each operation's age, so count them rather than compare
905 let ops = r.j(&["ops"]).ok().stdout.lines().count();
906 let out = r.j(&["undo"]);
907 assert_eq!(out.code, 1, "{}", out.stderr);
908 assert!(out.stderr.contains("cannot check out"), "{}", out.stderr);
909 assert!(out.stderr.contains("the operation is recorded"), "{}", out.stderr);
910 assert!(out.stderr.contains("`j redo` goes back"), "{}", out.stderr);
911 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
912 // the checkout wrote `a` before it failed, which the next run takes for
913 // the restored focus's own
914 assert_eq!(r.read("a"), "one\n");
915 r.j(&["id"]).ok();
916 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
917 r.j(&["redo"]).ok();
918 assert_eq!(r.read("a"), "two\n");
919 r.j(&["id"]).ok();
920 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 2);
923/// Path components, in the language's syntax, of a path under `first` far
924/// longer than PATH_MAX, each a name a checkout can create (§7.5 step 1)
925fn too_deep(first: &str) -> String {
926 format!("\"{}\" {}", first, vec![format!("\"{}\"", "d".repeat(250)); 20].join(" "))
929#[test]
930fn a_failed_checkout_leaves_ignored_files_alone() {
931 // §7.5 step 7: a failed checkout was put back, which scanned the whole
932 // directory with the `.gitignore` the checkout had just written, and so
933 // could read into the store, or delete, an ignored file; a checkout
934 // that fails is now left as it is, for the next run to carry on from.
935 // An ignored file put where the focus adds one the checkout did not
936 // reach is left alone by the runs after it, which do not track it, and
937 // by `j undo`, which takes back only what was written. (One there
938 // before the run refuses it, before anything is recorded:
939 // `a_checkout_writes_nothing_over_what_the_directory_does_not_track`.)
940 let r = setup();
941 r.write(".gitignore", "*.env\n");
942 r.write("keep.txt", "k\n");
943 r.j(&["describe \"base\""]).ok();
944 let secret = "API_KEY=hunter2\n";
945 r.write("secret.env", secret);
946 let log = r.j(&["log"]).ok().stdout;
947 let ops = r.j(&["ops"]).ok().stdout.lines().count();
948 // written in path order: `.gitignore`, `build/out`, then the deep path
949 // under `c` fails, before `local.env`. Each record is parenthesised:
950 // side by side, the second would update the first (§3).
951 let edit = format!(
952 "mapRoot (\\c -> c {{ files = filter (\\e -> e.path /= [\".gitignore\"]) c.files ++ [\
953 ({{ path = [\".gitignore\"], content = blob \"*.env\\n*.log\\n\" }}) \
954 ({{ path = [\"build\" \"out\"], content = blob \"built\\n\" }}) \
955 ({{ path = [\"local.env\"], content = blob \"committed\\n\" }}) \
956 ({{ path = [{}], content = blob \"deep\" }})] }})",
957 too_deep("c")
958 );
959 let paths = r.j(&[&format!("\\r -> show (map (\\e -> e.path) (files (({}) r)))", edit)]).ok().stdout;
960 let paths = paths.split_whitespace().collect::<Vec<_>>().join(" ");
961 assert!(paths.starts_with(r#"[["keep.txt"] [".gitignore"] ["build" "out"] ["local.env"] ["c" "#), "{}", paths);
962 let out = r.j(&[&edit]);
963 assert_eq!(out.code, 1, "{}", out.stderr);
964 assert!(out.stderr.contains("cannot check out"), "{}", out.stderr);
965 assert!(out.stderr.contains("the operation is recorded"), "{}", out.stderr);
966 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
967 assert_eq!(r.read(".gitignore"), "*.env\n*.log\n");
968 assert_eq!(r.read("build/out"), "built\n");
969 assert_eq!(r.read("secret.env"), secret);
970 assert!(!r.dir.join("local.env").exists());
971 r.write("local.env", "mine\n");
972 for run in ["log", "id"] {
973 r.j(&[run]).ok();
974 assert!(!has_blob(&r, secret));
975 assert!(!has_blob(&r, "mine\n"));
976 }
977 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
978 r.j(&["undo"]).ok();
979 assert_eq!(r.read(".gitignore"), "*.env\n");
980 assert!(!r.dir.join("build").exists());
981 assert_eq!(r.read("secret.env"), secret);
982 assert_eq!(r.read("local.env"), "mine\n");
983 assert!(!has_blob(&r, secret));
984 assert!(!has_blob(&r, "mine\n"));
985 r.j(&["id"]).ok();
986 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 2);
987 assert_eq!(r.j(&["log"]).ok().stdout, log);
990#[test]
991fn undoing_a_failed_checkout_keeps_the_ignored_files_where_it_wrote() {
992 // §7.5 step 7, §7.7: `j undo` after a failed checkout removes the files
993 // it added and, as any checkout does, the directories that leaves
994 // empty; one holding an ignored file is not, and stays, file and all
995 let r = setup();
996 r.write(".gitignore", "*.log\n");
997 r.write("keep.txt", "k\n");
998 r.j(&["describe \"base\""]).ok();
999 std::fs::create_dir(r.dir.join("logs")).unwrap();
1000 r.write("logs/old.log", "old\n");
1001 let log = r.j(&["log"]).ok().stdout;
1002 let ops = r.j(&["ops"]).ok().stdout.lines().count();
1003 // `logs/x` is written, then the deep path fails
1004 let edit = format!(
1005 "mapRoot (\\c -> c {{ files = c.files ++ [\
1006 ({{ path = [\"logs\" \"x\"], content = blob \"x\\n\" }}) \
1007 ({{ path = [{}], content = blob \"deep\" }})] }})",
1008 too_deep("zz")
1009 );
1010 let out = r.j(&[&edit]);
1011 assert_eq!(out.code, 1, "{}", out.stderr);
1012 assert!(out.stderr.contains("cannot check out"), "{}", out.stderr);
1013 assert!(out.stderr.contains("the operation is recorded"), "{}", out.stderr);
1014 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
1015 assert_eq!(r.read("logs/x"), "x\n");
1016 r.j(&["undo"]).ok();
1017 assert!(!r.dir.join("logs/x").exists());
1018 assert_eq!(r.read("logs/old.log"), "old\n");
1019 r.j(&["id"]).ok();
1020 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 2);
1021 assert_eq!(r.j(&["log"]).ok().stdout, log);
1024/// The entries of `.jj` a checkout moved files aside into (`clear_the_way`)
1025fn aside_in_jj(r: &Repo) -> Vec<String> {
1026 std::fs::read_dir(r.dir.join(".jj"))
1027 .unwrap()
1028 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
1029 .filter(|name| name.starts_with("aside"))
1030 .collect()
1033#[test]
1034fn a_failed_checkout_puts_back_the_ignored_file_it_was_to_write_again() {
1035 // §7.4, §7.5 step 7: an untracked file holding just what the checkout
1036 // writes at a path the focus adds was removed before the checkout, to
1037 // be written again; a checkout that failed before it got there left it
1038 // removed, and `j undo` did not bring it back. It is moved aside now,
1039 // and put back where the checkout did not write.
1040 let r = setup();
1041 r.write(".gitignore", "*.env\n");
1042 r.write("keep.txt", "k\n");
1043 r.j(&["describe \"base\""]).ok();
1044 r.write("local.env", "A=1\n");
1045 let log = r.j(&["log"]).ok().stdout;
1046 let ops = r.j(&["ops"]).ok().stdout.lines().count();
1047 // the deep path under `a` fails, before `local.env`
1048 let edit = format!(
1049 "mapRoot (\\c -> c {{ files = c.files ++ [\
1050 ({{ path = [{}], content = blob \"deep\" }}) \
1051 ({{ path = [\"local.env\"], content = blob \"A=1\\n\" }})] }})",
1052 too_deep("a")
1053 );
1054 let out = r.j(&[&edit]);
1055 assert_eq!(out.code, 1, "{}", out.stderr);
1056 assert!(out.stderr.contains("cannot check out"), "{}", out.stderr);
1057 assert!(out.stderr.contains("the operation is recorded"), "{}", out.stderr);
1058 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
1059 assert_eq!(r.read("local.env"), "A=1\n");
1060 assert_eq!(aside_in_jj(&r), Vec::<String>::new());
1061 r.j(&["id"]).ok();
1062 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
1063 r.j(&["undo"]).ok();
1064 assert_eq!(r.read("local.env"), "A=1\n");
1065 assert_eq!(aside_in_jj(&r), Vec::<String>::new());
1066 assert_eq!(r.j(&["log"]).ok().stdout, log);
1067 // a checkout that completes writes it
1068 let edit = "mapRoot (\\c -> c { files = c.files ++ [{ path = [\"local.env\"], content = blob \"A=1\\n\" }] })";
1069 r.j(&[edit]).ok();
1070 assert_eq!(r.read("local.env"), "A=1\n");
1071 assert_eq!(r.j(&["\\r -> text (contentAt [\"local.env\"] (files r))"]).ok().stdout, "A=1\n");
1072 assert_eq!(aside_in_jj(&r), Vec::<String>::new());
1075#[test]
1076#[cfg(unix)]
1077fn a_checkout_names_the_file_it_cannot_move_out_of_its_way() {
1078 // §7.4: an untracked file holding just what the checkout writes at a
1079 // path the focus adds, which could not be removed for it, made jj skip
1080 // the path, and the checkout failed saying only that files were not
1081 // written, naming none, on every run that persisted. It names the file
1082 // now, and why it could not be moved. (A directory the user cannot
1083 // write to holds it here; root can.)
1084 if unsafe { libc::geteuid() } == 0 {
1085 return;
1087 use std::os::unix::fs::PermissionsExt;
1088 let r = setup();
1089 r.write(".gitignore", "*.env\n");
1090 r.write("keep.txt", "k\n");
1091 r.j(&["describe \"A\""]).ok();
1092 let adds = "mapRoot (\\c -> c { files = c.files ++ [{ path = [\"etc\" \"local.env\"], content = blob \"A=1\\n\" }] })";
1093 r.j(&[&format!("describe \"B\" . {} . new", adds)]).ok();
1094 r.j(&[&new_above("A")]).ok();
1095 std::fs::create_dir(r.dir.join("etc")).unwrap();
1096 r.write("etc/local.env", "A=1\n");
1097 let mode = |m| std::fs::set_permissions(r.dir.join("etc"), std::fs::Permissions::from_mode(m)).unwrap();
1098 mode(0o555);
1099 let out = r.j(&[&new_above("B")]);
1100 let again = r.j(&["describe \"again\""]);
1101 mode(0o755);
1102 for out in [out, again] {
1103 assert_eq!(out.code, 1, "{}", out.stderr);
1104 assert!(out.stderr.contains("`etc/local.env` was not written"), "{}", out.stderr);
1105 assert!(out.stderr.contains("could not be moved"), "{}", out.stderr);
1106 assert!(out.stderr.contains("Permission denied"), "{}", out.stderr);
1108 assert_eq!(r.read("etc/local.env"), "A=1\n");
1109 assert_eq!(aside_in_jj(&r), Vec::<String>::new());
1110 // once the directory lets it be moved, the run completes the checkout
1111 r.j(&["describe \"done\""]).ok();
1112 assert_eq!(r.read("etc/local.env"), "A=1\n");
1113 assert_eq!(aside_in_jj(&r), Vec::<String>::new());
1114 r.j(&["id"]).ok();
1115 let text = "\\r -> text (contentAt [\"etc\" \"local.env\"] (files r))";
1116 assert_eq!(r.j(&[text]).ok().stdout, "A=1\n");
1119/// An expression that goes to a new commit above the one whose message is
1120/// `message`
1121fn new_above(message: &str) -> String {
1122 format!("new . goto (matching (\\c -> c.message == \"{}\") all)", message)
1125/// The focus's paths
1126const FOCUS_PATHS: &str = "\\r -> show (map (\\e -> e.path) (files r))";
1128#[test]
1129#[cfg(unix)]
1130fn a_checkout_writes_nothing_over_what_the_directory_does_not_track() {
1131 // §7.4, §7.5 step 7: jj's checkout writes no file where something
1132 // stands already, and skips the path without failing, recording it as
1133 // written, so the next run read what stood there into the focus: an
1134 // ignored file (a secret, say) in place of the focus's content. The
1135 // run now refuses before anything is recorded or written, naming what
1136 // is in the way where the focus adds a file: an ignored symlink above
1137 // it, an ignored file, a directory in its place holding an ignored
1138 // file, each of them in one refusal.
1139 let r = setup();
1140 r.write(".gitignore", "*.env\nlnk\n");
1141 r.write("keep.txt", "k\n");
1142 r.j(&["describe \"A\""]).ok();
1143 let adds = "mapRoot (\\c -> c { files = c.files ++ [({ path = [\"lnk\" \"f\"], content = blob \"f\\n\" }) \
1144 ({ path = [\"local.env\"], content = blob \"COMMITTED=1\\n\" }) \
1145 ({ path = [\"out\"], content = blob \"out\\n\" })] })";
1146 r.j(&[&format!("describe \"B\" . {} . new", adds)]).ok();
1147 r.j(&[&new_above("A")]).ok();
1148 assert!(!r.dir.join("local.env").exists());
1149 // outside the working directory, and removed with the repository
1150 let outside = r.cfg.join("outside");
1151 std::fs::create_dir(&outside).unwrap();
1152 std::os::unix::fs::symlink(&outside, r.dir.join("lnk")).unwrap();
1153 let secret = "SECRET=hunter2\n";
1154 r.write("local.env", secret);
1155 std::fs::create_dir_all(r.dir.join("out/sub")).unwrap();
1156 r.write("out/junk.env", "junk\n");
1157 let log = r.j(&["log"]).ok().stdout;
1158 let ops = r.j(&["ops"]).ok().stdout.lines().count();
1159 let refused = |says: &[&str]| {
1160 let out = r.j(&[&new_above("B")]);
1161 assert_eq!(out.code, 1, "{}", out.stderr);
1162 for said in says {
1163 assert!(out.stderr.contains(said), "{}", out.stderr);
1165 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops);
1166 assert_eq!(r.j(&["log"]).ok().stdout, log);
1167 assert!(!has_blob(&r, secret));
1168 };
1169 let out_dir = "`out` (a directory holding untracked files, such as `out/junk.env`)";
1170 refused(&["in the way of files it has: `lnk`, `local.env` and ", out_dir, "; move them aside"]);
1171 assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 0);
1172 std::fs::remove_file(r.dir.join("lnk")).unwrap();
1173 refused(&["in the way of files it has: `local.env` and ", out_dir, "; move them aside"]);
1174 assert_eq!(r.read("local.env"), secret);
1175 r.j(&["id"]).ok();
1176 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops);
1177 assert!(!has_blob(&r, secret));
1178 let aside = r.cfg.join("local.env");
1179 std::fs::rename(r.dir.join("local.env"), &aside).unwrap();
1180 refused(&[
1181 "it has `out`, and `out` in the working directory is a directory holding untracked files, such as `out/junk.env`; move it aside",
1182 ]);
1183 assert_eq!(r.read("out/junk.env"), "junk\n");
1184 // with those moved aside, B's files are written, over the empty
1185 // directories left in `out`
1186 std::fs::remove_file(r.dir.join("out/junk.env")).unwrap();
1187 r.j(&[&new_above("B")]).ok();
1188 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
1189 assert_eq!(r.read("lnk/f"), "f\n");
1190 assert_eq!(r.read("local.env"), "COMMITTED=1\n");
1191 assert_eq!(r.read("out"), "out\n");
1192 r.j(&["id"]).ok();
1193 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
1194 let paths = r.j(&[FOCUS_PATHS]).ok().stdout;
1195 assert_eq!(
1196 paths.split_whitespace().collect::<Vec<_>>().join(" "),
1197 r#"[[".gitignore"] ["keep.txt"] ["lnk" "f"] ["local.env"] ["out"]]"#
1198 );
1199 assert_eq!(std::fs::read_to_string(&aside).unwrap(), secret);
1200 assert!(!has_blob(&r, secret));
1201 // `undo` checks out as persistence does (§7.7)
1202 r.j(&[&new_above("A")]).ok();
1203 std::fs::rename(&aside, r.dir.join("local.env")).unwrap();
1204 let ops = r.j(&["ops"]).ok().stdout.lines().count();
1205 let out = r.j(&["undo"]);
1206 assert_eq!(out.code, 1, "{}", out.stderr);
1207 assert!(out.stderr.contains("an untracked `local.env` in the working directory is in its way; move it aside"), "{}", out.stderr);
1208 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops);
1209 assert_eq!(r.read("local.env"), secret);
1210 std::fs::rename(r.dir.join("local.env"), &aside).unwrap();
1211 r.j(&["undo"]).ok();
1212 assert_eq!(r.read("local.env"), "COMMITTED=1\n");
1213 assert!(!has_blob(&r, secret));
1216#[test]
1217fn a_refused_checkout_names_all_that_is_in_its_way() {
1218 // §7.4: a checkout refused over what the working directory does not
1219 // track named only the first path in its way, so a directory of build
1220 // outputs took a run per file to clear; and for a directory standing
1221 // where the focus has a file, it named a file in it, the first a read
1222 // of the directory found, and said to move that aside. The refusal
1223 // lists what is in the way now, ten paths and how many more, and a
1224 // directory by its own path.
1225 let r = setup();
1226 let dist = |what: &str| {
1227 std::fs::create_dir(r.dir.join("dist")).unwrap();
1228 for i in 1..=12 {
1229 r.write(&format!("dist/m{:02}.js", i), &format!("{} {}\n", what, i));
1231 };
1232 dist("release");
1233 r.write("build", "#!/bin/sh\n");
1234 r.write("keep", "k\n");
1235 r.j(&["describe \"v1\""]).ok();
1236 r.j(&["new"]).ok();
1237 std::fs::remove_dir_all(r.dir.join("dist")).unwrap();
1238 std::fs::remove_file(r.dir.join("build")).unwrap();
1239 std::fs::create_dir(r.dir.join("build")).unwrap();
1240 r.write("build/main.c", "c\n");
1241 r.write(".gitignore", "dist/\n*.o\n");
1242 r.j(&["describe \"v2\""]).ok();
1243 r.j(&["new"]).ok();
1244 // a local build
1245 dist("dev");
1246 r.write("build/1.o", "o\n");
1247 r.write("build/2.o", "o\n");
1248 let log = r.j(&["log"]).ok().stdout;
1249 let ops = r.j(&["ops"]).ok().stdout.lines().count();
1250 let out = r.j(&[&new_above("v1")]);
1251 assert_eq!(out.code, 1, "{}", out.stderr);
1252 let listed = "in the way of files it has: `build` (a directory holding untracked files, such as `build/";
1253 assert!(out.stderr.contains(listed), "{}", out.stderr);
1254 for i in 1..=9 {
1255 assert!(out.stderr.contains(&format!(", `dist/m{:02}.js`", i)), "{}", out.stderr);
1257 assert!(!out.stderr.contains("m10"), "{}", out.stderr);
1258 assert!(out.stderr.contains("`dist/m09.js` and 3 more; move them aside"), "{}", out.stderr);
1259 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops);
1260 assert_eq!(r.j(&["log"]).ok().stdout, log);
1261 assert_eq!(r.read("dist/m12.js"), "dev 12\n");
1262 assert_eq!(r.read("build/2.o"), "o\n");
1263 // with them moved aside, v1's files are written
1264 std::fs::remove_dir_all(r.dir.join("dist")).unwrap();
1265 std::fs::remove_file(r.dir.join("build/1.o")).unwrap();
1266 std::fs::remove_file(r.dir.join("build/2.o")).unwrap();
1267 r.j(&[&new_above("v1")]).ok();
1268 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
1269 assert_eq!(r.read("build"), "#!/bin/sh\n");
1270 assert_eq!(r.read("dist/m12.js"), "release 12\n");
1273#[test]
1274fn a_checkout_clears_the_empty_directories_where_it_writes_a_file() {
1275 // §7.4: a checkout writes no file over a directory, and skips it
1276 // without failing: where a tool had left empty directories, the next
1277 // run took the focus's file for deleted. Only a checkout over a stale
1278 // working copy cleared such directories; every checkout does now,
1279 // those in a directory the focus replaces by a file included.
1280 let r = setup();
1281 r.write("build", "b\n");
1282 r.write("keep", "k\n");
1283 r.j(&["describe \"P\""]).ok();
1284 r.j(&["new"]).ok();
1285 std::fs::remove_file(r.dir.join("build")).unwrap();
1286 r.j(&["describe \"Q\""]).ok();
1287 std::fs::create_dir_all(r.dir.join("build/sub/deeper")).unwrap();
1288 r.j(&["new . goto parents"]).ok();
1289 assert_eq!(r.read("build"), "b\n");
1290 let ops = r.j(&["ops"]).ok().stdout.lines().count();
1291 r.j(&["id"]).ok();
1292 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops);
1293 assert_eq!(r.j(&[FOCUS_PATHS]).ok().stdout.trim(), r#"[["build"] ["keep"]]"#);
1294 // `lib` holds the tracked `lib/x` and empty directories, and the focus
1295 // has the file `lib`
1296 r.write("lib", "lib\n");
1297 r.j(&["describe \"L\""]).ok();
1298 r.j(&["new"]).ok();
1299 std::fs::remove_file(r.dir.join("lib")).unwrap();
1300 std::fs::create_dir(r.dir.join("lib")).unwrap();
1301 r.write("lib/x", "x\n");
1302 r.j(&["describe \"D\""]).ok();
1303 std::fs::create_dir_all(r.dir.join("lib/e/f")).unwrap();
1304 r.j(&[&new_above("L")]).ok();
1305 assert_eq!(r.read("lib"), "lib\n");
1306 let ops = r.j(&["ops"]).ok().stdout.lines().count();
1307 r.j(&["id"]).ok();
1308 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops);
1309 assert_eq!(r.j(&[FOCUS_PATHS]).ok().stdout.trim(), r#"[["build"] ["keep"] ["lib"]]"#);
1312#[test]
1313#[cfg(unix)]
1314fn a_checkout_clears_nothing_through_a_symlink() {
1315 // §7.4: clearing the empty directories where a checkout over a stale
1316 // working copy writes a file looked at each path through the symlinks
1317 // above it: `x`, a symlink to a directory outside the working directory
1318 // that the focus has as a directory, had it remove an empty tree the
1319 // symlink's target held
1320 let r = setup();
1321 // outside the working directory, and removed with the repository
1322 let outside = r.cfg.join("outside");
1323 std::fs::create_dir_all(outside.join("y/z/deeper")).unwrap();
1324 r.write("keep", "k\n");
1325 std::os::unix::fs::symlink(&outside, r.dir.join("x")).unwrap();
1326 r.j(&["describe \"A\""]).ok();
1327 r.j(&["new"]).ok();
1328 std::fs::remove_file(r.dir.join("x")).unwrap();
1329 std::fs::create_dir_all(r.dir.join("x/y")).unwrap();
1330 r.write("x/y/z", "z\n");
1331 r.j(&["describe \"B\""]).ok();
1332 r.j(&[&new_above("A")]).ok();
1333 assert!(r.dir.join("x").symlink_metadata().unwrap().is_symlink());
1334 let saved: Vec<(PathBuf, Vec<u8>)> = std::fs::read_dir(r.dir.join(".jj/working_copy"))
1335 .unwrap()
1336 .map(|e| e.unwrap().path())
1337 .map(|p| (p.clone(), std::fs::read(p).unwrap()))
1338 .collect();
1339 r.j(&[&new_above("B")]).ok();
1340 assert!(outside.join("y/z/deeper").is_dir());
1341 assert_eq!(r.read("x/y/z"), "z\n");
1342 // the same over a stale working copy, as jj leaves it when it moves the
1343 // working-copy commit without updating the working directory
1344 for (path, bytes) in saved {
1345 std::fs::write(path, bytes).unwrap();
1347 std::fs::remove_dir_all(r.dir.join("x")).unwrap();
1348 std::os::unix::fs::symlink(&outside, r.dir.join("x")).unwrap();
1349 r.j(&["describe \"B2\""]).ok();
1350 assert!(outside.join("y/z/deeper").is_dir());
1351 assert_eq!(r.read("x/y/z"), "z\n");
1352 assert!(r.dir.join("x").symlink_metadata().unwrap().is_dir());
1355/// Run `edit`, whose checkout writes `m/0` … `m/<n-1>` and more after them,
1356/// and call `meanwhile` with the process stopped while it writes `m`, once
1357/// its operation is published
1358#[cfg(target_os = "linux")]
1359fn stopped_mid_checkout(r: &Repo, edit: &str, n: usize, meanwhile: impl FnOnce()) -> Out {
1360 stopped_mid_checkout_as(r, edit, n, |_| meanwhile())
1363/// `stopped_mid_checkout`, calling `meanwhile` with the stopped process's
1364/// id, which may signal it. The process ignores SIGXFSZ, so that a write
1365/// past a file size limit set on it fails (EFBIG) rather than killing it,
1366/// and dumps no core. A process a signal ended has as its code minus that
1367/// signal's number.
1368#[cfg(target_os = "linux")]
1369fn stopped_mid_checkout_as(r: &Repo, edit: &str, n: usize, meanwhile: impl FnOnce(libc::pid_t)) -> Out {
1370 use std::os::unix::process::{CommandExt, ExitStatusExt};
1371 let mut command = Command::new(j_bin());
1372 command
1373 .arg(edit)
1374 .current_dir(&r.dir)
1375 .env("XDG_CONFIG_HOME", &r.cfg)
1376 .env("NO_COLOR", "1")
1377 .stdout(Stdio::piped())
1378 .stderr(Stdio::piped());
1379 // an ignored signal stays ignored across exec
1380 unsafe {
1381 command.pre_exec(|| {
1382 libc::signal(libc::SIGXFSZ, libc::SIG_IGN);
1383 libc::setrlimit(libc::RLIMIT_CORE, &libc::rlimit { rlim_cur: 0, rlim_max: 0 });
1384 Ok(())
1385 });
1387 let mut child = command.spawn().unwrap();
1388 let pid = child.id() as libc::pid_t;
1389 // `m` is written in path order, `0` then `1`, each file created empty
1390 // and removed once before it is written
1391 let (first, second) = (r.dir.join("m").join("0"), r.dir.join("m").join("1"));
1392 while !second.exists() {
1393 assert!(child.try_wait().unwrap().is_none(), "exited before writing m/1");
1394 std::thread::yield_now();
1396 let mut status = 0;
1397 unsafe {
1398 libc::kill(pid, libc::SIGSTOP);
1399 assert_eq!(libc::waitpid(pid, &mut status, libc::WUNTRACED), pid);
1401 assert!(libc::WIFSTOPPED(status), "exited before it was stopped");
1402 // all of `m` is written before anything after it
1403 let written = std::fs::read_dir(r.dir.join("m")).unwrap().count();
1404 let in_time = first.exists() && written < n;
1405 let done = if in_time {
1406 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| meanwhile(pid)))
1407 } else {
1408 Ok(())
1409 };
1410 unsafe { libc::kill(pid, if in_time { libc::SIGCONT } else { libc::SIGKILL }) };
1411 let out = child.wait_with_output().unwrap();
1412 assert!(in_time, "stopped too late: {} of {} written", written, n);
1413 if let Err(e) = done {
1414 std::panic::resume_unwind(e);
1416 Out {
1417 code: out.status.code().unwrap_or_else(|| -out.status.signal().unwrap()),
1418 stdout: String::from_utf8_lossy(&out.stdout).to_string(),
1419 stderr: String::from_utf8_lossy(&out.stderr).to_string(),
1423/// An edit giving the focus `files` (the language's syntax, over the focus
1424/// `c`), `m/0` … `m/<n-1>`, and a path the checkout fails on after those
1425#[cfg(target_os = "linux")]
1426fn slow_failing_edit(files: &str, n: usize) -> String {
1427 format!(
1428 "mapRoot (\\c -> c {{ files = {} ++ map (\\i -> {{ path = [\"m\" (show i)], content = blob \"m\\n\" }}) (range 0 {}) ++ [{{ path = [{}], content = blob \"deep\" }}] }})",
1429 files,
1430 n,
1431 too_deep("zz")
1435/// An edit giving the focus `files` (the language's syntax, over the focus
1436/// `c`), the empty files `m/0` … `m/<n-1>`, and `zz`, which is not empty:
1437/// a file created is written in full, and only `zz` is cut short by a
1438/// full disk (`fill_disk`)
1439#[cfg(target_os = "linux")]
1440fn slow_edit(files: &str, n: usize) -> String {
1441 format!(
1442 "mapRoot (\\c -> c {{ files = {} ++ map (\\i -> {{ path = [\"m\" (show i)], content = blob \"\" }}) (range 0 {}) ++ [{{ path = [\"zz\"], content = blob \"z\\n\" }}] }})",
1443 files, n
1447/// Leave the stopped process `pid` no room to write: from here on each
1448/// write that would make a file longer than nothing fails, as on a full
1449/// disk, while it can still create, empty and remove files
1450#[cfg(target_os = "linux")]
1451fn fill_disk(pid: libc::pid_t) {
1452 let none = libc::rlimit { rlim_cur: 0, rlim_max: 0 };
1453 assert_eq!(unsafe { libc::prlimit(pid, libc::RLIMIT_FSIZE, &none, std::ptr::null_mut()) }, 0);
1456/// The number of files in `m`
1457#[cfg(target_os = "linux")]
1458fn in_m(r: &Repo) -> usize {
1459 std::fs::read_dir(r.dir.join("m")).map_or(0, |d| d.count())
1462#[test]
1463#[cfg(target_os = "linux")]
1464fn an_interrupted_checkout_is_carried_on_by_the_next_run() {
1465 // §1.3, §7.5 step 7: the checkout ran before the operation was
1466 // published, so a run killed part way through it (SIGKILL here; a crash,
1467 // a power cut) left the working copy at the commit the user was on,
1468 // and the next run recorded into that commit the files the checkout had
1469 // written. The operation is now published first: the next run takes
1470 // those files for the focus's own, records only what changed meanwhile,
1471 // and a run that changes the repository finishes the checkout.
1472 let r = setup();
1473 r.write("keep.txt", "k\n");
1474 r.j(&["describe \"base\""]).ok();
1475 r.j(&["new"]).ok();
1476 r.write("work.txt", "my work\n");
1477 r.j(&["describe \"W\""]).ok();
1478 r.write("work.txt", "my work, edited\n");
1479 let ops = r.j(&["ops"]).ok().stdout.lines().count();
1480 let n = 2000;
1481 // S, a new child of base: `work.txt`, after `m`, is still there when
1482 // the run is killed
1483 let edit = format!("describe \"S\" . ({}) . new . prev", slow_edit("c.files", n));
1484 let out = stopped_mid_checkout_as(&r, &edit, n, |pid| {
1485 r.write("mine.txt", "mine\n");
1486 unsafe { libc::kill(pid, libc::SIGKILL) };
1487 });
1488 assert_eq!(out.code, -libc::SIGKILL, "{}", out.stderr);
1489 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
1490 assert!(in_m(&r) < n);
1491 assert_eq!(r.read("work.txt"), "my work, edited\n");
1492 // W holds the edit, and S only what was made meanwhile
1493 let paths = "\\r -> show (map (\\m -> map (\\e -> e.path) (restrict (neg (under ./m)) \
1494 (focus (goto (matching (\\c -> c.message == m) all) r)).files)) [\"base\" \"W\" \"S\"])";
1495 let paths = r.j(&[paths]).ok().stdout;
1496 assert_eq!(
1497 paths.split_whitespace().collect::<Vec<_>>().join(" "),
1498 r#"[[["keep.txt"]] [["keep.txt"] ["work.txt"]] [["keep.txt"] ["mine.txt"] ["zz"]]]"#
1499 );
1500 let text = "\\r -> text (contentAt [\"work.txt\"] (files (goto (matching (\\c -> c.message == \"W\") all) r)))";
1501 assert_eq!(r.j(&[text]).ok().stdout, "my work, edited\n");
1502 r.j(&["describe \"S2\""]).ok();
1503 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 2);
1504 assert!(!r.dir.join("work.txt").exists());
1505 assert_eq!(in_m(&r), n);
1506 assert_eq!(r.read("zz"), "z\n");
1507 assert_eq!(r.read("mine.txt"), "mine\n");
1508 r.j(&["id"]).ok();
1509 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 2);
1512#[test]
1513#[cfg(target_os = "linux")]
1514fn a_checkout_asked_to_stop_completes_before_the_run_stops() {
1515 // §1.3: with the operation recorded before its checkout, a run stopped
1516 // part way through the checkout by Ctrl-C (SIGINT), SIGTERM, SIGHUP or
1517 // SIGQUIT left the working directory holding neither the old focus nor
1518 // the new one. Such a signal now waits until the checkout and the
1519 // working copy's state are saved, and then stops the run as it would
1520 // have: the directory holds the focus, and nothing is left to carry on.
1521 for sig in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP, libc::SIGQUIT] {
1522 let r = setup();
1523 r.write("keep.txt", "k\n");
1524 r.j(&["describe \"base\""]).ok();
1525 r.j(&["new"]).ok();
1526 r.write("work.txt", "my work\n");
1527 r.j(&["describe \"W\""]).ok();
1528 r.write("work.txt", "my work, edited\n");
1529 let ops = r.j(&["ops"]).ok().stdout.lines().count();
1530 let n = 2000;
1531 // S, a new child of base: the checkout removes `work.txt` after it
1532 // has written `m`
1533 let edit = format!("describe \"S\" . ({}) . new . prev", slow_edit("c.files", n));
1534 let out = stopped_mid_checkout_as(&r, &edit, n, |pid| {
1535 assert_eq!(unsafe { libc::kill(pid, sig) }, 0);
1536 });
1537 assert_eq!(out.code, -sig, "signal {}: {}", sig, out.stderr);
1538 assert_eq!(in_m(&r), n, "signal {}", sig);
1539 assert_eq!(r.read("zz"), "z\n");
1540 assert!(!r.dir.join("work.txt").exists());
1541 assert_eq!(r.read("keep.txt"), "k\n");
1542 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
1543 r.j(&["id"]).ok();
1544 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1, "signal {}", sig);
1545 let texts = r.j(&[&texts_by_message(&["[\"work.txt\"]"], &["W"])]).ok().stdout;
1546 assert_eq!(
1547 texts.split_whitespace().collect::<Vec<_>>().join(" "),
1548 r#"[["my work, edited\n"]]"#
1549 );
1553#[test]
1554#[cfg(target_os = "linux")]
1555fn a_failed_checkout_keeps_what_changed_meanwhile() {
1556 // §7.5 step 7: the put-back rescanned the whole directory, so a file
1557 // edited or created while the checkout ran could be taken back with
1558 // what the checkout wrote. Nothing is put back now, and the next run
1559 // records what changed meanwhile into the focus, beside what the
1560 // checkout wrote and the file the full disk cut short.
1561 let r = setup();
1562 r.write("keep.txt", "k\n");
1563 r.j(&["describe \"base\""]).ok();
1564 let ops = r.j(&["ops"]).ok().stdout.lines().count();
1565 let n = 2000;
1566 let out = stopped_mid_checkout_as(&r, &slow_edit("c.files", n), n, |pid| {
1567 fill_disk(pid);
1568 r.write("keep.txt", "edited meanwhile\n");
1569 r.write("mine.txt", "mine\n");
1570 std::fs::create_dir(r.dir.join("notes")).unwrap();
1571 r.write("notes/todo.md", "my notes\n");
1572 });
1573 assert_eq!(out.code, 1, "{}", out.stderr);
1574 assert!(out.stderr.contains("cannot check out"), "{}", out.stderr);
1575 assert!(out.stderr.contains("the operation is recorded"), "{}", out.stderr);
1576 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
1577 assert_eq!(r.read("keep.txt"), "edited meanwhile\n");
1578 assert_eq!(r.read("mine.txt"), "mine\n");
1579 assert_eq!(r.read("notes/todo.md"), "my notes\n");
1580 r.j(&["id"]).ok();
1581 let texts = "\\r -> show (map (\\e -> [e.path (text e.content)]) (restrict (neg (under ./m)) (files r)))";
1582 let texts = r.j(&[texts]).ok().stdout;
1583 let texts = texts.split_whitespace().collect::<Vec<_>>().join(" ");
1584 assert!(
1585 texts.starts_with(r#"[[["keep.txt"] "edited meanwhile\n"] [["mine.txt"] "mine\n"] [["notes" "todo.md"] "my notes\n"] [["zz"] "#),
1586 "{}",
1587 texts
1588 );
1589 assert_eq!(in_m(&r), n);
1590 r.j(&["id"]).ok();
1591 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 2);
1594#[test]
1595#[cfg(target_os = "linux")]
1596fn a_failed_checkout_keeps_what_was_saved_meanwhile_where_it_wrote() {
1597 // §7.5 step 7: the put-back gave each path the focus changes back what
1598 // the directory held there, so a file saved meanwhile at such a path
1599 // after the checkout wrote it, or at a path the focus adds before the
1600 // checkout got there, was lost unless the put-back recognised what the
1601 // checkout had written. Nothing is put back now: the next run takes the
1602 // directory for a change to what it held when the checkout began, and
1603 // each such file is a conflict with the focus's content, neither lost.
1604 // So is the file the full disk cut short. (A file the focus changes,
1605 // saved before the checkout reaches it, is written over, as in jj.)
1606 let r = setup();
1607 r.write("a.txt", "a\n");
1608 r.write("b.txt", "b\n");
1609 r.j(&["describe \"base\""]).ok();
1610 r.write("b.txt", "b edited\n");
1611 let ops = r.j(&["ops"]).ok().stdout.lines().count();
1612 let n = 2000;
1613 // `a.txt` and `b.txt` are written before `m` and saved over after;
1614 // `n.txt`, which the focus adds after `m`, is saved before the
1615 // checkout gets there; then `zz` fails on the full disk
1616 let files = "[({ path = [\"a.txt\"], content = blob \"focus a\\n\" }) \
1617 ({ path = [\"b.txt\"], content = blob \"focus b\\n\" }) \
1618 ({ path = [\"n.txt\"], content = blob \"focus n\\n\" })]";
1619 let out = stopped_mid_checkout_as(&r, &slow_edit(files, n), n, |pid| {
1620 fill_disk(pid);
1621 r.write("a.txt", "mine a\n");
1622 r.write("b.txt", "mine b\n");
1623 r.write("n.txt", "mine n\n");
1624 });
1625 assert_eq!(out.code, 1, "{}", out.stderr);
1626 assert!(out.stderr.contains("cannot check out"), "{}", out.stderr);
1627 assert!(out.stderr.contains("the operation is recorded"), "{}", out.stderr);
1628 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
1629 assert_eq!(r.read("a.txt"), "mine a\n");
1630 assert_eq!(r.read("b.txt"), "mine b\n");
1631 assert_eq!(r.read("n.txt"), "mine n\n");
1632 assert_eq!(r.read("zz"), "");
1633 r.j(&["id"]).ok();
1634 let conflicted = r.j(&["\\r -> show (conflicted (files r))"]).ok().stdout;
1635 assert_eq!(conflicted.trim(), r#"[["a.txt"] ["b.txt"] ["n.txt"] ["zz"]]"#);
1636 for (path, mine, focus) in [("a.txt", "mine a", "focus a"), ("b.txt", "mine b", "focus b"), ("n.txt", "mine n", "focus n")] {
1637 let text = r.read(path);
1638 assert!(text.contains(mine) && text.contains(focus), "{}: {}", path, text);
1640 assert!(r.read("zz").contains("z\n"), "{}", r.read("zz"));
1641 assert_eq!(in_m(&r), n);
1642 r.j(&["id"]).ok();
1643 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 2);
1646/// Whether the repository's git store holds `content` as a loose object,
1647/// as each object j writes is
1648fn has_blob(r: &Repo, content: &str) -> bool {
1649 let id = gix::objs::compute_hash(gix::hash::Kind::Sha1, gix::objs::Kind::Blob, content.as_bytes())
1650 .unwrap()
1651 .to_string();
1652 r.dir.join(".git/objects").join(&id[..2]).join(&id[2..]).exists()
1655#[test]
1656#[cfg(target_os = "linux")]
1657fn an_ignored_file_saved_meanwhile_where_the_focus_adds_one_is_left_alone() {
1658 // §7.5 step 7: an ignored file saved meanwhile at a path the focus adds
1659 // is left alone. The put-back knew the file by its inode, so one an
1660 // editor saved meanwhile by renaming a new file over the one the
1661 // checkout wrote was taken for the checkout's own, read into the store
1662 // and deleted. Neither the failed checkout, which writes no file over
1663 // one saved before it reached the path, nor the runs after it read or
1664 // remove such a file; those the checkout wrote stay, ignored. (One
1665 // there before the run refuses it, before anything is recorded:
1666 // `a_checkout_writes_nothing_over_what_the_directory_does_not_track`.)
1667 let r = setup();
1668 r.write(".gitignore", "*.env\n");
1669 r.write("keep.txt", "k\n");
1670 r.j(&["describe \"base\""]).ok();
1671 let log = r.j(&["log"]).ok().stdout;
1672 let ops = r.j(&["ops"]).ok().stdout.lines().count();
1673 let n = 2000;
1674 // the checkout writes `k.env` and `local.env` before `m`, and `o.env`
1675 // and `p.env` after it
1676 let files = "c.files ++ [({ path = [\"k.env\"], content = blob \"committed k\\n\" }) \
1677 ({ path = [\"local.env\"], content = blob \"committed\\n\" }) \
1678 ({ path = [\"o.env\"], content = blob \"committed o\\n\" }) \
1679 ({ path = [\"p.env\"], content = blob \"committed p\\n\" })]";
1680 let saved = "SECRET=v2, saved meanwhile\n";
1681 let out = stopped_mid_checkout(&r, &slow_failing_edit(files, n), n, || {
1682 r.write("local.env.tmp", saved);
1683 std::fs::rename(r.dir.join("local.env.tmp"), r.dir.join("local.env")).unwrap();
1684 r.write("o.env.tmp", "committed");
1685 std::fs::rename(r.dir.join("o.env.tmp"), r.dir.join("o.env")).unwrap();
1686 });
1687 assert_eq!(out.code, 1, "{}", out.stderr);
1688 assert!(out.stderr.contains("cannot check out"), "{}", out.stderr);
1689 assert!(out.stderr.contains("the operation is recorded"), "{}", out.stderr);
1690 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
1691 let unchanged = |r: &Repo| {
1692 assert_eq!(r.read("k.env"), "committed k\n");
1693 assert_eq!(r.read("local.env"), saved);
1694 assert_eq!(r.read("o.env"), "committed");
1695 assert_eq!(r.read("p.env"), "committed p\n");
1696 assert!(!has_blob(r, saved));
1697 assert!(!has_blob(r, "committed"));
1698 };
1699 unchanged(&r);
1700 // the run wrote the focus's objects before its checkout
1701 assert!(has_blob(&r, "committed p\n"));
1702 r.j(&["log"]).ok();
1703 unchanged(&r);
1704 r.j(&["undo"]).ok();
1705 unchanged(&r);
1706 assert!(!r.dir.join("m").exists());
1707 r.j(&["id"]).ok();
1708 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 2);
1709 assert_eq!(r.j(&["log"]).ok().stdout, log);
1712#[test]
1713#[cfg(target_os = "linux")]
1714fn an_ignored_file_put_where_a_checkout_cut_short_adds_one_is_not_read() {
1715 // §7.4, §7.5 step 7: the next run that persisted after a checkout cut
1716 // short completed it, and jj skipped the path where an ignored file had
1717 // been put meanwhile, recording it as written: the run after that read
1718 // the file into the focus. That run refuses now, naming the file, and
1719 // no run reads it. What the checkout wrote at an ignored path before it
1720 // was cut short, the focus's own, is written again.
1721 let r = setup();
1722 r.write(".gitignore", "*.env\n");
1723 r.write("keep.txt", "k\n");
1724 r.j(&["describe \"base\""]).ok();
1725 let ops = r.j(&["ops"]).ok().stdout.lines().count();
1726 let n = 2000;
1727 // `k.env` is written before `m`, `o.env` after it
1728 let files = "c.files ++ [({ path = [\"k.env\"], content = blob \"committed k\\n\" }) \
1729 ({ path = [\"o.env\"], content = blob \"committed\\n\" })]";
1730 let secret = "SECRET=hunter2\n";
1731 let out = stopped_mid_checkout_as(&r, &slow_edit(files, n), n, |pid| {
1732 r.write("o.env", secret);
1733 unsafe { libc::kill(pid, libc::SIGKILL) };
1734 });
1735 assert_eq!(out.code, -libc::SIGKILL, "{}", out.stderr);
1736 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
1737 assert!(in_m(&r) < n);
1738 assert_eq!(r.read("k.env"), "committed k\n");
1739 for run in ["log", "id"] {
1740 r.j(&[run]).ok();
1741 assert!(!has_blob(&r, secret));
1743 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
1744 let out = r.j(&["describe \"S\""]);
1745 assert_eq!(out.code, 1, "{}", out.stderr);
1746 assert!(out.stderr.contains("untracked `o.env`"), "{}", out.stderr);
1747 assert!(out.stderr.contains("move it aside"), "{}", out.stderr);
1748 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
1749 assert_eq!(r.read("o.env"), secret);
1750 r.j(&["id"]).ok();
1751 assert!(!has_blob(&r, secret));
1752 // moved aside, the run completes the checkout
1753 let aside = r.cfg.join("o.env");
1754 std::fs::rename(r.dir.join("o.env"), &aside).unwrap();
1755 r.j(&["describe \"S\""]).ok();
1756 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 2);
1757 assert_eq!(r.read("k.env"), "committed k\n");
1758 assert_eq!(r.read("o.env"), "committed\n");
1759 assert_eq!(in_m(&r), n);
1760 assert_eq!(r.read("zz"), "z\n");
1761 r.j(&["id"]).ok();
1762 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 2);
1763 assert!(!has_blob(&r, secret));
1766#[test]
1767#[cfg(target_os = "linux")]
1768fn a_file_a_checkout_cut_short_moved_aside_is_put_back_by_the_next_run() {
1769 // §7.4, §7.5 step 7: an untracked file holding just what the checkout
1770 // writes at a path the focus adds is moved aside into `.jj` for the
1771 // checkout, and put back where it does not get to write; a run killed
1772 // meanwhile leaves it there, which the next run puts back, and `j undo`
1773 // leaves it where it was
1774 let r = setup();
1775 r.write(".gitignore", "*.env\n");
1776 r.write("keep.txt", "k\n");
1777 r.j(&["describe \"base\""]).ok();
1778 r.write("o.env", "committed\n");
1779 let log = r.j(&["log"]).ok().stdout;
1780 let ops = r.j(&["ops"]).ok().stdout.lines().count();
1781 let n = 2000;
1782 // `o.env` is written after `m`
1783 let files = "c.files ++ [{ path = [\"o.env\"], content = blob \"committed\\n\" }]";
1784 let out = stopped_mid_checkout_as(&r, &slow_edit(files, n), n, |pid| {
1785 unsafe { libc::kill(pid, libc::SIGKILL) };
1786 });
1787 assert_eq!(out.code, -libc::SIGKILL, "{}", out.stderr);
1788 assert!(in_m(&r) < n);
1789 assert!(!r.dir.join("o.env").exists());
1790 assert_eq!(aside_in_jj(&r).len(), 1);
1791 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
1792 assert_eq!(r.read("o.env"), "committed\n");
1793 assert_eq!(aside_in_jj(&r), Vec::<String>::new());
1794 r.j(&["undo"]).ok();
1795 assert_eq!(r.read("o.env"), "committed\n");
1796 assert!(!r.dir.join("m").exists());
1797 assert_eq!(aside_in_jj(&r), Vec::<String>::new());
1798 assert_eq!(r.j(&["log"]).ok().stdout, log);
1801#[test]
1802#[cfg(target_os = "linux")]
1803fn a_file_moved_aside_that_a_full_disk_cut_short_is_put_back_whole() {
1804 // §7.4, §7.5 step 7: an untracked file holding just what the checkout
1805 // writes at a path the focus adds was moved aside for the checkout,
1806 // and removed once it returned wherever something stood at its path.
1807 // A write there that a full disk cut short left the ignored file,
1808 // which no run reads, empty: `j undo` left it so, and the checkout,
1809 // done again, was refused, the file being in its way. The file moved
1810 // aside is put back over the start of it the checkout wrote now.
1811 let r = setup();
1812 r.write(".gitignore", "zz\n");
1813 r.write("keep.txt", "k\n");
1814 r.j(&["describe \"base\""]).ok();
1815 r.write("zz", "z\n");
1816 let log = r.j(&["log"]).ok().stdout;
1817 let ops = r.j(&["ops"]).ok().stdout.lines().count();
1818 let n = 2000;
1819 // `zz`, written after `m`, is left empty
1820 let out = stopped_mid_checkout_as(&r, &slow_edit("c.files", n), n, fill_disk);
1821 assert_eq!(out.code, 1, "{}", out.stderr);
1822 assert!(out.stderr.contains("Failed to write the content to the file"), "{}", out.stderr);
1823 assert!(out.stderr.contains("the operation is recorded"), "{}", out.stderr);
1824 assert_eq!(in_m(&r), n);
1825 assert_eq!(r.read("zz"), "z\n");
1826 assert_eq!(aside_in_jj(&r), Vec::<String>::new());
1827 r.j(&["id"]).ok();
1828 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
1829 assert_eq!(r.read("zz"), "z\n");
1830 r.j(&["undo"]).ok();
1831 assert_eq!(r.read("zz"), "z\n");
1832 assert!(!r.dir.join("m").exists());
1833 assert_eq!(r.j(&["log"]).ok().stdout, log);
1834 // done again, the checkout completes
1835 r.j(&["redo"]).ok();
1836 assert_eq!(in_m(&r), n);
1837 assert_eq!(r.read("zz"), "z\n");
1838 assert_eq!(aside_in_jj(&r), Vec::<String>::new());
1841#[test]
1842#[cfg(target_os = "linux")]
1843fn a_file_moved_aside_that_a_killed_run_left_a_start_of_is_put_back_whole() {
1844 // §7.4, §7.5 step 7: a run killed while its checkout wrote a file it
1845 // had moved aside leaves a start of it at its path (jj's checkout
1846 // makes the file, empty, before it writes it, and a symlink's path is
1847 // tried with an empty file first), and the next run, finding something
1848 // there, removed the whole file it had moved aside, leaving the ignored
1849 // start of it, which no run reads. It puts the file back over a start
1850 // of itself now, and anything else standing there stays as it is.
1851 let r = setup();
1852 r.write("keep.txt", "k\n");
1853 r.j(&["describe \"base\""]).ok();
1854 // B tracks `t.env`, a symlink, which `.gitignore` ignores
1855 r.j(&["new"]).ok();
1856 std::os::unix::fs::symlink("target", r.dir.join("t.env")).unwrap();
1857 r.j(&["describe \"S\""]).ok();
1858 r.j(&["new"]).ok();
1859 r.write(".gitignore", "*.env\n");
1860 r.j(&["describe \"B\""]).ok();
1861 r.j(&[&new_above("base")]).ok();
1862 r.write(".gitignore", "*.env\n");
1863 r.j(&["describe \"A\""]).ok();
1864 let names = ["o.env", "p.env", "q.env", "r.env"];
1865 for name in names {
1866 r.write(name, "committed\n");
1868 std::os::unix::fs::symlink("target", r.dir.join("t.env")).unwrap();
1869 let log = r.j(&["log"]).ok().stdout;
1870 let ops = r.j(&["ops"]).ok().stdout.lines().count();
1871 let n = 2000;
1872 // each `.env` is written after `m`
1873 let files = "c.files ++ map (\\p -> { path = [p], content = blob \"committed\\n\" }) [\"o.env\" \"p.env\" \"q.env\" \"r.env\"]";
1874 let edit = format!("{} . {}", slow_edit(files, n), new_above("B"));
1875 let out = stopped_mid_checkout_as(&r, &edit, n, |pid| {
1876 unsafe { libc::kill(pid, libc::SIGKILL) };
1877 });
1878 assert_eq!(out.code, -libc::SIGKILL, "{}", out.stderr);
1879 assert!(in_m(&r) < n);
1880 assert_eq!(aside_in_jj(&r).len(), 1);
1881 for name in names.iter().chain(&["t.env"]) {
1882 assert!(r.dir.join(name).symlink_metadata().is_err(), "{}", name);
1884 // what a kill while each was written leaves, and what the user saved
1885 // there since, which differs, or goes on after it
1886 r.write("o.env", "comm");
1887 r.write("p.env", "");
1888 r.write("t.env", "");
1889 r.write("q.env", "mine\n");
1890 r.write("r.env", "committed\nand more\n");
1891 let kept = |r: &Repo| {
1892 assert_eq!(r.read("o.env"), "committed\n");
1893 assert_eq!(r.read("p.env"), "committed\n");
1894 assert_eq!(std::fs::read_link(r.dir.join("t.env")).unwrap(), PathBuf::from("target"));
1895 assert_eq!(r.read("q.env"), "mine\n");
1896 assert_eq!(r.read("r.env"), "committed\nand more\n");
1897 assert_eq!(aside_in_jj(r), Vec::<String>::new());
1898 };
1899 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
1900 kept(&r);
1901 r.j(&["undo"]).ok();
1902 kept(&r);
1903 assert!(!r.dir.join("m").exists());
1904 assert_eq!(r.j(&["log"]).ok().stdout, log);
1907#[test]
1908#[cfg(target_os = "linux")]
1909fn a_start_of_a_file_moved_aside_saved_meanwhile_stays_where_the_checkout_returns() {
1910 // §7.4, §7.5 step 7: a checkout that returns, having skipped a path,
1911 // wrote no file only in part, so a start of the file moved aside for
1912 // it, saved at its path while the checkout ran, is the user's, and
1913 // stays, holding what it held, as does anything else saved there
1914 let r = setup();
1915 r.write(".gitignore", "*.env\n");
1916 r.write("keep.txt", "k\n");
1917 r.j(&["describe \"base\""]).ok();
1918 r.write("o.env", "committed\n");
1919 let log = r.j(&["log"]).ok().stdout;
1920 let n = 2000;
1921 // `o.env` is written after `m`
1922 let files = "c.files ++ [{ path = [\"o.env\"], content = blob \"committed\\n\" }]";
1923 let out = stopped_mid_checkout(&r, &slow_edit(files, n), n, || {
1924 assert!(!r.dir.join("o.env").exists());
1925 r.write("o.env", "comm");
1926 });
1927 assert_eq!(out.code, 1, "{}", out.stderr);
1928 assert!(out.stderr.contains("`o.env` was not written"), "{}", out.stderr);
1929 assert!(out.stderr.contains("the operation is recorded"), "{}", out.stderr);
1930 assert_eq!(in_m(&r), n);
1931 assert_eq!(r.read("o.env"), "comm");
1932 assert_eq!(aside_in_jj(&r), Vec::<String>::new());
1933 r.j(&["undo"]).ok();
1934 assert_eq!(r.read("o.env"), "comm");
1935 assert_eq!(r.j(&["log"]).ok().stdout, log);
1938#[test]
1939#[cfg(target_os = "linux")]
1940fn an_ignored_file_saved_meanwhile_where_a_checkout_adds_one_fails_it() {
1941 // §7.4, §7.5 step 7: a file saved while the checkout runs, at a path
1942 // it adds after that, is in its way, and jj skipped the path, recording
1943 // it as written: the next run read an ignored one into the focus. The
1944 // checkout fails now, naming the path, and leaves the working copy
1945 // stale, so no run reads the file.
1946 let r = setup();
1947 r.write(".gitignore", "*.env\n");
1948 r.write("keep.txt", "k\n");
1949 r.j(&["describe \"base\""]).ok();
1950 let log = r.j(&["log"]).ok().stdout;
1951 let ops = r.j(&["ops"]).ok().stdout.lines().count();
1952 let n = 2000;
1953 // `o.env` is written after `m`
1954 let files = "c.files ++ [{ path = [\"o.env\"], content = blob \"committed\\n\" }]";
1955 let secret = "SECRET=hunter2\n";
1956 let out = stopped_mid_checkout(&r, &slow_edit(files, n), n, || r.write("o.env", secret));
1957 assert_eq!(out.code, 1, "{}", out.stderr);
1958 assert!(out.stderr.contains("`o.env`"), "{}", out.stderr);
1959 assert!(out.stderr.contains("the operation is recorded"), "{}", out.stderr);
1960 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
1961 // the checkout carried on past it
1962 assert_eq!(in_m(&r), n);
1963 assert_eq!(r.read("zz"), "z\n");
1964 assert_eq!(r.read("o.env"), secret);
1965 for run in ["log", "id"] {
1966 r.j(&[run]).ok();
1967 assert!(!has_blob(&r, secret));
1969 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
1970 let text = "\\r -> text (contentAt [\"o.env\"] (files r))";
1971 assert_eq!(r.j(&[text]).ok().stdout, "committed\n");
1972 let out = r.j(&["describe \"S\""]);
1973 assert_eq!(out.code, 1, "{}", out.stderr);
1974 assert!(out.stderr.contains("untracked `o.env`"), "{}", out.stderr);
1975 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
1976 r.j(&["undo"]).ok();
1977 assert_eq!(r.j(&["log"]).ok().stdout, log);
1978 assert!(!r.dir.join("m").exists());
1979 assert_eq!(r.read("o.env"), secret);
1980 r.j(&["id"]).ok();
1981 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 2);
1982 assert!(!has_blob(&r, secret));
1985/// Run `edit`, which must take a while to evaluate, over a directory with a
1986/// change to record, and call `meanwhile` with the process stopped once the
1987/// snapshot's operation is written, before it publishes one
1988#[cfg(target_os = "linux")]
1989fn stopped_before_publish(r: &Repo, edit: &str, meanwhile: impl FnOnce()) -> Out {
1990 let ops_dir = r.dir.join(".jj/repo/op_store/operations");
1991 let heads_dir = r.dir.join(".jj/repo/op_heads/heads");
1992 let count = || std::fs::read_dir(&ops_dir).unwrap().count();
1993 let heads = || {
1994 let mut names: Vec<_> = std::fs::read_dir(&heads_dir).unwrap().map(|e| e.unwrap().file_name()).collect();
1995 names.sort();
1996 names
1997 };
1998 let (before, published) = (count(), heads());
1999 let mut child = Command::new(j_bin())
2000 .arg(edit)
2001 .current_dir(&r.dir)
2002 .env("XDG_CONFIG_HOME", &r.cfg)
2003 .env("NO_COLOR", "1")
2004 .stdout(Stdio::piped())
2005 .stderr(Stdio::piped())
2006 .spawn()
2007 .unwrap();
2008 let pid = child.id() as libc::pid_t;
2009 while count() == before {
2010 assert!(child.try_wait().unwrap().is_none(), "exited before its snapshot");
2011 std::thread::yield_now();
2013 let mut status = 0;
2014 unsafe {
2015 libc::kill(pid, libc::SIGSTOP);
2016 assert_eq!(libc::waitpid(pid, &mut status, libc::WUNTRACED), pid);
2018 assert!(libc::WIFSTOPPED(status), "exited before it was stopped");
2019 let in_time = heads() == published;
2020 let done = if in_time {
2021 std::panic::catch_unwind(std::panic::AssertUnwindSafe(meanwhile))
2022 } else {
2023 Ok(())
2024 };
2025 unsafe { libc::kill(pid, if in_time { libc::SIGCONT } else { libc::SIGKILL }) };
2026 let out = child.wait_with_output().unwrap();
2027 assert!(in_time, "stopped too late: an operation was published");
2028 if let Err(e) = done {
2029 std::panic::resume_unwind(e);
2031 Out {
2032 code: out.status.code().unwrap_or(-1),
2033 stdout: String::from_utf8_lossy(&out.stdout).to_string(),
2034 stderr: String::from_utf8_lossy(&out.stderr).to_string(),
2038#[test]
2039#[cfg(target_os = "linux")]
2040fn a_run_that_fails_to_publish_writes_nothing() {
2041 // §7.5 step 7: the operation is published before the checkout, so one
2042 // that fails to publish records nothing and writes nothing: the
2043 // directory, its ignored files and its uncommitted edit are as they
2044 // were, and none of the ignored files is read into the store
2045 let r = setup();
2046 r.write(".gitignore", "*.env\n");
2047 r.write("keep.txt", "k\n");
2048 r.j(&["describe \"base\""]).ok();
2049 let secret = "SECRET=hunter2\n";
2050 r.write("local.env", secret);
2051 r.write("keep.txt", "my uncommitted edit\n");
2052 let log = r.j(&["log"]).ok().stdout;
2053 let ops = r.j(&["ops"]).ok().stdout.lines().count();
2054 // an ignored path the focus adds, where nothing stands: one where
2055 // `local.env` stands would refuse the run before it tries to publish
2056 let edit = "mapRoot (\\c -> c { message = show (foldl (+) 0 (range 0 300000)), \
2057 files = c.files ++ [({ path = [\"b\"], content = blob \"b\\n\" }) \
2058 ({ path = [\"other.env\"], content = blob \"committed\\n\" })] })";
2059 // a file where jj keeps its operation heads: publishing fails
2060 let heads = r.dir.join(".jj/repo/op_heads/heads");
2061 let aside = heads.with_extension("aside");
2062 let out = stopped_before_publish(&r, edit, || {
2063 std::fs::rename(&heads, &aside).unwrap();
2064 std::fs::write(&heads, "").unwrap();
2065 });
2066 std::fs::remove_file(&heads).unwrap();
2067 std::fs::rename(&aside, &heads).unwrap();
2068 assert_eq!(out.code, 1, "{}", out.stderr);
2069 assert!(out.stderr.contains("cannot publish"), "{}", out.stderr);
2070 assert!(!out.stderr.contains("recorded"), "{}", out.stderr);
2071 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops);
2072 assert_eq!(r.j(&["log"]).ok().stdout, log);
2073 assert_eq!(r.read("local.env"), secret);
2074 assert_eq!(r.read("keep.txt"), "my uncommitted edit\n");
2075 assert!(!r.dir.join("b").exists());
2076 assert!(!r.dir.join("other.env").exists());
2077 assert!(!has_blob(&r, secret));
2078 // the run wrote the focus's objects before it tried to publish
2079 assert!(has_blob(&r, "committed\n"));
2080 r.j(&["id"]).ok();
2081 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
2082 let text = r.j(&["\\r -> text (contentAt [\"keep.txt\"] (files r))"]).ok().stdout;
2083 assert_eq!(text, "my uncommitted edit\n");
2086#[test]
2087#[cfg(target_os = "linux")]
2088fn a_file_saved_while_the_program_runs_is_not_written_over() {
2089 // §7.5 step 7: the checkout's scan found a file saved after the run's
2090 // snapshot, while the program ran, and took the directory to hold the
2091 // snapshot there all the same; where the focus has that file otherwise,
2092 // the checkout wrote the focus's over the edit, which no snapshot or
2093 // operation held. Such a run now crashes before its operation is
2094 // recorded, naming the path, and writes nothing, and the next run
2095 // records the edit. One saved where the focus has what the snapshot
2096 // read is left, as before, for the next run to record.
2097 let r = setup();
2098 r.write("a", "base\n");
2099 r.write("same.txt", "same\n");
2100 r.j(&["describe \"P\""]).ok();
2101 r.j(&["new"]).ok();
2102 r.write("a", "child\n");
2103 r.j(&["describe \"C\""]).ok();
2104 // a change for the run's snapshot to record, whose operation
2105 // `stopped_before_publish` waits for
2106 r.write("keep.txt", "uncommitted\n");
2107 let ops = r.j(&["ops"]).ok().stdout.lines().count();
2108 // back to P, which has `a` otherwise, after a slow evaluation
2109 let edit = "prev . mapRoot (\\c -> c { message = show (foldl (+) 0 (range 0 300000)) })";
2110 let out = stopped_before_publish(&r, edit, || r.write("a", "saved meanwhile\n"));
2111 assert_eq!(out.code, 1, "{}", out.stderr);
2112 assert!(out.stderr.contains("`a` changed in the working directory"), "{}", out.stderr);
2113 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops);
2114 assert_eq!(r.read("a"), "saved meanwhile\n");
2115 assert_eq!(r.read("keep.txt"), "uncommitted\n");
2116 r.j(&["id"]).ok();
2117 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
2118 let texts = r.j(&[&texts_by_message(&["[\"a\"]", "[\"keep.txt\"]"], &["C"])]).ok().stdout;
2119 assert_eq!(
2120 texts.split_whitespace().collect::<Vec<_>>().join(" "),
2121 r#"[["saved meanwhile\n" "uncommitted\n"]]"#
2122 );
2123 // `same.txt`, which P has as the snapshot read it
2124 r.write("keep.txt", "uncommitted, again\n");
2125 let out = stopped_before_publish(&r, edit, || r.write("same.txt", "saved meanwhile\n"));
2126 out.ok();
2127 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 2);
2128 assert_eq!(r.read("same.txt"), "saved meanwhile\n");
2129 assert_eq!(r.read("a"), "base\n");
2130 assert!(!r.dir.join("keep.txt").exists());
2131 r.j(&["id"]).ok();
2132 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 3);
2133 let texts = r.j(&[&texts_by_message(&["[\"a\"]", "[\"same.txt\"]"], &["P"])]).ok().stdout;
2134 assert_eq!(
2135 texts.split_whitespace().collect::<Vec<_>>().join(" "),
2136 r#"[["base\n" "saved meanwhile\n"]]"#
2137 );
2140#[test]
2141#[cfg(target_os = "linux")]
2142fn a_checkout_that_fills_the_disk_is_recorded_and_carried_on_from() {
2143 // §7.5 step 7: a checkout that used up the disk was put back, and the
2144 // put-back had to write too; where it could not, the edit the checkout
2145 // had written over was recorded alone, or lost. The operation, which
2146 // holds the edit, is recorded before the checkout begins, and the next
2147 // run takes what the checkout wrote for the focus's own, and the file
2148 // it cut short for a conflict with what the focus holds there.
2149 let r = setup();
2150 r.write("keep.txt", "k\n");
2151 r.j(&["describe \"base\""]).ok();
2152 r.write("keep.txt", "my uncommitted edit\n");
2153 let ops = r.j(&["ops"]).ok().stdout.lines().count();
2154 let n = 2000;
2155 let out = stopped_mid_checkout_as(&r, &slow_edit("c.files", n), n, fill_disk);
2156 assert_eq!(out.code, 1, "{}", out.stderr);
2157 assert!(out.stderr.contains("cannot check out"), "{}", out.stderr);
2158 assert!(out.stderr.contains("the operation is recorded"), "{}", out.stderr);
2159 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
2160 assert_eq!(r.read("keep.txt"), "my uncommitted edit\n");
2161 assert_eq!(in_m(&r), n);
2162 assert_eq!(r.read("zz"), "");
2163 r.j(&["id"]).ok();
2164 let conflicted = r.j(&["\\r -> show (conflicted (files r))"]).ok().stdout;
2165 assert_eq!(conflicted.trim(), r#"[["zz"]]"#);
2166 let text = r.j(&["\\r -> text (contentAt [\"keep.txt\"] (files r))"]).ok().stdout;
2167 assert_eq!(text, "my uncommitted edit\n");
2168 r.j(&["id"]).ok();
2169 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 2);
2172#[test]
2173#[cfg(target_os = "linux")]
2174fn a_fetch_after_a_failed_checkout_leaves_every_command_working() {
2175 // §7.4, §7.6: when a checkout that filled the disk could not be put
2176 // back, the snapshot it had written over was recorded alone, leaving
2177 // the working-copy state behind the commit; one `j fetch` or `j push`
2178 // after that, and every run, `j log` included, exited 2 as over a stale
2179 // working copy, and `j undo` was refused. A failed checkout now leaves
2180 // its operation recorded and the state stale, which refuses nothing.
2181 let r = setup();
2182 r.write("a", "a\n");
2183 r.j(&["describe \"B\""]).ok();
2184 r.j(&["new"]).ok();
2185 r.j(&["describe \"W\""]).ok();
2186 r.write("a", "my uncommitted edit\n");
2187 let n = 2000;
2188 // S, a new child of B, writes `a` before `m`
2189 let files = "[{ path = [\"a\"], content = blob \"s\\n\" }]";
2190 let edit = format!("describe \"S\" . ({}) . new . prev", slow_edit(files, n));
2191 let out = stopped_mid_checkout_as(&r, &edit, n, fill_disk);
2192 assert_eq!(out.code, 1, "{}", out.stderr);
2193 assert!(out.stderr.contains("the operation is recorded"), "{}", out.stderr);
2194 let origin = r.cfg.join("origin.git");
2195 let git = Command::new("git").args(["init", "-q", "--bare"]).arg(&origin).status().unwrap();
2196 assert!(git.success());
2197 r.j(&["remote", origin.to_str().unwrap()]).ok();
2198 r.j(&["fetch"]).ok();
2199 r.j(&["log"]).ok();
2200 let texts = r.j(&[&texts_by_message(&["[\"a\"]"], &["B", "W", "S"])]).ok().stdout;
2201 assert_eq!(
2202 texts.split_whitespace().collect::<Vec<_>>().join(" "),
2203 r#"[["a\n"] ["my uncommitted edit\n"] ["s\n"]]"#
2204 );
2205 r.j(&["id"]).ok();
2206 r.j(&["describe \"S2\""]).ok();
2207 r.j(&["goto (matching (\\c -> c.message == \"W\") all)"]).ok();
2208 assert_eq!(r.read("a"), "my uncommitted edit\n");
2209 r.j(&["undo"]).ok();
2210 assert_eq!(r.read("a"), "s\n");
2213#[test]
2214#[cfg(target_os = "linux")]
2215fn a_failed_checkout_keeps_the_edit_of_an_immutable_focus_in_a_child() {
2216 // §7.2, §7.5 step 7: with an immutable working-copy commit, a checkout
2217 // that filled the disk could not be put back, and the snapshot it had
2218 // written over, recorded instead in a new child of the commit, needed
2219 // room on the disk as well: the uncommitted edit was lost. The
2220 // operation that holds the child is now recorded before the checkout.
2221 let r = setup();
2222 let cfg = r.cfg.join("j/config.j");
2223 let config = std::fs::read_to_string(&cfg).unwrap();
2224 let immutable = "immutable = ancestorsOf trunk\n";
2225 assert!(config.contains(immutable));
2226 let config = config.replace(immutable, "immutable = matching (\\c -> c.message == \"W\") all\n");
2227 std::fs::write(&cfg, config).unwrap();
2228 r.write("a", "a\n");
2229 r.j(&["describe \"B\""]).ok();
2230 r.j(&["new"]).ok();
2231 r.j(&["describe \"W\""]).ok();
2232 r.write("a", "my uncommitted edit\n");
2233 let ops = r.j(&["ops"]).ok().stdout.lines().count();
2234 let n = 2000;
2235 // from the child of W that holds the edit, S, a new child of B
2236 let files = "[{ path = [\"a\"], content = blob \"s\\n\" }]";
2237 let edit = format!("describe \"S\" . ({}) . new . prev . prev", slow_edit(files, n));
2238 let out = stopped_mid_checkout_as(&r, &edit, n, fill_disk);
2239 assert_eq!(out.code, 1, "{}", out.stderr);
2240 assert!(out.stderr.contains("the operation is recorded"), "{}", out.stderr);
2241 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
2242 let files = r.j(&[CONTENT_OF_A]).ok().stdout;
2243 assert_eq!(
2244 files.split_whitespace().collect::<Vec<_>>().join(" "),
2245 r#"[["" (blob "")] ["B" (blob "a\n")] ["W" (blob "a\n")] ["" (blob "my uncommitted edit\n")] ["S" (blob "s\n")]]"#
2246 );
2247 r.j(&["goto (matching (\\c -> contentAt [\"a\"] c.files == blob \"my uncommitted edit\\n\") all)"]).ok();
2248 assert_eq!(r.read("a"), "my uncommitted edit\n");
2251#[test]
2252fn checkouts_that_fail_in_a_row_leave_each_edit_in_its_commit() {
2253 // §7.4, §7.5 step 7: a failed checkout leaves the working-copy state
2254 // stale, recording what the directory held when the checkout began, so
2255 // the runs after it take only what changed since for their changes:
2256 // here an uncommitted edit before each of two failed checkouts, each
2257 // recorded into the commit it was made on
2258 let r = setup();
2259 r.write("a", "a\n");
2260 r.write("d", "d\n");
2261 r.j(&["describe \"base\""]).ok();
2262 r.j(&["new"]).ok();
2263 r.j(&["describe \"W\""]).ok();
2264 let ops = r.j(&["ops"]).ok().stdout.lines().count();
2265 let deep = format!("{{ path = [{}], content = blob \"deep\" }}", too_deep("zz"));
2266 // N1, a new child of W, then N2, a new child of W beside it
2267 for (path, edit, name, to) in [("a", "edit 1\n", "N1", "new"), ("d", "edit 2\n", "N2", "new . prev")] {
2268 r.write(path, edit);
2269 let e = format!("describe \"{}\" . mapRoot (\\c -> c {{ files = c.files ++ [{}] }}) . {}", name, deep, to);
2270 let out = r.j(&[&e]);
2271 assert_eq!(out.code, 1, "{}", out.stderr);
2272 assert!(out.stderr.contains("the operation is recorded"), "{}", out.stderr);
2274 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 2);
2275 // N2's `d` was written before its path under `zz` failed
2276 assert_eq!(r.read("a"), "edit 1\n");
2277 assert_eq!(r.read("d"), "d\n");
2278 r.j(&["id"]).ok();
2279 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 2);
2280 let texts = r.j(&[&texts_by_message(&["[\"a\"]", "[\"d\"]"], &["base", "W", "N1", "N2"])]).ok().stdout;
2281 assert_eq!(
2282 texts.split_whitespace().collect::<Vec<_>>().join(" "),
2283 r#"[["a\n" "d\n"] ["edit 1\n" "d\n"] ["edit 1\n" "edit 2\n"] ["edit 1\n" "d\n"]]"#
2284 );
2287#[test]
2288#[cfg(target_os = "linux")]
2289fn a_failed_checkout_leaves_a_nested_repository_alone() {
2290 // §7.5 step 7: no run reads a directory holding `.git` or `.jj`, so
2291 // what a checkout writes there is never recorded; the put-back removed
2292 // what a failed one had written there and named what it left. Nothing
2293 // there is removed now: not what was there before, not what was saved
2294 // meanwhile, and, by `j undo`, not what the checkout wrote either.
2295 let r = setup();
2296 r.write("keep.txt", "k\n");
2297 r.j(&["describe \"base\""]).ok();
2298 std::fs::create_dir(r.dir.join("lib")).unwrap();
2299 std::fs::create_dir(r.dir.join("lib/.git")).unwrap();
2300 r.write("lib/own.c", "own\n");
2301 let log = r.j(&["log"]).ok().stdout;
2302 let ops = r.j(&["ops"]).ok().stdout.lines().count();
2303 let n = 2000;
2304 // `lib/x.c` and `lib/y.c` are written before `m`; `lib/y.c` is saved
2305 // over meanwhile
2306 let files = "c.files ++ [({ path = [\"lib\" \"x.c\"], content = blob \"x\\n\" }) \
2307 ({ path = [\"lib\" \"y.c\"], content = blob \"y\\n\" })]";
2308 let out = stopped_mid_checkout(&r, &slow_failing_edit(files, n), n, || {
2309 r.write("lib/y.c", "mine\n");
2310 });
2311 assert_eq!(out.code, 1, "{}", out.stderr);
2312 assert!(out.stderr.contains("cannot check out"), "{}", out.stderr);
2313 assert!(out.stderr.contains("the operation is recorded"), "{}", out.stderr);
2314 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
2315 r.j(&["undo"]).ok();
2316 assert_eq!(r.j(&["log"]).ok().stdout, log);
2317 assert!(!r.dir.join("m").exists());
2318 assert_eq!(r.read("lib/own.c"), "own\n");
2319 assert_eq!(r.read("lib/y.c"), "mine\n");
2320 assert_eq!(r.read("lib/x.c"), "x\n");
2323#[test]
2324#[cfg(target_os = "linux")]
2325fn a_failed_checkout_removes_nothing_through_a_symlink() {
2326 // §7.5 step 7, §7.7: the put-back removed what the checkout had added
2327 // one file at a time; a directory the checkout made, replaced meanwhile
2328 // by a symlink to one elsewhere holding the same file, was in the way.
2329 // Nothing is removed after a failed checkout now, and the symlink is
2330 // the user's change, which `j undo` refuses to write over.
2331 let r = setup();
2332 // where the filesystem holds no symlink, none is followed
2333 if std::os::unix::fs::symlink("x", r.dir.join("probe")).is_err() {
2334 return;
2336 std::fs::remove_file(r.dir.join("probe")).unwrap();
2337 r.write("keep.txt", "k\n");
2338 r.j(&["describe \"base\""]).ok();
2339 let ops = r.j(&["ops"]).ok().stdout.lines().count();
2340 let n = 2000;
2341 // `d/f` is written before `m`
2342 let files = "c.files ++ [{ path = [\"d\" \"f\"], content = blob \"f\\n\" }]";
2343 // outside the working directory, and removed with the repository
2344 let elsewhere = r.cfg.join("elsewhere");
2345 let out = stopped_mid_checkout(&r, &slow_failing_edit(files, n), n, || {
2346 std::fs::rename(r.dir.join("d"), &elsewhere).unwrap();
2347 std::os::unix::fs::symlink(&elsewhere, r.dir.join("d")).unwrap();
2348 });
2349 assert_eq!(out.code, 1, "{}", out.stderr);
2350 assert!(out.stderr.contains("the operation is recorded"), "{}", out.stderr);
2351 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
2352 let out = r.j(&["undo"]);
2353 assert_eq!(out.code, 1, "{}", out.stderr);
2354 assert!(out.stderr.contains("working copy has changes not in @"), "{}", out.stderr);
2355 assert_eq!(std::fs::read_to_string(elsewhere.join("f")).unwrap(), "f\n");
2356 assert!(r.dir.join("d").symlink_metadata().unwrap().is_symlink());
2359#[test]
2360fn a_file_a_failed_checkout_replaced_by_directories_comes_back() {
2361 // §7.5 step 7: the checkout removed the file `zz` and made directories
2362 // for a path under it until the path grew too long. No scan sees empty
2363 // directories, but a checkout writes no file over one: `j undo` skipped
2364 // writing `zz` back without an error, and the next run recorded it as
2365 // deleted. A checkout removes such a tree of empty directories where it
2366 // writes a file.
2367 let r = setup();
2368 r.write("zz", "precious\n");
2369 r.write("a", "k\n");
2370 r.j(&["describe \"W\""]).ok();
2371 let log = r.j(&["log"]).ok().stdout;
2372 let ops = r.j(&["ops"]).ok().stdout.lines().count();
2373 let edit = format!(
2374 "mapRoot (\\c -> c {{ files = filter (\\e -> e.path /= [\"zz\"]) c.files ++ [{{ path = [{}], content = blob \"deep\" }}] }})",
2375 too_deep("zz")
2376 );
2377 let out = r.j(&[&edit]);
2378 assert_eq!(out.code, 1, "{}", out.stderr);
2379 assert!(out.stderr.contains("cannot check out"), "{}", out.stderr);
2380 assert!(out.stderr.contains("the operation is recorded"), "{}", out.stderr);
2381 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
2382 assert!(r.dir.join("zz").is_dir());
2383 r.j(&["undo"]).ok();
2384 assert_eq!(r.j(&["log"]).ok().stdout, log);
2385 assert_eq!(r.read("zz"), "precious\n");
2386 let paths = r.j(&["\\r -> show (map (\\e -> e.path) (files r))"]).ok().stdout;
2387 assert_eq!(paths.trim(), r#"[["a"] ["zz"]]"#);
2388 r.j(&["id"]).ok();
2389 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 2);
2392#[test]
2393fn a_conflict_a_failed_checkout_wrote_is_read_back_as_that_conflict() {
2394 // §7.4, §7.5 step 7: a checkout records a conflict in the working-copy
2395 // state as it writes its markers, so a checkout that fails records none
2396 // of those it wrote, and the next run read each file as the text of its
2397 // markers: a conflict nested in the focus's, which `j undo` refused to
2398 // write over. A file holding exactly what the checkout writes for the
2399 // focus's conflict is read back as that conflict, markers made longer
2400 // than any line of its sides that looks like one included.
2401 let r = setup();
2402 r.write("a.txt", "base\n");
2403 r.write("keep.txt", "k\n");
2404 r.j(&["describe \"base\""]).ok();
2405 r.j(&["new"]).ok();
2406 r.write("a.txt", "<<<<<<< left\n");
2407 r.j(&["describe \"left\""]).ok();
2408 r.j(&["new . goto parents"]).ok();
2409 r.write("a.txt", "right\n");
2410 r.j(&["describe \"right\""]).ok();
2411 r.j(&["rebase siblings"]).ok();
2412 let conflicted = "\\r -> show (conflicted (files r))";
2413 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"a.txt\"]]");
2414 let log = r.j(&["log"]).ok().stdout;
2415 let ops = r.j(&["ops"]).ok().stdout.lines().count();
2416 // the conflict is written to `c.txt` and over `keep.txt` too, then the
2417 // deep path fails
2418 let conflict = "(filter (\\e -> e.path == [\"a.txt\"]) c.files)";
2419 let edit = format!(
2420 "mapRoot (\\c -> c {{ files = filter (\\e -> e.path /= [\"keep.txt\"]) c.files \
2421 ++ map (\\e -> e {{ path = [\"c.txt\"] }}) {0} ++ map (\\e -> e {{ path = [\"keep.txt\"] }}) {0} \
2422 ++ [{{ path = [{1}], content = blob \"deep\" }}] }})",
2423 conflict,
2424 too_deep("zz")
2425 );
2426 let out = r.j(&[&edit]);
2427 assert_eq!(out.code, 1, "{}", out.stderr);
2428 assert!(out.stderr.contains("cannot check out"), "{}", out.stderr);
2429 assert!(out.stderr.contains("the operation is recorded"), "{}", out.stderr);
2430 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
2431 assert!(r.read("c.txt").contains("<<<<<<<<"), "{}", r.read("c.txt"));
2432 assert_eq!(r.read("c.txt"), r.read("keep.txt"));
2433 r.j(&["id"]).ok();
2434 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
2435 let all = r.j(&[conflicted]).ok().stdout;
2436 assert_eq!(all.split_whitespace().collect::<Vec<_>>().join(" "), r#"[["a.txt"] ["c.txt"] ["keep.txt"]]"#);
2437 r.j(&["undo"]).ok();
2438 assert_eq!(r.j(&["log"]).ok().stdout, log);
2439 assert!(!r.dir.join("c.txt").exists());
2440 assert_eq!(r.read("keep.txt"), "k\n");
2441 r.j(&["id"]).ok();
2442 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 2);
2445#[test]
2446#[cfg(target_os = "linux")]
2447fn a_checkout_whose_state_fails_to_save_is_carried_on_from() {
2448 // §7.4, §7.5 step 7: a checkout that completed, but whose working-copy
2449 // state could not then be saved, left the state behind the commit, and
2450 // once any file was edited every run was refused as over a stale
2451 // working copy. The next run takes the directory for the focus's files
2452 // now, and records only the edit.
2453 let r = setup();
2454 r.write("keep.txt", "k\n");
2455 r.j(&["describe \"base\""]).ok();
2456 let ops = r.j(&["ops"]).ok().stdout.lines().count();
2457 let n = 2000;
2458 // a directory where the state is saved: saving it fails
2459 let state = r.dir.join(".jj/working_copy/tree_state");
2460 let aside = state.with_extension("aside");
2461 let out = stopped_mid_checkout(&r, &slow_edit("c.files", n), n, || {
2462 std::fs::rename(&state, &aside).unwrap();
2463 std::fs::create_dir(&state).unwrap();
2464 std::fs::write(state.join("x"), "").unwrap();
2465 });
2466 std::fs::remove_dir_all(&state).unwrap();
2467 std::fs::rename(&aside, &state).unwrap();
2468 assert_eq!(out.code, 1, "{}", out.stderr);
2469 assert!(out.stderr.contains("cannot save the working copy's state"), "{}", out.stderr);
2470 assert!(out.stderr.contains("the operation is recorded"), "{}", out.stderr);
2471 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
2472 assert_eq!(in_m(&r), n);
2473 assert_eq!(r.read("zz"), "z\n");
2474 r.write("keep.txt", "edited\n");
2475 r.j(&["log"]).ok();
2476 r.j(&["id"]).ok();
2477 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 2);
2478 let texts = r.j(&["\\r -> show (map (\\e -> text e.content) (restrict (neg (under ./m)) (files r)))"]).ok().stdout;
2479 assert_eq!(texts.trim(), r#"["edited\n" "z\n"]"#);
2480 r.j(&["id"]).ok();
2481 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 2);
2484/// Whether `dir`'s filesystem folds case, judged as the backend judges it:
2485/// `.JJ` names the workspace's `.jj`, which the directory does not list
2486/// under that spelling
2487fn folds_case(dir: &std::path::Path) -> bool {
2488 dir.join(".JJ").symlink_metadata().is_ok()
2489 && !std::fs::read_dir(dir).unwrap().any(|e| e.unwrap().file_name() == ".JJ")
2492#[test]
2493fn a_failed_case_only_rename_keeps_the_edits() {
2494 // §7.5 step 7: the put-back checked the tree back out in one go, in
2495 // path order. Where the filesystem folds case, a focus renaming
2496 // `README` to `readme` had removed `README` and written `readme` when
2497 // its checkout failed; the put-back wrote `README` first, which
2498 // `readme` was in the way of, then removed `readme`, so neither
2499 // spelling was left and the uncommitted edit was lost, as was a file
2500 // in a directory renamed only in case. Nothing is put back now: the
2501 // edits are in the focus, and on disk under one spelling or the other.
2502 // Run with TMPDIR on such a mount (exFAT through FUSE) to take that
2503 // case; elsewhere the two names are two files.
2504 let rename = |from: &str, to: &str| {
2505 format!("map (\\e -> if e.path == {} then e {{ path = {} }} else e)", from, to)
2506 };
2507 let edit = |first: &str| {
2508 format!(
2509 "mapRoot (\\c -> c {{ files = {} ({} c.files) ++ [{{ path = [{}], content = blob \"deep\" }}] }})",
2510 rename("[\"README\"]", "[\"readme\"]"),
2511 rename("[\"Docs\" \"a\"]", "[\"docs\" \"a\"]"),
2512 too_deep(first)
2514 };
2515 let listed = |dir: &std::path::Path| {
2516 let mut names: Vec<String> = std::fs::read_dir(dir)
2517 .unwrap()
2518 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
2519 .filter(|n| !n.starts_with('.'))
2520 .collect();
2521 names.sort();
2522 names
2523 };
2524 // the checkout fails after the renames (at `zz`), or before them (at
2525 // `A`), when `readme` and `docs` name the untouched `README` and `Docs`
2526 // where case folds
2527 for (first, readme, docs) in [("zz", "readme", "docs"), ("A", "README", "Docs")] {
2528 let r = setup();
2529 r.write("README", "hello\n");
2530 std::fs::create_dir(r.dir.join("Docs")).unwrap();
2531 r.write("Docs/a", "a\n");
2532 r.write("notes", "n\n");
2533 r.j(&["describe \"base\""]).ok();
2534 r.write("README", "hello, my uncommitted edit\n");
2535 r.write("Docs/a", "a, my uncommitted edit\n");
2536 let ops = r.j(&["ops"]).ok().stdout.lines().count();
2537 let out = r.j(&[&edit(first)]);
2538 assert_eq!(out.code, 1, "{}", out.stderr);
2539 assert!(out.stderr.contains("cannot check out"), "{}", out.stderr);
2540 assert!(out.stderr.contains("the operation is recorded"), "{}", out.stderr);
2541 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
2542 let mut names = vec![docs.to_string(), readme.to_string(), "notes".to_string(), first.to_string()];
2543 names.sort();
2544 assert_eq!(listed(&r.dir), names, "{}", first);
2545 assert_eq!(listed(&r.dir.join(docs)), ["a"], "{}", first);
2546 assert_eq!(r.read(readme), "hello, my uncommitted edit\n");
2547 assert_eq!(r.read(&format!("{}/a", docs)), "a, my uncommitted edit\n");
2548 // the next run records nothing: the edits are in the focus
2549 r.j(&["id"]).ok();
2550 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
2551 let texts = r
2552 .j(&["\\r -> show [(contentAt [\"docs\" \"a\"] (files r)) (contentAt [\"readme\"] (files r)) (contentAt [\"notes\"] (files r))]"])
2553 .ok()
2554 .stdout;
2555 assert_eq!(
2556 texts.split_whitespace().collect::<Vec<_>>().join(" "),
2557 r#"[(blob "a, my uncommitted edit\n") (blob "hello, my uncommitted edit\n") (blob "n\n")]"#
2558 );
2562#[test]
2563fn reserved_names_in_another_case_refused_where_the_filesystem_folds_case() {
2564 // on a case-folding filesystem (macOS, Windows, exFAT) `.GIT` is `.git`,
2565 // which jj's checkout refuses after the operation is recorded or, where
2566 // each spelling has its own inode number (exfat-fuse), writes into;
2567 // elsewhere it is an ordinary name the snapshot tracks. Run with TMPDIR
2568 // on such a mount to take the first branch.
2569 let r = setup();
2570 r.write("a.txt", "x\n");
2571 r.j(&["describe \"stable\""]).ok();
2572 let folds = folds_case(&r.dir);
2573 let ops = r.j(&["ops"]).ok().stdout.lines().count();
2574 let edit = r#"mapRoot (\c -> c { files = c.files ++ [{ path = ["sub" ".GIT" "x"], content = blob "x" }] })"#;
2575 let dry = r.j(&[&format!("tree . validate . {}", edit)]);
2576 let out = r.j(&[edit]);
2577 if folds {
2578 assert_eq!(dry.code, 1, "validate accepted it: {}", dry.stdout);
2579 assert_eq!(out.code, 1, "{}", out.stderr);
2580 assert!(out.stderr.contains("path component"), "{}", out.stderr);
2581 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops);
2582 } else {
2583 assert_eq!(dry.code, 0, "{}", dry.stderr);
2584 assert_eq!(out.code, 0, "{}", out.stderr);
2585 assert_eq!(r.read("sub/.GIT/x"), "x");
2587 r.j(&["describe \"after\""]).ok();
2590#[test]
2591fn root_path_entry_is_a_crash_not_a_panic() {
2592 // jj's tree builder asserts a path is not the root: exit 101 and a Rust
2593 // panic, which `or` could not catch
2594 let r = setup();
2595 r.write("a.txt", "x\n");
2596 r.j(&["describe \"stable\""]).ok();
2597 let log = r.j(&["log"]).ok().stdout;
2598 let out = r.j(&["replay [{ path = ./, content = blob \"x\" }] ({ from = [], to = [] })"]);
2599 assert_eq!(out.code, 1, "{}", out.stderr);
2600 assert!(out.stderr.starts_with("j: crash:"), "{}", out.stderr);
2601 let out = r
2602 .j(&["(show (replay [] ({ from = [], to = [{ path = ./, content = blob \"x\" }] }))) or \"caught\""])
2603 .ok();
2604 assert_eq!(out.stdout.trim(), "caught");
2605 let edit = "mapRoot (\\c -> c { files = c.files ++ [{ path = ./, content = blob \"x\" }] })";
2606 let out = r.j(&[&format!("tree . validate . {}", edit)]);
2607 assert_eq!(out.code, 1, "validate accepted it: {}", out.stdout);
2608 let out = r.j(&[edit]);
2609 assert_eq!(out.code, 1, "{}", out.stderr);
2610 assert!(out.stderr.contains("root path"), "{}", out.stderr);
2611 assert_eq!(r.j(&["log"]).ok().stdout, log);
2614#[test]
2615fn file_and_directory_at_one_path_refused() {
2616 // a jj tree holds `a` as a file or as a directory, not both: persisting
2617 // kept `a/b` and silently dropped the file `a`, and replay did the same,
2618 // disagreeing with the in-memory backend (§7.3, §10)
2619 let r = setup();
2620 r.write("a.txt", "x\n");
2621 r.j(&["describe \"stable\""]).ok();
2622 let log = r.j(&["log"]).ok().stdout;
2623 let edit = "mapRoot (\\c -> c { files = [({ path = [\"a\"], content = blob \"x\\n\" }) ({ path = [\"a\" \"b\"], content = blob \"y\\n\" })] })";
2624 let out = r.j(&[&format!("tree . validate . {}", edit)]);
2625 assert_eq!(out.code, 1, "validate accepted it: {}", out.stdout);
2626 let out = r.j(&[edit]);
2627 assert_eq!(out.code, 1, "{}", out.stderr);
2628 assert!(out.stderr.contains("both a file and a directory"), "{}", out.stderr);
2629 assert_eq!(r.j(&["log"]).ok().stdout, log);
2630 assert_eq!(r.read("a.txt"), "x\n");
2631 assert!(!r.dir.join("a").exists());
2632 let out = r.j(&[
2633 "let x = [({ path = ./a, content = blob \"file\" }) ({ path = ./a/b, content = blob \"nested\" })] in replay x ({ from = [], to = [] })",
2634 ]);
2635 assert_eq!(out.code, 1, "replay dropped a file: {}", out.stdout);
2636 assert!(out.stderr.contains("both a file and a directory"), "{}", out.stderr);
2639#[test]
2640fn replay_making_a_file_and_a_directory_of_one_path_refused() {
2641 // rebasing a commit that adds `a/b` onto one with a file `a`: jj's merge
2642 // makes one conflict at `a` with the directory as a side, so the rebase
2643 // and its `validate` dry run exited 0 with `a/b` gone from `files` (and,
2644 // while such a side was stored as an empty file, from every tree). The
2645 // in-memory backend left both `a` and `a/b` instead, which its next
2646 // replay refused (§7.3).
2647 let clash = "would be a file on one side and a directory on another";
2648 let r = setup();
2649 r.write("a", "a\n");
2650 r.j(&["describe \"B\""]).ok();
2651 r.j(&["new . top"]).ok();
2652 std::fs::create_dir(r.dir.join("a")).unwrap();
2653 r.write("a/b", "nested\n");
2654 r.j(&["describe \"S\""]).ok();
2655 let log = r.j(&["log"]).ok().stdout;
2656 let out = r.j(&["tree . validate . rebase siblings"]);
2657 assert_eq!(out.code, 1, "validate accepted it: {}", out.stdout);
2658 assert!(out.stderr.contains(&format!("`a` {}", clash)), "{}", out.stderr);
2659 let out = r.j(&["rebase siblings"]);
2660 assert_eq!(out.code, 1, "{}", out.stderr);
2661 assert!(out.stderr.contains(&format!("`a` {}", clash)), "{}", out.stderr);
2662 assert_eq!(r.j(&["log"]).ok().stdout, log);
2663 let paths = r.j(&["\\r -> show (map (.path) (files r))"]).ok().stdout;
2664 assert_eq!(paths.trim(), "[[\"a\" \"b\"]]");
2665 assert_eq!(r.read("a/b"), "nested\n");
2666 // the builtin, either way round, and catchable
2667 let file = "[{ path = ./a, content = blob \"x\" }]";
2668 let dir = "[{ path = ./a/b, content = blob \"y\" }]";
2669 for (onto, to) in [(file, dir), (dir, file)] {
2670 let e = format!("replay {} ({{ from = [], to = {} }})", onto, to);
2671 let out = r.j(&[&format!("show ({})", e)]);
2672 assert_eq!(out.code, 1, "{}", out.stdout);
2673 assert!(out.stderr.contains(clash), "{}", out.stderr);
2674 let out = r.j(&[&format!("(show ({})) or \"caught\"", e)]).ok();
2675 assert_eq!(out.stdout.trim(), "caught");
2677 // jj merges `a` as one value, so the deletion of `a/z` onto a snapshot
2678 // that made the directory `a` a file is refused too, where the in-memory
2679 // merge goes path by path and gives the file (§7.3, §10)
2680 let e = "replay [{ path = ./a, content = blob \"f\" }] \
2681 ({ from = [({ path = ./a/y, content = blob \"y\" }) ({ path = ./a/z, content = blob \"z\" })], \
2682 to = [{ path = ./a/y, content = blob \"y\" }] })";
2683 let out = r.j(&[&format!("show ({})", e)]);
2684 assert_eq!(out.code, 1, "{}", out.stdout);
2685 assert!(out.stderr.contains(&format!("`a` {}", clash)), "{}", out.stderr);
2688#[test]
2689fn conflict_directory_sides_kept_whole() {
2690 // jj keeps a conflict between a file and a directory as one path whose
2691 // side is the whole directory. Such a side was read as an empty file and
2692 // written back as one: the directory was gone from the stored tree, and
2693 // rebasing the commit back where its sides cancel left an empty file `a`
2694 // in place of the user's `a/b` (§7.3).
2695 let conflicted = "\\r -> show (conflicted (files r))";
2696 let paths = "\\r -> show (map (.path) (files r))";
2697 let onto = |m: &str| format!("rebase (matching (\\c -> c.message == \"{}\") all)", m);
2699 // R replaces the file `a` with a directory, L deletes it: R onto L is a
2700 // conflict with R's directory as a side, and no side a file
2701 let r = setup();
2702 r.write("a", "base\n");
2703 r.j(&["describe \"P\""]).ok();
2704 r.j(&["new"]).ok();
2705 std::fs::remove_file(r.dir.join("a")).unwrap();
2706 r.j(&["describe \"L\""]).ok();
2707 r.j(&["new . goto parents"]).ok();
2708 r.write("a", "other\n");
2709 r.j(&["describe \"Q\""]).ok();
2710 r.j(&["new . goto parents"]).ok();
2711 std::fs::remove_file(r.dir.join("a")).unwrap();
2712 std::fs::create_dir(r.dir.join("a")).unwrap();
2713 r.write("a/b", "deep\n");
2714 r.j(&["describe \"R\""]).ok();
2715 r.j(&[&onto("L")]).ok();
2716 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"a\"]]");
2717 // a rewrite writes the conflict back as it was read
2718 r.j(&["describe \"R again\""]).ok();
2719 // onto Q's file `a`: the directory stays a side of R's conflict, now
2720 // beside Q's file, and is not refused, as it was a side already (§7.3)
2721 r.j(&[&onto("Q")]).ok();
2722 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"a\"]]");
2723 // R back onto P: the sides cancel, leaving R's own directory
2724 r.j(&[&onto("P")]).ok();
2725 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[]");
2726 assert_eq!(r.j(&[paths]).ok().stdout.trim(), "[[\"a\" \"b\"]]");
2727 assert_eq!(r.read("a/b"), "deep\n");
2729 // both sides replace the directory `a` with a file: the base is the
2730 // directory, and it cancels against P's when R goes back
2731 let r = setup();
2732 r.write("base.txt", "base\n");
2733 std::fs::create_dir(r.dir.join("a")).unwrap();
2734 r.write("a/b", "deep\n");
2735 r.j(&["describe \"P\""]).ok();
2736 r.j(&["new"]).ok();
2737 std::fs::remove_dir_all(r.dir.join("a")).unwrap();
2738 r.write("a", "left\n");
2739 r.j(&["describe \"L\""]).ok();
2740 r.j(&["new . goto parents"]).ok();
2741 std::fs::remove_dir_all(r.dir.join("a")).unwrap();
2742 r.write("a", "right\n");
2743 r.j(&["describe \"R\""]).ok();
2744 r.j(&["rebase siblings"]).ok();
2745 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"a\"]]");
2746 let back = "\\r -> show (conflicted (files (rebase (parents . prev) r)))";
2747 assert_eq!(r.j(&[back]).ok().stdout.trim(), "[]");
2748 r.j(&["rebase (parents . prev)"]).ok();
2749 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[]");
2750 assert_eq!(r.j(&[paths]).ok().stdout.trim(), "[[\"a\"] [\"base.txt\"]]");
2751 assert_eq!(r.read("a"), "right\n");
2754#[test]
2755fn conflict_between_a_file_and_a_directory_resolved_in_a_child_squashes() {
2756 // resolving a conflict in a child and squashing it: contract replays the
2757 // child with `from` the conflicted parent, whose file side the merge
2758 // carries beside the child's directory. That mix came in with the
2759 // conflict, but replay refused it as one it made, so `squash`, its
2760 // dry run, `abandon . goto parents`, and the law
2761 // (abandon . contract m . split m) r = r all crashed (§7.3, §8)
2762 let conflicted = "\\r -> show (conflicted (files r))";
2763 let paths = "\\r -> show (map (.path) (files r))";
2764 let resolve_into_directory = |r: &Repo| {
2765 r.j(&["new"]).ok();
2766 let a = r.dir.join("a");
2767 std::fs::remove_file(&a).or_else(|_| std::fs::remove_dir_all(&a)).unwrap();
2768 std::fs::create_dir(&a).unwrap();
2769 r.write("a/b", "deep2\n");
2770 r.j(&["describe \"C\""]).ok();
2771 };
2772 let squashes = |r: &Repo| {
2773 for m in ["everything", "(under ./a)"] {
2774 let law = format!("\\r -> remove ((abandon . contract {m} . split {m}) r) == r");
2775 assert_eq!(r.j(&[&law]).ok().stdout.trim(), "true", "{}", m);
2777 r.j(&["tree . validate . squash"]).ok();
2778 r.j(&["tree . validate . abandon . goto parents"]).ok();
2779 r.j(&["squash"]).ok();
2780 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[]");
2781 assert_eq!(r.j(&[paths]).ok().stdout.trim(), "[[\"a\" \"b\"]]");
2782 let parent = "\\r -> show (conflicted (files (up r)))";
2783 assert_eq!(r.j(&[parent]).ok().stdout.trim(), "[]");
2784 assert_eq!(r.read("a/b"), "deep2\n");
2785 };
2786 let onto_l = "rebase (matching (\\c -> c.message == \"L\") all)";
2788 // jj's conflict between R's directory and L's deletion of P's file
2789 let r = setup();
2790 r.write("a", "base\n");
2791 r.j(&["describe \"P\""]).ok();
2792 r.j(&["new"]).ok();
2793 std::fs::remove_file(r.dir.join("a")).unwrap();
2794 r.j(&["describe \"L\""]).ok();
2795 r.j(&["new . goto parents"]).ok();
2796 std::fs::remove_file(r.dir.join("a")).unwrap();
2797 std::fs::create_dir(r.dir.join("a")).unwrap();
2798 r.write("a/b", "deep\n");
2799 r.j(&["describe \"R\""]).ok();
2800 r.j(&[onto_l]).ok();
2801 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"a\"]]");
2802 resolve_into_directory(&r);
2803 squashes(&r);
2805 // a conflict between two files, resolved into a directory; S, beside L,
2806 // makes `a` a directory
2807 let r = setup();
2808 r.write("a", "base\n");
2809 r.j(&["describe \"P\""]).ok();
2810 r.j(&["new"]).ok();
2811 r.write("a", "left\n");
2812 r.j(&["describe \"L\""]).ok();
2813 r.j(&["new . goto parents"]).ok();
2814 std::fs::remove_file(r.dir.join("a")).unwrap();
2815 std::fs::create_dir(r.dir.join("a")).unwrap();
2816 r.write("a/x", "d\n");
2817 r.j(&["describe \"S\""]).ok();
2818 r.j(&["new . goto parents"]).ok();
2819 r.write("a", "right\n");
2820 r.j(&["describe \"R\""]).ok();
2821 r.j(&[onto_l]).ok();
2822 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"a\"]]");
2823 // `a/b` added beside the conflict at `a` is carried as one of its sides,
2824 // where the in-memory merge refuses it (§7.3, §10)
2825 let e = "\\r -> show (conflicted (replay (files r) \
2826 ({ from = [], to = [{ path = ./a/b, content = blob \"b\" }] })))";
2827 assert_eq!(r.j(&[e]).ok().stdout.trim(), "[[\"a\"]]");
2828 // R onto S: S's directory is a side of R's conflict beside its file
2829 // `right`, and only `to` holds a conflict at `a`, so it is carried too
2830 let onto_s = "rebase (matching (\\c -> c.message == \"S\") all)";
2831 let e = format!("\\r -> show (conflicted (files ({} r)))", onto_s);
2832 assert_eq!(r.j(&[&e]).ok().stdout.trim(), "[[\"a\"]]");
2833 r.j(&[&format!("tree . validate . {}", onto_s)]).ok();
2834 resolve_into_directory(&r);
2835 squashes(&r);
2837 // a conflict below the path is none at it: the merge still makes the
2838 // clash between `onto`'s file and `to`'s directory, and is refused
2839 let r = setup();
2840 std::fs::create_dir(r.dir.join("a")).unwrap();
2841 r.write("a/b", "base\n");
2842 r.j(&["describe \"P\""]).ok();
2843 r.j(&["new"]).ok();
2844 r.write("a/b", "left\n");
2845 r.j(&["describe \"L\""]).ok();
2846 r.j(&["new . goto parents"]).ok();
2847 r.write("a/b", "right\n");
2848 r.j(&["describe \"R\""]).ok();
2849 r.j(&[onto_l]).ok();
2850 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"a\" \"b\"]]");
2851 let e = "\\r -> show (replay [{ path = ./a, content = blob \"f\" }] \
2852 ({ from = files r, to = [{ path = ./a/b, content = blob \"r\" }] }))";
2853 let out = r.j(&[e]);
2854 assert_eq!(out.code, 1, "{}", out.stdout);
2855 assert!(
2856 out.stderr.contains("`a` would be a file on one side and a directory on another"),
2857 "{}",
2858 out.stderr
2859 );
2862#[test]
2863fn a_file_replacing_a_directory_with_a_conflict_inside_is_recorded() {
2864 // the mirror of the test above: a conflict inside the directory `a`,
2865 // resolved in a child by replacing `a` with a file. jj's scan keeps the
2866 // directory's sides at `a` where a file is found there, and drops only
2867 // the entries below it, so the file reached no tree: a debug build
2868 // failed an assertion on every run that scanned (exit 101, `undo`
2869 // included), and a release build recorded the child without `a` (§7.4)
2870 let conflicted = "\\r -> show (conflicted (files r))";
2871 let paths = "\\r -> show (map (.path) (files r))";
2872 let onto_l = "rebase (matching (\\c -> c.message == \"L\") all)";
2873 // R, rebased onto its sibling L, conflicted at `path`
2874 let conflict_at = |path: &str, beside: &[&str]| {
2875 let r = setup();
2876 let write = |content: &str| {
2877 std::fs::create_dir_all(r.dir.join(path).parent().unwrap()).unwrap();
2878 r.write(path, content);
2879 };
2880 write("base\n");
2881 for p in beside {
2882 r.write(p, "beside\n");
2884 r.j(&["describe \"P\""]).ok();
2885 r.j(&["new"]).ok();
2886 write("left\n");
2887 r.j(&["describe \"L\""]).ok();
2888 r.j(&["new . goto parents"]).ok();
2889 write("right\n");
2890 r.j(&["describe \"R\""]).ok();
2891 r.j(&[onto_l]).ok();
2892 r.j(&["new"]).ok();
2894 };
2896 let r = conflict_at("a/b", &[]);
2897 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"a\" \"b\"]]");
2898 // a checkout that writes the file `a` over the conflicted directory,
2899 // and then fails, leaves the file there: the next run scans it over
2900 // that directory, which the working-copy state still records
2901 let ops = r.j(&["ops"]).ok().stdout.lines().count();
2902 let edit = format!(
2903 "\\r -> mapRoot (\\c -> c {{ files = [({{ path = ./a, content = blob \"f\" }}) \
2904 ({{ path = [{}], content = blob \"deep\" }})] }}) r",
2905 too_deep("d")
2906 );
2907 let out = r.j(&[&edit]);
2908 assert_eq!(out.code, 1, "{}", out.stderr);
2909 assert!(out.stderr.contains("cannot check out"), "{}", out.stderr);
2910 assert!(out.stderr.contains("the operation is recorded"), "{}", out.stderr);
2911 assert_eq!(r.read("a"), "f");
2912 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
2913 r.j(&["id"]).ok();
2914 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
2915 r.j(&["undo"]).ok();
2916 assert!(r.read("a/b").contains("<<<<<<<"), "{}", r.read("a/b"));
2917 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"a\" \"b\"]]");
2918 std::fs::remove_dir_all(r.dir.join("a")).unwrap();
2919 r.write("a", "file\n");
2920 // `undo` scans to refuse a working directory not recorded
2921 let out = r.j(&["undo"]);
2922 assert_eq!(out.code, 1, "{}", out.stderr);
2923 assert!(out.stderr.contains("working copy has changes not in @"), "{}", out.stderr);
2924 assert_eq!(r.j(&[paths]).ok().stdout.trim(), "[[\"a\"]]");
2925 r.j(&["describe \"C\""]).ok();
2926 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[]");
2927 let content = "\\r -> show (contentAt [\"a\"] (files r))";
2928 assert_eq!(r.j(&[content]).ok().stdout.trim(), "blob \"file\\n\"");
2929 // R keeps its conflict; the child resolves it
2930 let parent = "\\r -> show (conflicted (files (up r)))";
2931 assert_eq!(r.j(&[parent]).ok().stdout.trim(), "[[\"a\" \"b\"]]");
2932 for m in ["everything", "(under ./a)"] {
2933 let law = format!("\\r -> remove ((abandon . contract {m} . split {m}) r) == r");
2934 assert_eq!(r.j(&[&law]).ok().stdout.trim(), "true", "{}", m);
2936 r.j(&["tree . validate . squash"]).ok();
2937 r.j(&["squash"]).ok();
2938 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[]");
2939 assert_eq!(r.j(&[paths]).ok().stdout.trim(), "[[\"a\"]]");
2940 assert_eq!(r.j(&[parent]).ok().stdout.trim(), "[]");
2941 assert_eq!(r.read("a"), "file\n");
2943 // deeper down, beside a file the directory keeps
2944 let r = conflict_at("a/x/b", &["a/c"]);
2945 std::fs::remove_dir_all(r.dir.join("a/x")).unwrap();
2946 r.write("a/x", "file\n");
2947 r.j(&["id"]).ok();
2948 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[]");
2949 assert_eq!(r.j(&[paths]).ok().stdout.trim(), "[[\"a\" \"c\"] [\"a\" \"x\"]]");
2950 assert_eq!(r.read("a/x"), "file\n");
2953#[cfg(unix)]
2954#[test]
2955fn a_file_written_over_a_conflict_with_a_side_that_is_no_file_is_recorded() {
2956 // a conflict at `a` with a side that is a directory or a symlink is
2957 // checked out as jj's description of its sides. A file written there
2958 // in its place reached no tree: jj's scan found no file merge to read
2959 // it into and kept the conflict, so the run showed the conflict,
2960 // `describe` recorded it, and a checkout wrote the description back
2961 // over the file, which no blob held (§7.4)
2962 let conflicted = "\\r -> show (conflicted (files r))";
2963 let content = "\\r -> show (contentAt [\"a\"] (files r))";
2964 let goto = |m: &str| format!("goto (matching (\\c -> c.message == \"{}\") all)", m);
2965 // R, rebased onto its sibling L, where each changes P's file `a`
2966 let conflict = |left: &dyn Fn(&Repo), right: &dyn Fn(&Repo)| {
2967 let r = setup();
2968 r.write("a", "base\n");
2969 r.j(&["describe \"P\""]).ok();
2970 r.j(&["new"]).ok();
2971 std::fs::remove_file(r.dir.join("a")).unwrap();
2972 left(&r);
2973 r.j(&["describe \"L\""]).ok();
2974 r.j(&["new . goto parents"]).ok();
2975 std::fs::remove_file(r.dir.join("a")).unwrap();
2976 right(&r);
2977 r.j(&["describe \"R\""]).ok();
2978 r.j(&["rebase (matching (\\c -> c.message == \"L\") all)"]).ok();
2979 r.j(&["new"]).ok();
2980 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"a\"]]");
2981 assert!(r.read("a").starts_with("Conflict:\n"), "{}", r.read("a"));
2983 };
2985 // R's directory against L's deletion
2986 let r = conflict(&|_| {}, &|r| {
2987 std::fs::create_dir(r.dir.join("a")).unwrap();
2988 r.write("a/b", "deep\n");
2989 });
2990 // the description, left as it is or written again, is the conflict
2991 let description = r.read("a");
2992 r.j(&["id"]).ok();
2993 r.write("a", &description);
2994 r.j(&["id"]).ok();
2995 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"a\"]]");
2996 r.write("a", "resolved\n");
2997 // `undo` scans to refuse a working directory not recorded
2998 let out = r.j(&["undo"]);
2999 assert_eq!(out.code, 1, "{}", out.stderr);
3000 assert!(out.stderr.contains("working copy has changes not in @"), "{}", out.stderr);
3001 assert_eq!(r.j(&[content]).ok().stdout.trim(), "blob \"resolved\\n\"");
3002 r.j(&["describe \"C\""]).ok();
3003 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[]");
3004 r.j(&[&goto("P")]).ok();
3005 assert_eq!(r.read("a"), "base\n");
3006 r.j(&[&goto("C")]).ok();
3007 assert_eq!(r.read("a"), "resolved\n");
3008 assert_eq!(r.j(&[content]).ok().stdout.trim(), "blob \"resolved\\n\"");
3009 // R keeps its conflict; the child resolves it
3010 let parent = "\\r -> show (conflicted (files (up r)))";
3011 assert_eq!(r.j(&[parent]).ok().stdout.trim(), "[[\"a\"]]");
3012 r.j(&["squash"]).ok();
3013 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[]");
3014 assert_eq!(r.j(&[parent]).ok().stdout.trim(), "[]");
3015 assert_eq!(r.read("a"), "resolved\n");
3017 // L's symlink against R's file; `a` stays tracked where `.gitignore`
3018 // has come to match it, as a tracked file does
3019 let r = conflict(&|r| std::os::unix::fs::symlink("elsewhere", r.dir.join("a")).unwrap(), &|r| {
3020 r.write("a", "right\n")
3021 });
3022 r.write(".gitignore", "a\n");
3023 r.write("a", "resolved\n");
3024 r.j(&["id"]).ok();
3025 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[]");
3026 assert_eq!(r.j(&[content]).ok().stdout.trim(), "blob \"resolved\\n\"");
3027 assert_eq!(r.read("a"), "resolved\n");
3030/// The repository `r` as jj-lib loads it at its head operation, for what a
3031/// test cannot make or see through `j`: a tree as jj itself writes one
3032fn jj_repo(r: &Repo) -> std::sync::Arc<jj_lib::repo::ReadonlyRepo> {
3033 use jj_lib::config::{ConfigLayer, ConfigSource, StackedConfig};
3034 use jj_lib::default_backend_factories::{default_backend_factories, default_working_copy_factories};
3035 let mut config = StackedConfig::with_defaults();
3036 let user = "[user]\nname = \"Test User\"\nemail = \"test@example.com\"\n";
3037 config.add_layer(ConfigLayer::parse(ConfigSource::User, user).unwrap());
3038 let settings = jj_lib::settings::UserSettings::from_config(config).unwrap();
3039 let ws = jj_lib::workspace::Workspace::load(
3040 &settings,
3041 &r.dir,
3042 &default_backend_factories(),
3043 &default_working_copy_factories(),
3045 .unwrap();
3046 pollster::block_on(ws.repo_loader().load_at_head()).unwrap()
3049/// The stored commit that `id`, an expression of the repo `r` giving one
3050/// commit's `Id`, names
3051fn jj_commit(r: &Repo, repo: &std::sync::Arc<jj_lib::repo::ReadonlyRepo>, id: &str) -> jj_lib::commit::Commit {
3052 use jj_lib::repo::Repo as _;
3053 let hash = r.j(&[&format!("\\r -> (meta ({})).hash", id)]).ok().stdout;
3054 let id = jj_lib::backend::CommitId::try_from_hex(hash.trim()).unwrap();
3055 repo.store().get_commit(&id).unwrap()
3058/// Rebase the commit `id` names onto the one `onto` names, as jj does: it
3059/// merges their trees as trees, so where the commit is conflicted, a path
3060/// can keep each term of its merge, a pair of sides that cancel included
3061fn jj_rebase(r: &Repo, id: &str, onto: &str) {
3062 let repo = jj_repo(r);
3063 let commit = jj_commit(r, &repo, id);
3064 let onto = jj_commit(r, &repo, onto);
3065 let mut tx = repo.start_transaction();
3066 pollster::block_on(jj_lib::rewrite::rebase_commit(tx.repo_mut(), commit, vec![onto.id().clone()])).unwrap();
3067 pollster::block_on(tx.repo_mut().rebase_descendants()).unwrap();
3068 pollster::block_on(tx.commit("rebase")).unwrap();
3071#[cfg(unix)]
3072#[test]
3073fn a_conflict_with_a_side_that_is_no_file_left_as_written_stays_when_its_sides_cancel() {
3074 // a tree of five sides, as jj's rebase of a conflicted commit makes one,
3075 // with a file conflict at `a` and a symlink/file conflict at `b`, whose
3076 // description names L's symlink as removed once and added twice (`j`
3077 // reads the conflict with the pair dropped, but keeps the tree as jj
3078 // stored it). Resolving `a` lets the scan write a tree of three
3079 // sides, where `b`'s conflict has the pair cancelled and describes
3080 // itself in three lines, while the file, which nobody touched, still
3081 // holds the five a checkout wrote. The scan took that file for one
3082 // written over the conflict and recorded the description as `b`'s text:
3083 // the run showed `b` resolved, `undo` refused a directory nobody had
3084 // changed, and `describe` recorded the text in C (§7.4)
3085 let conflicted = "\\r -> show (conflicted (files r))";
3086 let parent = "\\r -> show (conflicted (files (up r)))";
3087 let content = "\\r -> show (contentAt [\"b\"] (files r))";
3088 let goto = |m: &str| format!("goto (matching (\\c -> c.message == \"{}\") all)", m);
3089 let r = setup();
3090 r.write("a", "base\n");
3091 r.write("b", "b0\n");
3092 r.j(&["describe \"P\""]).ok();
3093 r.j(&["new"]).ok();
3094 std::fs::remove_file(r.dir.join("b")).unwrap();
3095 std::os::unix::fs::symlink("elsewhere", r.dir.join("b")).unwrap();
3096 r.j(&["describe \"L\""]).ok();
3097 r.j(&["new . goto parents"]).ok();
3098 r.write("a", "right\n");
3099 r.write("b", "b2\n");
3100 r.j(&["describe \"R\""]).ok();
3101 r.j(&["rebase (matching (\\c -> c.message == \"L\") all)"]).ok();
3102 r.j(&[&goto("L")]).ok();
3103 r.j(&["new"]).ok();
3104 r.write("a", "other\n");
3105 r.j(&["describe \"M\""]).ok();
3106 // R onto M as jj rebases it, keeping the pair at `b` (replay drops it)
3107 jj_rebase(&r, "head (matching (\\c -> c.message == \"R\") all r)", "r.root.id");
3108 r.j(&[&goto("R")]).ok();
3109 r.j(&["new"]).ok();
3110 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"a\"] [\"b\"]]");
3111 let repo = jj_repo(&r);
3112 let tree = |id: &str| jj_commit(&r, &repo, id).tree_ids().clone();
3113 assert_eq!(tree("r.root.id").iter().count(), 5);
3114 assert_eq!(tree("r.root.id"), tree("(up r).root.id"));
3115 let description = r.read("b");
3116 assert_eq!(description.lines().count(), 6, "{}", description);
3117 let modified = || std::fs::symlink_metadata(r.dir.join("b")).unwrap().modified().unwrap();
3118 let written = modified();
3120 r.write("a", "resolved\n");
3121 r.j(&["describe \"C\""]).ok();
3122 r.j(&["new"]).ok();
3123 assert_eq!(r.j(&[parent]).ok().stdout.trim(), "[[\"b\"]]");
3124 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"b\"]]");
3125 assert_eq!(r.read("b"), description);
3126 assert_eq!(modified(), written);
3127 let out = r.j(&["undo"]);
3128 assert_eq!(out.code, 0, "{}", out.stderr);
3129 r.j(&["redo"]).ok();
3130 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"b\"]]");
3131 assert_eq!(modified(), written);
3132 // the scan reads no file the working copy's state records as it was
3133 // written, as jj's reads none: one describing the conflict otherwise,
3134 // as another version of jj may have, stays the conflict
3135 let other = description.replace("Conflict:", "CONFLICT:");
3136 r.write("b", &other);
3137 let file = std::fs::File::options().write(true).open(r.dir.join("b")).unwrap();
3138 file.set_modified(written).unwrap();
3139 drop(file);
3140 r.j(&["id"]).ok();
3141 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"b\"]]");
3142 assert_eq!(r.read("b"), other);
3143 // the description written again, as an editor saving it unchanged
3144 // does, is still the conflict
3145 r.write("b", &description);
3146 r.j(&["id"]).ok();
3147 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"b\"]]");
3148 // and so is it where the working-copy state is lost, and made again
3149 // recording no file: nothing is recorded, and `undo` does not refuse
3150 let ops = r.j(&["ops"]).ok().stdout.lines().count();
3151 std::fs::remove_file(r.dir.join(".jj/working_copy/tree_state")).unwrap();
3152 r.j(&["id"]).ok();
3153 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops);
3154 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"b\"]]");
3155 let out = r.j(&["undo"]);
3156 assert_eq!(out.code, 0, "{}", out.stderr);
3157 r.j(&["redo"]).ok();
3158 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"b\"]]");
3159 // anything else written over it is recorded
3160 r.write("b", "mine\n");
3161 r.j(&["describe \"D\""]).ok();
3162 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[]");
3163 assert_eq!(r.j(&[content]).ok().stdout.trim(), "blob \"mine\\n\"");
3164 assert_eq!(r.j(&[parent]).ok().stdout.trim(), "[[\"b\"]]");
3167#[test]
3168fn edit_applied_to_an_edit_is_a_contract_crash() {
3169 // `new new` gives `new` a function where its signature wants a Repo: a
3170 // contract crash (§4.13), so nothing is persisted (§1.2). It used to
3171 // compose instead, and persisted two new commits.
3172 let r = setup();
3173 let before = r.j(&["length . commits . top"]).ok().stdout;
3174 let out = r.j(&["new new"]);
3175 assert_eq!(out.code, 1, "stdout: {}", out.stdout);
3176 assert!(
3177 out.stderr.contains("contract: new expected Repo (record) as argument 1, got function"),
3178 "{}",
3179 out.stderr
3180 );
3181 let after = r.j(&["length . commits . top"]).ok().stdout;
3182 assert_eq!(before, after);
3185#[test]
3186fn split_and_contract_paths() {
3187 let r = setup();
3188 r.write("code.rs", "fn main() {}\n");
3189 r.write("doc.md", "# doc\n");
3190 r.j(&["describe \"both\""]).ok();
3191 r.j(&["split (ext \"rs\")"]).ok();
3192 let out = r.j(&["tree"]).ok();
3193 assert!(out.stdout.contains("both"), "{}", out.stdout);
3196#[test]
3197fn label_literal_empty_without_remote() {
3198 let r = setup();
3199 let out = r.j(&["labelled \"main\""]).ok();
3200 assert!(out.stdout.contains("none") || out.stdout.contains("0"), "{}", out.stdout);
3201 let out = r.j(&["trunk"]).ok();
3202 assert!(out.stdout.contains("none") || out.stdout.contains("0"), "{}", out.stdout);
3205#[test]
3206fn interrupted_expression_persists_nothing() {
3207 let r = setup();
3208 r.write("a.txt", "x\n");
3209 r.j(&["describe \"stable\""]).ok();
3210 // a non-terminating expression; kill it quickly
3211 let mut child = Command::new(j_bin())
3212 .arg("let go = \\n -> go (n + 1) in go 0")
3213 .current_dir(&r.dir)
3214 .env("XDG_CONFIG_HOME", &r.cfg)
3215 .env("NO_COLOR", "1")
3216 .stdout(Stdio::null())
3217 .stderr(Stdio::null())
3218 .spawn()
3219 .unwrap();
3220 std::thread::sleep(std::time::Duration::from_millis(500));
3221 child.kill().unwrap();
3222 let _ = child.wait();
3223 // lock released, repo intact
3224 let out = r.j(&["status"]).ok();
3225 assert!(out.stdout.contains("stable"));
3228#[test]
3229fn init_refuses_inside_existing_repo() {
3230 let r = setup();
3231 let out = r.j(&["init"]);
3232 assert_eq!(out.code, 2);
3233 assert!(out.stderr.contains("already"), "{}", out.stderr);
3236#[test]
3237fn missing_config_is_created_editable() {
3238 let dir = std::env::temp_dir().join(format!(
3239 "j-cli-nocfg-{}-{}",
3240 std::process::id(),
3241 std::time::SystemTime::now()
3242 .duration_since(std::time::UNIX_EPOCH)
3243 .unwrap()
3244 .as_nanos()
3245 ));
3246 let _ = std::fs::remove_dir_all(&dir);
3247 std::fs::create_dir_all(&dir).unwrap();
3248 let cfg_home = dir.join("nonexistent");
3249 let out = Command::new(j_bin())
3250 .arg("status")
3251 .current_dir(&dir)
3252 .env("XDG_CONFIG_HOME", &cfg_home)
3253 .output()
3254 .unwrap();
3255 // no config error: the default config is materialised instead
3256 let stderr = String::from_utf8_lossy(&out.stderr);
3257 assert!(!stderr.contains("no config"), "{}", stderr);
3258 assert!(stderr.contains("created an editable default config"), "{}", stderr);
3259 // the config now exists at the user path and is editable by the owner
3260 let cfg = cfg_home.join("j/config.j");
3261 let written = std::fs::read_to_string(&cfg).expect("config was created");
3262 assert!(written.contains("treeWith"), "default config content");
3263 #[cfg(unix)]
3265 use std::os::unix::fs::PermissionsExt;
3266 assert_eq!(std::fs::metadata(&cfg).unwrap().permissions().mode() & 0o777, 0o600);
3268 let _ = std::fs::remove_dir_all(&dir);
3271// ----------------------------------------------------------------------
3272// regressions
3273// ----------------------------------------------------------------------
3275#[test]
3276fn read_only_run_does_not_hide_working_copy_changes() {
3277 // A printing run snapshots the working directory but records no
3278 // operation. It must not save the scanned tree state either: doing so
3279 // told the next run's incremental snapshot that the directory was already
3280 // recorded, so it rebuilt the Repo from the stale tree in the working-copy
3281 // commit — and a later persisting run then checked that stale tree back
3282 // out, discarding the edits from disk (§7.4/§7.7).
3283 let r = setup();
3284 r.write("a.txt", "one\n");
3285 r.j(&["id"]).ok();
3286 r.write("a.txt", "edited content\n");
3287 r.write("b.txt", "brand new\n");
3288 for _ in 0..3 {
3289 let out = r.j(&["files"]).ok().stdout;
3290 assert!(out.contains("a.txt"), "{}", out);
3291 assert!(out.contains("b.txt"), "b.txt missing from: {}", out);
3293 // and the edits survive the next persisting run, on disk and in the commit
3294 r.j(&["describe \"after reads\""]).ok();
3295 assert_eq!(r.read("a.txt"), "edited content\n");
3296 assert_eq!(r.read("b.txt"), "brand new\n");
3297 let files = r.j(&["files"]).ok().stdout;
3298 assert!(files.contains("b.txt"), "{}", files);
3301#[test]
3302fn refused_reserved_command_does_not_hide_working_copy_changes() {
3303 // `undo` refuses a dirty working copy; the snapshot it took to decide
3304 // that must not be saved either, for the same reason
3305 let r = setup();
3306 r.write("a.txt", "one\n");
3307 r.j(&["id"]).ok();
3308 r.write("a.txt", "edited content\n");
3309 assert_eq!(r.j(&["undo"]).code, 1);
3310 let out = r.j(&["files"]).ok().stdout;
3311 assert!(out.contains("a.txt"), "{}", out);
3312 // the size column reflects the edited file, not the recorded one
3313 assert!(out.contains("15 B"), "expected the edited size in: {}", out);
3316#[test]
3317fn tree_does_not_mark_commits_empty() {
3318 // `empty` was read off a file count that is 0 whenever the parent's file
3319 // list was never materialized, so every commit whose parent skipped
3320 // loading files rendered with the empty glyph
3321 let r = setup();
3322 for n in ["a", "b", "c", "d"] {
3323 r.write(&format!("{}.txt", n), &format!("content {}\n", n));
3324 r.j(&[&format!("describe \"commit {}\"", n)]).ok();
3325 r.j(&["new"]).ok();
3327 let out = r.j(&["tree"]).ok().stdout;
3328 // each commit that added a file must not carry the empty glyph (the
3329 // legend at the bottom names it too, so check the commit rows only)
3330 for n in ["a", "b", "c", "d"] {
3331 let row = out
3332 .lines()
3333 .find(|l| l.contains(&format!("commit {}", n)))
3334 .unwrap_or_else(|| panic!("no row for commit {} in:\n{}", n, out));
3335 assert!(!row.contains('◌'), "commit {} marked empty: {:?}", n, row);
3339/// The tree row of the commit whose message is `msg`.
3340fn tree_row<'a>(out: &'a str, msg: &str) -> &'a str {
3341 out.lines()
3342 .find(|l| l.ends_with(&format!(" {}", msg)))
3343 .unwrap_or_else(|| panic!("no row for {:?} in:\n{}", msg, out))
3346#[test]
3347fn dry_run_tree_glyphs_come_from_the_edited_history() {
3348 // `⊗` and `◌` were read off the stored commits by change id, so a dry run
3349 // drew what is stored rather than what the edit would produce (§1.2): a
3350 // rebase that conflicts showed no `⊗`, an edit that resolves a stored
3351 // conflict kept it, and emptying a commit left its glyph and its
3352 // untouched child's as they were
3353 let r = setup();
3354 r.write("f.txt", "base\n");
3355 r.j(&["describe \"base\""]).ok();
3356 r.j(&["new"]).ok();
3357 r.write("f.txt", "left\n");
3358 r.j(&["describe \"left\""]).ok();
3359 r.j(&["describe \"tip\" . new"]).ok();
3360 // `right`: a sibling of `left` on `base`
3361 r.j(&["new . prev . prev"]).ok();
3362 r.write("f.txt", "right\n");
3363 r.j(&["describe \"right\""]).ok();
3365 let out = r.j(&["tree . rebase siblings"]).ok().stdout;
3366 assert!(tree_row(&out, "right").contains('⊗'), "conflict not drawn:\n{}", out);
3368 let emptied = "tree . at siblings (\\r -> r { root = r.root { files = (up r).root.files } })";
3369 let out = r.j(&[emptied]).ok().stdout;
3370 assert!(tree_row(&out, "left").contains('◌'), "emptied commit not empty:\n{}", out);
3371 assert!(!tree_row(&out, "tip").contains('◌'), "changed child still empty:\n{}", out);
3373 // stored with the conflict, then resolved by an edit that is not persisted
3374 r.j(&["rebase siblings"]).ok();
3375 let out = r.j(&["tree"]).ok().stdout;
3376 assert!(tree_row(&out, "right").contains('⊗'), "{}", out);
3377 let resolved = "tree . (\\r -> r { root = r.root { files = (up r).root.files } })";
3378 let out = r.j(&[resolved]).ok().stdout;
3379 let row = tree_row(&out, "right");
3380 assert!(!row.contains('⊗') && row.contains('◌'), "resolved commit drawn as {:?}:\n{}", row, out);
3383#[test]
3384fn tree_draws_a_minted_commit_after_its_stored_sibling() {
3385 // a dry run's new commit has no stored time; it sorted as the oldest, so
3386 // it took its parent's lane and ended it, and the stored sibling forked
3387 // from under it, drawn as its child. Minted commits come last
3388 // (specs/tree.md Step 1), and the stored sibling forks from the parent.
3389 let r = setup();
3390 r.j(&["describe \"base\""]).ok();
3391 r.j(&["describe \"stored\" . new"]).ok();
3392 let out = r.j(&["tree . new . prev"]).ok().stdout;
3393 let rows: Vec<&str> = out.lines().collect();
3394 let stored = rows.iter().position(|l| l.ends_with(" stored")).unwrap();
3395 let minted = rows.iter().position(|l| l.starts_with('▶')).unwrap();
3396 assert!(stored < minted, "minted commit drawn first:\n{}", out);
3397 assert!(rows[stored].contains('├'), "{}", out);
3398 assert!(!rows[minted].contains('├'), "{}", out);
3401#[test]
3402fn tree_draws_the_size_bar_of_the_focus_parent() {
3403 // the focus's parent diffs against its own parent, whose file list was
3404 // only loaded when that grandparent was the root: every other parent of
3405 // the focus lost its bar (specs/tree.md, Step 4 column 5)
3406 let r = setup();
3407 for (n, body) in [("A", "a\n"), ("B", "b\nb\nb\n"), ("C", "c\n")] {
3408 r.write(&format!("{}.txt", n), body);
3409 r.j(&[&format!("describe \"{}\"", n)]).ok();
3410 if n != "C" {
3411 r.j(&["new"]).ok();
3414 let out = r.j(&["tree"]).ok().stdout;
3415 let bars = ['▁', '▂', '▃', '▅', '▇'];
3416 for n in ["B", "C"] {
3417 assert!(tree_row(&out, n).contains(bars), "no size bar on {}:\n{}", n, out);
3419 // the grandparent is neither the focus nor next to it: no bar at detail 1
3420 assert!(!tree_row(&out, "A").contains(bars), "{}", out);
3423#[test]
3424fn tree_data_sizes_commits_by_their_changes() {
3425 // the size bar counted the whole length of both versions of each changed
3426 // file, so a one-line edit to a long file drew `▇`, and the files column
3427 // counted the whole snapshot, so an empty commit showed its parent's file
3428 // count (§7.11 column 5; specs/tree.md Step 4 column 9)
3429 let r = setup();
3430 let big: String = (1..=1500).map(|i| format!("{}\n", i)).collect();
3431 r.write("big", &big);
3432 r.write("small", "hello\n");
3433 r.j(&["describe \"big file\""]).ok();
3434 r.j(&["new"]).ok();
3435 r.write("big", &big.replace("\n750\n", "\nseven fifty\n"));
3436 r.j(&["describe \"one line edit\""]).ok();
3437 r.j(&["new"]).ok();
3438 r.j(&["describe \"empty commit\""]).ok();
3439 let row = |out: &str, msg: &str| -> (String, String) {
3440 let row = out
3441 .lines()
3442 .find(|l| l.contains(&format!(" {} ", msg)))
3443 .unwrap_or_else(|| panic!("no row for {:?} in:\n{}", msg, out))
3444 .to_string();
3445 let bar: String = row.chars().filter(|c| "▁▂▃▅▇".contains(*c)).collect();
3446 let toks: Vec<&str> = row.split_whitespace().collect();
3447 let at = toks.iter().position(|t| *t == "files").expect("files column");
3448 (bar, toks[at - 1].to_string())
3449 };
3450 let out = r.j(&["treeData"]).ok().stdout;
3451 assert_eq!(row(&out, "big file"), ("▇".into(), "2".into()), "{}", out);
3452 assert_eq!(row(&out, "one line edit"), ("▁".into(), "1".into()), "{}", out);
3453 assert_eq!(row(&out, "empty commit"), ("".into(), "0".into()), "{}", out);
3454 // at detail 1 the first commit is not next to the focus and draws no
3455 // bar; its files column is counted all the same
3456 let detail1 = "treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = \"never\", lanes = 4, author = false, date = false, files = true })";
3457 let out = r.j(&[detail1]).ok().stdout;
3458 assert_eq!(row(&out, "big file"), ("".into(), "2".into()), "{}", out);
3459 assert_eq!(row(&out, "one line edit"), ("▁".into(), "1".into()), "{}", out);
3460 assert_eq!(row(&out, "empty commit"), ("".into(), "0".into()), "{}", out);
3463#[test]
3464fn focus_lists_the_paths_the_commit_changes() {
3465 // `j focus` listed the whole snapshot, counted and unmarked, so a deleted
3466 // file never showed, and any commit shown as a block was drawn as the
3467 // focus (§5.1)
3468 let r = setup();
3469 r.write("a.txt", "a\n");
3470 r.write("b.txt", "b\n");
3471 r.j(&["describe \"A\""]).ok();
3472 r.j(&["new"]).ok();
3473 r.write("a.txt", "a2\n");
3474 std::fs::remove_file(r.dir.join("b.txt")).unwrap();
3475 r.write("c.txt", "c\n");
3476 r.j(&["describe \"B\""]).ok();
3477 let out = r.j(&["focus"]).ok().stdout;
3478 let lines: Vec<&str> = out.lines().collect();
3479 assert!(lines[0].starts_with("◉ ") && lines[0].ends_with(" B"), "{}", out);
3480 assert!(lines[1].ends_with(" · 3 files"), "{}", out);
3481 assert_eq!(lines[2..], ["", " ~ a.txt", " − b.txt", " + c.txt"], "{}", out);
3482 // the parent is drawn as `tree` draws it
3483 let out = r.j(&["\\r -> (up r).root"]).ok().stdout;
3484 let tree = r.j(&["tree"]).ok().stdout;
3485 let lines: Vec<&str> = out.lines().collect();
3486 assert!(tree_row(&tree, "A").contains('●'), "{}", tree);
3487 assert!(lines[0].starts_with("● ") && lines[0].ends_with(" A"), "{}", out);
3488 assert!(lines[1].ends_with(" · 2 files"), "{}", out);
3489 assert_eq!(lines[2..], ["", " + a.txt", " + b.txt"], "{}", out);
3490 // a new commit changes nothing
3491 r.j(&["new"]).ok();
3492 let out = r.j(&["focus"]).ok().stdout;
3493 let lines: Vec<&str> = out.lines().collect();
3494 assert!(lines[0].starts_with("◌ "), "{}", out);
3495 assert!(lines[1].ends_with(" · 0 files"), "{}", out);
3496 assert_eq!(lines.len(), 2, "{}", out);
3499#[test]
3500fn a_commit_is_shown_where_it_stands_after_the_snapshot() {
3501 // a commit's block read its parent from the repository as loaded, before
3502 // the snapshot, while the commit came from the repository the expression
3503 // was given, where the snapshot has rebased every descendant of the
3504 // focus: a dirty focus's edits showed as its child's own changes, and an
3505 // empty grandchild was drawn `○` with a path it does not change (§5.1)
3506 let r = setup();
3507 r.write("a.txt", "one\n");
3508 r.j(&["describe \"A\""]).ok();
3509 r.j(&["new"]).ok();
3510 r.write("c.txt", "c\n");
3511 r.j(&["describe \"C\""]).ok();
3512 r.j(&["new"]).ok();
3513 r.j(&["describe \"E\""]).ok();
3514 r.j(&["prev"]).ok();
3515 r.j(&["prev"]).ok();
3516 r.write("a.txt", "one\ntwo\n");
3517 let out = r.j(&["focus . next"]).ok().stdout;
3518 let lines: Vec<&str> = out.lines().collect();
3519 assert!(lines[0].starts_with("○ ") && lines[0].ends_with(" C"), "{}", out);
3520 assert!(lines[1].ends_with(" · 1 files"), "{}", out);
3521 assert_eq!(lines[2..], ["", " + c.txt"], "{}", out);
3522 let tree = r.j(&["tree"]).ok().stdout;
3523 assert!(tree_row(&tree, "E").contains('◌'), "{}", tree);
3524 let out = r.j(&["focus . next . next"]).ok().stdout;
3525 let lines: Vec<&str> = out.lines().collect();
3526 assert!(lines[0].starts_with("◌ ") && lines[0].ends_with(" E"), "{}", out);
3527 assert!(lines[1].ends_with(" · 0 files"), "{}", out);
3528 assert_eq!(lines.len(), 2, "{}", out);
3529 // a commit table draws each row as the commit's line, glyph included
3530 r.j(&["new"]).ok();
3531 r.write("d.txt", "d\n");
3532 r.j(&["describe \"N\""]).ok();
3533 let tree = r.j(&["tree"]).ok().stdout;
3534 let out = r.j(&["ancestors"]).ok().stdout;
3535 let lines: Vec<&str> = out.lines().collect();
3536 assert_eq!(lines.len(), 3, "{}", out);
3537 assert!(tree_row(&tree, "N").contains('◉'), "{}", tree);
3538 assert!(lines[0].starts_with("◉ ") && lines[0].ends_with(" N"), "{}", out);
3539 assert!(tree_row(&tree, "A").contains('●'), "{}", tree);
3540 assert!(lines[1].starts_with("● ") && lines[1].ends_with(" A"), "{}", out);
3541 assert!(lines[2].starts_with("⌂ "), "{}", out);
3544#[test]
3545fn a_commit_table_reads_its_commits_after_the_snapshot() {
3546 // a list of ids looked its commits up in the repository as loaded, before
3547 // the snapshot rebased the focus's descendants: a child that the working
3548 // directory's edit puts in conflict was drawn `○` where `tree` and the
3549 // child's own line draw `⊗` (§5.1)
3550 let r = setup();
3551 r.write("a.txt", "one\n");
3552 r.j(&["describe \"A\""]).ok();
3553 r.j(&["new"]).ok();
3554 r.write("a.txt", "C\n");
3555 r.j(&["describe \"C\""]).ok();
3556 r.j(&["prev"]).ok();
3557 r.write("a.txt", "zero\n");
3558 let tree = r.j(&["tree"]).ok().stdout;
3559 assert!(tree_row(&tree, "C").contains('⊗'), "{}", tree);
3560 let out = r.j(&["\\r -> [(next r).root.id]"]).ok().stdout;
3561 assert!(out.starts_with("⊗ ") && out.ends_with(" C\n"), "{}", out);
3562 let out = r.j(&["\\r -> { c = (next r).root }"]).ok().stdout;
3563 assert!(out.starts_with("c ⊗ ") && out.ends_with(" C\n"), "{}", out);
3566#[test]
3567fn redo_twice_in_a_row() {
3568 // the second redo followed the redo marker to the undo it reversed and
3569 // restored *that* view, instead of looking outward for an undo that had
3570 // not been reversed yet (§7.7)
3571 let r = setup();
3572 r.write("a.txt", "x\n");
3573 // two operations on the same commit, so undoing both clears the message
3574 r.j(&["describe \"one\""]).ok();
3575 r.j(&["describe \"two\""]).ok();
3576 r.j(&["undo"]).ok();
3577 assert!(r.j(&["status"]).ok().stdout.contains("one"));
3578 r.j(&["undo"]).ok();
3579 let cleared = r.j(&["status"]).ok().stdout;
3580 assert!(!cleared.contains("one"), "{}", cleared);
3581 r.j(&["redo"]).ok();
3582 let first = r.j(&["status"]).ok().stdout;
3583 assert!(first.contains("one"), "first redo: {}", first);
3584 r.j(&["redo"]).ok();
3585 let second = r.j(&["status"]).ok().stdout;
3586 assert!(second.contains("two"), "second redo did not restore it:\n{}", second);
3589#[test]
3590fn undo_redo_cycles_are_stable() {
3591 // alternating undo/redo must keep returning to the same two states
3592 let r = setup();
3593 r.write("a.txt", "x\n");
3594 r.j(&["describe \"one\""]).ok();
3595 for i in 0..3 {
3596 r.j(&["undo"]).ok();
3597 let after = r.j(&["status"]).ok().stdout;
3598 assert!(!after.contains("one"), "cycle {}: {}", i, after);
3599 r.j(&["redo"]).ok();
3600 let back = r.j(&["status"]).ok().stdout;
3601 assert!(back.contains("one"), "cycle {}: {}", i, back);
3605#[test]
3606fn extract_sees_through_lazy_file_lists() {
3607 // a commit's `files` is a thunk until something asks for it; `extract`
3608 // walked past it, so it found no entries at all on a real repository
3609 // while finding them all on the in-memory backend
3610 let r = setup();
3611 r.write("a.txt", "x\n");
3612 r.write("b.txt", "y\n");
3613 r.j(&["id"]).ok();
3614 let entries = r.j(&["length . extract Entry"]).ok().stdout;
3615 assert_eq!(entries.trim(), "2", "extract Entry: {}", entries);
3616 let blobs = r.j(&["length . extract Blob"]).ok().stdout;
3617 assert_eq!(blobs.trim(), "2", "extract Blob: {}", blobs);
3620#[test]
3621fn moving_the_focus_rewrites_uncommitted_edits() {
3622 // A persisting run that starts with uncommitted edits folds their
3623 // snapshot into its operation and then checks out the new focus. The
3624 // checkout diffed from the tree the working copy had last *recorded*,
3625 // not from the snapshot, so a path whose recorded content equals the new
3626 // focus's was never rewritten: `prev` left the child's edit on disk, and
3627 // the next run recorded it into the parent (§7.4, §7.5 step 7).
3628 let r = setup();
3629 r.write("a.txt", "one\n");
3630 r.j(&["describe \"first\""]).ok();
3631 r.j(&["new"]).ok();
3632 r.write("a.txt", "two\n");
3633 r.j(&["prev"]).ok();
3634 assert_eq!(r.read("a.txt"), "one\n");
3635 r.j(&["describe \"first, again\""]).ok();
3636 let files = r.j(&["\\r -> show r.root.files"]).ok().stdout;
3637 assert!(files.contains("blob \"one\\n\""), "the parent took the edit: {}", files);
3638 // the edit itself lives on in the child it was snapshotted into
3639 r.j(&["next"]).ok();
3640 assert_eq!(r.read("a.txt"), "two\n");
3643#[test]
3644fn abandon_discards_uncommitted_edits_from_disk() {
3645 // the same stale diff: the new focus, an empty child of the abandoned
3646 // commit's parent, has exactly the recorded tree, so the checkout did
3647 // nothing at all and the discarded
3648 // edits, deletions and new files stayed on disk to be recorded into the
3649 // new focus by the next run
3650 let r = setup();
3651 r.write("a.txt", "orig\n");
3652 r.write("b.txt", "bee\n");
3653 r.j(&["describe \"base\""]).ok();
3654 r.j(&["new"]).ok();
3655 r.write("a.txt", "scratch\n");
3656 std::fs::remove_file(r.dir.join("b.txt")).unwrap();
3657 r.write("c.txt", "junk\n");
3658 r.j(&["abandon"]).ok();
3659 assert_eq!(r.read("a.txt"), "orig\n");
3660 assert_eq!(r.read("b.txt"), "bee\n");
3661 assert!(!r.dir.join("c.txt").exists(), "c.txt survived the abandon");
3662 // the directory now equals the focus, so recording it is a no-op
3663 r.j(&["id"]).ok();
3664 let ops = r.j(&["ops"]).ok().stdout;
3665 assert!(ops.lines().next().unwrap_or("").contains("abandon"), "{}", ops);
3668#[test]
3669fn reordering_a_snapshot_is_not_a_change() {
3670 // `select` lists the entries a fileset matches first, so a `contract m`
3671 // that moves nothing still hands persistence a parent whose entries are
3672 // in another order. Comparing snapshots as lists called that a change:
3673 // every such run rewrote the parent and each commit above it (new hashes
3674 // and committer times, the same trees) and recorded an operation. A
3675 // snapshot stands for a tree, which has no order (§7.3, §7.5 step 4).
3676 let r = setup();
3677 r.write("a.txt", "a\n");
3678 std::fs::create_dir(r.dir.join("h")).unwrap();
3679 r.write("h/x", "x\n");
3680 r.j(&["describe \"base\""]).ok();
3681 r.j(&["new"]).ok();
3682 r.write("h/x", "x2\n");
3683 r.j(&["describe \"child\""]).ok();
3684 // the first contract moves h/x into base; after it, one moves nothing
3685 r.j(&["contract (under ./h)"]).ok();
3686 let hashes = || {
3687 r.j(&["\\r -> map (\\i -> (meta i).hash) (ancestors r)"])
3688 .ok()
3689 .stdout
3690 };
3691 let before = hashes();
3692 let ops = r.j(&["ops"]).ok().stdout.lines().count();
3693 let rotate = "mapRoot (\\c -> c { files = tail c.files ++ [(head c.files)] })";
3694 for edit in [
3695 "contract (under ./h)".to_string(),
3696 "tree . validate . contract (under ./h)".to_string(),
3697 // the same kind of reorder written by hand, on the focus and its parent
3698 rotate.to_string(),
3699 format!("at parents ({})", rotate),
3700 ] {
3701 r.j(&[&edit]).ok();
3702 let now = r.j(&["ops"]).ok().stdout.lines().count();
3703 assert_eq!(now, ops, "{} was recorded", edit);
3704 assert_eq!(hashes(), before, "{} rewrote a commit", edit);
3706 // an edit in the working directory is recorded, and the parent, which
3707 // comes back only reordered, is kept as stored
3708 let parent_hash = || r.j(&["\\r -> (meta (up r).root.id).hash"]).ok().stdout;
3709 let parent = parent_hash();
3710 r.write("a.txt", "edited\n");
3711 r.j(&["contract (under ./h)"]).ok();
3712 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
3713 assert_eq!(parent_hash(), parent, "the parent was rewritten");
3714 // a real change to the parent is recorded
3715 r.write("h/x", "x3\n");
3716 r.j(&["contract (under ./h)"]).ok();
3717 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 2);
3718 assert_ne!(parent_hash(), parent);
3719 let base = "\\r -> text (contentAt ./h/x (up r).root.files)";
3720 assert_eq!(r.j(&[base]).ok().stdout, "x3\n");
3723#[test]
3724fn replacing_a_file_with_a_directory_survives_persistence() {
3725 // checking out from the stale recorded tree turned a file <-> directory
3726 // swap into working-copy state that contradicts the tree: debug builds
3727 // panicked in jj-lib on this and every later run, release builds dropped
3728 // the directory from disk and from the commit on the next snapshot
3729 let r = setup();
3730 r.write("a", "hi\n");
3731 r.j(&["describe \"file\""]).ok();
3732 r.j(&["new"]).ok();
3733 std::fs::remove_file(r.dir.join("a")).unwrap();
3734 std::fs::create_dir(r.dir.join("a")).unwrap();
3735 r.write("a/b.txt", "t\n");
3736 r.j(&["describe \"dir\""]).ok();
3737 r.j(&["tree"]).ok();
3738 r.j(&["id"]).ok();
3739 assert_eq!(r.read("a/b.txt"), "t\n");
3740 let files = r.j(&["files"]).ok().stdout;
3741 assert!(files.contains("b.txt"), "{}", files);
3742 // and back: a directory replaced by a file
3743 r.j(&["new"]).ok();
3744 std::fs::remove_dir_all(r.dir.join("a")).unwrap();
3745 r.write("a", "file again\n");
3746 r.j(&["describe \"file again\""]).ok();
3747 r.j(&["tree"]).ok();
3748 r.j(&["id"]).ok();
3749 assert_eq!(r.read("a"), "file again\n");
3750 // the checkout itself swaps them back when the focus moves
3751 r.j(&["prev"]).ok();
3752 assert_eq!(r.read("a/b.txt"), "t\n");
3753 r.j(&["prev"]).ok();
3754 assert_eq!(r.read("a"), "hi\n");
3757#[test]
3758fn editing_a_conflict_keeps_its_long_markers() {
3759 // a conflict whose sides hold a 7-dash line (a markdown underline) is
3760 // written with longer markers, and the working copy remembers their
3761 // length. Checking out after a snapshot reset the state of every path
3762 // the snapshot changed, forgetting it, and when the checkout did not
3763 // rewrite the file the next run read it back with 7-character markers:
3764 // the side's own `-------` broke the parse, so a conflict edited with its
3765 // markers kept was recorded as resolved, markers and all (§7.3, §7.4)
3766 let r = setup();
3767 r.write("README.md", "Intro\n");
3768 r.j(&["describe \"base\""]).ok();
3769 r.j(&["new"]).ok();
3770 r.write("README.md", "Intro\n\nUsage\n-------\nA text\n");
3771 r.j(&["describe \"A\""]).ok();
3772 r.j(&["prev"]).ok();
3773 r.j(&["new"]).ok();
3774 r.write("README.md", "Intro\n\nInstall\n-------\nB text\n");
3775 r.j(&["describe \"B\""]).ok();
3776 r.j(&["rebase (matching (\\c -> c.message == \"A\") all)"]).ok();
3777 let text = r.read("README.md");
3778 assert!(text.contains("\n<<<<<<<<<<< "), "not written with long markers:\n{}", text);
3779 let conflicted = "\\r -> show (conflicted r.root.files)";
3780 // a persisting run that leaves the edited file as the snapshot read it
3781 r.write("README.md", &text.replace("B text\n", "B text, edited\n"));
3782 r.j(&["describe \"B edited\""]).ok();
3783 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"README.md\"]]");
3784 r.j(&["id"]).ok();
3785 let content = "\\r -> show (contentAt [\"README.md\"] r.root.files)";
3786 let stored = r.j(&[content]).ok().stdout;
3787 assert!(stored.contains("unresolved") && stored.contains("B text, edited"), "{}", stored);
3788 // one that moves the focus to a child with the same files
3789 r.write("README.md", &text.replace("B text\n", "B text, edited twice\n"));
3790 r.j(&["new"]).ok();
3791 let status = r.j(&["\\r -> show [(status r).changed (status r).conflicts]"]).ok().stdout;
3792 assert_eq!(status.trim(), "[[] [[\"README.md\"]]]");
3793 assert!(r.read("README.md").contains("B text, edited twice\n"));
3796#[test]
3797fn a_directory_that_cannot_be_scanned_mid_run_does_not_fail_the_checkout() {
3798 // §7.4, §7.7: persist's checkout scans the working directory again. An
3799 // entry that appeared during evaluation and cannot be read (a directory
3800 // deeper than PATH_MAX here; an unreadable mount, a temporary file that
3801 // vanishes mid-scan) failed that scan after the operation had been
3802 // published: the run crashed with it recorded and the working copy never
3803 // advanced, and once the directory was gone the next run snapshotted the
3804 // child's files into the new focus, its parent. The checkout now
3805 // describes the snapshot instead, as it did before it rescanned.
3806 let r = setup();
3807 r.write("a", "p\n");
3808 r.j(&["describe \"P\""]).ok();
3809 r.j(&["new"]).ok();
3810 r.write("a", "x1\n");
3811 r.j(&["describe \"X\""]).ok();
3812 r.write("a", "x2\n");
3813 // built outside the working directory: 20 levels of 250 bytes
3814 let half: PathBuf = (0..10).map(|_| "d".repeat(250)).collect();
3815 let deep = r.cfg.join("deep");
3816 std::fs::create_dir_all(deep.join(&half)).unwrap();
3817 std::fs::create_dir_all(r.cfg.join("more").join(&half)).unwrap();
3818 std::fs::rename(r.cfg.join("more"), deep.join(&half).join("more")).unwrap();
3819 let ops_dir = r.dir.join(".jj/repo/op_store/operations");
3820 let count = || std::fs::read_dir(&ops_dir).unwrap().count();
3821 let before = count();
3822 let mut child = Command::new(j_bin())
3823 .arg("\\r -> prev (describe (show (foldl (+) 0 (range 0 1000000))) r)")
3824 .current_dir(&r.dir)
3825 .env("XDG_CONFIG_HOME", &r.cfg)
3826 .env("NO_COLOR", "1")
3827 .stdout(Stdio::null())
3828 .stderr(Stdio::piped())
3829 .spawn()
3830 .unwrap();
3831 // the snapshot's operation is written before evaluation: move the
3832 // directory in while the expression runs
3833 while count() == before && child.try_wait().unwrap().is_none() {
3834 std::thread::sleep(std::time::Duration::from_millis(1));
3836 std::fs::rename(&deep, r.dir.join("deep")).unwrap();
3837 let out = child.wait_with_output().unwrap();
3838 assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
3839 assert_eq!(r.read("a"), "p\n");
3840 std::fs::remove_dir_all(r.dir.join("deep")).unwrap();
3841 let files = r.j(&[CONTENT_OF_A]).ok().stdout;
3842 assert_eq!(
3843 files.trim(),
3844 r#"[["" (blob "")] ["P" (blob "p\n")] ["499999500000" (blob "x2\n")]]"#
3845 );
3848/// A repository whose working copy is stale, as jj leaves it when it moves
3849/// the working-copy commit without updating the working directory (a
3850/// command run with `--ignore-working-copy`, or in another workspace): the
3851/// focus is B, while the directory and the working-copy state hold its
3852/// parent A. `a` is 1 in A and 2 in B, `b` is only in A, `z` only in B.
3853fn stale_working_copy() -> Repo {
3854 let r = setup();
3855 r.write("a", "1\n");
3856 r.write("b", "b\n");
3857 r.j(&["describe \"A\""]).ok();
3858 let saved: Vec<(PathBuf, Vec<u8>)> = std::fs::read_dir(r.dir.join(".jj/working_copy"))
3859 .unwrap()
3860 .map(|e| e.unwrap().path())
3861 .map(|p| (p.clone(), std::fs::read(p).unwrap()))
3862 .collect();
3863 r.j(&["new"]).ok();
3864 r.write("a", "2\n");
3865 std::fs::remove_file(r.dir.join("b")).unwrap();
3866 r.write("z", "z\n");
3867 r.j(&["describe \"B\""]).ok();
3868 for (path, bytes) in saved {
3869 std::fs::write(path, bytes).unwrap();
3871 r.write("a", "1\n");
3872 r.write("b", "b\n");
3873 std::fs::remove_file(r.dir.join("z")).unwrap();
3877/// Each commit's message and paths, top-down
3878const PATHS_BY_COMMIT: &str =
3879 "\\r -> show (map (\\c -> [c.message (map (\\e -> e.path) c.files)]) (commits (top r)))";
3881#[test]
3882fn a_run_over_a_stale_working_copy_writes_the_focus() {
3883 // §7.4: with nothing snapshotted, persist's checkout took the directory
3884 // to hold the focus as loaded. Its rescan found otherwise, so it reset
3885 // the state to B, whose tree the focus shares: the checkout wrote
3886 // nothing, and the state was saved claiming B's files over A's. The
3887 // next run recorded A's files into the focus, a silent revert of B.
3888 let r = stale_working_copy();
3889 r.j(&["describe \"B2\""]).ok();
3890 assert_eq!(r.read("a"), "2\n");
3891 assert!(!r.dir.join("b").exists());
3892 assert_eq!(r.read("z"), "z\n");
3893 // the directory now holds the focus, so there is nothing to record
3894 let ops = r.j(&["ops"]).ok().stdout.lines().count();
3895 r.j(&["id"]).ok();
3896 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops);
3897 let files = r.j(&[CONTENT_OF_A]).ok().stdout;
3898 assert_eq!(files.trim(), r#"[["" (blob "")] ["A" (blob "1\n")] ["B2" (blob "2\n")]]"#);
3899 let paths = r.j(&[PATHS_BY_COMMIT]).ok().stdout;
3900 assert_eq!(paths.trim(), r#"[["" []] ["A" [["a"] ["b"]]] ["B2" [["a"] ["z"]]]]"#);
3903#[test]
3904fn a_failed_checkout_over_a_stale_working_copy_is_carried_on_from() {
3905 // §7.4, §7.5 step 7: a checkout that fails over a stale working copy
3906 // leaves it stale, recording what the directory held when the
3907 // checkout began, so the next run takes what the checkout wrote for
3908 // the focus's own; `j undo` goes back, and checks out B, which the
3909 // directory did not hold before
3910 let r = stale_working_copy();
3911 let log = r.j(&["log"]).ok().stdout;
3912 let ops = r.j(&["ops"]).ok().stdout.lines().count();
3913 // written in path order: `a`, `b` (removed), then the path under `m`
3914 // fails before `z` is reached
3915 let edit = format!(
3916 "mapRoot (\\c -> c {{ files = c.files ++ [{{ path = [{}], content = blob \"deep\" }}] }})",
3917 too_deep("m")
3918 );
3919 let out = r.j(&[&edit]);
3920 assert_eq!(out.code, 1, "{}", out.stderr);
3921 assert!(out.stderr.contains("cannot check out"), "{}", out.stderr);
3922 assert!(out.stderr.contains("the operation is recorded"), "{}", out.stderr);
3923 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
3924 assert_eq!(r.read("a"), "2\n");
3925 assert!(!r.dir.join("b").exists());
3926 assert!(!r.dir.join("z").exists());
3927 r.j(&["id"]).ok();
3928 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
3929 let files = r.j(&[CONTENT_OF_A]).ok().stdout;
3930 assert_eq!(files.trim(), r#"[["" (blob "")] ["A" (blob "1\n")] ["B" (blob "2\n")]]"#);
3931 let z = r.j(&["\\r -> show (contentAt [\"z\"] (files r))"]).ok().stdout;
3932 assert_eq!(z.trim(), r#"blob "z\n""#);
3933 r.j(&["undo"]).ok();
3934 assert_eq!(r.j(&["log"]).ok().stdout, log);
3935 assert_eq!(r.read("a"), "2\n");
3936 assert!(!r.dir.join("b").exists());
3937 assert_eq!(r.read("z"), "z\n");
3938 r.j(&["id"]).ok();
3939 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 2);
3942#[test]
3943fn a_stale_working_copy_with_changes_records_them_onto_the_focus() {
3944 // §7.4: the snapshot read the directory against the stale state and
3945 // recorded all of it into the focus, so one new file beside A's files
3946 // undid B; then every run, printing or not, was refused until the
3947 // directory was put back by hand. What the directory holds is a change
3948 // to what the state records, and only that change is recorded, onto
3949 // the focus.
3950 let r = stale_working_copy();
3951 r.write("notes.txt", "note\n");
3952 let paths = r.j(&["\\r -> show (map (\\e -> e.path) (files r))"]).ok().stdout;
3953 assert_eq!(paths.trim(), r#"[["a"] ["notes.txt"] ["z"]]"#);
3954 r.j(&["id"]).ok();
3955 let paths = r.j(&[PATHS_BY_COMMIT]).ok().stdout;
3956 assert_eq!(paths.trim(), r#"[["" []] ["A" [["a"] ["b"]]] ["B" [["a"] ["notes.txt"] ["z"]]]]"#);
3957 let files = r.j(&[CONTENT_OF_A]).ok().stdout;
3958 assert_eq!(files.trim(), r#"[["" (blob "")] ["A" (blob "1\n")] ["B" (blob "2\n")]]"#);
3959 // and the focus is checked out
3960 assert_eq!(r.read("a"), "2\n");
3961 assert!(!r.dir.join("b").exists());
3962 assert_eq!(r.read("z"), "z\n");
3963 assert_eq!(r.read("notes.txt"), "note\n");
3964 let ops = r.j(&["ops"]).ok().stdout.lines().count();
3965 r.j(&["id"]).ok();
3966 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops);
3969#[test]
3970fn an_edit_over_a_stale_working_copy_to_a_file_the_focus_changed_is_a_conflict() {
3971 // §7.4: B changed `a` since the state was checked out, and so did the
3972 // user, differently: neither change is lost
3973 let r = stale_working_copy();
3974 r.write("a", "mine\n");
3975 r.j(&["id"]).ok();
3976 let conflicted = r.j(&["\\r -> show (conflicted (files r))"]).ok().stdout;
3977 assert_eq!(conflicted.trim(), r#"[["a"]]"#);
3978 let text = r.read("a");
3979 assert!(text.contains("mine\n") && text.contains("2\n"), "{}", text);
3982#[test]
3983fn a_directory_swapped_with_a_file_after_a_failed_checkout_is_kept() {
3984 // §7.4: over a stale working copy, the snapshot merged what the
3985 // directory changed with what the commit changed. Where the user had
3986 // put a directory in place of a file the commit changes, or a file in
3987 // place of a directory holding one, that merge gave one conflict at the
3988 // path between files and a directory, whose directory side j reads as
3989 // empty, and the next checkout wrote jj's description of it there,
3990 // deleting the files the directory held. The snapshot now takes what
3991 // the directory holds at such a path, as over a working copy that is
3992 // not stale; the commit's change there is left in the operation before.
3993 for (before, after) in [("z", "z/new"), ("z/x", "z")] {
3994 let r = setup();
3995 std::fs::create_dir_all(r.dir.join(before).parent().unwrap()).unwrap();
3996 r.write(before, "1\n");
3997 r.j(&["describe \"A\""]).ok();
3998 // the checkout fails at the path under `m`, before it reaches `z`
3999 let path: Vec<String> = before.split('/').map(|c| format!("\"{}\"", c)).collect();
4000 let edit = format!(
4001 "mapRoot (\\c -> c {{ files = [({{ path = [{}], content = blob \"2\\n\" }}) ({{ path = [{}], content = blob \"deep\" }})] }})",
4002 path.join(" "),
4003 too_deep("m")
4004 );
4005 let out = r.j(&[&edit]);
4006 assert_eq!(out.code, 1, "{}: {}", before, out.stderr);
4007 assert!(out.stderr.contains("the operation is recorded"), "{}: {}", before, out.stderr);
4008 assert_eq!(r.read(before), "1\n");
4009 let ops = r.j(&["ops"]).ok().stdout.lines().count();
4010 if before == "z" {
4011 std::fs::remove_file(r.dir.join("z")).unwrap();
4012 } else {
4013 std::fs::remove_dir_all(r.dir.join("z")).unwrap();
4015 std::fs::create_dir_all(r.dir.join(after).parent().unwrap()).unwrap();
4016 r.write(after, "mine\n");
4017 // the path the checkout failed on is left out of the focus
4018 r.j(&["mapRoot (\\c -> c { files = restrict (neg (under ./m)) c.files })"]).ok();
4019 assert_eq!(r.read(after), "mine\n", "{}", before);
4020 let path: Vec<String> = after.split('/').map(|c| format!("\"{}\"", c)).collect();
4021 let paths = r.j(&[FOCUS_PATHS]).ok().stdout;
4022 assert_eq!(paths.trim(), format!("[[{}]]", path.join(" ")), "{}", before);
4023 let conflicted = r.j(&["\\r -> show (conflicted (files r))"]).ok().stdout;
4024 assert_eq!(conflicted.trim(), "[]", "{}", before);
4025 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
4026 r.j(&["id"]).ok();
4027 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1, "{}", before);
4028 assert_eq!(r.read(after), "mine\n", "{}", before);
4032#[test]
4033fn a_conflict_with_a_directory_side_is_kept_where_a_failed_checkout_left_the_path() {
4034 // §7.4, §7.7: R's `z` is a conflict between L's deletion of a file and
4035 // R's directory in its place. A checkout from O, a child with its own
4036 // file `z`, back to R wrote `a` and then failed before it reached `z`.
4037 // The snapshot took what the directory held at every conflict of its
4038 // merge that set a directory against a file, R's own included, although
4039 // the directory held what the last checkout left at `z`: `j undo` was
4040 // refused as holding changes not in @, and the next persisting run
4041 // recorded O's file into R, its conflict and R's `z/x` gone. Only a
4042 // path the directory changed takes what it holds there.
4043 let conflicted = "\\r -> show (conflicted (files r))";
4044 let goto = |m: &str| format!("goto (matching (\\c -> c.message == \"{}\") all)", m);
4045 for undo in [true, false] {
4046 let r = setup();
4047 r.write("z", "base\n");
4048 r.j(&["describe \"P\""]).ok();
4049 r.j(&["new"]).ok();
4050 std::fs::remove_file(r.dir.join("z")).unwrap();
4051 r.j(&["describe \"L\""]).ok();
4052 r.j(&["new . goto parents"]).ok();
4053 std::fs::remove_file(r.dir.join("z")).unwrap();
4054 std::fs::create_dir(r.dir.join("z")).unwrap();
4055 r.write("z/x", "x\n");
4056 r.j(&["describe \"R\""]).ok();
4057 r.j(&["rebase (matching (\\c -> c.message == \"L\") all)"]).ok();
4058 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"z\"]]");
4059 assert!(r.read("z").starts_with("Conflict:\n"), "{}", r.read("z"));
4060 r.j(&["new"]).ok();
4061 r.write("z", "other\n");
4062 r.j(&["describe \"O\""]).ok();
4063 let log = r.j(&["log"]).ok().stdout;
4064 let ops = r.j(&["ops"]).ok().stdout.lines().count();
4065 // written in path order: `a`, then the path under `m` fails, before
4066 // `z` is reached
4067 let edit = format!(
4068 "mapRoot (\\c -> c {{ files = c.files ++ [({{ path = [\"a\"], content = blob \"a\\n\" }}) ({{ path = [{}], content = blob \"deep\" }})] }}) . {}",
4069 too_deep("m"),
4070 goto("R")
4071 );
4072 let out = r.j(&[&edit]);
4073 assert_eq!(out.code, 1, "{}", out.stderr);
4074 assert!(out.stderr.contains("the operation is recorded"), "{}", out.stderr);
4075 assert_eq!(r.read("a"), "a\n");
4076 assert_eq!(r.read("z"), "other\n");
4077 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"z\"]]");
4078 if undo {
4079 r.j(&["undo"]).ok();
4080 assert_eq!(r.j(&["log"]).ok().stdout, log);
4081 assert!(!r.dir.join("a").exists());
4082 assert_eq!(r.read("z"), "other\n");
4083 r.j(&["id"]).ok();
4084 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 2);
4085 continue;
4087 // the path the checkout failed on is left out of the focus
4088 r.j(&["mapRoot (\\c -> c { files = restrict (neg (under ./m)) c.files })"]).ok();
4089 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"z\"]]");
4090 assert_eq!(r.j(&[FOCUS_PATHS]).ok().stdout.trim(), "[[\"a\"] [\"z\"]]");
4091 let description = r.read("z");
4092 assert!(description.starts_with("Conflict:\n") && description.contains("tree"), "{}", description);
4093 r.j(&["id"]).ok();
4094 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 2);
4095 // the child still holds its own file
4096 r.j(&[&goto("O")]).ok();
4097 assert_eq!(r.read("z"), "other\n");
4101#[test]
4102fn a_stale_working_copy_without_changes_is_undone_and_redone() {
4103 // §7.4, §7.7: `undo` and `redo` refuse only a directory holding changes
4104 // no commit has, and a directory holding what the stale state records
4105 // holds none
4106 let r = stale_working_copy();
4107 let log = r.j(&["log"]).ok().stdout;
4108 let ops = r.j(&["ops"]).ok().stdout.lines().count();
4109 r.j(&["undo"]).ok();
4110 r.j(&["redo"]).ok();
4111 assert_eq!(r.j(&["log"]).ok().stdout, log);
4112 assert_eq!(r.read("a"), "2\n");
4113 assert!(!r.dir.join("b").exists());
4114 assert_eq!(r.read("z"), "z\n");
4115 r.j(&["id"]).ok();
4116 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 2);
4119#[test]
4120fn a_stale_working_copy_holding_the_focus_records_nothing() {
4121 // §7.4: a directory that holds the focus's files over a stale state
4122 // has nothing to record and is not refused; the state is brought up to
4123 // date, so a change made from there is recorded as usual
4124 let r = stale_working_copy();
4125 let ops = r.j(&["ops"]).ok().stdout.lines().count();
4126 r.write("a", "2\n");
4127 std::fs::remove_file(r.dir.join("b")).unwrap();
4128 r.write("z", "z\n");
4129 r.j(&["id"]).ok();
4130 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops);
4131 r.write("notes.txt", "note\n");
4132 r.j(&["id"]).ok();
4133 let paths = r.j(&[PATHS_BY_COMMIT]).ok().stdout;
4134 assert_eq!(paths.trim(), r#"[["" []] ["A" [["a"] ["b"]]] ["B" [["a"] ["notes.txt"] ["z"]]]]"#);
4137#[test]
4138fn a_lost_working_copy_state_records_the_directory_as_it_is() {
4139 // §7.4: jj makes a new working-copy state, recording no file, where it
4140 // finds none, at the operation the working copy names: the head's. It
4141 // was taken for a stale one, as it records other files than the
4142 // commit, and the snapshot merged the directory onto the commit with
4143 // no file as the base: each file edited since the last checkout became
4144 // a conflict of the edit with the commit's content, and each one
4145 // deleted came back. A state saved at the head operation is current,
4146 // and the directory is recorded as it is, as jj records it.
4147 let r = setup();
4148 r.write("f", "1\n");
4149 r.write("h", "gone\n");
4150 r.j(&["describe \"A\""]).ok();
4151 r.j(&["new"]).ok();
4152 r.write("f", "2\n");
4153 std::fs::remove_file(r.dir.join("h")).unwrap();
4154 std::fs::remove_file(r.dir.join(".jj/working_copy/tree_state")).unwrap();
4155 let conflicted = "\\r -> show (conflicted (files r))";
4156 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[]");
4157 let ops = r.j(&["ops"]).ok().stdout.lines().count();
4158 r.j(&["id"]).ok();
4159 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
4160 assert_eq!(r.read("f"), "2\n");
4161 assert!(!r.dir.join("h").exists());
4162 let paths = r.j(&[PATHS_BY_COMMIT]).ok().stdout;
4163 assert_eq!(paths.trim(), r#"[["" []] ["A" [["f"] ["h"]]] ["" [["f"]]]]"#);
4164 let f = r.j(&["\\r -> show (contentAt [\"f\"] (files r))"]).ok().stdout;
4165 assert_eq!(f.trim(), r#"blob "2\n""#);
4166 // and the state is saved again, with nothing left to record
4167 r.j(&["id"]).ok();
4168 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops + 1);
4171#[test]
4172fn a_conflict_left_as_written_stays_when_the_working_copy_state_is_lost() {
4173 // §7.4: a working-copy state made again where it was lost records no
4174 // conflict, so the scan reads each file the checkout wrote for one as
4175 // the text of its markers. A file holding exactly what a checkout
4176 // writes for one of the commit's conflicts is read back as that
4177 // conflict: a directory nobody touched has nothing to record, and
4178 // `undo` and `redo` do not refuse it.
4179 let r = setup();
4180 r.write("a.txt", "base\n");
4181 r.write("keep.txt", "k\n");
4182 r.j(&["describe \"base\""]).ok();
4183 r.j(&["new"]).ok();
4184 r.write("a.txt", "<<<<<<< left\n");
4185 r.j(&["describe \"left\""]).ok();
4186 r.j(&["new . goto parents"]).ok();
4187 r.write("a.txt", "right\n");
4188 r.j(&["describe \"right\""]).ok();
4189 r.j(&["rebase siblings"]).ok();
4190 let conflicted = "\\r -> show (conflicted (files r))";
4191 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"a.txt\"]]");
4192 let written = r.read("a.txt");
4193 let log = r.j(&["log"]).ok().stdout;
4194 let ops = r.j(&["ops"]).ok().stdout.lines().count();
4195 std::fs::remove_file(r.dir.join(".jj/working_copy/tree_state")).unwrap();
4196 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"a.txt\"]]");
4197 r.j(&["id"]).ok();
4198 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops);
4199 r.j(&["undo"]).ok();
4200 r.j(&["redo"]).ok();
4201 assert_eq!(r.j(&["log"]).ok().stdout, log);
4202 assert_eq!(r.read("a.txt"), written);
4203 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"a.txt\"]]");
4206#[test]
4207fn replay_carries_conflicts_through() {
4208 // replay unwrapped each input tree's ids as resolved, so every rebase,
4209 // squash or abandon that replayed a snapshot holding a conflict panicked
4210 // (exit 101, uncatchable by `or`) instead of keeping the conflict (§7.3,
4211 // §8); the in-memory backend handled it, so only a real repo showed it
4212 let r = setup();
4213 r.write("a.txt", "base\n");
4214 r.j(&["describe \"base\""]).ok();
4215 r.j(&["new"]).ok();
4216 r.write("a.txt", "left\n");
4217 r.j(&["describe \"left\""]).ok();
4218 r.j(&["new . goto parents"]).ok();
4219 r.write("a.txt", "right\n");
4220 r.j(&["describe \"right\""]).ok();
4221 // all three inputs are still resolved here; the result is not
4222 r.j(&["rebase siblings"]).ok();
4223 let conflicted = "\\r -> show (conflicted (files r))";
4224 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"a.txt\"]]");
4225 // the §8 laws, with a conflicted snapshot in each position
4226 for law in [
4227 "\\r -> replay (files r) ({ from = files r, to = files r }) == files r",
4228 "\\r -> replay (files r) ({ from = [], to = [] }) == files r",
4229 "\\r -> replay [] ({ from = [], to = files r }) == files r",
4230 "\\r -> replay (files r) ({ from = files r, to = files (up r) }) == files (up r)",
4231 ] {
4232 assert_eq!(r.j(&[law]).ok().stdout.trim(), "true", "{}", law);
4234 // rebase parents r = r: nothing to record. Count the operations: the
4235 // listing shows each one's age, which can tick over between the runs
4236 let ops = r.j(&["ops"]).ok().stdout.lines().count();
4237 r.j(&["rebase parents"]).ok();
4238 assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), ops);
4239 // squashing into the conflicted commit keeps its conflict
4240 r.j(&["new"]).ok();
4241 r.write("b.txt", "bee\n");
4242 r.j(&["squash"]).ok();
4243 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"a.txt\"]]");
4244 assert_eq!(r.read("b.txt"), "bee\n");
4245 assert!(r.read("a.txt").contains("<<<<<<<"), "{}", r.read("a.txt"));
4246 // abandoning the side the conflict came from replays the child onto
4247 // base, where jj's merge resolves it to the child's own side
4248 let named = |m: &str| format!("(matching (\\c -> c.message == \"{}\") all)", m);
4249 r.j(&[&format!("abandon . goto {}", named("left"))]).ok();
4250 assert_eq!(r.j(&["\\r -> show (conflicts r)"]).ok().stdout.trim(), "[]");
4251 let files = format!("\\r -> show (map (\\i -> (commitAt i r).files) ({} r))", named("right"));
4252 let files = r.j(&[&files]).ok().stdout;
4253 assert!(files.contains("blob \"right\\n\"") && files.contains("blob \"bee\\n\""), "{}", files);
4256#[test]
4257fn a_replayed_conflict_keeps_no_sides_that_cancel() {
4258 // replay kept every term of the merge of a conflicted input, sides that
4259 // cancel included: rebasing a stack in which each commit conflicts with
4260 // the new base gave the second its parent's conflict with a pair of
4261 // sides added that cancel, so it listed its parent's path as changed,
4262 // its inherited conflict was not its parent's, and each commit up the
4263 // stack stored two sides more than the one below it (§7.3)
4264 let named = |m: &str| format!("(matching (\\c -> c.message == \"{}\") all)", m);
4265 let r = setup();
4266 r.write("a", "a1\na2\na3\n");
4267 r.write("b", "b1\nb2\nb3\n");
4268 r.j(&["describe \"trunk\""]).ok();
4269 r.j(&["new"]).ok();
4270 r.write("a", "a1\nA2 one\na3\n");
4271 r.j(&["describe \"one\""]).ok();
4272 r.j(&["new"]).ok();
4273 r.write("b", "b1\nB2 two\nb3\n");
4274 r.j(&["describe \"two\""]).ok();
4275 r.j(&[&format!("new . goto {}", named("trunk"))]).ok();
4276 r.write("a", "a1\nA2 trunk\na3\n");
4277 r.write("b", "b1\nB2 trunk\nb3\n");
4278 r.j(&["describe \"trunk 2\""]).ok();
4279 r.j(&[&format!("goto {}", named("one"))]).ok();
4280 r.j(&[&format!("rebase {}", named("trunk 2"))]).ok();
4281 r.j(&[&format!("goto {}", named("two"))]).ok();
4282 let conflicted = "\\r -> show (conflicted (files r))";
4283 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"a\"] [\"b\"]]");
4284 // two changed `b` only; its `a` is one's conflict, of three terms
4285 assert_eq!(r.j(&["\\r -> show (changed r)"]).ok().stdout.trim(), "[[\"b\"]]");
4286 let inherited = "\\r -> contentAt [\"a\"] (files r) == contentAt [\"a\"] (files (up r))";
4287 assert_eq!(r.j(&[inherited]).ok().stdout.trim(), "true");
4288 let text = r.j(&["\\r -> text (contentAt [\"a\"] (files r))"]).ok().stdout;
4289 assert_eq!(text.matches("%%%%%%%").count(), 1, "{}", text);
4290 // and each is stored as a merge of three trees
4291 let repo = jj_repo(&r);
4292 for m in ["one", "two"] {
4293 let commit = jj_commit(&r, &repo, &format!("head ({} r)", named(m)));
4294 assert_eq!(commit.tree_ids().iter().count(), 3, "{}", m);
4298#[test]
4299fn a_conflict_jj_merged_reads_without_the_sides_that_cancel_and_keeps_its_tree() {
4300 // jj's rebase keeps each term of a merge it cannot resolve, so in a
4301 // stack jj rebased, where each commit conflicts with the new base, the
4302 // second commit stores its parent's conflict with a pair of sides that
4303 // cancel added. `j` read it so, as a change the commit did not make.
4304 // Read without the pair, the conflict is the parent's; and the tree is
4305 // written as jj stored it wherever the files are still its, so `new`
4306 // leaves an empty child, as jj's `new` does, not one of another tree
4307 // for the same files (§7.3, §7.5)
4308 let named = |m: &str| format!("(matching (\\c -> c.message == \"{}\") all)", m);
4309 let r = setup();
4310 r.write("a", "a1\na2\na3\n");
4311 r.write("b", "b1\nb2\nb3\n");
4312 r.j(&["describe \"trunk\""]).ok();
4313 r.j(&["new"]).ok();
4314 r.write("a", "a1\nA2 one\na3\n");
4315 r.j(&["describe \"one\""]).ok();
4316 r.j(&["new"]).ok();
4317 r.write("b", "b1\nB2 two\nb3\n");
4318 r.j(&["describe \"two\""]).ok();
4319 r.j(&[&format!("new . goto {}", named("trunk"))]).ok();
4320 r.write("a", "a1\nA2 trunk\na3\n");
4321 r.write("b", "b1\nB2 trunk\nb3\n");
4322 r.j(&["describe \"trunk 2\""]).ok();
4323 // one onto trunk 2 as jj rebases it, and two with it
4324 jj_rebase(&r, &format!("head ({} r)", named("one")), "r.root.id");
4325 let repo = jj_repo(&r);
4326 let tree = |m: &str| jj_commit(&r, &repo, &format!("head ({} r)", named(m))).tree_ids().clone();
4327 let stored = tree("two");
4328 assert_eq!(stored.iter().count(), 5);
4329 r.j(&[&format!("goto {}", named("two"))]).ok();
4330 let conflicted = "\\r -> show (conflicted (files r))";
4331 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"a\"] [\"b\"]]");
4332 assert_eq!(r.j(&["\\r -> show (changed r)"]).ok().stdout.trim(), "[[\"b\"]]");
4333 let inherited = "\\r -> contentAt [\"a\"] (files r) == contentAt [\"a\"] (files (up r))";
4334 assert_eq!(r.j(&[inherited]).ok().stdout.trim(), "true");
4335 // describing it, and its parent, keeps the tree jj stored
4336 r.j(&["describe \"two, described\""]).ok();
4337 r.j(&["at parents (describe \"one, described\")"]).ok();
4338 let repo = jj_repo(&r);
4339 let tree = |id: &str| jj_commit(&r, &repo, id).tree_ids().clone();
4340 assert_eq!(tree("r.root.id"), stored);
4341 // and a new child holds it too
4342 r.j(&["new"]).ok();
4343 let repo = jj_repo(&r);
4344 let tree = |id: &str| jj_commit(&r, &repo, id).tree_ids().clone();
4345 assert_eq!(tree("r.root.id"), stored);
4346 assert_eq!(tree("(up r).root.id"), stored);
4347 assert_eq!(r.j(&["\\r -> show (changed r)"]).ok().stdout.trim(), "[]");
4350#[test]
4351fn a_commit_left_with_its_parents_files_is_written_with_the_parents_tree() {
4352 // `squash`, `rebase` and `abandon` build a commit's files anew, and a
4353 // tree built from them holds each conflict less the sides that cancel.
4354 // Where the parent is a tree jj merged, which keeps them, the child
4355 // left with its parent's files got another tree for them: jj took it
4356 // for a change at every conflicted path, `tree` did not mark it empty,
4357 // and the checkout rewrote the conflict files without jj's labels
4358 // (§7.5). Here the parent is made only with `j`: the snapshot rebases
4359 // the stack onto its edited base as jj does
4360 let named = |m: &str| format!("(matching (\\c -> c.message == \"{}\") all)", m);
4361 let r = setup();
4362 r.write("a", "a1\na2\na3\n");
4363 r.write("b", "b1\nb2\nb3\n");
4364 r.j(&["describe \"B\""]).ok();
4365 r.j(&["new"]).ok();
4366 r.write("a", "a1\nA2 one\na3\n");
4367 r.j(&["describe \"one\""]).ok();
4368 r.j(&["new"]).ok();
4369 r.write("b", "b1\nB2 two\nb3\n");
4370 r.j(&["describe \"two\""]).ok();
4371 r.j(&[&format!("goto {}", named("B"))]).ok();
4372 r.write("a", "a1\nA2 B\na3\n");
4373 r.write("b", "b1\nB2 B\nb3\n");
4374 r.j(&["id"]).ok();
4375 let two = format!("head ({} r)", named("two"));
4376 let stored = jj_commit(&r, &jj_repo(&r), &two).tree_ids().clone();
4377 assert_eq!(stored.iter().count(), 5);
4378 // the commit `id` names holds two's tree, and jj takes it for empty
4379 let empty_on_two = |id: &str, what: &str| {
4380 let repo = jj_repo(&r);
4381 let c = jj_commit(&r, &repo, id);
4382 assert_eq!(jj_commit(&r, &repo, &two).tree_ids(), &stored, "{}", what);
4383 assert_eq!(c.tree_ids(), &stored, "{}", what);
4384 assert!(pollster::block_on(c.is_empty(repo.as_ref())).unwrap(), "{}", what);
4385 assert_eq!(r.j(&[&format!("\\r -> show (changed (by ({}) r))", id)]).ok().stdout.trim(), "[]");
4386 };
4387 // squash: two is rewritten with its own files, and the new focus on it
4388 // holds them
4389 r.j(&[&format!("goto {}", named("two"))]).ok();
4390 r.j(&["new"]).ok();
4391 let labelled = r.read("a");
4392 assert!(labelled.contains("\"two\" (rebased revision)"), "{}", labelled);
4393 r.j(&["squash"]).ok();
4394 empty_on_two("r.root.id", "squash");
4395 assert_eq!(r.read("a"), labelled);
4396 // squash into one, whose tree of three terms the list builds again with
4397 // the same ones but no labels: the new focus keeps jj's labels too
4398 r.j(&[&format!("new . goto {}", named("one"))]).ok();
4399 let labelled = r.read("a");
4400 assert!(labelled.contains("\"one\" (rebased revision)"), "{}", labelled);
4401 r.j(&["squash"]).ok();
4402 let repo = jj_repo(&r);
4403 let one = jj_commit(&r, &repo, "(up r).root.id").tree();
4404 assert_eq!(one.tree_ids().iter().count(), 3);
4405 assert_eq!(jj_commit(&r, &repo, "r.root.id").tree().tree_ids_and_labels(), one.tree_ids_and_labels());
4406 assert_eq!(r.read("a"), labelled);
4407 // rebase: an empty commit replayed onto two
4408 r.j(&[&format!("new . goto {}", named("B"))]).ok();
4409 r.j(&["describe \"empty\""]).ok();
4410 r.j(&[&format!("rebase {}", named("two"))]).ok();
4411 empty_on_two("r.root.id", "rebase");
4412 // abandon: Y, empty, replayed from X onto two
4413 r.j(&[&format!("new . goto {}", named("two"))]).ok();
4414 r.write("c", "c\n");
4415 r.j(&["describe \"X\""]).ok();
4416 r.j(&["new"]).ok();
4417 r.j(&["describe \"Y\""]).ok();
4418 r.j(&[&format!("goto {}", named("X"))]).ok();
4419 r.j(&["abandon"]).ok();
4420 empty_on_two(&format!("head ({} r)", named("Y")), "abandon");
4421 // a squash that changes the parent's files gives it a tree of its own,
4422 // which the new focus holds
4423 r.j(&[&format!("new . goto {}", named("two"))]).ok();
4424 r.write("d", "d\n");
4425 r.j(&["squash"]).ok();
4426 let repo = jj_repo(&r);
4427 let c = jj_commit(&r, &repo, "r.root.id");
4428 let parent = jj_commit(&r, &repo, "(up r).root.id").tree_ids().clone();
4429 assert_ne!(parent, stored);
4430 assert_eq!(c.tree_ids(), &parent);
4431 assert!(pollster::block_on(c.is_empty(repo.as_ref())).unwrap());
4432 let parent_files = "\\r -> show (map (.path) (files (up r)))";
4433 assert_eq!(r.j(&[parent_files]).ok().stdout.trim(), "[[\"a\"] [\"b\"] [\"d\"]]");
4436#[test]
4437fn a_commit_moved_onto_its_own_files_is_written_with_the_parents_tree() {
4438 // A commit can come to hold its parent's files while its own read as
4439 // they did: an empty commit rebased onto another tree for the same
4440 // files, an empty child of a commit squashed into its parent, a change
4441 // the working copy reverts. Rewritten with its own stored tree, or
4442 // snapshotted into the tree the snapshot built, it held another tree
4443 // for its parent's files: jj took it for a change at each conflicted
4444 // path, and `tree` did not mark it empty, though `changed` listed
4445 // nothing and the dry run of the same edit marked it (§7.4, §7.5).
4446 // The stack is the one above, with two empty children of two
4447 let named = |m: &str| format!("(matching (\\c -> c.message == \"{}\") all)", m);
4448 let head = |m: &str| format!("head ({} r)", named(m));
4449 let r = setup();
4450 r.write("a", "a1\na2\na3\n");
4451 r.write("b", "b1\nb2\nb3\n");
4452 r.j(&["describe \"B\""]).ok();
4453 r.j(&["new"]).ok();
4454 r.write("a", "a1\nA2 one\na3\n");
4455 r.j(&["describe \"one\""]).ok();
4456 r.j(&["new"]).ok();
4457 r.write("b", "b1\nB2 two\nb3\n");
4458 r.j(&["describe \"two\""]).ok();
4459 r.j(&["describe \"E\" . new"]).ok();
4460 r.j(&[&format!("describe \"G\" . new . goto {}", named("two"))]).ok();
4461 r.j(&[&format!("goto {}", named("B"))]).ok();
4462 r.write("a", "a1\nA2 B\na3\n");
4463 r.write("b", "b1\nB2 B\nb3\n");
4464 r.j(&["id"]).ok();
4465 let repo = jj_repo(&r);
4466 let stored = jj_commit(&r, &repo, &head("two")).tree_ids().clone();
4467 assert_eq!(stored.iter().count(), 5);
4468 for m in ["E", "G"] {
4469 assert_eq!(jj_commit(&r, &repo, &head(m)).tree_ids(), &stored, "{}", m);
4471 // the commit `m` names holds its parent's tree, and jj takes it for empty
4472 let empty = |m: &str, what: &str| {
4473 use jj_lib::repo::Repo as _;
4474 let repo = jj_repo(&r);
4475 let c = jj_commit(&r, &repo, &head(m));
4476 let parent = repo.store().get_commit(&c.parent_ids()[0]).unwrap();
4477 assert_eq!(c.tree_ids(), parent.tree_ids(), "{}: {}", what, m);
4478 assert!(pollster::block_on(c.is_empty(repo.as_ref())).unwrap(), "{}: {}", what, m);
4479 let changed = format!("\\r -> show (changed (by ({}) r))", head(m));
4480 assert_eq!(r.j(&[&changed]).ok().stdout.trim(), "[]", "{}: {}", what, m);
4481 };
4482 // whether the coloured tree `expr` renders draws the message `m` as an
4483 // empty commit's, dim and italic
4484 let marked_empty = |expr: &str, m: &str| {
4485 let out = Command::new(j_bin())
4486 .arg(expr)
4487 .current_dir(&r.dir)
4488 .env("XDG_CONFIG_HOME", &r.cfg)
4489 .env_remove("NO_COLOR")
4490 .output()
4491 .unwrap();
4492 assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
4493 String::from_utf8_lossy(&out.stdout).contains(&format!("\x1b[2;3m{}\x1b[0m", m))
4494 };
4495 let coloured = "treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = \"always\", lanes = 4, author = false, date = false, files = false })";
4496 // rebase: Y, two's change picked onto one, holds two's files in a tree
4497 // `j` builds, of three terms; G, empty on two, is moved onto it
4498 let one = named("one");
4499 r.j(&[&format!("describe \"Y\" . child (\\c -> c.message == \"\") . goto {} . pick {} . goto {}", one, one, named("two"))])
4500 .ok();
4501 let y = jj_commit(&r, &jj_repo(&r), &head("Y")).tree_ids().clone();
4502 assert_eq!(y.iter().count(), 3);
4503 r.j(&[&format!("rebase {} . goto {}", named("Y"), named("G"))]).ok();
4504 empty("G", "rebase");
4505 // a change the working copy reverts: R and S, each holding `z`, are
4506 // rebased onto two, which gives each a tree `j` builds; with `z`
4507 // removed, the snapshot alone gives R two's tree, and the snapshot a
4508 // rewrite of S's message folds in gives S two's
4509 for (m, record) in [("R", "id"), ("S", "describe \"S, reverted\"")] {
4510 r.j(&[&format!("new . goto {}", one)]).ok();
4511 r.write("z", "z\n");
4512 r.j(&[&format!("describe \"{}\"", m)]).ok();
4513 r.j(&[&format!("rebase {}", named("two"))]).ok();
4514 std::fs::remove_file(r.dir.join("z")).unwrap();
4515 r.j(&[record]).ok();
4517 empty("R", "snapshot");
4518 empty("S, reverted", "describe");
4519 // squash: two is folded into one, which it rewrites with a tree `j`
4520 // builds; E, R and S, empty on two, are moved onto it. The tree marks
4521 // E empty after the squash as its dry run does
4522 let squash = format!("describe \"F\" . squash . goto {}", named("two"));
4523 assert!(marked_empty(&format!("{} . validate . {}", coloured, squash), "E"));
4524 r.j(&[&squash]).ok();
4525 for m in ["E", "R", "S, reverted", "F"] {
4526 empty(m, "squash");
4528 assert!(marked_empty(coloured, "E"));
4529 let repo = jj_repo(&r);
4530 assert_eq!(jj_commit(&r, &repo, &head("one")).tree_ids().iter().count(), 3);
4533#[test]
4534fn a_conflict_whose_sides_left_are_directories_keeps_its_files() {
4535 // a conflict at `d` whose file sides cancel, leaving directories only.
4536 // jj keeps it as one conflict at the path; without its file sides it
4537 // would be one between the directories, which jj merges entry by
4538 // entry. So `j` reads it with every side, and a new child keeps it
4539 // (§7.3)
4540 let r = setup();
4541 let dir = |x: &str| {
4542 let _ = std::fs::remove_file(r.dir.join("d"));
4543 std::fs::create_dir_all(r.dir.join("d")).unwrap();
4544 r.write("d/x", x);
4545 };
4546 let file = || {
4547 let _ = std::fs::remove_dir_all(r.dir.join("d"));
4548 r.write("d", "f\n");
4549 };
4550 let sides: [(&str, &dyn Fn()); 5] =
4551 [("A", &|| dir("1\n")), ("B", &file), ("C", &file), ("D", &|| dir("0\n")), ("E", &|| dir("2\n"))];
4552 for (m, write) in sides {
4553 write();
4554 r.write("e", m);
4555 r.j(&[&format!("describe \"{}\"", m)]).ok();
4556 r.j(&["new"]).ok();
4558 // A - B + C - D + E, as a commit X on E
4559 use jj_lib::repo::Repo as _;
4560 let named = |m: &str| format!("head ((matching (\\c -> c.message == \"{}\") all) r)", m);
4561 let repo = jj_repo(&r);
4562 let tree = |m: &str| jj_commit(&r, &repo, &named(m)).tree_ids().as_resolved().unwrap().clone();
4563 let trees = jj_lib::merge::Merge::from_vec(["A", "B", "C", "D", "E"].map(tree).to_vec());
4564 let tree = jj_lib::merged_tree::MergedTree::new(
4565 repo.store().clone(),
4566 trees,
4567 jj_lib::conflict_labels::ConflictLabels::unlabeled(),
4568 );
4569 let mut tx = repo.start_transaction();
4570 let parent = jj_commit(&r, &repo, &named("E")).id().clone();
4571 pollster::block_on(tx.repo_mut().new_commit(vec![parent], tree.clone()).set_description("X").write()).unwrap();
4572 pollster::block_on(tx.commit("X")).unwrap();
4573 r.j(&["goto (matching (\\c -> c.message == \"X\") all)"]).ok();
4574 let conflicted = "\\r -> show (conflicted (files r))";
4575 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"d\"] [\"e\"]]");
4576 assert_eq!(r.j(&["\\r -> show (map (.path) (files r))"]).ok().stdout.trim(), "[[\"d\"] [\"e\"]]");
4577 let text = r.j(&["\\r -> text (contentAt [\"d\"] (files r))"]).ok().stdout;
4578 assert_eq!(text.matches("%%%%%%%").count(), 2, "{}", text);
4579 r.j(&["new"]).ok();
4580 let repo = jj_repo(&r);
4581 assert_eq!(jj_commit(&r, &repo, "r.root.id").tree_ids(), tree.tree_ids());
4584#[cfg(unix)]
4585#[test]
4586fn conflict_sides_keep_absence_and_file_type() {
4587 // a conflict blob kept each side as bare bytes: a side the path was
4588 // deleted on came back as an empty file, and every side lost its
4589 // executable bit or symlink type (§7.3). The conflicted checkout of an
4590 // executable file was not executable, and rebasing a conflicted commit
4591 // back where it came from, whose sides must cancel, left an empty file
4592 // for its deletion, dropped +x, and kept a symlink conflict.
4593 use std::os::unix::fs::{symlink, PermissionsExt};
4594 let r = setup();
4595 let exec = |p: &str| {
4596 let mode = std::fs::metadata(r.dir.join(p)).unwrap().permissions().mode();
4597 mode & 0o111 != 0
4598 };
4599 let relink = |target: &str| {
4600 let _ = std::fs::remove_file(r.dir.join("l"));
4601 symlink(target, r.dir.join("l")).unwrap();
4602 };
4603 r.write("f.txt", "base\n");
4604 r.write("g.sh", "x\n");
4605 std::fs::set_permissions(r.dir.join("g.sh"), std::fs::Permissions::from_mode(0o755)).unwrap();
4606 relink("a");
4607 r.j(&["describe \"A\""]).ok();
4608 r.j(&["new"]).ok();
4609 r.write("f.txt", "mod\n");
4610 r.write("g.sh", "y\n");
4611 relink("b");
4612 r.j(&["describe \"B\""]).ok();
4613 r.j(&["new"]).ok();
4614 std::fs::remove_file(r.dir.join("f.txt")).unwrap();
4615 r.write("g.sh", "z\n");
4616 relink("c");
4617 r.j(&["describe \"C\""]).ok();
4618 assert!(exec("g.sh"));
4619 // C onto A: a modify/delete conflict, and executable and symlink sides
4620 r.j(&["rebase (parents . prev)"]).ok();
4621 let conflicted = "\\r -> show (conflicted (files r))";
4622 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[[\"f.txt\"] [\"g.sh\"] [\"l\"]]");
4623 // every side of g.sh is executable, so its materialised conflict is
4624 assert!(exec("g.sh"), "the conflicted checkout of g.sh lost +x");
4625 // C back onto B: B's sides cancel, leaving C's own change
4626 r.j(&["rebase siblings"]).ok();
4627 assert_eq!(r.j(&[conflicted]).ok().stdout.trim(), "[]");
4628 let paths = r.j(&["\\r -> show (map (.path) (files r))"]).ok().stdout;
4629 assert_eq!(paths.trim(), "[[\"g.sh\"] [\"l\"]]");
4630 assert_eq!(r.read("g.sh"), "z\n");
4631 assert!(exec("g.sh"), "g.sh lost +x");
4632 assert_eq!(std::fs::read_link(r.dir.join("l")).unwrap(), PathBuf::from("c"));