1//! Remote workflow tests (§7.6, §7.8): clone/fetch/push against a local
2//! bare git remote, label flows, push refusals.
5use std::process::Command;
8 option_env!("CARGO_BIN_EXE_j").map(PathBuf::from).unwrap_or_else(|| {
9 let mut p = std::env::current_exe().unwrap();
17fn uniq(tag: &str) -> PathBuf {
18 use std::sync::atomic::{AtomicUsize, Ordering};
19 static N: AtomicUsize = AtomicUsize::new(0);
20 std::env::temp_dir().join(format!(
21 "j-remote-test-{}-{}-{}-{}",
24 N.fetch_add(1, Ordering::Relaxed),
25 std::time::SystemTime::now()
26 .duration_since(std::time::UNIX_EPOCH)
46 assert_eq!(self.code, 0, "stderr: {}", self.stderr);
51fn git(dir: &PathBuf, args: &[&str]) -> String {
52 let out = Command::new("git")
59 "git {:?} failed: {}",
61 String::from_utf8_lossy(&out.stderr)
63 String::from_utf8_lossy(&out.stdout).to_string()
66/// `git` with `input` on its standard input
67fn git_stdin(dir: &PathBuf, args: &[&str], input: &str) -> String {
69 use std::process::Stdio;
70 let mut child = Command::new("git")
73 .stdin(Stdio::piped())
74 .stdout(Stdio::piped())
75 .stderr(Stdio::piped())
78 child.stdin.take().unwrap().write_all(input.as_bytes()).unwrap();
79 let out = child.wait_with_output().unwrap();
82 "git {:?} failed: {}",
84 String::from_utf8_lossy(&out.stderr)
86 String::from_utf8_lossy(&out.stdout).to_string()
89/// Whether `dir`'s filesystem folds case, judged as the backend judges it:
90/// `.JJ` names the workspace's `.jj`, which the directory does not list
91/// under that spelling
92fn folds_case(dir: &std::path::Path) -> bool {
93 dir.join(".JJ").symlink_metadata().is_ok()
94 && !std::fs::read_dir(dir).unwrap().any(|e| e.unwrap().file_name() == ".JJ")
98 let dir = uniq("env");
99 let remote = uniq("remote.git");
100 let cfg = uniq("cfg");
101 std::fs::create_dir_all(&dir).unwrap();
102 std::fs::create_dir_all(&cfg.join("j")).unwrap();
103 let mut config = include_str!("../config.j").to_string();
104 config = config.replace("Your Name", "Test User");
105 config = config.replace("you@example.com", "test@example.com");
106 std::fs::write(cfg.join("j/config.j"), config).unwrap();
107 // bare remote with one commit on master
108 git(&dir, &["init", "--bare", remote.to_str().unwrap()]);
109 let seed = uniq("seed");
110 std::fs::create_dir_all(&seed).unwrap();
111 git(&seed, &["init", "-q", "."]);
112 std::fs::write(seed.join("a.txt"), "one\n").unwrap();
113 git(&seed, &["add", "."]);
114 git(&seed, &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qm", "one"]);
115 git(&seed, &["remote", "add", "origin", remote.to_str().unwrap()]);
116 git(&seed, &["push", "-q", "origin", "master"]);
117 let _ = std::fs::remove_dir_all(&seed);
118 Env { dir, remote, cfg }
122 fn j(&self, workdir: &PathBuf, args: &[&str]) -> Out {
123 self.j_env(workdir, args, &[])
126 /// `j` with `vars` added to its environment
127 fn j_env(&self, workdir: &PathBuf, args: &[&str], vars: &[(&str, &std::path::Path)]) -> Out {
128 let out = Command::new(j_bin())
130 .current_dir(workdir)
131 .env("XDG_CONFIG_HOME", &self.cfg)
132 .env("NO_COLOR", "1")
133 .envs(vars.iter().copied())
137 code: out.status.code().unwrap_or(-1),
138 stdout: String::from_utf8_lossy(&out.stdout).to_string(),
139 stderr: String::from_utf8_lossy(&out.stderr).to_string(),
146 let _ = std::fs::remove_dir_all(&self.dir);
147 let _ = std::fs::remove_dir_all(&self.remote);
148 let _ = std::fs::remove_dir_all(&self.cfg);
153fn clone_sets_origin_and_labels() {
155 let dest = uniq("clone");
156 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
157 let out = env.j(&dest, &["tree"]).ok();
158 assert!(out.stdout.contains("master"), "{}", out.stdout);
159 assert!(out.stdout.contains("◆"), "{}", out.stdout); // immutable
160 let out = env.j(&dest, &["trunk"]).ok();
161 assert!(!out.stdout.contains("none"), "{}", out.stdout);
165fn clone_checks_out_the_parents_files() {
166 // §7.8: the working-copy commit clone creates is empty, so the default
167 // bookmark's files are checked out and nothing is changed in it. Built on
168 // the empty tree, it deleted every file: the clone held none, and the
169 // first persisting run recorded their deletion in the user's change.
171 let dest = env.dir.join("clone");
172 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
173 assert!(dest.join("a.txt").is_file(), "the clone checked out no files");
174 assert_eq!(std::fs::read_to_string(dest.join("a.txt")).unwrap(), "one\n");
175 assert_eq!(env.j(&dest, &["changed"]).ok().stdout.trim(), "none");
176 env.j(&dest, &["describe \"mine\""]).ok();
177 assert_eq!(env.j(&dest, &["changed"]).ok().stdout.trim(), "none");
180/// a git repository in `dir` on branch `main` with one commit, `base`,
181/// holding a.txt and b.txt
182fn git_repo_with_base(dir: &PathBuf) {
183 std::fs::create_dir_all(dir).unwrap();
184 git(dir, &["init", "-q", "-b", "main", "."]);
185 std::fs::write(dir.join("a.txt"), "one\n").unwrap();
186 std::fs::write(dir.join("b.txt"), "two\n").unwrap();
187 git(dir, &["add", "."]);
188 git(dir, &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qm", "base"]);
192fn init_over_git_records_the_heads_files() {
193 // §7.8: init over an existing git repository gives the working-copy
194 // commit the head's files. Built on the empty tree, the operation init
195 // recorded deleted every file, so undoing the first run after init
196 // emptied the working directory.
198 let dir = env.dir.join("repo");
199 git_repo_with_base(&dir);
200 env.j(&dir, &["init"]).ok();
201 assert_eq!(env.j(&dir, &["changed"]).ok().stdout.trim(), "none");
202 env.j(&dir, &["describe \"x\""]).ok();
203 env.j(&dir, &["undo"]).ok();
204 assert!(dir.join("a.txt").is_file(), "undo deleted a.txt");
205 assert_eq!(std::fs::read_to_string(dir.join("b.txt")).unwrap(), "two\n");
206 assert_eq!(env.j(&dir, &["changed"]).ok().stdout.trim(), "none");
210fn init_and_clone_are_not_undone() {
211 // §7.7: before `init` or `clone` there was no repository, so the
212 // operation each records is not undone. Undoing it restored jj's view
213 // from before, whose working-copy commit is an empty child of the root,
214 // and checking that out deleted every file of the head: right after the
215 // command, or once everything run since had been undone. An orphan HEAD
216 // keeps the working-copy commit jj created, and the refs init imports
217 // were an operation of their own, which undo took back.
219 let ops = |dir: &PathBuf| env.j(dir, &["ops"]).ok().stdout.lines().count();
220 let refused = |dir: &PathBuf| {
222 let out = env.j(dir, &["undo"]);
223 assert_eq!(out.code, 1, "undo was recorded: {}", out.stderr);
224 assert!(out.stderr.contains("nothing to undo"), "{}", out.stderr);
225 assert_eq!(ops(dir), n);
227 let dir = env.dir.join("repo");
228 git_repo_with_base(&dir);
229 env.j(&dir, &["init"]).ok();
231 env.j(&dir, &["describe \"x\""]).ok();
232 env.j(&dir, &["undo"]).ok();
234 assert_eq!(std::fs::read_to_string(dir.join("a.txt")).unwrap(), "one\n");
235 assert_eq!(std::fs::read_to_string(dir.join("b.txt")).unwrap(), "two\n");
236 assert_eq!(env.j(&dir, &["changed"]).ok().stdout.trim(), "none");
238 let orphan = env.dir.join("orphan");
239 git_repo_with_base(&orphan);
240 git(&orphan, &["switch", "-q", "--orphan", "fresh"]);
241 env.j(&orphan, &["init"]).ok();
243 let out = env.j(&orphan, &["tree"]).ok().stdout;
244 assert!(out.contains("base"), "{}", out);
246 let dest = env.dir.join("clone");
247 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
249 env.j(&dest, &["describe \"x\""]).ok();
250 env.j(&dest, &["undo"]).ok();
252 assert_eq!(std::fs::read_to_string(dest.join("a.txt")).unwrap(), "one\n");
253 assert_eq!(env.j(&dest, &["changed"]).ok().stdout.trim(), "none");
257fn clone_leaves_out_paths_a_checkout_cannot_create() {
258 // §7.8: a default bookmark can hold a path jj's checkout refuses, such
259 // as `.jj/x`. The working-copy commit held it, so the checkout failed
260 // after clone had recorded its operations, leaving no file on disk and
261 // `changed` empty, and every persisting run was refused for the focus's
262 // `.jj` (§7.5 step 1). The commit now leaves such paths out, and their
263 // removal is its change.
265 let other = env.dir.join("other");
266 git(&env.dir, &["clone", "-q", env.remote.to_str().unwrap(), other.to_str().unwrap()]);
267 std::fs::create_dir_all(other.join(".jj")).unwrap();
268 std::fs::write(other.join(".jj/x"), "evil\n").unwrap();
269 std::fs::create_dir_all(other.join("fixture/.jj")).unwrap();
270 std::fs::write(other.join("fixture/.jj/repo"), "vendored\n").unwrap();
271 std::fs::write(other.join("fixture/kept"), "kept\n").unwrap();
272 git(&other, &["add", "-f", ".jj", "fixture"]);
273 // a name no filesystem holds, which git stores all the same
274 let long = format!("long/{}", "n".repeat(300));
275 let blob = git(&other, &["hash-object", "-w", "a.txt"]);
276 let info = format!("100644,{},{}", blob.trim(), long);
277 git(&other, &["update-index", "--add", "--cacheinfo", &info]);
278 git(&other, &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qm", "withjj"]);
279 git(&other, &["push", "-q", "origin", "master"]);
280 let dest = env.dir.join("clone");
281 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
282 assert_eq!(std::fs::read_to_string(dest.join("a.txt")).unwrap(), "one\n");
283 assert_eq!(std::fs::read_to_string(dest.join("fixture/kept")).unwrap(), "kept\n");
284 assert!(!dest.join(".jj/x").exists());
285 assert!(!dest.join("fixture/.jj").exists());
286 let changed = env.j(&dest, &["changed"]).ok().stdout;
287 let changed: Vec<&str> = changed.lines().collect();
288 assert_eq!(changed, [".jj/x", "fixture/.jj/repo", long.as_str()]);
289 env.j(&dest, &["describe \"x\""]).ok();
290 env.j(&dest, &["new"]).ok();
291 assert_eq!(env.j(&dest, &["changed"]).ok().stdout.trim(), "none");
295fn clone_keeps_long_names_the_filesystem_holds() {
296 // §7.8: the working-copy commit clone starts on left out every name
297 // over 255 bytes, though exFAT, NTFS and APFS hold one of 100 `日`
298 // (300 bytes): the clone's first change was that file's removal, which
299 // a push would publish. It is left out only where the filesystem does
302 let other = env.dir.join("other");
303 git(&env.dir, &["clone", "-q", env.remote.to_str().unwrap(), other.to_str().unwrap()]);
304 let wide = format!("wide/{}", "日".repeat(100));
305 let blob = git(&other, &["hash-object", "-w", "a.txt"]);
306 let info = format!("100644,{},{}", blob.trim(), wide);
307 git(&other, &["update-index", "--add", "--cacheinfo", &info]);
308 git(&other, &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qm", "wide"]);
309 git(&other, &["push", "-q", "origin", "master"]);
310 let dest = env.dir.join("clone");
311 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
312 let holds = std::fs::write(env.dir.join("日".repeat(100)), "").is_ok();
313 let changed = env.j(&dest, &["changed"]).ok().stdout;
315 assert_eq!(std::fs::read_to_string(dest.join(&wide)).unwrap(), "one\n");
316 assert_eq!(changed.trim(), "none");
318 assert!(!dest.join("wide").exists());
319 assert_eq!(changed.trim(), wide);
321 assert_eq!(std::fs::read_to_string(dest.join("a.txt")).unwrap(), "one\n");
325fn clone_leaves_out_git_in_another_case_where_the_filesystem_folds_case() {
326 // §7.5 step 1, §7.8: where the filesystem folds case, `.GIT` is the
327 // clone's own `.git`. exfat-fuse gives each spelling its own inode
328 // number, so judged by file identity it did not fold case, and the
329 // checkout of a default branch holding `.GIT/hooks/post-checkout`
330 // installed a hook the next `git checkout` ran. Run with TMPDIR on such
331 // a mount to take the first branch.
333 // git refuses `.GIT` in an index, so the tree is built by hand
334 let hook = env.dir.join("hook");
335 std::fs::write(&hook, "#!/bin/sh\necho hooked\n").unwrap();
336 let blob = git(&env.remote, &["hash-object", "-w", hook.to_str().unwrap()]);
337 let mktree = |entries: String| git_stdin(&env.remote, &["mktree"], &entries).trim().to_string();
338 let hooks = mktree(format!("100755 blob {}\tpost-checkout\n", blob.trim()));
339 let dot_git = mktree(format!("040000 tree {}\thooks\n", hooks));
340 let top = git(&env.remote, &["ls-tree", "master"]);
341 let tree = mktree(format!("{}040000 tree {}\t.GIT\n", top, dot_git));
344 &["-c", "user.email=t@t", "-c", "user.name=T", "commit-tree", &tree, "-p", "master", "-m", "hook"],
346 git(&env.remote, &["update-ref", "refs/heads/master", commit.trim()]);
347 let dest = env.dir.join("clone");
348 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
349 assert!(!dest.join(".git/hooks/post-checkout").exists());
350 let changed = env.j(&dest, &["changed"]).ok().stdout;
351 if folds_case(&dest) {
352 assert_eq!(changed.trim(), ".GIT/hooks/post-checkout");
354 let kept = std::fs::read_to_string(dest.join(".GIT/hooks/post-checkout")).unwrap();
355 assert_eq!(kept, "#!/bin/sh\necho hooked\n");
356 assert_eq!(changed.trim(), "none");
358 assert_eq!(std::fs::read_to_string(dest.join("a.txt")).unwrap(), "one\n");
362fn clone_that_cannot_check_out_leaves_nothing_behind() {
363 // §7.8: clone checked the working-copy commit out after recording its
364 // operation, so a checkout that failed part way (a symlink target or a
365 // path longer than PATH_MAX here; a full disk) left a clone whose
366 // working-copy commit held files never written: `zz`, after the failing
367 // path, and the user's first `describe` recorded it as deleted. The
368 // deep path also left a chain of empty directories. Like `git clone`, a
369 // clone that fails now records nothing and leaves nothing behind.
371 let other = env.dir.join("other");
372 git(&env.dir, &["clone", "-q", env.remote.to_str().unwrap(), other.to_str().unwrap()]);
373 std::fs::write(other.join("zz"), "z\n").unwrap();
374 std::fs::write(env.dir.join("target"), "x".repeat(5000)).unwrap();
375 let target = git(&other, &["hash-object", "-w", env.dir.join("target").to_str().unwrap()]);
376 let link = format!("120000,{},m_link", target.trim());
377 git(&other, &["update-index", "--add", "--cacheinfo", &link]);
378 git(&other, &["add", "zz"]);
379 git(&other, &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qm", "link"]);
380 git(&other, &["push", "-q", "origin", "master"]);
381 let clone = |dest: &PathBuf| {
382 let out = env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]);
383 assert_eq!(out.code, 1, "{}", out.stderr);
384 assert!(out.stderr.contains("cannot check out"), "{}", out.stderr);
386 let dest = env.dir.join("clone");
388 assert!(!dest.exists(), "the failed clone left {}", dest.display());
389 // a directory that was there, empty, stays so
390 std::fs::create_dir(&dest).unwrap();
392 assert_eq!(std::fs::read_dir(&dest).unwrap().count(), 0);
394 git(&other, &["rm", "-q", "--cached", "m_link"]);
395 let deep = vec!["d".repeat(250); 20].join("/");
396 let blob = git(&other, &["hash-object", "-w", "zz"]);
397 let info = format!("100644,{},{}", blob.trim(), deep);
398 git(&other, &["update-index", "--add", "--cacheinfo", &info]);
399 git(&other, &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qm", "deep"]);
400 git(&other, &["push", "-q", "origin", "master"]);
401 let dest = env.dir.join("deep");
403 assert!(!dest.exists(), "the failed clone left {}", dest.display());
407fn failed_clone_removes_only_the_directories_it_created() {
408 // §7.8: the emptiness checks ran on DIR as written, so `newdir/..` (or
409 // `new/../keep`) passed them while `newdir` did not exist yet; creating
410 // it made DIR the existing, non-empty working directory (or `keep`).
411 // The clone went into it, and when it failed its cleanup removed
412 // everything there. A failed clone into `a/b/c` left `a/b`. A DIR that
413 // exists only once its parents are created is now refused, and a failed
414 // clone removes the DIR it created and each missing parent it created
415 // that is then empty, and nothing else.
417 let work = env.dir.join("work");
418 std::fs::create_dir_all(work.join("keep")).unwrap();
419 std::fs::create_dir_all(work.join("inner/src")).unwrap();
420 std::fs::write(work.join("notes.txt"), "n\n").unwrap();
421 std::fs::write(work.join("keep/notes.txt"), "k\n").unwrap();
422 std::fs::write(work.join("inner/src/main.rs"), "m\n").unwrap();
423 let listing = |dir: &PathBuf| {
424 let mut all = Vec::new();
425 let mut stack = vec![dir.clone()];
426 while let Some(d) = stack.pop() {
427 for e in std::fs::read_dir(&d).unwrap() {
428 let p = e.unwrap().path();
429 all.push(p.strip_prefix(dir).unwrap().display().to_string());
438 let before = listing(&work);
439 let missing = env.dir.join("nowhere.git");
441 (&work, "newdir/.."),
442 (&work, "new/../keep"),
443 (&work.join("inner"), "x/../.."),
445 for url in [&env.remote, &missing] {
446 let out = env.j(cwd, &["clone", url.to_str().unwrap(), dest]);
447 assert_eq!(out.code, 2, "clone into {}: {}", dest, out.stderr);
448 assert_eq!(listing(&work), before, "clone into {}", dest);
451 for dest in ["a/b/c", "new/../fresh"] {
452 let out = env.j(&work, &["clone", missing.to_str().unwrap(), dest]);
453 assert_eq!(out.code, 1, "clone into {}: {}", dest, out.stderr);
454 assert_eq!(listing(&work), before, "clone into {}", dest);
456 // A parent it created keeps what something else put there while the
457 // clone ran: removing each one whole deleted a sibling clone, made in
458 // parallel into the same new `vendor/`, when this one failed. Here the
459 // `git` that fetches writes into the parent, then fails
460 use std::os::unix::fs::PermissionsExt;
461 let wrapper = env.dir.join("git-fetch-fails");
462 for (keep, left) in [
463 ("a/keep.txt", &["a", "a/keep.txt"][..]),
464 ("a/b/keep.txt", &["a", "a/b", "a/b/keep.txt"][..]),
466 let script = format!(
467 "#!/bin/sh\ncase \" $* \" in *\" fetch \"*) echo k > '{}'; exit 1;; esac\nexec git \"$@\"\n",
468 work.join(keep).display()
470 std::fs::write(&wrapper, script).unwrap();
471 std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
472 let out = Command::new(j_bin())
473 .args(["clone", env.remote.to_str().unwrap(), "a/b/c"])
475 .env("XDG_CONFIG_HOME", &env.cfg)
476 .env("NO_COLOR", "1")
477 .env("J_GIT", &wrapper)
480 let stderr = String::from_utf8_lossy(&out.stderr);
481 assert_eq!(out.status.code(), Some(1), "{}", stderr);
482 assert!(stderr.contains("fetch failed"), "{}", stderr);
483 let mut expected = before.clone();
484 expected.extend(left.iter().map(|p| p.to_string()));
486 assert_eq!(listing(&work), expected, "{} written during the clone", keep);
487 assert_eq!(std::fs::read_to_string(work.join(keep)).unwrap(), "k\n");
488 std::fs::remove_dir_all(work.join("a")).unwrap();
490 env.j(&work, &["clone", env.remote.to_str().unwrap(), "new/../fresh"]).ok();
491 assert_eq!(std::fs::read_to_string(work.join("fresh/a.txt")).unwrap(), "one\n");
495fn failed_clone_does_not_follow_a_symlink_put_in_place_of_its_directories() {
496 // §7.8: a failed clone removed DIR with `remove_dir_all` and emptied a
497 // DIR it had found empty through `read_dir`, each by its path. A symlink
498 // put in place of a parent it created, or of the DIR it found empty,
499 // while the clone ran had that cleanup follow it and delete what the
500 // symlink names: `outside/c` and `outside/keep.txt` here. One put in
501 // place of the DIR it created was removed. A path that no longer holds
502 // the directory the clone created or found is now left, and the error
504 use std::os::unix::fs::PermissionsExt;
506 let work = env.dir.join("work");
507 let outside = env.dir.join("outside");
508 let moved = env.dir.join("moved");
509 let wrapper = env.dir.join("git-fetch-swaps");
510 for (dest, swap, named) in [
511 // a parent it created, through which DIR now leads elsewhere too
512 ("a/b/c", "a/b", "left `a/b/c` and `a/b`, as they are no longer the directories this clone created"),
513 // the DIR it created
514 ("a/b/c", "a/b/c", "left `a/b/c`, as it is no longer the directory this clone created"),
515 // the DIR it found empty
516 ("e", "e", "left what `e` holds, as it is no longer the directory this clone found empty"),
518 for d in [&work, &outside, &moved] {
519 let _ = std::fs::remove_dir_all(d);
520 std::fs::create_dir_all(d).unwrap();
522 std::fs::create_dir(outside.join("c")).unwrap();
523 std::fs::write(outside.join("keep.txt"), "k\n").unwrap();
524 std::fs::write(outside.join("c/keep.txt"), "k\n").unwrap();
526 std::fs::create_dir(work.join("e")).unwrap();
528 // the `git` that fetches puts the symlink in place, then fails
529 let script = format!(
530 "#!/bin/sh\ncase \" $* \" in *\" fetch \"*) mv '{}' '{}' && ln -s '{}' '{}'; exit 1;; esac\nexec git \"$@\"\n",
531 work.join(swap).display(),
532 moved.join("was").display(),
534 work.join(swap).display(),
536 std::fs::write(&wrapper, script).unwrap();
537 std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
538 let out = Command::new(j_bin())
539 .args(["clone", env.remote.to_str().unwrap(), dest])
541 .env("XDG_CONFIG_HOME", &env.cfg)
542 .env("NO_COLOR", "1")
543 .env("J_GIT", &wrapper)
546 let stderr = String::from_utf8_lossy(&out.stderr);
547 assert_eq!(out.status.code(), Some(1), "{}", stderr);
548 assert!(stderr.contains("fetch failed"), "{}", stderr);
549 assert!(stderr.contains(named), "{} in place of {}: {}", swap, dest, stderr);
550 assert_eq!(std::fs::read_to_string(outside.join("keep.txt")).unwrap(), "k\n", "{}", swap);
551 assert_eq!(std::fs::read_to_string(outside.join("c/keep.txt")).unwrap(), "k\n", "{}", swap);
552 assert!(work.join(swap).symlink_metadata().unwrap().is_symlink(), "{}", swap);
553 // what the clone had made is where the symlink moved it to
554 assert!(moved.join("was").is_dir(), "{}", swap);
556 std::fs::remove_dir_all(&work).unwrap();
559/// Whether the signal `sig` sent to the process `pid` as a whole is still
560/// waiting for one of its threads to take it
561#[cfg(target_os = "linux")]
562fn signal_pending(pid: libc::pid_t, sig: libc::c_int) -> bool {
563 let status = std::fs::read_to_string(format!("/proc/{}/status", pid)).unwrap_or_default();
566 .find_map(|l| l.strip_prefix("ShdPnd:"))
567 .is_some_and(|mask| u64::from_str_radix(mask.trim(), 16).unwrap() & (1 << (sig - 1)) != 0)
571#[cfg(target_os = "linux")]
572fn an_interrupted_clone_leaves_nothing_behind() {
573 // §1.3, §7.8: a clone removed what it made only when it failed. Ctrl-C
574 // (SIGINT), SIGTERM, SIGHUP or SIGQUIT while it fetched killed it at
575 // once, leaving DIR holding `.git` and `.jj`, with no remote and no
576 // working-copy commit, and a retry was refused: "already contains a jj
577 // repository". A signal sent to `j` alone also left its `git fetch`
578 // writing there. Such a signal now waits for the step in progress, the
579 // git it runs included, then fails the clone, which removes what it
580 // made, and then takes effect. One the clone was started ignoring, as
581 // `nohup` starts it, is still ignored, by the git it runs too.
582 use std::os::unix::fs::PermissionsExt;
583 use std::os::unix::process::{CommandExt, ExitStatusExt};
585 let work = env.dir.join("work");
586 std::fs::create_dir_all(work.join("e")).unwrap();
587 let (started, go) = (env.dir.join("started"), env.dir.join("go"));
588 let wrapper = env.dir.join("git-waits");
589 let names = |dir: &PathBuf| {
590 let mut all: Vec<String> = std::fs::read_dir(dir)
592 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
597 let remote = env.remote.to_str().unwrap();
598 // `j clone remote dest`, whose `git` running `step` says it has started
599 // and waits to be let go, sent `sig` meanwhile, to its process group
600 // (as a Ctrl-C at the terminal is) when `group`, else to it alone; it
601 // starts ignoring `sig` when `ignored`
602 let clone = |step: &str, sig: libc::c_int, group: bool, ignored: bool, dest: &str| {
603 let _ = std::fs::remove_file(&started);
604 let _ = std::fs::remove_file(&go);
605 let script = format!(
606 "#!/bin/sh\ncase \" $* \" in *\" {} \"*) : > '{}'; while [ ! -e '{}' ]; do sleep 0.01; done;; esac\nexec git \"$@\"\n",
611 std::fs::write(&wrapper, script).unwrap();
612 std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
613 let mut command = Command::new(j_bin());
615 .args(["clone", remote, dest])
617 .env("XDG_CONFIG_HOME", &env.cfg)
618 .env("NO_COLOR", "1")
619 .env("J_GIT", &wrapper)
620 .stdout(std::process::Stdio::piped())
621 .stderr(std::process::Stdio::piped())
623 // an ignored signal stays ignored across exec; SIGQUIT dumps no core
625 command.pre_exec(move || {
627 libc::signal(sig, libc::SIG_IGN);
629 libc::setrlimit(libc::RLIMIT_CORE, &libc::rlimit { rlim_cur: 0, rlim_max: 0 });
633 let mut child = command.spawn().unwrap();
634 let pid = child.id() as libc::pid_t;
635 while !started.exists() {
636 assert!(child.try_wait().unwrap().is_none(), "ended before {} started", step);
637 std::thread::yield_now();
639 assert_eq!(unsafe { libc::kill(if group { -pid } else { pid }, sig) }, 0);
640 // `j` takes the signal before its git can go on
641 while signal_pending(pid, sig) && child.try_wait().unwrap().is_none() {
642 std::thread::yield_now();
644 std::fs::write(&go, "").unwrap();
645 let out = child.wait_with_output().unwrap();
646 (out.status, String::from_utf8_lossy(&out.stderr).to_string())
648 for (step, sig, group, dest) in [
649 ("fetch", libc::SIGINT, true, "a/b/c"),
650 ("fetch", libc::SIGTERM, false, "e"),
651 ("fetch", libc::SIGQUIT, false, "c"),
652 ("ls-remote", libc::SIGHUP, false, "c"),
654 let (status, stderr) = clone(step, sig, group, false, dest);
655 // the DIR it created is gone, its parents too; the one it found
657 assert_eq!(names(&work), ["e"], "{} during {} into {}", sig, step, dest);
658 assert_eq!(names(&work.join("e")), [] as [String; 0], "{} during {}", sig, step);
659 assert_eq!(status.signal(), Some(sig), "{} during {}: {}", sig, step, stderr);
660 // a Ctrl-C stops the git too (here the shell that runs it), and `j`
661 // may take it only once the fetch has failed
663 stderr.contains("clone interrupted") || group && stderr.contains("fetch failed: git was stopped"),
667 // and a retry clones there
668 env.j(&work, &["clone", remote, dest]).ok();
669 assert_eq!(std::fs::read_to_string(work.join(dest).join("a.txt")).unwrap(), "one\n");
670 std::fs::remove_dir_all(work.join(dest.split('/').next().unwrap())).unwrap();
671 std::fs::create_dir_all(work.join("e")).unwrap();
673 let (status, stderr) = clone("fetch", libc::SIGHUP, true, true, "c");
674 assert!(status.success(), "{}", stderr);
675 assert_eq!(std::fs::read_to_string(work.join("c/a.txt")).unwrap(), "one\n");
676 env.j(&work.join("c"), &["fetch"]).ok();
680fn init_over_git_takes_a_directory_it_cannot_scan() {
681 // §7.4, §7.8: init over an existing git working tree scans it before
682 // checking out, and a directory that scan cannot read (one deeper than
683 // PATH_MAX here) crashed init after its operation was recorded. Init now
684 // takes the directory as it is: each run's snapshot refuses it, and
685 // records nothing, until it is removed.
687 let dir = env.dir.join("repo");
688 git_repo_with_base(&dir);
689 let half: PathBuf = (0..10).map(|_| "d".repeat(250)).collect();
690 std::fs::create_dir_all(dir.join("deep").join(&half)).unwrap();
691 std::fs::create_dir_all(env.dir.join("more").join(&half)).unwrap();
692 std::fs::rename(env.dir.join("more"), dir.join("deep").join(&half).join("more")).unwrap();
693 env.j(&dir, &["init"]).ok();
694 assert_eq!(env.j(&dir, &["changed"]).code, 2);
695 std::fs::remove_dir_all(dir.join("deep")).unwrap();
696 assert_eq!(env.j(&dir, &["changed"]).ok().stdout.trim(), "none");
697 let ops = env.j(&dir, &["ops"]).ok().stdout;
698 assert!(ops.lines().next().unwrap_or("").contains("init"), "{}", ops);
702fn clone_starts_on_the_remotes_default_bookmark() {
703 // §7.8: the working-copy commit is a child of the target of the bookmark
704 // the remote's HEAD names (master here), not of whichever bookmark sorts
707 let other = env.dir.join("other");
708 git(&env.dir, &["clone", "-q", env.remote.to_str().unwrap(), other.to_str().unwrap()]);
709 git(&other, &["checkout", "-qb", "aaa"]);
710 std::fs::write(other.join("side.txt"), "side\n").unwrap();
711 git(&other, &["add", "side.txt"]);
712 git(&other, &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qm", "side"]);
713 git(&other, &["push", "-q", "origin", "aaa"]);
714 let dest = env.dir.join("clone");
715 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
716 let parent = env.j(&dest, &["(.message) . focus . up"]).ok().stdout;
717 assert_eq!(parent.trim(), "one");
718 assert!(!dest.join("side.txt").exists(), "aaa was checked out");
719 assert_eq!(env.j(&dest, &["changed"]).ok().stdout.trim(), "none");
723fn fetched_branch_with_a_reserved_name_does_not_block_persisting() {
724 // git stores `.jj` paths, so a branch on the remote can hold one. It is
725 // mutable (not an ancestor of trunk) and labelled, so it cannot be
726 // abandoned, and refusing its names crashed every persisting run in the
727 // clone (`describe`, `new`, `j id`), though none of them writes or
728 // checks it out (§7.5 step 1)
730 let other = env.dir.join("other");
731 git(&env.dir, &["clone", "-q", env.remote.to_str().unwrap(), other.to_str().unwrap()]);
732 git(&other, &["checkout", "-qb", "feature"]);
733 std::fs::create_dir_all(other.join("fixture/.jj")).unwrap();
734 std::fs::write(other.join("fixture/.jj/repo"), "vendored\n").unwrap();
735 git(&other, &["add", "-f", "fixture"]);
736 git(&other, &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qm", "feat"]);
737 git(&other, &["push", "-q", "origin", "feature"]);
738 let dest = env.dir.join("clone");
739 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
740 let ops = || env.j(&dest, &["ops"]).ok().stdout.lines().count();
742 env.j(&dest, &["tree . validate . describe \"x\""]).ok();
743 env.j(&dest, &["describe \"hello\""]).ok();
744 env.j(&dest, &["new"]).ok();
745 std::fs::write(dest.join("new.txt"), "hi\n").unwrap();
746 env.j(&dest, &["id"]).ok();
747 // rewriting it keeps the files jj stored
748 env.j(&dest, &["at %feature (describe \"renamed\")"]).ok();
749 assert_eq!(ops(), before + 4);
750 // checking it out is still refused, and records nothing
751 let out = env.j(&dest, &["goto %feature"]);
752 assert_eq!(out.code, 1, "{}", out.stdout);
753 assert!(out.stderr.contains("path component"), "{}", out.stderr);
754 assert_eq!(ops(), before + 4);
755 assert!(!dest.join("fixture").exists());
759fn fetched_descendant_with_a_reserved_name_does_not_block_snapshots() {
760 // §7.5 step 1: a snapshot that rewrites the focus rebases the fetched
761 // branch above it, so the branch's files differ, and all of its names
762 // were checked again: `.jj`, a name over 255 bytes on ext4, `.GIT`
763 // where the filesystem folds case. Every persisting run crashed, `j id`
764 // included, until the edit on disk was undone, though none checks the
765 // branch out and jj stored those names already. Only the paths the
766 // branch did not hold are checked now. Run with TMPDIR on a
767 // case-folding mount to exercise `.GIT`.
769 let other = env.dir.join("other");
770 git(&env.dir, &["clone", "-q", env.remote.to_str().unwrap(), other.to_str().unwrap()]);
771 git(&other, &["checkout", "-qb", "base"]);
772 std::fs::write(other.join("b.txt"), "b\n").unwrap();
773 git(&other, &["add", "b.txt"]);
774 git(&other, &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qm", "base"]);
775 git(&other, &["push", "-q", "origin", "base"]);
776 // git refuses `.GIT` in an index, so the tree is built by hand
777 let blob = git(&env.remote, &["hash-object", "-w", other.join("a.txt").to_str().unwrap()]);
778 let mktree = |entries: String| git_stdin(&env.remote, &["mktree"], &entries).trim().to_string();
779 let x = mktree(format!("100644 blob {}\tx\n", blob.trim()));
780 let wide = mktree(format!("100644 blob {}\t{}\n", blob.trim(), "日".repeat(100)));
781 let top = git(&env.remote, &["ls-tree", "base"]);
782 let tree = mktree(format!(
783 "{}040000 tree {x}\t.GIT\n040000 tree {x}\t.jj\n040000 tree {wide}\twide\n",
788 &["-c", "user.email=t@t", "-c", "user.name=T", "commit-tree", &tree, "-p", "base", "-m", "feat"],
790 git(&env.remote, &["update-ref", "refs/heads/feature", commit.trim()]);
791 let dest = env.dir.join("clone");
792 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
793 env.j(&dest, &["goto %base"]).ok();
794 let ops = || env.j(&dest, &["ops"]).ok().stdout.lines().count();
796 std::fs::write(dest.join("b.txt"), "b\nedited\n").unwrap();
797 env.j(&dest, &["tree . validate . id"]).ok();
798 env.j(&dest, &["id"]).ok();
799 env.j(&dest, &["describe \"base edited\""]).ok();
800 env.j(&dest, &["new"]).ok();
801 assert_eq!(ops(), before + 3);
802 // the branch was rebased onto the edit and keeps its names
803 let feature = |expr: &str| env.j(&dest, &[&format!("{} . files . goto %feature", expr)]).ok().stdout;
804 assert_eq!(feature("contentAt ./b.txt"), "b\nedited\n");
805 let paths = feature("map (.path)");
806 let wide = format!("wide/{}", "日".repeat(100));
807 assert_eq!(paths.lines().collect::<Vec<_>>(), [".GIT/x", ".jj/x", "a.txt", "b.txt", wide.as_str()]);
808 // checking it out is still refused, and records nothing
809 let out = env.j(&dest, &["goto %feature"]);
810 assert_eq!(out.code, 1, "{}", out.stdout);
811 assert!(out.stderr.contains("path component"), "{}", out.stderr);
812 assert_eq!(ops(), before + 3);
816fn init_over_git_starts_on_head() {
817 // §7.8: init over an existing git repository creates the working-copy
818 // commit as a child of git's HEAD: not of the bookmark that sorts first,
819 // and also when HEAD is detached from every branch
821 let commit = |dir: &PathBuf, file: &str, msg: &str| {
822 std::fs::write(dir.join(file), format!("{}\n", msg)).unwrap();
823 git(dir, &["add", file]);
824 git(dir, &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qm", msg]);
826 let dir = env.dir.join("repo");
827 git_repo_with_base(&dir);
828 git(&dir, &["checkout", "-qb", "aaa"]);
829 commit(&dir, "side.txt", "side");
830 git(&dir, &["checkout", "-q", "main"]);
831 commit(&dir, "a.txt", "main work");
832 env.j(&dir, &["init"]).ok();
833 let parent = env.j(&dir, &["(.message) . focus . up"]).ok().stdout;
834 assert_eq!(parent.trim(), "main work");
835 assert!(!dir.join("side.txt").exists());
836 assert_eq!(env.j(&dir, &["changed"]).ok().stdout.trim(), "none");
838 let dir = env.dir.join("detached");
839 git_repo_with_base(&dir);
840 git(&dir, &["checkout", "-q", "--detach"]);
841 commit(&dir, "d.txt", "detached work");
842 env.j(&dir, &["init"]).ok();
843 let parent = env.j(&dir, &["(.message) . focus . up"]).ok().stdout;
844 assert_eq!(parent.trim(), "detached work");
845 assert_eq!(env.j(&dir, &["changed"]).ok().stdout.trim(), "none");
849fn init_over_git_keeps_uncommitted_changes() {
850 // §7.8: init writes no file over an existing git working tree; what it
851 // holds beyond the head (an edit, a deletion) becomes the working-copy
852 // commit's change rather than being checked out over
854 let dir = env.dir.join("repo");
855 git_repo_with_base(&dir);
856 std::fs::write(dir.join("a.txt"), "edited\n").unwrap();
857 std::fs::remove_file(dir.join("b.txt")).unwrap();
858 env.j(&dir, &["init"]).ok();
859 assert_eq!(std::fs::read_to_string(dir.join("a.txt")).unwrap(), "edited\n");
860 assert!(!dir.join("b.txt").exists(), "init restored a deleted file");
861 let changed = env.j(&dir, &["changed"]).ok().stdout;
862 assert!(changed.contains("a.txt") && changed.contains("b.txt"), "{}", changed);
866fn merge_ancestors_through_every_parent_are_immutable() {
867 // §7.5 step 3: every ancestor of a merge is immutable, including those
868 // reached only through its second parent. Following first parents only,
869 // the side branch was mutable: describing or abandoning one of its
870 // commits exited 0, and jj rebased the read-only merge (§7.2) onto the
871 // result, dropping the side's files from it on abandon.
873 let commit = |dir: &PathBuf, file: &str, msg: &str| {
874 std::fs::write(dir.join(file), format!("{}\n", msg)).unwrap();
875 git(dir, &["add", file]);
876 git(dir, &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qm", msg]);
878 let dir = env.dir.join("repo");
879 git_repo_with_base(&dir);
880 git(&dir, &["checkout", "-qb", "side"]);
881 commit(&dir, "s1.txt", "side one");
882 commit(&dir, "s2.txt", "side two");
883 git(&dir, &["checkout", "-q", "main"]);
884 commit(&dir, "m.txt", "main work");
885 git(&dir, &["-c", "user.email=t@t", "-c", "user.name=T", "merge", "-q", "side", "-m", "merge"]);
886 let merge = git(&dir, &["rev-parse", "HEAD"]).trim().to_string();
887 env.j(&dir, &["init"]).ok();
888 let merge_hash = || {
889 let expr = r#"\r -> (meta (head (matching (\c -> startsWith "merge" c.message) all r))).hash"#;
890 env.j(&dir, &[expr]).ok().stdout.trim().to_string()
892 assert_eq!(merge_hash(), merge);
894 let tree = env.j(&dir, &["tree"]).ok().stdout;
895 for msg in ["side one", "side two"] {
896 let line = tree.lines().find(|l| l.contains(msg)).unwrap();
897 assert!(line.contains('◆'), "`{}` is not drawn immutable:\n{}", msg, tree);
900 let side = |msg: &str, edit: &str| {
901 format!(r#"at (matching (\c -> startsWith "{}" c.message) all) ({})"#, msg, edit)
903 for edit in [side("side one", "describe \"edited\""), side("side two", "abandon")] {
904 // validate is an honest dry run of what persistence refuses
905 let out = env.j(&dir, &[&format!("tree . validate . {}", edit)]);
906 assert_eq!(out.code, 1, "validate accepted `{}`:\n{}", edit, out.stdout);
907 assert!(out.stderr.contains("is immutable"), "{}", out.stderr);
908 let out = env.j(&dir, &[&edit]);
909 assert_eq!(out.code, 1, "persistence accepted `{}`", edit);
910 assert!(out.stderr.contains("is immutable"), "{}", out.stderr);
911 assert_eq!(merge_hash(), merge, "`{}` rewrote the merge", edit);
913 assert_eq!(env.j(&dir, &["changed"]).ok().stdout.trim(), "none");
917fn push_label_and_relabel() {
919 let dest = uniq("clone");
920 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
921 std::fs::write(dest.join("a.txt"), "two\n").unwrap();
922 env.j(&dest, &["describe \"work\""]).ok();
923 env.j(&dest, &["push (label \"feature\" here)"]).ok();
924 let log = git(&env.remote, &["log", "feature", "--oneline"]);
925 assert!(log.contains("work"), "{}", log);
926 // squash locally then relabel moves the bookmark
927 env.j(&dest, &["new"]).ok();
928 std::fs::write(dest.join("b.txt"), "three\n").unwrap();
929 env.j(&dest, &["describe \"more\""]).ok();
930 env.j(&dest, &["squash"]).ok();
931 env.j(&dest, &["push relabel"]).ok();
932 // squash kept the parent's message but folded the files; the bookmark
933 // moved to the rewritten commit
934 let show = git(&env.remote, &["show", "feature:b.txt"]);
935 assert!(show.contains("three"), "{}", show);
937 env.j(&dest, &["push (unlabel \"feature\")"]).ok();
938 let branches = git(&env.remote, &["branch"]);
939 assert!(!branches.contains("feature"), "{}", branches);
945 let dest = uniq("clone");
946 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
947 std::fs::write(dest.join("a.txt"), "two\n").unwrap();
948 env.j(&dest, &["describe \"work\""]).ok();
949 env.j(&dest, &["push (label \"old\" here)"]).ok();
950 env.j(&dest, &["push (rename \"old\" \"new\")"]).ok();
951 let branches = git(&env.remote, &["branch"]);
952 assert!(branches.contains("new"), "{}", branches);
953 assert!(!branches.contains("old"), "{}", branches);
959 let dest = uniq("clone");
960 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
961 // empty description refused
962 let out = env.j(&dest, &["push (label \"x\" here)"]);
963 assert_eq!(out.code, 1);
964 assert!(out.stderr.contains("empty description"), "{}", out.stderr);
965 // deleting a bookmark the remote doesn't have
966 let out = env.j(&dest, &["push (unlabel \"nothere\")"]);
967 assert_eq!(out.code, 1);
968 // moving immutable master forward is allowed: the child descends from it
969 // (moving it elsewhere is refused: push_refuses_moving_an_immutable_bookmark)
970 std::fs::write(dest.join("a.txt"), "x\n").unwrap();
971 env.j(&dest, &["describe \"w\""]).ok();
972 let out = env.j(&dest, &["push (label \"master\" here)"]);
973 assert_eq!(out.code, 0, "{}", out.stderr);
977fn push_of_an_empty_list_pushes_nothing() {
978 // §7.6: an empty list of records sets and deletes no bookmark. It went to
979 // git as a push with no refspecs, which git fills in from push.default:
980 // it published a local git branch nobody named, or failed with "has no
983 let master = || git(&env.remote, &["rev-parse", "master"]);
984 let before = master();
985 let work = env.dir.join("work");
986 git(&env.dir, &["clone", "-q", env.remote.to_str().unwrap(), work.to_str().unwrap()]);
987 git(&work, &["checkout", "-q", "master"]);
988 std::fs::write(work.join("l.txt"), "local\n").unwrap();
989 git(&work, &["add", "l.txt"]);
990 git(&work, &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qm", "local unpushed"]);
991 env.j(&work, &["init"]).ok();
992 env.j(&work, &["push []"]).ok();
993 assert_eq!(master(), before, "push [] moved the remote's master");
994 // it records one operation (§7.7)
995 let ops = env.j(&work, &["ops"]).ok().stdout;
996 assert!(ops.lines().next().unwrap_or("").ends_with(" []"), "{}", ops);
998 // a clone's git branch has no upstream
999 let dest = env.dir.join("clone");
1000 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
1001 env.j(&dest, &["describe \"x\""]).ok();
1002 env.j(&dest, &["push []"]).ok();
1003 env.j(&dest, &["push (label \"nope\" (labelled \"zzz\"))"]).ok();
1004 assert_eq!(master(), before);
1005 let refs = git(&env.remote, &["for-each-ref", "--format=%(refname)"]);
1006 assert_eq!(refs.trim(), "refs/heads/master");
1008 // without origin it still exits 1
1009 let plain = env.dir.join("plain");
1010 std::fs::create_dir_all(&plain).unwrap();
1011 env.j(&plain, &["init"]).ok();
1012 let out = env.j(&plain, &["push []"]);
1013 assert_eq!(out.code, 1);
1014 assert!(out.stderr.contains("no remote origin"), "{}", out.stderr);
1018fn push_refuses_moving_an_immutable_bookmark() {
1019 // §7.6: a bookmark whose current target is immutable may only move to a
1020 // descendant of that target, and may not be deleted while no bookmark
1021 // left on origin reaches that target. Nothing checked this: pushes
1022 // rewound master to its parent and moved it onto a side line with exit 0
1024 let seed = env.dir.join("seed");
1025 git(&env.dir, &["clone", "-q", env.remote.to_str().unwrap(), seed.to_str().unwrap()]);
1026 git(&seed, &["checkout", "-q", "master"]);
1027 git(&seed, &["push", "-q", "origin", "master:old"]);
1028 std::fs::write(seed.join("a.txt"), "two\n").unwrap();
1029 git(&seed, &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qam", "two"]);
1030 git(&seed, &["push", "-q", "origin", "master"]);
1031 let refs = || git(&env.remote, &["for-each-ref"]);
1032 let dest = env.dir.join("clone");
1033 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
1034 let before = refs();
1036 // backward, to its parent
1037 let out = env.j(&dest, &["push (label \"master\" (labelled \"old\"))"]);
1038 assert_eq!(out.code, 1, "rewound master");
1039 assert!(out.stderr.contains("immutable"), "{}", out.stderr);
1040 assert_eq!(refs(), before);
1041 // sideways, onto a new line off its parent
1042 env.j(&dest, &["new . goto (labelled \"old\")"]).ok();
1043 std::fs::write(dest.join("side.txt"), "side\n").unwrap();
1044 env.j(&dest, &["describe \"side\""]).ok();
1045 let out = env.j(&dest, &["push (label \"master\" here)"]);
1046 assert_eq!(out.code, 1, "moved master sideways");
1047 assert!(out.stderr.contains("immutable"), "{}", out.stderr);
1048 assert_eq!(refs(), before);
1049 // deleted while no other bookmark reaches its commit: `old` is on the
1050 // parent (deletes another bookmark reaches are allowed:
1051 // push_deletes_an_immutable_bookmark_another_one_reaches)
1052 let out = env.j(&dest, &["push (unlabel \"master\")"]);
1053 assert_eq!(out.code, 1, "deleted the only bookmark on an immutable commit");
1054 assert!(out.stderr.contains("immutable"), "{}", out.stderr);
1055 assert_eq!(refs(), before);
1057 // forward is allowed, and so is moving a mutable bookmark anywhere
1058 env.j(&dest, &["push (label \"old\" trunk)"]).ok();
1059 env.j(&dest, &["push (label \"side\" here)"]).ok();
1060 env.j(&dest, &["push (label \"side\" trunk)"]).ok();
1061 let master = git(&env.remote, &["rev-parse", "master"]);
1062 assert_eq!(git(&env.remote, &["rev-parse", "old"]), master);
1063 assert_eq!(git(&env.remote, &["rev-parse", "side"]), master);
1065 // each of the three names on "two" is reached by the other two, but a
1066 // bookmark the push deletes reaches nothing after it: deleting all three
1067 // at once is refused, and none of them is deleted
1068 let before = refs();
1069 let out = env.j(&dest, &["push (unlabel \"master\" ++ unlabel \"old\" ++ unlabel \"side\")"]);
1070 assert_eq!(out.code, 1, "deleted every bookmark on an immutable commit");
1071 assert!(out.stderr.contains("immutable"), "{}", out.stderr);
1072 assert_eq!(refs(), before);
1076fn push_deletes_an_immutable_bookmark_another_one_reaches() {
1077 // §7.6: a delete of a bookmark on an immutable commit is refused only
1078 // while no bookmark left on origin after the push reaches that commit.
1079 // Every such delete was refused, so config.j's `unlabel "feature"` after
1080 // the merge failed whenever the merge kept feature's commit (a
1081 // fast-forward or a merge commit), and a name ever put on trunk could
1082 // never be deleted or renamed
1084 let refs = || git(&env.remote, &["for-each-ref", "--format=%(refname) %(objectname)"]);
1085 let dest = env.dir.join("clone");
1086 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
1088 // `feature`, merged by fast-forwarding master onto it
1089 std::fs::write(dest.join("f.txt"), "feat\n").unwrap();
1090 env.j(&dest, &["describe \"feat\""]).ok();
1091 env.j(&dest, &["push (label \"feature\" here)"]).ok();
1092 env.j(&dest, &["push (label \"master\" (labelled \"feature\"))"]).ok();
1093 let out = env.j(&dest, &["push (unlabel \"feature\")"]);
1094 assert_eq!(out.code, 0, "fast-forwarded feature: {}", out.stderr);
1096 // `side`, merged on the remote with a merge commit
1097 env.j(&dest, &["new . goto trunk"]).ok();
1098 std::fs::write(dest.join("s.txt"), "side\n").unwrap();
1099 env.j(&dest, &["describe \"side\""]).ok();
1100 env.j(&dest, &["push (label \"side\" here)"]).ok();
1101 let work = env.dir.join("work");
1102 git(&env.dir, &["clone", "-q", env.remote.to_str().unwrap(), work.to_str().unwrap()]);
1103 git(&work, &["checkout", "-q", "master"]);
1106 &["-c", "user.email=t@t", "-c", "user.name=T", "merge", "-q", "--no-ff", "origin/side", "-m", "merge side"],
1108 git(&work, &["push", "-q", "origin", "master"]);
1109 env.j(&dest, &["fetch"]).ok();
1110 let out = env.j(&dest, &["push (unlabel \"side\")"]);
1111 assert_eq!(out.code, 0, "side merged with a merge commit: {}", out.stderr);
1113 // a second name on trunk's commit, renamed and deleted
1114 env.j(&dest, &["push (label \"x\" trunk)"]).ok();
1115 let out = env.j(&dest, &["push (rename \"x\" \"y\")"]);
1116 assert_eq!(out.code, 0, "renamed an alias of master: {}", out.stderr);
1117 let out = env.j(&dest, &["push (unlabel \"y\")"]);
1118 assert_eq!(out.code, 0, "deleted an alias of master: {}", out.stderr);
1119 let merge = git(&env.remote, &["rev-parse", "master"]);
1120 assert_eq!(refs(), format!("refs/heads/master {}", merge));
1122 // master itself: deleting it leaves nothing reaching the merge, but a
1123 // bookmark the same push creates there does (the remote's default
1124 // branch becomes `main` first, so git would delete master)
1125 git(&env.remote, &["symbolic-ref", "HEAD", "refs/heads/main"]);
1126 let out = env.j(&dest, &["push (unlabel \"master\")"]);
1127 assert_eq!(out.code, 1, "deleted the only bookmark on trunk");
1128 assert!(out.stderr.contains("immutable"), "{}", out.stderr);
1129 assert_eq!(refs(), format!("refs/heads/master {}", merge));
1130 let out = env.j(&dest, &["push (rename \"master\" \"main\")"]);
1131 assert_eq!(out.code, 0, "renamed master: {}", out.stderr);
1132 assert_eq!(refs(), format!("refs/heads/main {}", merge));
1136fn push_deletes_an_immutable_bookmark_only_after_the_update_that_reaches_it() {
1137 // §7.6: a delete of a bookmark on an immutable commit that only a
1138 // bookmark the same push sets still reaches is sent after that update is
1139 // accepted. Both went in one git push, which is not atomic: when the
1140 // remote rejected `main`, `push (rename "master" "main")` deleted master
1141 // anyway and left no bookmark on origin reaching trunk's history
1142 use std::os::unix::fs::PermissionsExt;
1144 let refs = || git(&env.remote, &["for-each-ref", "--format=%(refname) %(objectname)"]);
1145 let hook = env.remote.join("hooks/update");
1146 let reject = |name: &str| {
1147 let script = format!("#!/bin/sh\n[ \"$1\" = refs/heads/{} ] && exit 1\nexit 0\n", name);
1148 std::fs::write(&hook, script).unwrap();
1149 std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
1151 let dest = env.dir.join("clone");
1152 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
1153 let one = git(&env.remote, &["rev-parse", "master"]).trim().to_string();
1154 let top_op = || env.j(&dest, &["ops"]).ok().stdout.lines().next().unwrap_or("").to_string();
1155 let ops = || env.j(&dest, &["ops"]).ok().stdout.lines().count();
1156 let labelled = |label: &str| {
1157 let tree = env.j(&dest, &["tree"]).ok().stdout;
1158 tree.lines().any(|l| l.contains('◆') && l.contains("one") && l.contains(label))
1160 // the remote's default branch is `main`, so git lets master be deleted
1161 git(&env.remote, &["symbolic-ref", "HEAD", "refs/heads/main"]);
1163 // the update is rejected: the delete is not sent, and nothing is recorded
1166 let out = env.j(&dest, &["push (rename \"master\" \"main\")"]);
1167 assert_eq!(out.code, 1, "{}", out.stderr);
1168 assert!(out.stderr.contains("refs/heads/main"), "{}", out.stderr);
1169 assert!(out.stderr.contains("not sent: refs/heads/master"), "{}", out.stderr);
1170 assert_eq!(refs(), format!("refs/heads/master {}\n", one));
1171 assert_eq!(ops(), before);
1172 assert!(labelled("master"), "trunk's commit is no longer immutable");
1174 // one update of two is accepted: it is recorded, the delete still waits
1175 let out = env.j(&dest, &["push (\\r -> label \"keep\" trunk r ++ rename \"master\" \"main\" r)"]);
1176 assert_eq!(out.code, 1, "{}", out.stderr);
1177 assert!(out.stderr.contains("not sent: refs/heads/master"), "{}", out.stderr);
1178 assert_eq!(refs(), format!("refs/heads/keep {one}\nrefs/heads/master {one}\n"));
1179 assert!(top_op().contains("label \"keep\""), "{}", top_op());
1180 assert!(labelled("keep") && labelled("master"));
1182 // every update is accepted and the delete rejected: the one operation
1183 // records the update
1184 env.j(&dest, &["push (unlabel \"keep\")"]).ok();
1186 let out = env.j(&dest, &["push (rename \"master\" \"main\")"]);
1187 assert_eq!(out.code, 1, "{}", out.stderr);
1188 assert!(out.stderr.contains("push rejected: refs/heads/master"), "{}", out.stderr);
1189 assert_eq!(refs(), format!("refs/heads/main {one}\nrefs/heads/master {one}\n"));
1190 assert!(top_op().contains("rename \"master\" \"main\""), "{}", top_op());
1191 assert!(labelled("main") && labelled("master"));
1195fn push_refuses_a_waiting_delete_whose_name_nests_with_a_set_one() {
1196 // §7.6: git cannot hold a bookmark and one under its name at once, so
1197 // when the deletes wait, a bookmark the push creates under the name of
1198 // one it deletes, or the reverse, is rejected by the first git push and
1199 // the deletes are never sent. `push (rename "master" "master/legacy")`
1200 // sent that push and exited 1 naming only the rejected ref, and renaming
1201 // trunk's only bookmark under itself could never succeed; it is refused
1202 // before anything is sent, saying how to do it instead
1203 use std::os::unix::fs::PermissionsExt;
1205 let refs = || git(&env.remote, &["for-each-ref", "--format=%(refname) %(objectname)"]);
1206 // every git push that reaches the remote is logged, each followed by an
1208 let log = env.dir.join("pushes.log");
1209 let hook = env.remote.join("hooks/pre-receive");
1210 std::fs::write(&hook, format!("#!/bin/sh\n{{ cat; echo; }} >> '{}'\n", log.display())).unwrap();
1211 std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
1213 let pushes = std::fs::read_to_string(&log).unwrap_or_default();
1214 let _ = std::fs::remove_file(&log);
1217 let dest = env.dir.join("clone");
1218 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
1219 let one = git(&env.remote, &["rev-parse", "master"]).trim().to_string();
1220 let zero = "0".repeat(one.len());
1221 let ops = || env.j(&dest, &["ops"]).ok().stdout.lines().count();
1222 // a bookmark that does not reach trunk's commit
1223 env.j(&dest, &["new . top"]).ok();
1224 std::fs::write(dest.join("f.txt"), "feat\n").unwrap();
1225 env.j(&dest, &["describe \"feat\""]).ok();
1226 env.j(&dest, &["push (label \"feature/x\" here)"]).ok();
1227 let feat = git(&env.remote, &["rev-parse", "feature/x"]).trim().to_string();
1228 let before_refs = refs();
1229 // the remote's default branch is elsewhere, so git lets master be deleted
1230 git(&env.remote, &["symbolic-ref", "HEAD", "refs/heads/other"]);
1231 assert!(sent().contains("refs/heads/feature/x"), "the hook logged no push");
1234 let out = env.j(&dest, &["push (rename \"master\" \"master/legacy\")"]);
1235 assert_eq!(out.code, 1, "{}", out.stderr);
1236 assert!(out.stderr.contains("`master` must stay on origin until `master/legacy`"), "{}", out.stderr);
1237 assert!(out.stderr.contains("push (label \"tmp\" (labelled \"master\"))"), "{}", out.stderr);
1238 assert_eq!(sent(), "", "a git push was sent");
1239 assert_eq!(refs(), before_refs);
1240 assert_eq!(ops(), before);
1242 // every delete waits with master's, and a deleted bookmark nests with
1243 // one set at the name it is under as well (`feature/x` and `feature`)
1244 let out = env.j(&dest, &["push (\\r -> rename \"master\" \"main\" r ++ rename \"feature/x\" \"feature\" r)"]);
1245 assert_eq!(out.code, 1, "{}", out.stderr);
1246 assert!(out.stderr.contains("`feature/x` must stay on origin until `feature`"), "{}", out.stderr);
1247 assert!(out.stderr.contains("(labelled \"master\")"), "{}", out.stderr);
1248 assert_eq!(sent(), "", "a git push was sent");
1249 assert_eq!(refs(), before_refs);
1250 assert_eq!(ops(), before);
1252 // a name nests only with the names under it, past a `/`: `master2`
1253 // shares a prefix with master and is created, and master deleted in a
1254 // second git push once it is accepted
1255 let out = env.j(&dest, &["push (rename \"master\" \"master2\")"]);
1256 assert_eq!(out.code, 0, "{}", out.stderr);
1257 let pushes = sent();
1258 let pushes: Vec<&str> = pushes.split_terminator("\n\n").collect();
1259 assert_eq!(pushes, [format!("{zero} {one} refs/heads/master2"), format!("{one} {zero} refs/heads/master")]);
1260 assert_eq!(refs(), format!("refs/heads/feature/x {feat}\nrefs/heads/master2 {one}\n"));
1261 // with no trunk left nothing is immutable, and the rename back is one
1263 env.j(&dest, &["push (rename \"master2\" \"master\")"]).ok();
1264 assert_eq!(refs(), before_refs);
1265 assert_eq!(sent().split_terminator("\n\n").count(), 1);
1267 // the way round it: with the temporary label on origin the delete does
1268 // not wait, and the rename is one git push
1269 env.j(&dest, &["push (label \"tmp\" (labelled \"master\"))"]).ok();
1270 env.j(&dest, &["push (rename \"master\" \"master/legacy\")"]).ok();
1271 env.j(&dest, &["push (unlabel \"tmp\")"]).ok();
1272 assert_eq!(refs(), format!("refs/heads/feature/x {feat}\nrefs/heads/master/legacy {one}\n"));
1273 let tree = env.j(&dest, &["tree"]).ok().stdout;
1274 assert!(tree.lines().any(|l| l.contains("one") && l.contains("master/legacy")), "{}", tree);
1278fn push_refusing_a_nesting_delete_advises_a_label_no_bookmark_has() {
1279 // §7.6: the refusal above advised `push (label "tmp" (labelled
1280 // "master"))` whatever the remote held. Followed as written, it moved a
1281 // `tmp` the user had on origin to trunk's commit and then deleted it; it
1282 // was rejected with `tmp/wip` there; and when the refused push itself
1283 // deleted `tmp` it changed nothing and the refusal came back. The label
1284 // it advises is on no bookmark, no record names it, and it nests with
1287 let refs = || git(&env.remote, &["for-each-ref", "--format=%(refname) %(objectname)"]);
1288 let dest = env.dir.join("clone");
1289 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
1290 let one = git(&env.remote, &["rev-parse", "master"]).trim().to_string();
1291 // the user's own `tmp` and `tmp-1/wip`, which do not reach trunk's commit
1292 env.j(&dest, &["new . top"]).ok();
1293 std::fs::write(dest.join("f.txt"), "work\n").unwrap();
1294 env.j(&dest, &["describe \"my work\""]).ok();
1295 env.j(&dest, &["push (\\r -> label \"tmp\" here r ++ label \"tmp-1/wip\" here r)"]).ok();
1296 let work = git(&env.remote, &["rev-parse", "tmp"]).trim().to_string();
1297 // the remote's default branch is elsewhere, so git lets master be deleted
1298 git(&env.remote, &["symbolic-ref", "HEAD", "refs/heads/other"]);
1299 // `expr` is refused; the steps its message gives are run around it as
1300 // written, and the first is returned
1301 let follow = |expr: &str| {
1302 let out = env.j(&dest, &[expr]);
1303 assert_eq!(out.code, 1, "{}", out.stderr);
1304 let steps: Vec<&str> = out.stderr.split('`').filter(|s| s.starts_with("push (")).collect();
1305 assert_eq!(steps.len(), 2, "{}", out.stderr);
1306 env.j(&dest, &[steps[0]]).ok();
1307 env.j(&dest, &[expr]).ok();
1308 env.j(&dest, &[steps[1]]).ok();
1309 steps[0].to_string()
1312 // `tmp` is on origin, and `tmp-1` nests with `tmp-1/wip` there
1313 let step = follow("push (rename \"master\" \"master/legacy\")");
1314 assert_eq!(step, "push (label \"tmp-2\" (labelled \"master\"))");
1317 format!("refs/heads/master/legacy {one}\nrefs/heads/tmp {work}\nrefs/heads/tmp-1/wip {work}\n")
1320 // with no trunk left nothing is immutable, and the rename back goes
1321 env.j(&dest, &["push (rename \"master/legacy\" \"master\")"]).ok();
1322 // the push deletes `tmp` and `tmp-1/wip`, and creates `tmp-3` and a name
1324 let expr = "push (\\r -> rename \"master\" \"master/legacy\" r ++ rename \"tmp\" \"tmp-2/x\" r ++ rename \"tmp-1/wip\" \"tmp-3\" r)";
1325 let step = follow(expr);
1326 assert_eq!(step, "push (label \"tmp-4\" (labelled \"master\"))");
1329 format!("refs/heads/master/legacy {one}\nrefs/heads/tmp-2/x {work}\nrefs/heads/tmp-3 {work}\n")
1334fn push_moves_a_bookmark_whose_remote_commit_was_rewritten() {
1335 // §7.6: only the visible commit carrying a change id is in the immutable
1336 // set. Bookmarks pushed at a commit that was then amended here, and the
1337 // amended commit put on trunk, still point at the hidden original on
1338 // the remote. The check found their change id in the immutable set and
1339 // tested descent from the hidden commit, which nothing descends from:
1340 // they could never be moved or deleted again
1342 let rev = |name: &str| git(&env.remote, &["rev-parse", name]);
1343 let dest = env.dir.join("clone");
1344 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
1345 std::fs::write(dest.join("a.txt"), "two\n").unwrap();
1346 env.j(&dest, &["describe \"work\""]).ok();
1347 let expr = "push (\\r -> label \"feature\" here r ++ label \"other\" here r ++ label \"gone\" here r)";
1348 env.j(&dest, &[expr]).ok();
1349 let pushed = rev("feature");
1350 env.j(&dest, &["describe \"work v2\""]).ok();
1351 env.j(&dest, &["push (label \"master\" here)"]).ok();
1352 assert_ne!(rev("master"), pushed);
1354 // onto a child of the amended commit
1355 std::fs::write(dest.join("b.txt"), "three\n").unwrap();
1356 env.j(&dest, &["describe \"next\""]).ok();
1357 let out = env.j(&dest, &["push (label \"other\" here)"]);
1358 assert_eq!(out.code, 0, "moved onto a child: {}", out.stderr);
1359 assert_eq!(git(&env.remote, &["rev-parse", "other^"]), rev("master"));
1361 let out = env.j(&dest, &["push (unlabel \"gone\")"]);
1362 assert_eq!(out.code, 0, "deleted: {}", out.stderr);
1363 assert!(!git(&env.remote, &["branch"]).contains("gone"));
1364 // onto the amended commit itself
1365 let out = env.j(&dest, &["push relabel"]);
1366 assert_eq!(out.code, 0, "relabelled: {}", out.stderr);
1367 assert_eq!(rev("feature"), rev("master"));
1371fn push_checks_only_the_commits_it_sends() {
1372 // §7.6: the unresolved-file and empty-description refusals are about
1373 // commits that would be sent. The check walked every ancestor of the new
1374 // and old targets, so one empty message the remote already had refused
1375 // every later push on top of it, and deletes, which send nothing
1377 let seed = env.dir.join("seed");
1378 git(&env.dir, &["clone", "-q", env.remote.to_str().unwrap(), seed.to_str().unwrap()]);
1379 git(&seed, &["checkout", "-q", "master"]);
1380 std::fs::write(seed.join("a.txt"), "blank\n").unwrap();
1383 &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qa", "--allow-empty-message", "-m", ""],
1385 git(&seed, &["push", "-q", "origin", "master"]);
1386 let dest = env.dir.join("clone");
1387 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
1388 std::fs::write(dest.join("a.txt"), "work\n").unwrap();
1389 env.j(&dest, &["describe \"work\""]).ok();
1390 env.j(&dest, &["push (label \"feature\" here)"]).ok();
1391 assert!(git(&env.remote, &["log", "--oneline", "feature"]).contains("work"));
1392 env.j(&dest, &["push (label \"copy\" (labelled \"feature\"))"]).ok();
1393 env.j(&dest, &["push (unlabel \"copy\")"]).ok();
1394 assert!(!git(&env.remote, &["branch"]).contains("copy"));
1395 // a new commit without a message is still refused
1396 env.j(&dest, &["new"]).ok();
1397 std::fs::write(dest.join("b.txt"), "more\n").unwrap();
1398 let out = env.j(&dest, &["push (label \"feature\" here)"]);
1399 assert_eq!(out.code, 1);
1400 assert!(out.stderr.contains("empty description"), "{}", out.stderr);
1401 assert!(git(&env.remote, &["log", "-1", "--format=%s", "feature"]).contains("work"));
1405fn partial_push_records_what_the_remote_accepted() {
1406 // git push is not atomic: when the remote accepts `aaa` and rejects
1407 // `bbb`, aaa is on the remote. The run exited 1 without recording the
1408 // push, so the aaa label was missing until the next fetch and a push
1409 // naming aaa was refused or rejected on its stale lease (§7.6: a label
1410 // is the remote's bookmark as of the last push)
1411 use std::os::unix::fs::PermissionsExt;
1413 let hook = env.remote.join("hooks/update");
1414 std::fs::write(&hook, "#!/bin/sh\n[ \"$1\" = refs/heads/bbb ] && exit 1\nexit 0\n").unwrap();
1415 std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
1416 let dest = env.dir.join("clone");
1417 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
1418 std::fs::write(dest.join("a.txt"), "two\n").unwrap();
1419 env.j(&dest, &["describe \"work\""]).ok();
1420 let expr = "push (\\r -> label \"aaa\" here r ++ label \"bbb\" here r)";
1421 let out = env.j(&dest, &[expr]);
1422 assert_eq!(out.code, 1, "{}", out.stderr);
1423 assert!(out.stderr.contains("bbb"), "{}", out.stderr);
1424 let branches = git(&env.remote, &["branch"]);
1425 assert!(branches.contains("aaa") && !branches.contains("bbb"), "{}", branches);
1426 // the accepted half is the push's one operation
1427 let ops = env.j(&dest, &["ops"]).ok().stdout;
1428 assert!(ops.lines().next().unwrap_or("").contains("label \"aaa\""), "{}", ops);
1429 let tree = env.j(&dest, &["tree"]).ok().stdout;
1430 assert!(tree.lines().any(|l| l.contains("work") && l.contains("aaa")), "{}", tree);
1431 env.j(&dest, &["push (unlabel \"aaa\")"]).ok();
1432 assert!(!git(&env.remote, &["branch"]).contains("aaa"));
1436fn push_conflicted_commit_refused() {
1438 let dest = uniq("clone");
1439 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
1440 // create a conflict artificially is involved; instead push invalid name
1441 std::fs::write(dest.join("a.txt"), "x\n").unwrap();
1442 env.j(&dest, &["describe \"w\""]).ok();
1443 let out = env.j(&dest, &["push (label \"bad..name\" here)"]);
1444 assert_eq!(out.code, 1);
1448fn fetch_brings_new_commits() {
1450 let dest = uniq("clone");
1451 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
1453 let other = uniq("other");
1454 git(&env.dir, &["clone", "-q", env.remote.to_str().unwrap(), other.to_str().unwrap()]);
1455 std::fs::write(other.join("a.txt"), "moved\n").unwrap();
1456 git(&other, &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qam", "moved"]);
1457 git(&other, &["push", "-q", "origin", "master"]);
1458 env.j(&dest, &["fetch"]).ok();
1459 let out = env.j(&dest, &["tree"]).ok();
1460 assert!(out.stdout.contains("moved"), "{}", out.stdout);
1461 // rebase trunk brings the stack up to date
1462 env.j(&dest, &["rebase trunk"]).ok();
1463 let out = env.j(&dest, &["log"]).ok();
1464 assert!(out.stdout.contains("moved"), "{}", out.stdout);
1467/// A clone `w` of `env`'s remote, whose master holds `one`, then `s` and
1468/// `t`, each adding the file of its name, and `seed`, a git clone a
1469/// collaborator pushes from; the clone's working-copy commit is an empty
1471fn clone_above_s_and_t(env: &Env) -> (PathBuf, PathBuf) {
1472 let seed = env.dir.join("seed");
1473 git(&env.dir, &["clone", "-q", env.remote.to_str().unwrap(), seed.to_str().unwrap()]);
1474 for name in ["s", "t"] {
1475 std::fs::write(seed.join(name), format!("{}\n", name)).unwrap();
1476 git(&seed, &["add", name]);
1477 git(&seed, &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qm", name]);
1479 git(&seed, &["push", "-q", "origin", "master"]);
1480 let w = env.dir.join("w");
1481 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), w.to_str().unwrap()]).ok();
1485/// Have the collaborator in `seed` replace `t` on master by `u`, as a
1486/// force-push does: the next fetch abandons `t`
1487fn force_push_dropping_t(seed: &PathBuf) {
1488 git(seed, &["reset", "-q", "--hard", "HEAD~1"]);
1489 std::fs::write(seed.join("u"), "u\n").unwrap();
1490 git(seed, &["add", "u"]);
1491 git(seed, &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qm", "u"]);
1492 git(seed, &["push", "-qf", "origin", "master"]);
1495const PARENT_MESSAGE: &str = "\\r -> (focus (prev r)).message";
1496const FOCUS_PATHS: &str = "\\r -> show (map (\\e -> e.path) (files r))";
1499fn a_fetch_that_abandons_the_working_copys_parent_records_later_edits_onto_it() {
1500 // §7.4, §7.6: fetch never touches the working directory, so when its
1501 // import abandons the commit the working-copy commit is built on (a
1502 // force-push, a deleted branch, a squash-merge upstream), the
1503 // working-copy commit is rebased and the working copy left stale.
1504 // Every snapshotting run, printing ones too, then exited 2 once the
1505 // directory held an edit, blaming jj --ignore-working-copy or another
1506 // workspace, and `undo` exited 1: only restoring the directory by hand
1507 // got the repository going again. The directory's changes since the
1508 // last checkout are now recorded onto the rebased commit.
1510 let (w, seed) = clone_above_s_and_t(&env);
1511 std::fs::write(w.join("mine"), "mine\n").unwrap();
1512 std::fs::write(w.join("s"), "s\nedited\n").unwrap();
1513 force_push_dropping_t(&seed);
1514 env.j(&w, &["fetch"]).ok();
1515 assert_eq!(std::fs::read_to_string(w.join("t")).unwrap(), "t\n");
1516 // a printing run shows the edits on the rebased commit, `t` gone
1517 assert_eq!(env.j(&w, &[PARENT_MESSAGE]).ok().stdout.trim(), "s");
1518 let paths = env.j(&w, &[FOCUS_PATHS]).ok().stdout;
1519 assert_eq!(paths.trim(), r#"[["a.txt"] ["mine"] ["s"]]"#);
1520 // `undo` refuses to write over edits no commit holds, as ever
1521 let out = env.j(&w, &["undo"]);
1522 assert_eq!(out.code, 1, "{}", out.stderr);
1523 assert!(out.stderr.contains("changes not in @"), "{}", out.stderr);
1524 // `j id` records them, and writes the rebased commit's files
1525 env.j(&w, &["id"]).ok();
1526 assert!(!w.join("t").exists());
1527 assert_eq!(std::fs::read_to_string(w.join("mine")).unwrap(), "mine\n");
1528 assert_eq!(std::fs::read_to_string(w.join("s")).unwrap(), "s\nedited\n");
1529 let texts = env.j(&w, &["\\r -> show (map (\\e -> [e.path (text e.content)]) (files r))"]).ok().stdout;
1531 texts.split_whitespace().collect::<Vec<_>>().join(" "),
1532 r#"[[["a.txt"] "one\n"] [["mine"] "mine\n"] [["s"] "s\nedited\n"]]"#
1534 assert_eq!(env.j(&w, &[PARENT_MESSAGE]).ok().stdout.trim(), "s");
1535 // the directory holds the focus now, so there is nothing more to record
1536 let ops = env.j(&w, &["ops"]).ok().stdout.lines().count();
1537 env.j(&w, &["id"]).ok();
1538 assert_eq!(env.j(&w, &["ops"]).ok().stdout.lines().count(), ops);
1542fn a_fetch_that_abandons_the_working_copys_parent_is_undone_and_redone() {
1543 // §7.6, §7.7: after such a fetch, a directory with no edit in it was
1544 // refused by `undo` and `redo` too, as holding changes not in @
1546 let (w, seed) = clone_above_s_and_t(&env);
1547 force_push_dropping_t(&seed);
1548 env.j(&w, &["fetch"]).ok();
1549 env.j(&w, &["undo"]).ok();
1550 assert_eq!(env.j(&w, &[PARENT_MESSAGE]).ok().stdout.trim(), "t");
1551 assert_eq!(std::fs::read_to_string(w.join("t")).unwrap(), "t\n");
1552 let ops = env.j(&w, &["ops"]).ok().stdout.lines().count();
1553 env.j(&w, &["id"]).ok();
1554 assert_eq!(env.j(&w, &["ops"]).ok().stdout.lines().count(), ops);
1555 // redoing the fetch checks the rebased commit out, without `t`
1556 env.j(&w, &["redo"]).ok();
1557 assert_eq!(env.j(&w, &[PARENT_MESSAGE]).ok().stdout.trim(), "s");
1558 assert!(!w.join("t").exists());
1559 env.j(&w, &["id"]).ok();
1560 assert_eq!(env.j(&w, &["ops"]).ok().stdout.lines().count(), ops + 1);
1564fn an_edit_to_a_file_a_fetch_abandoned_is_kept_as_a_conflict() {
1565 // §7.4, §7.6: the working-copy commit is pushed as `wip`, and the
1566 // branch deleted upstream: the fetch abandons the commit itself, and a
1567 // new empty one takes its place on `one`. An edit made since to the
1568 // file `wip` held is not lost: the commit gets it as a conflict between
1569 // its deletion and the edit.
1571 let w = env.dir.join("w");
1572 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), w.to_str().unwrap()]).ok();
1573 std::fs::write(w.join("mine"), "mine\n").unwrap();
1574 env.j(&w, &["describe \"wip\""]).ok();
1575 env.j(&w, &["push (label \"wip\" here)"]).ok();
1576 git(&env.dir, &["--git-dir", env.remote.to_str().unwrap(), "branch", "-D", "wip"]);
1577 env.j(&w, &["fetch"]).ok();
1578 std::fs::write(w.join("mine"), "mine\nand more\n").unwrap();
1579 assert_eq!(env.j(&w, &[PARENT_MESSAGE]).ok().stdout.trim(), "one");
1580 env.j(&w, &["id"]).ok();
1581 let conflicted = env.j(&w, &["\\r -> show (conflicted (files r))"]).ok().stdout;
1582 assert_eq!(conflicted.trim(), r#"[["mine"]]"#);
1583 let text = std::fs::read_to_string(w.join("mine")).unwrap();
1584 assert!(text.contains("and more"), "{}", text);
1588fn a_conflict_a_fetch_makes_with_a_directory_side_is_kept_beside_other_edits() {
1589 // §7.4, §7.6: `t` replaced the directory `p` of `dir` by a file, which
1590 // the working-copy commit edits; the fetch abandons `t` and rebases the
1591 // commit onto `dir`, with a conflict at `p` between the edit and
1592 // `dir`'s directory, and leaves the working copy stale. The snapshot
1593 // took what the directory held at every conflict of its merge that set
1594 // a directory against a file, the commit's own included, where the
1595 // directory held what the last checkout left: with any other edit
1596 // there to record, the conflict was resolved to the local file and
1597 // `p/x` dropped from the commit, and a push would have deleted it
1598 // upstream. Only a path the directory changed takes what it holds.
1599 for edit in [false, true] {
1601 let seed = env.dir.join("seed");
1602 git(&env.dir, &["clone", "-q", env.remote.to_str().unwrap(), seed.to_str().unwrap()]);
1603 std::fs::create_dir(seed.join("p")).unwrap();
1604 std::fs::write(seed.join("p/x"), "x\n").unwrap();
1605 git(&seed, &["add", "."]);
1606 git(&seed, &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qm", "dir"]);
1607 git(&seed, &["rm", "-rq", "p"]);
1608 std::fs::write(seed.join("p"), "p1\n").unwrap();
1609 git(&seed, &["add", "."]);
1610 git(&seed, &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qm", "t"]);
1611 git(&seed, &["push", "-q", "origin", "master"]);
1612 let w = env.dir.join("w");
1613 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), w.to_str().unwrap()]).ok();
1614 std::fs::write(w.join("p"), "mine\n").unwrap();
1615 env.j(&w, &["describe \"mine\""]).ok();
1616 force_push_dropping_t(&seed);
1617 env.j(&w, &["fetch"]).ok();
1618 assert_eq!(env.j(&w, &[PARENT_MESSAGE]).ok().stdout.trim(), "dir");
1619 let conflicted = "\\r -> show (conflicted (files r))";
1620 assert_eq!(env.j(&w, &[conflicted]).ok().stdout.trim(), r#"[["p"]]"#, "{}", edit);
1622 std::fs::write(w.join("a.txt"), "one\nedited\n").unwrap();
1624 env.j(&w, &["describe \"mine, rebased\""]).ok();
1625 assert_eq!(env.j(&w, &[conflicted]).ok().stdout.trim(), r#"[["p"]]"#, "{}", edit);
1626 let a = env.j(&w, &["\\r -> text (contentAt [\"a.txt\"] (files r))"]).ok().stdout;
1627 assert_eq!(a, if edit { "one\nedited\n" } else { "one\n" });
1628 let description = std::fs::read_to_string(w.join("p")).unwrap();
1629 assert!(description.starts_with("Conflict:\n") && description.contains("tree"), "{}", description);
1630 // the directory holds the focus now, so there is nothing more to record
1631 let ops = env.j(&w, &["ops"]).ok().stdout.lines().count();
1632 env.j(&w, &["id"]).ok();
1633 assert_eq!(env.j(&w, &["ops"]).ok().stdout.lines().count(), ops, "{}", edit);
1639fn a_fetch_that_replaces_a_symlink_by_a_directory_has_nothing_cleared_through_it() {
1640 // §7.4, §7.6: the fetch rebases the working-copy commit onto `dir`,
1641 // where `link`, a symlink to a directory outside the working directory,
1642 // is a directory holding `link/cache/x`, and leaves the working copy
1643 // stale. The next run that persisted cleared the empty directories
1644 // where its checkout wrote a file, looking at each path through the
1645 // symlinks above it, and so removed the empty tree `cache/x` outside.
1647 let shared = env.dir.join("shared");
1648 std::fs::create_dir_all(shared.join("cache/x/tmp")).unwrap();
1649 let seed = env.dir.join("seed");
1650 git(&env.dir, &["clone", "-q", env.remote.to_str().unwrap(), seed.to_str().unwrap()]);
1651 std::fs::create_dir_all(seed.join("link/cache")).unwrap();
1652 std::fs::write(seed.join("link/cache/x"), "x\n").unwrap();
1653 git(&seed, &["add", "."]);
1654 git(&seed, &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qm", "dir"]);
1655 git(&seed, &["rm", "-rq", "link"]);
1656 std::os::unix::fs::symlink(&shared, seed.join("link")).unwrap();
1657 git(&seed, &["add", "."]);
1658 git(&seed, &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qm", "use shared"]);
1659 git(&seed, &["push", "-q", "origin", "master"]);
1660 let w = env.dir.join("w");
1661 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), w.to_str().unwrap()]).ok();
1662 assert!(w.join("link").symlink_metadata().unwrap().is_symlink());
1663 // the collaborator drops `use shared`
1664 git(&seed, &["reset", "-q", "--hard", "HEAD~1"]);
1665 std::fs::write(seed.join("u"), "u\n").unwrap();
1666 git(&seed, &["add", "u"]);
1667 git(&seed, &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qm", "u"]);
1668 git(&seed, &["push", "-qf", "origin", "master"]);
1669 env.j(&w, &["fetch"]).ok();
1670 assert_eq!(env.j(&w, &[PARENT_MESSAGE]).ok().stdout.trim(), "dir");
1671 env.j(&w, &["describe \"mine\""]).ok();
1672 assert!(shared.join("cache/x/tmp").is_dir());
1673 assert!(w.join("link").symlink_metadata().unwrap().is_dir());
1674 assert_eq!(std::fs::read_to_string(w.join("link/cache/x")).unwrap(), "x\n");
1678fn fetch_without_origin_is_exit_1() {
1680 let dir = uniq("plain");
1681 std::fs::create_dir_all(&dir).unwrap();
1682 env.j(&dir, &["init"]).ok();
1683 let out = env.j(&dir, &["fetch"]);
1684 assert_eq!(out.code, 1);
1685 assert!(out.stderr.contains("no remote origin"), "{}", out.stderr);
1689fn remote_command_sets_url() {
1691 let dir = uniq("plain");
1692 std::fs::create_dir_all(&dir).unwrap();
1693 env.j(&dir, &["init"]).ok();
1694 env.j(&dir, &["remote", env.remote.to_str().unwrap()]).ok();
1695 env.j(&dir, &["fetch"]).ok();
1696 let out = env.j(&dir, &["tree"]).ok();
1697 assert!(out.stdout.contains("master"), "{}", out.stdout);
1701fn a_local_remote_is_stored_resolved_against_the_current_directory() {
1702 // §7.8: `clone` and `remote` stored a local path as typed, and git
1703 // resolves a relative one against the directory each later run is in:
1704 // cloned by `../remote.git` from a sibling directory, the clone's every
1705 // `fetch` and `push` failed with "Could not find repository", and so
1706 // did those of a repository whose `remote` was set from a subdirectory.
1707 // Such a path is now stored resolved against the current directory, as
1708 // `git clone` stores it; other URLs are stored unresolved.
1710 let work = env.dir.join("work");
1711 std::fs::create_dir_all(&work).unwrap();
1712 let name = env.remote.file_name().unwrap().to_str().unwrap();
1713 let stored = |dir: &PathBuf| git(dir, &["config", "--get", "remote.origin.url"]).trim().to_string();
1714 let same_dir = |url: &str, dir: &PathBuf| {
1715 assert!(std::path::Path::new(url).is_absolute(), "stored {}", url);
1716 assert_eq!(std::fs::canonicalize(url).unwrap(), std::fs::canonicalize(dir).unwrap());
1718 // `work` is in `env.dir`, a sibling of the remote
1719 env.j(&work, &["clone", &format!("../../{}", name), "c"]).ok();
1720 let c = work.join("c");
1721 same_dir(&stored(&c), &env.remote);
1722 // the clone's operation is described with the path as stored
1723 let described = |dir: &PathBuf, url: &str| {
1724 let ops = env.j(dir, &["ops"]).ok().stdout;
1725 assert!(ops.lines().any(|l| l.ends_with(&format!(" clone {}", url))), "{}", ops);
1727 described(&c, &stored(&c));
1728 env.j(&c, &["fetch"]).ok();
1729 std::fs::write(c.join("b.txt"), "b\n").unwrap();
1730 env.j(&c, &["describe \"b\""]).ok();
1731 env.j(&c, &["push (label \"feature\" here)"]).ok();
1732 git(&env.remote, &["rev-parse", "--verify", "-q", "refs/heads/feature"]);
1734 // set from a subdirectory, fetched from the top
1735 let other = env.dir.join("other.git");
1736 git(&env.dir, &["clone", "-q", "--bare", env.remote.to_str().unwrap(), other.to_str().unwrap()]);
1737 std::fs::create_dir(c.join("sub")).unwrap();
1738 env.j(&c.join("sub"), &["remote", "../../../other.git"]).ok();
1739 same_dir(&stored(&c), &other);
1740 env.j(&c, &["fetch"]).ok();
1743 "file://../project.git",
1744 "https://example.com/team/project.git",
1745 "git@example.com:team/project.git",
1746 "ssh://git@example.com/team/project.git",
1748 env.j(&c, &["remote", url]).ok();
1749 assert_eq!(stored(&c), url);
1751 // any other URL is described as given, which git may store otherwise
1752 // (it lowercases a host): an ssh that runs the command here
1753 let ssh = env.dir.join("ssh");
1754 std::fs::write(&ssh, "#!/bin/sh\nshift\nexec sh -c \"git ${1#git-}\"\n").unwrap();
1755 use std::os::unix::fs::PermissionsExt;
1756 std::fs::set_permissions(&ssh, std::fs::Permissions::from_mode(0o755)).unwrap();
1757 let url = format!("MyHost:{}", env.remote.display());
1758 let vars = [("GIT_SSH_COMMAND", ssh.as_path()), ("GIT_SSH_VARIANT", std::path::Path::new("simple"))];
1759 env.j_env(&work, &["clone", &url, "s"], &vars).ok();
1760 described(&work.join("s"), &url);
1761 // a URL that cannot be parsed is a usage error, which changes nothing
1762 let bad = "ssh://example.com:port/project.git";
1763 let out = env.j(&c, &["remote", bad]);
1764 assert_eq!(out.code, 2, "{}", out.stderr);
1765 assert_eq!(stored(&c), "ssh://git@example.com/team/project.git");
1766 let out = env.j(&work, &["clone", bad, "d"]);
1767 assert_eq!(out.code, 2, "{}", out.stderr);
1768 assert!(!work.join("d").exists());
1772fn a_relative_remote_is_reached_from_a_subdirectory() {
1773 // §7.8: an origin stored relative (by git, or by j before it resolved
1774 // local paths) was resolved against the directory j ran in, so fetch
1775 // and push failed from a subdirectory with "Could not find
1776 // repository"; git resolves it from the top of the working tree
1778 let g = env.dir.join("g");
1779 std::fs::create_dir(&g).unwrap();
1780 git(&g, &["init", "-q", "."]);
1781 let name = env.remote.file_name().unwrap().to_str().unwrap();
1782 git(&g, &["remote", "add", "origin", &format!("../../{}", name)]);
1783 env.j(&g, &["init"]).ok();
1784 let sub = g.join("sub");
1785 std::fs::create_dir(&sub).unwrap();
1786 env.j(&sub, &["fetch"]).ok();
1787 assert!(env.j(&g, &["tree"]).ok().stdout.contains("master"));
1788 std::fs::write(g.join("b.txt"), "b\n").unwrap();
1789 env.j(&sub, &["describe \"b\""]).ok();
1790 env.j(&sub, &["push (label \"feature\" here)"]).ok();
1791 git(&env.remote, &["rev-parse", "--verify", "-q", "refs/heads/feature"]);
1792 // the push records where the bookmark went
1793 let out = env.j(&sub, &["\\r -> (focus r).labels"]).ok();
1794 assert!(out.stdout.contains("feature"), "{}", out.stdout);
1798fn a_local_remote_under_home_is_stored_as_given() {
1799 // §7.8: a `~` the shell did not expand (`j 'remote ~/r.git'`, or any
1800 // URL on stdin) was resolved as a directory named `~` in the current
1801 // one, so every later fetch and push failed with "Could not find
1802 // repository"; git expands it to the home directory on each fetch and
1803 // push, wherever it runs, so it is stored as given
1805 let home = env.dir.join("home");
1806 std::fs::create_dir(&home).unwrap();
1807 git(&env.dir, &["clone", "-q", "--bare", env.remote.to_str().unwrap(), home.join("r.git").to_str().unwrap()]);
1808 let vars = [("HOME", home.as_path())];
1809 let stored = |dir: &PathBuf| git(dir, &["config", "--get", "remote.origin.url"]).trim().to_string();
1810 let work = env.dir.join("work");
1811 std::fs::create_dir(&work).unwrap();
1812 env.j_env(&work, &["clone", "~/r.git", "c"], &vars).ok();
1813 let c = work.join("c");
1814 assert_eq!(stored(&c), "~/r.git");
1815 assert_eq!(std::fs::read_to_string(c.join("a.txt")).unwrap(), "one\n");
1816 // set from the top, used from a subdirectory
1817 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), "d"]).ok();
1818 let d = env.dir.join("d");
1819 env.j_env(&d, &["remote ~/r.git"], &vars).ok();
1820 assert_eq!(stored(&d), "~/r.git");
1821 std::fs::create_dir(d.join("sub")).unwrap();
1822 std::fs::write(d.join("b.txt"), "b\n").unwrap();
1823 env.j(&d, &["describe \"b\""]).ok();
1824 env.j_env(&d.join("sub"), &["push (label \"feature\" here)"], &vars).ok();
1825 git(&home.join("r.git"), &["rev-parse", "--verify", "-q", "refs/heads/feature"]);
1826 env.j_env(&d.join("sub"), &["fetch"], &vars).ok();
1831fn a_local_remote_whose_path_is_not_utf8_is_refused() {
1832 // §7.8: a relative local path resolved into a directory whose name is
1833 // not UTF-8 was stored as typed, as jj-lib's add_remote takes UTF-8,
1834 // and then every fetch and push from anywhere else failed with "Could
1835 // not find repository"; it is a usage error that changes nothing
1836 use std::ffi::OsStr;
1837 use std::os::unix::ffi::OsStrExt;
1839 let nu = env.dir.join(OsStr::from_bytes(b"nu\xff"));
1840 if std::fs::create_dir(&nu).is_err() {
1841 // a filesystem that takes only UTF-8 names (APFS) cannot hold one
1844 let status = Command::new("git")
1845 .args(["clone", "-q", "--bare"])
1847 .arg(nu.join("r.git"))
1850 assert!(status.success());
1851 let out = env.j(&nu, &["clone", "r.git", "c"]);
1852 assert_eq!(out.code, 2, "{}", out.stderr);
1853 assert!(out.stderr.contains("not UTF-8"), "{}", out.stderr);
1854 assert!(!nu.join("c").exists());
1855 // `remote` from a clone in that directory
1856 env.j(&nu, &["clone", env.remote.to_str().unwrap(), "d"]).ok();
1857 let d = nu.join("d");
1858 let before = git(&d, &["config", "--get", "remote.origin.url"]);
1859 let out = env.j(&d, &["remote", "../r.git"]);
1860 assert_eq!(out.code, 2, "{}", out.stderr);
1861 assert_eq!(git(&d, &["config", "--get", "remote.origin.url"]), before);
1862 env.j(&d, &["fetch"]).ok();
1866fn undo_push_restores_labels() {
1868 let dest = uniq("clone");
1869 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
1870 std::fs::write(dest.join("a.txt"), "two\n").unwrap();
1871 env.j(&dest, &["describe \"work\""]).ok();
1872 let _before = env.j(&dest, &["log"]).ok().stdout;
1873 env.j(&dest, &["push (label \"feature\" here)"]).ok();
1874 let after = env.j(&dest, &["labelled \"feature\""]).ok();
1875 assert!(after.stdout.contains("vwq") || !after.stdout.contains("none"), "{}", after.stdout);
1876 let out2 = env.j(&dest, &["labelled \"feature\""]).ok();
1877 assert!(!out2.stdout.contains("none"), "label not visible after push: {}", out2.stdout);
1878 env.j(&dest, &["undo"]).ok();
1879 // undoing a push restores the recorded labels (§7.7): feature is gone
1880 let labels = env.j(&dest, &["trunk"]).ok().stdout;
1881 let out = env.j(&dest, &["labelled \"feature\""]).ok().stdout;
1882 assert!(out.contains("none") || out.contains("0 items"), "{}", out);
1884 // the remote itself is untouched
1885 let branches = git(&env.remote, &["branch"]);
1886 assert!(branches.contains("feature"), "{}", branches);
1890fn dirty_immutable_focus_snapshots_into_a_new_child() {
1891 // §7.2: when the working-copy commit is immutable (here: just pushed as
1892 // master), the snapshot cannot go into it; it goes into a new child, the
1893 // immutable commit keeps its stored files and hash, and `j id` records
1894 // the child (§1.2 step 8) so undo is not refused forever (§7.7)
1896 let dest = uniq("clone");
1897 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
1898 std::fs::write(dest.join("a.txt"), "two\n").unwrap();
1899 env.j(&dest, &["describe \"work\""]).ok();
1900 env.j(&dest, &["push (label \"master\" here)"]).ok();
1901 let pushed = git(&dest, &["rev-parse", "refs/remotes/origin/master"]).trim().to_string();
1902 let trunk_hash = || {
1903 env.j(&dest, &["\\r -> (meta (head (trunk r))).hash"]).ok().stdout.trim().to_string()
1905 let trunk_text = || {
1906 env.j(&dest, &["\\r -> map (\\e -> text e.content) (commitAt (head (trunk r)) r).files"])
1910 assert_eq!(trunk_hash(), pushed);
1912 // a printing run sees the edit in the focus, not in the pushed commit
1913 std::fs::write(dest.join("a.txt"), "modified\n").unwrap();
1914 assert_eq!(trunk_hash(), pushed, "a printing run reported the snapshot's rewrite");
1915 assert!(trunk_text().contains("two"), "{}", trunk_text());
1916 let status = env.j(&dest, &["status"]).ok().stdout;
1917 assert!(status.contains("a.txt"), "the focus does not hold the edit:\n{}", status);
1919 // a persisting run records the edit in the child
1920 env.j(&dest, &["describe \"wip\""]).ok();
1921 assert_eq!(trunk_hash(), pushed, "the pushed commit was rewritten");
1922 assert!(trunk_text().contains("two"), "{}", trunk_text());
1923 let status = env.j(&dest, &["status"]).ok().stdout;
1924 assert!(status.contains("a.txt") && status.contains("wip"), "{}", status);
1925 assert_eq!(std::fs::read_to_string(dest.join("a.txt")).unwrap(), "modified\n");
1927 // back onto the pushed commit, dirty again: `j id` records the edit
1928 env.j(&dest, &["undo"]).ok();
1929 assert_eq!(std::fs::read_to_string(dest.join("a.txt")).unwrap(), "two\n");
1930 std::fs::write(dest.join("a.txt"), "again\n").unwrap();
1931 env.j(&dest, &["id"]).ok();
1932 let ops = env.j(&dest, &["ops"]).ok().stdout;
1933 assert!(ops.lines().next().unwrap_or("").ends_with(" id"), "`j id` recorded nothing:\n{}", ops);
1934 assert_eq!(trunk_hash(), pushed, "`j id` rewrote the pushed commit");
1935 let status = env.j(&dest, &["status"]).ok().stdout;
1936 assert!(status.contains("a.txt"), "{}", status);
1937 // the working copy is clean now, so undo and redo go through
1938 env.j(&dest, &["undo"]).ok();
1939 assert_eq!(std::fs::read_to_string(dest.join("a.txt")).unwrap(), "two\n");
1940 env.j(&dest, &["redo"]).ok();
1941 assert_eq!(std::fs::read_to_string(dest.join("a.txt")).unwrap(), "again\n");
1942 assert_eq!(git(&env.remote, &["rev-parse", "master"]).trim(), pushed);
1946fn dirty_immutable_focus_leaves_its_descendants_alone() {
1947 // §7.2: the snapshot of an immutable working-copy commit goes into a new
1948 // child, so it does not rebase the commits above it either — here the
1949 // remote's master, fetched on top of the working-copy commit
1951 let dest = uniq("clone");
1952 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
1953 std::fs::write(dest.join("a.txt"), "two\n").unwrap();
1954 env.j(&dest, &["describe \"work\""]).ok();
1955 env.j(&dest, &["push (label \"master\" here)"]).ok();
1956 let other = uniq("other");
1957 git(&env.dir, &["clone", "-q", env.remote.to_str().unwrap(), other.to_str().unwrap()]);
1958 std::fs::write(other.join("c.txt"), "top\n").unwrap();
1959 git(&other, &["add", "c.txt"]);
1960 git(&other, &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qm", "top"]);
1961 git(&other, &["push", "-q", "origin", "master"]);
1962 let _ = std::fs::remove_dir_all(&other);
1963 env.j(&dest, &["fetch"]).ok();
1964 let top = git(&env.remote, &["rev-parse", "master"]).trim().to_string();
1965 let trunk_hash = || {
1966 env.j(&dest, &["\\r -> (meta (head (trunk r))).hash"]).ok().stdout.trim().to_string()
1968 assert_eq!(trunk_hash(), top);
1969 std::fs::write(dest.join("a.txt"), "dirty\n").unwrap();
1970 assert_eq!(trunk_hash(), top, "a printing run reported a rebase of master");
1971 env.j(&dest, &["id"]).ok();
1972 assert_eq!(trunk_hash(), top, "`j id` rebased master onto the snapshot");
1973 let status = env.j(&dest, &["status"]).ok().stdout;
1974 assert!(status.contains("a.txt"), "{}", status);
1978fn reordering_an_immutable_snapshot_is_not_a_change() {
1979 // a `contract m` that moves nothing into master still lists master's
1980 // entries in another order (`select` puts the ones m matches first).
1981 // Comparing snapshots as lists refused that as "immutable (files
1982 // changed)", in the `validate` dry run too, although no content moved;
1983 // a snapshot stands for a tree, which has no order (§7.3, §7.5 step 3)
1985 let dest = uniq("clone");
1986 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
1987 std::fs::create_dir(dest.join("h")).unwrap();
1988 std::fs::write(dest.join("h/x"), "x\n").unwrap();
1989 env.j(&dest, &["describe \"work\""]).ok();
1990 env.j(&dest, &["push (label \"master\" here)"]).ok();
1991 let pushed = git(&dest, &["rev-parse", "refs/remotes/origin/master"]).trim().to_string();
1992 let trunk_hash = || {
1993 env.j(&dest, &["\\r -> (meta (head (trunk r))).hash"]).ok().stdout.trim().to_string()
1995 let ops = || env.j(&dest, &["ops"]).ok().stdout.lines().count();
1996 let edits = ["tree . validate . contract (under ./h)", "contract (under ./h)"];
1997 // the working-copy commit is master itself, so the focus is the new child
1998 // it was loaded with (§7.2); that child is not recorded either (§1.2)
2001 env.j(&dest, &[e]).ok();
2002 assert_eq!(ops(), n, "{} was recorded", e);
2004 assert_eq!(trunk_hash(), pushed);
2005 // from a mutable child of master: recording that child makes one
2006 env.j(&dest, &["describe \"child\""]).ok();
2008 let focus_hash = env.j(&dest, &["\\r -> (meta r.root.id).hash"]).ok().stdout;
2010 env.j(&dest, &[e]).ok();
2011 assert_eq!(ops(), n, "{} was recorded", e);
2013 assert_eq!(trunk_hash(), pushed);
2014 assert_eq!(env.j(&dest, &["\\r -> (meta r.root.id).hash"]).ok().stdout, focus_hash);
2015 // moving real content into master is still refused
2016 std::fs::write(dest.join("h/x"), "changed\n").unwrap();
2017 let out = env.j(&dest, &["contract (under ./h)"]);
2018 assert_eq!(out.code, 1, "{}", out.stdout);
2019 assert!(out.stderr.contains("immutable (files changed)"), "{}", out.stderr);
2023fn clone_default_dir_name() {
2025 // clone URL without DIR: last path component minus .git
2026 let workdir = uniq("work");
2027 std::fs::create_dir_all(&workdir).unwrap();
2028 env.j(&workdir, &["clone", env.remote.to_str().unwrap()]).ok();
2034 .trim_end_matches(".git")
2036 let dest = workdir.join(&name);
2037 assert!(dest.join(".jj").exists(), "no clone at {}", dest.display());
2041fn clone_names_its_directory_after_the_repository() {
2042 // §7.8: the default DIR was what followed the URL's last `/`, minus
2043 // `.git`: empty for `<repo>/.git`, so the clone failed with "cannot
2044 // create : …" (" already contains a jj repository" inside one), and
2045 // `host:repo` for an scp-style `host:repo.git`
2047 let proj = env.dir.join("proj");
2048 git(&env.dir, &["clone", "-q", env.remote.to_str().unwrap(), proj.to_str().unwrap()]);
2049 for (i, url) in [format!("{}/.git", proj.display()), format!("{}/.git/", proj.display())].iter().enumerate() {
2050 let work = env.dir.join(format!("work{}", i));
2051 std::fs::create_dir(&work).unwrap();
2052 env.j(&work, &["clone", url]).ok();
2053 assert_eq!(std::fs::read_to_string(work.join("proj/a.txt")).unwrap(), "one\n");
2055 // `host:repo.git`, through an ssh that runs the command here
2056 let work = env.dir.join("scp");
2057 std::fs::create_dir(&work).unwrap();
2058 git(&env.dir, &["clone", "-q", "--bare", env.remote.to_str().unwrap(), work.join("r.git").to_str().unwrap()]);
2059 let ssh = env.dir.join("ssh");
2060 std::fs::write(&ssh, "#!/bin/sh\nshift\nexec sh -c \"git ${1#git-}\"\n").unwrap();
2061 use std::os::unix::fs::PermissionsExt;
2062 std::fs::set_permissions(&ssh, std::fs::Permissions::from_mode(0o755)).unwrap();
2063 let vars = [("GIT_SSH_COMMAND", ssh.as_path()), ("GIT_SSH_VARIANT", std::path::Path::new("simple"))];
2064 env.j_env(&work, &["clone", "MyHost:r.git"], &vars).ok();
2065 assert_eq!(std::fs::read_to_string(work.join("r/a.txt")).unwrap(), "one\n");
2066 // a URL that names no directory needs DIR
2067 let work = env.dir.join("none");
2068 std::fs::create_dir(&work).unwrap();
2069 for url in ["/", "..", "MyHost:"] {
2070 let out = env.j(&work, &["clone", url]);
2071 assert_eq!(out.code, 2, "{}: {}", url, out.stderr);
2072 assert!(out.stderr.contains("j clone URL DIR"), "{}: {}", url, out.stderr);
2074 assert_eq!(std::fs::read_dir(&work).unwrap().count(), 0);
2077/// The reference config.j as a user copied it before `commits` became the
2078/// builtin `subtreeCommits`, as it was then byte for byte: it declares no
2079/// such builtin, and defines `commits` and `ancestors` recursively.
2080fn config_before_subtree_commits() -> String {
2081 let mut config = include_str!("../config.j").to_string();
2082 for (now, before) in [
2085 "subtreeCommits : a -> [Commit] -- every commit of a tree in preorder, in one\n",
2086 " -- pass: what `commits` (below) is\n",
2092 "-- The builtin is \\t -> t.root :: (concat (map commits t.children) or [])\n",
2093 "-- in one pass; written that way it copies the list below every commit, and\n",
2094 "-- so takes time quadratic in the length of the history.\n",
2095 "commits : a -> [Commit]\n",
2096 "commits = subtreeCommits\n",
2099 "commits : a -> [Commit]\n",
2100 "commits = \\t -> t.root :: (concat (map commits t.children) or [])\n",
2104 "ancestors = \\repo -> repo.root.id :: map (\\f -> f.parent.id) repo.context\n",
2105 "ancestors = \\repo -> repo.root.id :: ((let p = up repo in ancestors p) or [])\n",
2108 assert_eq!(config.matches(now).count(), 1, "config.j no longer has {:?}", now);
2109 config = config.replacen(now, before, 1);
2111 assert!(!config.contains("subtreeCommits"));
2112 config.replace("Your Name", "Test User").replace("you@example.com", "test@example.com")
2116fn a_config_copied_before_subtree_commits_loads_and_agrees() {
2117 // A user's config.j is never updated (§6.1). One copied before the
2118 // reference config's `commits` became the builtin `subtreeCommits` does
2119 // not declare it, and walks the history recursively. It must still load,
2120 // and show and refuse exactly what the reference config does, with
2121 // `trunk` and `immutable` non-empty.
2123 let dest = env.dir.join("clone");
2124 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
2125 std::fs::write(dest.join("b.txt"), "two\n").unwrap();
2126 env.j(&dest, &["new . describe \"two\""]).ok();
2127 std::fs::write(dest.join("c.txt"), "three\n").unwrap();
2128 env.j(&dest, &["describe \"three\""]).ok();
2137 "length . commits . top",
2138 "commits ({ root = 1, children = [({ root = 2, children = [({ root = 3 })] }) 7] })",
2141 "describe \"x\" . goto trunk",
2142 "length . all . validate . new . goto trunk",
2144 let run = || -> Vec<(i32, String, String)> {
2148 let o = env.j(&dest, &[e]);
2149 (o.code, o.stdout, o.stderr)
2153 let reference = run();
2154 std::fs::write(env.cfg.join("j/config.j"), config_before_subtree_commits()).unwrap();
2156 for ((e, r), b) in exprs.iter().zip(&reference).zip(&before) {
2157 assert_eq!(r, b, "{}", e);
2159 // what it does not declare is unbound (§6.2), and edits still persist
2160 let out = env.j(&dest, &["subtreeCommits"]);
2161 assert_eq!(out.code, 1, "{}", out.stdout);
2162 env.j(&dest, &["describe \"four\""]).ok();
2163 assert!(env.j(&dest, &["log"]).ok().stdout.contains("four"));
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list