// Copyright 2025 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 testutils::TestResult; use testutils::git; use crate::common::CommandOutput; use crate::common::TestEnvironment; use crate::common::TestWorkDir; #[must_use] fn get_colocation_status(work_dir: &TestWorkDir) -> CommandOutput { work_dir.run_jj([ "git", "colocation", "status", "--ignore-working-copy", "--quiet", // suppress hint ]) } fn read_git_target(workspace_root: &std::path::Path) -> String { let mut path = workspace_root.to_path_buf(); path.extend([".jj", "repo", "store", "git_target"]); std::fs::read_to_string(path).unwrap() } #[test] fn test_git_colocation_enable_success() -> TestResult { let test_env = TestEnvironment::default(); // Initialize a non-colocated Jujutsu/Git workspace test_env .run_jj_in( test_env.env_root(), ["git", "init", "--no-colocate", "repo"], ) .success(); let work_dir = test_env.work_dir("repo"); let workspace_root = work_dir.root(); // Need at least one commit to be able to set git HEAD later work_dir.run_jj(["new"]).success(); // Verify it's not colocated initially assert!(!workspace_root.join(".git").exists()); assert_eq!(read_git_target(workspace_root), "git"); // And that there is no Git HEAD yet insta::assert_snapshot!(get_colocation_status(&work_dir), @" Workspace 'default' is currently not colocated with Git. Last imported/exported Git HEAD: (none) [EOF] "); // Run colocate command let output = work_dir.run_jj(["git", "colocation", "enable"]); insta::assert_snapshot!(output, @" ------- stderr ------- Workspace successfully converted into a colocated Jujutsu/Git workspace. [EOF] "); // Verify colocate succeeded assert!(workspace_root.join(".git").exists()); assert!( !workspace_root .join(".jj") .join("repo") .join("store") .join("git") .exists() ); assert_eq!(read_git_target(workspace_root), "../../../.git"); // Verify .jj/.gitignore was created let gitignore_content = std::fs::read_to_string(workspace_root.join(".jj").join(".gitignore"))?; assert_eq!(gitignore_content, "/*\n"); // Verify that Git HEAD was set correctly insta::assert_snapshot!(get_colocation_status(&work_dir), @" Workspace 'default' is currently colocated with Git. Last imported/exported Git HEAD: e8849ae12c709f2321908879bc724fdb2ab8a781 [EOF] "); // Verify that the repo changed let output = work_dir.run_jj(["op", "show", "-T", "description ++ '\n'"]); insta::assert_snapshot!(output, @" set git head to working copy parent [EOF] "); Ok(()) } #[test] fn test_git_colocation_enable_empty() { let test_env = TestEnvironment::default(); // Initialize a non-colocated Jujutsu/Git workspace test_env .run_jj_in( test_env.env_root(), ["git", "init", "--no-colocate", "repo"], ) .success(); let work_dir = test_env.work_dir("repo"); let setup_op_id = work_dir.current_operation_id(); // Run colocate command let output = work_dir.run_jj(["git", "colocation", "enable"]); insta::assert_snapshot!(output, @" ------- stderr ------- Workspace successfully converted into a colocated Jujutsu/Git workspace. [EOF] "); // Verify that Git HEAD was set correctly insta::assert_snapshot!(get_colocation_status(&work_dir), @" Workspace 'default' is currently colocated with Git. Last imported/exported Git HEAD: (none) [EOF] "); // No repo change required assert_eq!(setup_op_id, work_dir.current_operation_id()); } #[test] fn test_git_colocation_enable_already_colocated() { let test_env = TestEnvironment::default(); // Initialize a colocated Jujutsu/Git repo test_env .run_jj_in(test_env.env_root(), ["git", "init", "--colocate", "repo"]) .success(); let work_dir = test_env.work_dir("repo"); // Try to colocate it again - should fail let output = work_dir.run_jj(["git", "colocation", "enable"]); insta::assert_snapshot!(output, @" ------- stderr ------- Workspace is already colocated with Git. [EOF] "); } #[test] fn test_git_colocation_enable_with_existing_git_dir() -> TestResult { let test_env = TestEnvironment::default(); // Initialize a non-colocated Jujutsu/Git repo test_env .run_jj_in( test_env.env_root(), ["git", "init", "--no-colocate", "repo"], ) .success(); let work_dir = test_env.work_dir("repo"); let workspace_root = work_dir.root(); // Create a .git directory manually std::fs::create_dir(workspace_root.join(".git"))?; std::fs::write(workspace_root.join(".git").join("dummy"), "dummy")?; // Try to colocate - should fail let output = work_dir.run_jj(["git", "colocation", "enable"]); insta::assert_snapshot!(output.strip_stderr_last_line(), @" ------- stderr ------- Error: A .git directory already exists in the workspace root. Cannot colocate. [EOF] [exit status: 1] "); Ok(()) } #[test] fn test_git_colocation_enable_external_git_repo() { let test_env = TestEnvironment::default(); // Initialize a Jujutsu workspace backed by an external Git repository let git_repo_path = test_env.env_root().join("git-repo"); git::init(&git_repo_path); test_env .run_jj_in( test_env.env_root(), [ "git", "init", "repo", "--git-repo", git_repo_path.to_str().unwrap(), ], ) .success(); let work_dir = test_env.work_dir("repo"); // The status hint shouldn't suggest enabling colocation let output = work_dir.run_jj(["git", "colocation", "status"]); insta::assert_snapshot!(output, @" Workspace 'default' is currently not colocated with Git. Last imported/exported Git HEAD: (none) [EOF] ------- stderr ------- Hint: Colocation cannot be enabled because the workspace is backed by an external Git repository. [EOF] "); // Trying to colocate should fail with a clean error let output = work_dir.run_jj(["git", "colocation", "enable"]); insta::assert_snapshot!(output, @" ------- stderr ------- Error: Cannot colocate a workspace backed by an external Git repository at $TEST_ENV/git-repo/.git [EOF] [exit status: 1] "); } #[test] fn test_git_colocation_disable_success() { let test_env = TestEnvironment::default(); // Create a colocated Jujutsu/Git repo test_env .run_jj_in(test_env.env_root(), ["git", "init", "--colocate", "repo"]) .success(); let work_dir = test_env.work_dir("repo"); let workspace_root = work_dir.root(); // Need at least one commit to be able to set git HEAD later work_dir.run_jj(["new"]).success(); // Verify that Git HEAD is set insta::assert_snapshot!(get_colocation_status(&work_dir), @" Workspace 'default' is currently colocated with Git. Last imported/exported Git HEAD: e8849ae12c709f2321908879bc724fdb2ab8a781 [EOF] "); // Verify it's colocated assert!(workspace_root.join(".git").exists()); assert_eq!(read_git_target(workspace_root), "../../../.git"); // Disable colocation let output = work_dir.run_jj(["git", "colocation", "disable"]); insta::assert_snapshot!(output, @" ------- stderr ------- Workspace successfully converted into a non-colocated Jujutsu/Git workspace. [EOF] "); // Verify that disable colocation succeeded assert!(!workspace_root.join(".git").exists()); assert!( workspace_root .join(".jj") .join("repo") .join("store") .join("git") .exists() ); assert_eq!(read_git_target(workspace_root), "git"); assert!(!workspace_root.join(".jj").join(".gitignore").exists()); // Verify that Git HEAD was removed correctly insta::assert_snapshot!(get_colocation_status(&work_dir), @" Workspace 'default' is currently not colocated with Git. Last imported/exported Git HEAD: (none) [EOF] "); // Verify that the repo changed let output = work_dir.run_jj(["op", "show", "-T", "description ++ '\n'"]); insta::assert_snapshot!(output, @" remove git head reference [EOF] "); } #[test] fn test_git_colocation_disable_empty() { let test_env = TestEnvironment::default(); // Create a colocated Jujutsu/Git repo test_env .run_jj_in(test_env.env_root(), ["git", "init", "--colocate", "repo"]) .success(); let work_dir = test_env.work_dir("repo"); let setup_op_id = work_dir.current_operation_id(); // Verify that Git HEAD is unset insta::assert_snapshot!(get_colocation_status(&work_dir), @" Workspace 'default' is currently colocated with Git. Last imported/exported Git HEAD: (none) [EOF] "); // Disable colocation let output = work_dir.run_jj(["git", "colocation", "disable"]); insta::assert_snapshot!(output, @" ------- stderr ------- Workspace successfully converted into a non-colocated Jujutsu/Git workspace. [EOF] "); // No repo change required assert_eq!(setup_op_id, work_dir.current_operation_id()); } #[test] fn test_git_colocation_disable_not_colocated() { let test_env = TestEnvironment::default(); // Initialize a non-colocated Jujutsu/Git repo test_env .run_jj_in( test_env.env_root(), ["git", "init", "--no-colocate", "repo"], ) .success(); let work_dir = test_env.work_dir("repo"); // Try to disable colocation when not colocated - should fail let output = work_dir.run_jj(["git", "colocation", "disable"]); insta::assert_snapshot!(output, @" ------- stderr ------- Workspace is already not colocated with Git. [EOF] "); } #[test] fn test_git_colocation_enable_disable_with_pack_files() { let test_env = TestEnvironment::default(); test_env .run_jj_in(test_env.env_root(), ["git", "init", "--colocate", "repo"]) .success(); let work_dir = test_env.work_dir("repo"); let workspace_root = work_dir.root(); let git_store_path = workspace_root .join(".jj") .join("repo") .join("store") .join("git"); work_dir.write_file("file", "contents\n"); work_dir.run_jj(["commit", "-m", "first"]).success(); // Pack the loose objects. Unlike loose objects, pack files stay open // (memory-mapped) for as long as the Git repository is open, and Windows // refuses to rename a directory that contains an open file. Moving the Git // repository between .git and .jj/repo/store/git used to fail with "Access // is denied" once a pack existed. work_dir.run_jj(["util", "gc"]).success(); assert!(has_pack_files(&workspace_root.join(".git"))); let output = work_dir.run_jj(["git", "colocation", "disable"]); insta::assert_snapshot!(output, @" ------- stderr ------- Workspace successfully converted into a non-colocated Jujutsu/Git workspace. [EOF] "); assert!(!workspace_root.join(".git").exists()); assert_eq!(read_git_target(workspace_root), "git"); let output = work_dir.run_jj(["git", "colocation", "enable"]); insta::assert_snapshot!(output, @" ------- stderr ------- Workspace successfully converted into a colocated Jujutsu/Git workspace. [EOF] "); assert!(!git_store_path.exists()); assert_eq!(read_git_target(workspace_root), "../../../.git"); // The repository is still usable afterwards let output = work_dir.run_jj(["log", "-r", "@-", "-T", "description"]); insta::assert_snapshot!(output, @" ○ first │ ~ [EOF] "); } #[test] fn test_git_colocation_status_non_colocated() { let test_env = TestEnvironment::default(); // Initialize a non-colocated Jujutsu/Git repo test_env .run_jj_in( test_env.env_root(), ["git", "init", "--no-colocate", "repo"], ) .success(); let work_dir = test_env.work_dir("repo"); // Check status - should show non-colocated let output = work_dir.run_jj(["git", "colocation", "status"]); insta::assert_snapshot!(output, @" Workspace 'default' is currently not colocated with Git. Last imported/exported Git HEAD: (none) [EOF] ------- stderr ------- Hint: To enable colocation, run: `jj git colocation enable` [EOF] "); } #[test] fn test_git_colocation_status_colocated() { let test_env = TestEnvironment::default(); // Initialize a colocated jj repo test_env .run_jj_in(test_env.env_root(), ["git", "init", "--colocate", "repo"]) .success(); let work_dir = test_env.work_dir("repo"); // Check status - should show colocated let output = work_dir.run_jj(["git", "colocation", "status"]); insta::assert_snapshot!(output, @" Workspace 'default' is currently colocated with Git. Last imported/exported Git HEAD: (none) [EOF] ------- stderr ------- Hint: To disable colocation, run: `jj git colocation disable` [EOF] "); } #[test] fn test_git_colocation_in_secondary_workspace() { let test_env = TestEnvironment::default(); test_env .run_jj_in(".", ["git", "init", "--no-colocate", "main"]) .success(); let main_dir = test_env.work_dir("main"); main_dir .run_jj(["workspace", "add", "../secondary"]) .success(); let secondary_dir = test_env.work_dir("secondary"); let output = secondary_dir.run_jj(["git", "colocation", "status"]); insta::assert_snapshot!(output, @" Workspace 'secondary' is currently not colocated with Git. Last imported/exported Git HEAD: (none) [EOF] ------- stderr ------- Hint: To enable colocation, run: `jj git colocation enable` [EOF] "); let output = secondary_dir.run_jj(["git", "colocation", "enable"]); insta::assert_snapshot!(output, @" ------- stderr ------- Workspace successfully converted into a colocated Jujutsu/Git workspace. [EOF] "); assert!(test_env.env_root().join("secondary/.git").is_file()); let output = secondary_dir.run_jj(["git", "colocation", "disable"]); insta::assert_snapshot!(output.normalize_backslash(), @r#" ------- stderr ------- Removed Git worktree for "$TEST_ENV/secondary". Workspace successfully converted into a non-colocated Jujutsu/Git workspace. [EOF] "#); assert!(!test_env.env_root().join("secondary/.git").exists()); } #[test] fn test_git_colocation_enable_disable_child_workspace() { let test_env = TestEnvironment::default(); test_env.add_config("git.colocate = true"); test_env .run_jj_in(".", ["git", "init", "--colocate", "main"]) .success(); let main_dir = test_env.work_dir("main"); main_dir.write_file("file", "contents"); main_dir.run_jj(["commit", "-m", "initial"]).success(); main_dir .run_jj(["workspace", "add", "--no-colocate", "../secondary"]) .success(); let secondary_dir = test_env.work_dir("secondary"); assert!(!test_env.env_root().join("secondary/.git").exists()); let output = secondary_dir.run_jj(["git", "colocation", "status"]); insta::assert_snapshot!(output, @" Workspace 'secondary' is currently not colocated with Git. Last imported/exported Git HEAD: (none) [EOF] ------- stderr ------- Hint: To enable colocation, run: `jj git colocation enable` [EOF] "); let output = secondary_dir.run_jj(["git", "colocation", "enable"]); insta::assert_snapshot!(output, @" ------- stderr ------- Workspace successfully converted into a colocated Jujutsu/Git workspace. [EOF] "); assert!(test_env.env_root().join("secondary/.git").is_file()); // The workspace's .jj directory must be excluded, or Git would report it as // untracked in the worktree. assert!( test_env .env_root() .join("secondary/.jj/.gitignore") .is_file() ); let secondary_repo = git::open(test_env.env_root().join("secondary")); insta::assert_debug_snapshot!(git::status(&secondary_repo), @r#" [ GitStatus { path: ".jj/.gitignore", status: Worktree( Ignored, ), }, GitStatus { path: ".jj/repo", status: Worktree( Ignored, ), }, GitStatus { path: ".jj/working_copy", status: Worktree( Ignored, ), }, ] "#); let output = secondary_dir.run_jj(["git", "colocation", "status", "--quiet"]); insta::assert_snapshot!(output, @" Workspace 'secondary' is currently colocated with Git. Last imported/exported Git HEAD: 7b22a8cbe888adcb4d5ff6dd46a38049e870c6ab [EOF] "); let output = secondary_dir.run_jj(["git", "colocation", "disable"]); insta::assert_snapshot!(output.normalize_backslash(), @r#" ------- stderr ------- Removed Git worktree for "$TEST_ENV/secondary". Workspace successfully converted into a non-colocated Jujutsu/Git workspace. [EOF] "#); assert!(!test_env.env_root().join("secondary/.git").exists()); assert!( !test_env .env_root() .join("secondary/.jj/.gitignore") .exists() ); let output = secondary_dir.run_jj(["git", "colocation", "status", "--quiet"]); insta::assert_snapshot!(output, @" Workspace 'secondary' is currently not colocated with Git. Last imported/exported Git HEAD: (none) [EOF] "); } #[test] fn test_git_colocation_enable_child_workspace_with_existing_git_dir() -> TestResult { let test_env = TestEnvironment::default(); test_env.add_config("git.colocate = true"); test_env .run_jj_in(".", ["git", "init", "--colocate", "main"]) .success(); let main_dir = test_env.work_dir("main"); main_dir .run_jj(["workspace", "add", "--no-colocate", "../secondary"]) .success(); let secondary_dir = test_env.work_dir("secondary"); secondary_dir.create_dir(".git"); let main_repo = git::open(test_env.env_root().join("main")); let worktree_count = main_repo.worktrees()?.len(); let output = secondary_dir.run_jj(["git", "colocation", "enable"]); insta::assert_snapshot!(output, @" ------- stderr ------- Error: A .git file or directory already exists in the Git worktree destination [EOF] [exit status: 1] "); assert_eq!(main_repo.worktrees()?.len(), worktree_count); Ok(()) } fn has_pack_files(git_dir: &std::path::Path) -> bool { std::fs::read_dir(git_dir.join("objects").join("pack")) .unwrap() .any(|entry| { entry .unwrap() .path() .extension() .is_some_and(|ext| ext == "pack") }) }