// Copyright 2026 The Jujutsu Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use std::ffi::OsStr; use crate::common::CommandOutput; use crate::common::TestEnvironment; use crate::common::TestWorkDir; fn normalize_git_hashes(output: CommandOutput) -> CommandOutput { output .normalize_stdout_with(normalize_git_hashes_in_text) .normalize_stderr_with(normalize_git_hashes_in_text) } fn normalize_git_hashes_in_text(text: String) -> String { let text = regex::Regex::new(r"[0-9a-f]{10,40}") .unwrap() .replace_all(&text, "") .into_owned(); regex::Regex::new(r"[k-z]{8} [0-9a-f]{8}") .unwrap() .replace_all(&text, "") .into_owned() } fn machine_completion_values(output: CommandOutput) -> Vec { let output = output.success(); let candidates: Vec = serde_json::from_str(output.stdout.raw()).unwrap(); candidates .into_iter() .map(|candidate| candidate["value"].as_str().unwrap().to_owned()) .collect() } fn run_git(work_dir: &TestWorkDir<'_>, args: I) where I: IntoIterator, S: AsRef, { let output = std::process::Command::new("git") .current_dir(work_dir.root()) .args(args) .output() .unwrap(); assert!( output.status.success(), "git command failed\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); } #[test] fn test_submodule_bind_updates_existing_gitlink() { let test_env = TestEnvironment::default(); test_env .run_jj_in(".", ["git", "init", "--colocate", "submodule"]) .success(); let submodule_dir = test_env.work_dir("submodule"); submodule_dir.write_file("file", "first"); submodule_dir .run_jj(["commit", "-m", "first submodule commit"]) .success(); let old_target = submodule_dir .run_jj(["log", "-r@-", "--no-graph", "-T", "commit_id"]) .success() .stdout .raw() .trim() .to_owned(); submodule_dir.write_file("file", "second"); submodule_dir .run_jj(["commit", "-m", "second submodule commit"]) .success(); let new_target = submodule_dir .run_jj(["log", "-r@-", "--no-graph", "-T", "commit_id"]) .success() .stdout .raw() .trim() .to_owned(); test_env .run_jj_in(".", ["git", "init", "--colocate", "repo"]) .success(); let work_dir = test_env.work_dir("repo"); let submodule_url = format!("{}/submodule", test_env.env_root().display()); run_git( &work_dir, [ "-c", "protocol.file.allow=always", "submodule", "add", submodule_url.as_str(), "sub", ], ); run_git(&work_dir, ["-C", "sub", "checkout", &old_target]); run_git( &work_dir, ["-C", "sub", "branch", "new-target", &new_target], ); run_git(&work_dir, ["add", "sub"]); run_git( &work_dir, [ "-c", "user.email=test@example.com", "-c", "user.name=Test user", "commit", "-m", "add submodule", ], ); insta::assert_snapshot!(work_dir.run_jj(["diff", "--summary"]), @r#" ------- stderr ------- ignoring git submodule at "sub" Done importing changes from the underlying Git repo. [EOF] "#); let output = work_dir.run_jj(["submodule", "bind", "sub", "-r", "new-target"]); insta::assert_snapshot!(normalize_git_hashes(output), @r#" ------- stderr ------- Updated Git submodule sub: old: new: Rewritten superproject commit: (no description set) ignoring git submodule at "sub" Working copy (@) now at: (no description set) Parent commit (@-) : master | add submodule Added 0 files, modified 1 files, removed 0 files [EOF] "#); insta::assert_snapshot!(work_dir.run_jj(["diff", "--summary"]), @r#" M sub [EOF] "#); let diff_git = work_dir.run_jj(["diff", "--git"]); assert!(diff_git.stdout.raw().contains(&new_target[..10])); insta::assert_snapshot!(normalize_git_hashes(diff_git), @r#" diff --git a/sub b/sub index .. 160000 [EOF] "#); } #[test] fn test_submodule_bind_revision_completion_uses_jj_submodule() { let test_env = TestEnvironment::default(); test_env .run_jj_in(".", ["git", "init", "--colocate", "repo"]) .success(); let work_dir = test_env.work_dir("repo"); work_dir .run_jj(["bookmark", "create", "super-target", "-r", "@"]) .success(); work_dir.create_dir("sub"); test_env .run_jj_in("repo/sub", ["git", "init", "--colocate", "."]) .success(); let submodule_dir = test_env.work_dir("repo/sub"); submodule_dir.write_file("file", "contents"); submodule_dir .run_jj(["commit", "-m", "submodule commit"]) .success(); submodule_dir .run_jj(["bookmark", "create", "sub-target", "-r", "@-"]) .success(); let values = machine_completion_values(work_dir.run_jj([ "util", "complete", "--index", "5", "--", "jj", "submodule", "bind", "sub", "-r", "sub-", ])); assert!(values.contains(&"sub-target".to_owned())); assert!(!values.contains(&"super-target".to_owned())); } #[test] fn test_submodule_bind_revision_completion_uses_git_submodule() { let test_env = TestEnvironment::default(); test_env .run_jj_in(".", ["git", "init", "--colocate", "repo"]) .success(); let work_dir = test_env.work_dir("repo"); work_dir .run_jj(["bookmark", "create", "super-target", "-r", "@"]) .success(); work_dir.create_dir("sub"); work_dir.write_file("sub/file", "contents"); run_git(&work_dir, ["-C", "sub", "init"]); run_git(&work_dir, ["-C", "sub", "add", "file"]); run_git( &work_dir, [ "-C", "sub", "-c", "user.email=test@example.com", "-c", "user.name=Test user", "commit", "-m", "submodule commit", ], ); run_git(&work_dir, ["-C", "sub", "branch", "sub-target"]); let values = machine_completion_values(work_dir.run_jj([ "util", "complete", "--index", "5", "--", "jj", "submodule", "bind", "sub", "-r", "sub-", ])); assert!(values.contains(&"sub-target".to_owned())); assert!(!values.contains(&"super-target".to_owned())); } #[test] fn test_submodule_bind_at_resolves_in_jj_submodule() { let test_env = TestEnvironment::default(); test_env .run_jj_in(".", ["git", "init", "--colocate", "submodule"]) .success(); let submodule_dir = test_env.work_dir("submodule"); submodule_dir.write_file("file", "contents"); submodule_dir .run_jj(["commit", "-m", "submodule commit"]) .success(); let old_target = submodule_dir .run_jj(["log", "-r@-", "--no-graph", "-T", "commit_id"]) .success() .stdout .raw() .trim() .to_owned(); test_env .run_jj_in(".", ["git", "init", "--colocate", "repo"]) .success(); let work_dir = test_env.work_dir("repo"); let submodule_url = format!("{}/submodule", test_env.env_root().display()); run_git( &work_dir, [ "-c", "protocol.file.allow=always", "submodule", "add", submodule_url.as_str(), "sub", ], ); run_git(&work_dir, ["-C", "sub", "checkout", &old_target]); run_git(&work_dir, ["add", "sub"]); run_git( &work_dir, [ "-c", "user.email=test@example.com", "-c", "user.name=Test user", "commit", "-m", "add submodule", ], ); insta::assert_snapshot!(work_dir.run_jj(["diff", "--summary"]), @r#" ------- stderr ------- ignoring git submodule at "sub" Done importing changes from the underlying Git repo. [EOF] "#); test_env .run_jj_in("repo/sub", ["git", "init", "--git-repo=."]) .success(); let jj_wc_target = test_env .run_jj_in("repo/sub", ["log", "-r@", "--no-graph", "-T", "commit_id"]) .success() .stdout .raw() .trim() .to_owned(); assert_ne!(jj_wc_target, old_target); let output = work_dir.run_jj(["submodule", "bind", "sub", "-r", "@"]); insta::assert_snapshot!(normalize_git_hashes(output), @r#" ------- stderr ------- Updated Git submodule sub: old: new: Rewritten superproject commit: (no description set) ignoring git submodule at "sub" Working copy (@) now at: (no description set) Parent commit (@-) : master | add submodule Added 0 files, modified 1 files, removed 0 files [EOF] "#); let diff_git = work_dir.run_jj(["diff", "--git"]); assert!(diff_git.stdout.raw().contains(&jj_wc_target[..10])); insta::assert_snapshot!(normalize_git_hashes(diff_git), @r#" diff --git a/sub b/sub index .. 160000 [EOF] "#); }