// 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 std::io::ErrorKind; use std::io::Write as _; use std::path::Path; use itertools::Itertools as _; use jj_lib::commit::Commit; use jj_lib::file_util::IoResultExt as _; use jj_lib::git; use jj_lib::git::GitSubprocessOptions; use jj_lib::op_store::OperationId; use jj_lib::op_store::RefTarget; use jj_lib::repo::Repo as _; use jj_lib::settings::UserSettings; use crate::cli_util::CommandHelper; use crate::cli_util::WorkspaceCommandHelper; use crate::command_error::CommandError; use crate::command_error::user_error; use crate::command_error::user_error_with_message; use crate::commands::git::maybe_add_gitignore; use crate::git_util::is_colocated_git_workspace; use crate::git_util::unlink_git_worktree; use crate::ui::Ui; /// Show the current colocation status #[derive(clap::Args, Clone, Debug)] pub struct GitColocationStatusArgs {} /// Convert into a colocated Jujutsu/Git repository /// /// This moves the underlying Git repository that is found inside the .jj /// directory to the root of the Jujutsu workspace. This allows you to /// use Git commands directly in the Jujutsu workspace. #[derive(clap::Args, Clone, Debug)] pub struct GitColocationEnableArgs {} /// Convert into a non-colocated Jujutsu/Git repository /// /// This moves the Git repository that is at the root of the Jujutsu /// workspace into the .jj directory. Once this is done you will no longer /// be able to use Git commands directly in the Jujutsu workspace. #[derive(clap::Args, Clone, Debug)] pub struct GitColocationDisableArgs {} /// Manage Jujutsu repository colocation with Git #[derive(clap::Subcommand, Clone, Debug)] pub enum GitColocationCommand { Disable(GitColocationDisableArgs), Enable(GitColocationEnableArgs), Status(GitColocationStatusArgs), } pub async fn cmd_git_colocation( ui: &mut Ui, command: &CommandHelper, subcommand: &GitColocationCommand, ) -> Result<(), CommandError> { match subcommand { GitColocationCommand::Disable(args) => cmd_git_colocation_disable(ui, command, args).await, GitColocationCommand::Enable(args) => cmd_git_colocation_enable(ui, command, args).await, GitColocationCommand::Status(args) => cmd_git_colocation_status(ui, command, args).await, } } async fn cmd_git_colocation_status( ui: &mut Ui, command: &CommandHelper, _args: &GitColocationStatusArgs, ) -> Result<(), CommandError> { let workspace_command = command.workspace_helper(ui).await?; git::get_git_backend(workspace_command.repo().store())?; let is_colocated = is_colocated_git_workspace(workspace_command.workspace())?; let workspace_name = workspace_command.workspace_name(); let git_head = workspace_command.repo().view().git_head(workspace_name); if is_colocated { writeln!( ui.stdout(), "Workspace '{}' is currently colocated with Git.", workspace_name.as_symbol() )?; } else { writeln!( ui.stdout(), "Workspace '{}' is currently not colocated with Git.", workspace_name.as_symbol() )?; } // git_head should be absent in non-colocated workspace, but print the // actual status so we can debug problems. writeln!( ui.stdout(), "Last imported/exported Git HEAD: {}", git_head .iter() .map(|maybe_id| match maybe_id { Some(id) => id.to_string(), None => "(none)".to_owned(), }) .join(", ") )?; if is_colocated { writeln!( ui.hint_default(), "To disable colocation, run: `jj git colocation disable`" )?; } else if !is_child_workspace(&workspace_command) && !workspace_command .repo_path() .join("store") .join("git") .exists() { writeln!( ui.hint_default(), "Colocation cannot be enabled because the workspace is backed by an external Git \ repository." )?; } else { writeln!( ui.hint_default(), "To enable colocation, run: `jj git colocation enable`" )?; } Ok(()) } async fn cmd_git_colocation_enable( ui: &mut Ui, command: &CommandHelper, _args: &GitColocationEnableArgs, ) -> Result<(), CommandError> { let workspace_command = command.workspace_helper(ui).await?; let git_backend = git::get_git_backend(workspace_command.repo().store())?; // Ensure that the workspace is not already colocated before proceeding if is_colocated_git_workspace(workspace_command.workspace())? { writeln!(ui.status(), "Workspace is already colocated with Git.")?; return Ok(()); } // And that it has a working copy (whose parent we'll use later to set the git // HEAD) let wc_commit_id = workspace_command .get_wc_commit_id() .ok_or_else(|| user_error("This command requires a working copy"))? .clone(); let workspace_root = workspace_command.workspace_root().to_owned(); let settings = workspace_command.settings().clone(); let op_id = workspace_command.repo().op_id().clone(); if is_child_workspace(&workspace_command) { let subprocess_options = GitSubprocessOptions::from_settings(workspace_command.settings())?; git::create_worktree( workspace_command.repo().store(), subprocess_options, &workspace_root, )?; drop(workspace_command); } else { let jj_repo_path = workspace_command.repo_path(); let git_store_path = jj_repo_path.join("store").join("git"); let git_target_path = jj_repo_path.join("store").join("git_target"); let dot_git_path = workspace_root.join(".git"); let git_repo_path = git_backend.git_repo_path().to_owned(); // Release the Git repository before moving it. The workspace helper // keeps it open (gix memory-maps pack files), and Windows refuses to // rename a directory while any file in it is open, failing with // "Access is denied". drop(workspace_command); // Move the git repository from .jj/repo/store/git to .git std::fs::rename(&git_store_path, &dot_git_path).map_err(|err| match err.kind() { ErrorKind::AlreadyExists | ErrorKind::DirectoryNotEmpty => user_error( "A .git directory already exists in the workspace root. Cannot colocate.", ), // An external Git repository (e.g. created by `jj git init // --git-repo=`) isn't managed by jj and cannot be moved. ErrorKind::NotFound => user_error(format!( "Cannot colocate a workspace backed by an external Git repository at {}", git_repo_path.display() )), _ => user_error_with_message( "Failed to move Git repository from .jj/repo/store/git to workspace root \ directory.", err, ), })?; // Update the git_target file to point to the new location let git_target_content = "../../../.git"; std::fs::write(&git_target_path, git_target_content).context(git_target_path)?; // Make the Git repository non-bare set_git_repo_bare(&dot_git_path, false)?; } // Reload the workspace command helper to ensure it picks up the changes let mut workspace_command = reload_workspace_helper(ui, command, &workspace_root, &settings, &op_id).await?; // Add .jj/.gitignore to prevent Git from tracking jj's repo maybe_add_gitignore(&workspace_command)?; // Update git HEAD to point to the working-copy commit's parent let wc_commit = workspace_command .repo() .store() .get_commit_async(&wc_commit_id) .await?; set_git_head_to_wc_parent(ui, &mut workspace_command, &wc_commit).await?; writeln!( ui.status(), "Workspace successfully converted into a colocated Jujutsu/Git workspace." )?; Ok(()) } async fn cmd_git_colocation_disable( ui: &mut Ui, command: &CommandHelper, _args: &GitColocationDisableArgs, ) -> Result<(), CommandError> { let workspace_command = command.workspace_helper(ui).await?; git::get_git_backend(workspace_command.repo().store())?; if !is_colocated_git_workspace(workspace_command.workspace())? { writeln!(ui.status(), "Workspace is already not colocated with Git.")?; return Ok(()); } let workspace_root = workspace_command.workspace_root().to_owned(); let settings = workspace_command.settings().clone(); let op_id = workspace_command.repo().op_id().clone(); if is_child_workspace(&workspace_command) { let subprocess_options = GitSubprocessOptions::from_settings(workspace_command.settings())?; unlink_git_worktree( ui, workspace_command.repo().store(), subprocess_options, &workspace_root, )?; drop(workspace_command); } else { let git_store_path = workspace_command.repo_path().join("store").join("git"); let git_target_path = workspace_command .repo_path() .join("store") .join("git_target"); let dot_git_path = workspace_root.join(".git"); // Release the Git repository before moving it (see // `cmd_git_colocation_enable`). drop(workspace_command); // Move the Git repository from .git into .jj/repo/store/git std::fs::rename(&dot_git_path, &git_store_path).map_err(|e| { user_error_with_message("Failed to move Git repository to .jj/repo/store/git", e) })?; // Make the Git repository bare set_git_repo_bare(&git_store_path, true)?; // Update the git_target file to point to the internal git store let git_target_content = "git"; std::fs::write(&git_target_path, git_target_content).context(&git_target_path)?; } // Remove the .jj/.gitignore since Git no longer needs to ignore jj's repo std::fs::remove_file(workspace_root.join(".jj").join(".gitignore")).ok(); // Reload the workspace command helper to ensure it picks up the changes let mut workspace_command = reload_workspace_helper(ui, command, &workspace_root, &settings, &op_id).await?; remove_git_head(ui, &mut workspace_command).await?; writeln!( ui.status(), "Workspace successfully converted into a non-colocated Jujutsu/Git workspace." )?; Ok(()) } /// Set the Git repository at `path` to be bare or non-bare fn set_git_repo_bare(path: &std::path::Path, bare: bool) -> Result<(), CommandError> { let bare_str = if bare { "true" } else { "false" }; let config_path = path.join("config"); let mut config_file = gix::config::File::from_path_no_includes(config_path.clone(), gix::config::Source::Local) .map_err(|err| user_error_with_message("Failed to open Git config file.", err))?; config_file .set_raw_value("core.bare", bare_str) .map_err(|err| { user_error_with_message( format!("Failed to set core.bare to {bare_str} in Git config."), err, ) })?; git::save_git_config(&config_file).map_err(|err| { user_error_with_message( format!( "Failed to write to Git config file at {}.", config_path.display() ), err, ) })?; Ok(()) } /// Set the git HEAD to the working copy commit's parent async fn set_git_head_to_wc_parent( ui: &mut Ui, workspace_command: &mut WorkspaceCommandHelper, wc_commit: &Commit, ) -> Result<(), CommandError> { let workspace_name = workspace_command.workspace_name().to_owned(); let workspace_root = workspace_command.workspace_root().to_owned(); let mut tx = workspace_command.start_transaction(); git::reset_head(tx.repo_mut(), &workspace_name, &workspace_root, wc_commit).await?; if tx.repo().has_changes() { tx.finish(ui, "set git head to working copy parent").await?; } Ok(()) } /// Remove the git HEAD reference async fn remove_git_head( ui: &mut Ui, workspace_command: &mut WorkspaceCommandHelper, ) -> Result<(), CommandError> { let workspace_name = workspace_command.workspace_name().to_owned(); let mut tx = workspace_command.start_transaction(); tx.repo_mut() .set_git_head_target(&workspace_name, RefTarget::absent()); if tx.repo().has_changes() { tx.finish(ui, "remove git head reference").await?; } Ok(()) } /// Loads a fresh workspace helper at the given operation, so that it picks up /// changes made to the repository on disk. async fn reload_workspace_helper( ui: &mut Ui, command: &CommandHelper, workspace_root: &Path, settings: &UserSettings, op_id: &OperationId, ) -> Result { let workspace = command.load_workspace_at(workspace_root, settings)?; let op = workspace.repo_loader().load_operation(op_id).await?; let repo = workspace.repo_loader().load_at(&op).await?; let workspace_command = command.for_workable_repo(ui, workspace, repo)?; Ok(workspace_command) } fn is_child_workspace(workspace_command: &WorkspaceCommandHelper) -> bool { workspace_command .workspace_root() .join(".jj") .join("repo") .is_file() }