diff --git a/CHANGELOG.md b/CHANGELOG.md index 880d71a306..48fe2f932e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [documentation](https://martinvonz.github.io/jj/latest/install-and-setup/#command-line-completion) to activate them. +* New command `jj git colocate` that turns a non-colocated repo into a + colocated one. + ### Fixed bugs ## [0.23.0] - 2024-11-06 diff --git a/cli/src/cli_util.rs b/cli/src/cli_util.rs index b3babc3c89..bd172c682a 100644 --- a/cli/src/cli_util.rs +++ b/cli/src/cli_util.rs @@ -386,6 +386,10 @@ impl CommandHelper { WorkspaceCommandHelper::new(ui, workspace, repo, env, self.is_at_head_operation()) } + pub fn get_store_factories(&self) -> &StoreFactories { + &self.data.store_factories + } + pub fn get_working_copy_factory(&self) -> Result<&dyn WorkingCopyFactory, CommandError> { let loader = self.workspace_loader()?; diff --git a/cli/src/commands/git/colocate.rs b/cli/src/commands/git/colocate.rs new file mode 100644 index 0000000000..0c4aeb8175 --- /dev/null +++ b/cli/src/commands/git/colocate.rs @@ -0,0 +1,142 @@ +// Copyright 2024 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::fs; + +use jj_lib::backend::Backend; +use jj_lib::file_util::IoResultExt; +use jj_lib::git::{self}; +use jj_lib::repo::Repo; +use tracing::instrument; + +use crate::cli_util::CommandHelper; +use crate::command_error::internal_error; +use crate::command_error::user_error; +use crate::command_error::CommandError; +use crate::commands::git::maybe_add_gitignore; +use crate::ui::Ui; + +/// Make the current workspace colocated +#[derive(clap::Args, Clone, Debug)] +pub struct GitColocateArgs {} + +#[instrument(skip_all)] +pub fn cmd_git_colocate( + ui: &mut Ui, + command: &CommandHelper, + _args: &GitColocateArgs, +) -> Result<(), CommandError> { + let workspace_command = command.workspace_helper(ui)?; + let workspace = workspace_command.workspace(); + + if workspace_command.working_copy_shared_with_git() { + return Err(user_error(format!( + "Workspace '{}' is already colocated.", + workspace.workspace_id().as_str() + ))); + } + + let Some(git_backend) = workspace_command.git_backend() else { + return Err(user_error( + "This repo is not using the Git backend, so you cannot colocate a workspace.", + )); + }; + + let dotgit_path = workspace.workspace_root().join(".git"); + if dotgit_path.exists() { + return Err(user_error(format!( + "Path {} already exists, cannot create a corresponding git worktree here.", + dotgit_path.display() + ))); + } + + let git_repo = git_backend.git_repo(); + if workspace.is_primary_workspace() { + // Change the settings before we mess up the paths + let common_dir = git_repo.common_dir(); + let mut config = + gix::config::File::from_git_dir(common_dir.to_path_buf()).map_err(internal_error)?; + config + .set_raw_value(&"core.bare", "false") + .map_err(internal_error)?; + let config_path = common_dir.join("config"); + let mut file = fs::OpenOptions::new() + .write(true) + .open(&config_path) + .context(config_path)?; + config + .write_to(&mut file) + .map_err(|e| internal_error(format!("Could not write git config file: {e}")))?; + drop(file); + + let old_path = workspace_command.repo_path().join("store").join("git"); + let new_path = workspace.workspace_root().join(".git"); + fs::rename(&old_path, &new_path).context(old_path)?; + let git_target_path = workspace_command + .repo_path() + .join("store") + .join("git_target"); + fs::write(&git_target_path, "../../../.git").context(git_target_path)?; + // we write .gitignore below + } else { + return Err(internal_error( + "Unimplemented: colocating a secondary workspace", + )); + } + + // Both the Workspace (i.e. the store) and the WorkspaceCommandHelper + // need to be reloaded to pick up changes to colocation. + // + // This way we end up with a git HEAD written immediately, rather than + // next time @ moves. And you can immediately start running git commands. + let workspace = workspace + .reload( + command.workspace_loader()?, + command.settings(), + command.get_working_copy_factory()?, + command.get_store_factories(), + ) + .map_err(internal_error)?; + + let repo = workspace.repo_loader().load_at_head(command.settings())?; + let mut command = command.for_workable_repo(ui, workspace, repo)?; + + maybe_add_gitignore(&command)?; + + let Some(git_backend) = command.git_backend() else { + return Err(internal_error("Reloaded repo no longer backed by git")); + }; + let Some(wc_commit_id) = command.get_wc_commit_id() else { + return Err(internal_error("Could not get the working copy")); + }; + + let name = command.workspace_id().as_str().to_owned(); + let wc_commit = command.repo().store().get_commit(wc_commit_id)?; + + if let Some(parent_id) = wc_commit.parent_ids().first() { + if parent_id == git_backend.root_commit_id() { + // No need to run reset_head, all it will do is show "Nothing changed" + return Ok(()); + } + } + + let git2_repo = git_backend.open_git_repo()?; + let mut tx = command.start_transaction(); + + git::reset_head(tx.repo_mut(), &git2_repo, &wc_commit)?; + + tx.finish(ui, format!("Colocated existing workspace {name}"))?; + + Ok(()) +} diff --git a/cli/src/commands/git/mod.rs b/cli/src/commands/git/mod.rs index 1c7fee9818..69fd5e7826 100644 --- a/cli/src/commands/git/mod.rs +++ b/cli/src/commands/git/mod.rs @@ -13,6 +13,7 @@ // limitations under the License. pub mod clone; +pub mod colocate; pub mod export; pub mod fetch; pub mod import; @@ -25,6 +26,8 @@ use clap::Subcommand; use self::clone::cmd_git_clone; use self::clone::GitCloneArgs; +use self::colocate::cmd_git_colocate; +use self::colocate::GitColocateArgs; use self::export::cmd_git_export; use self::export::GitExportArgs; use self::fetch::cmd_git_fetch; @@ -52,6 +55,7 @@ use crate::ui::Ui; #[derive(Subcommand, Clone, Debug)] pub enum GitCommand { Clone(GitCloneArgs), + Colocate(GitColocateArgs), Export(GitExportArgs), Fetch(GitFetchArgs), Import(GitImportArgs), @@ -70,6 +74,7 @@ pub fn cmd_git( ) -> Result<(), CommandError> { match subcommand { GitCommand::Clone(args) => cmd_git_clone(ui, command, args), + GitCommand::Colocate(args) => cmd_git_colocate(ui, command, args), GitCommand::Export(args) => cmd_git_export(ui, command, args), GitCommand::Fetch(args) => cmd_git_fetch(ui, command, args), GitCommand::Import(args) => cmd_git_import(ui, command, args), diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap index c8850015a2..9dd36d3746 100644 --- a/cli/tests/cli-reference@.md.snap +++ b/cli/tests/cli-reference@.md.snap @@ -1,7 +1,6 @@ --- source: cli/tests/test_generate_md_cli_help.rs description: "AUTO-GENERATED FILE, DO NOT EDIT. This cli reference is generated by a test as an `insta` snapshot. MkDocs includes this snapshot from docs/cli-reference.md." -snapshot_kind: text --- @@ -49,6 +48,7 @@ This document contains the help content for the `jj` command-line program. * [`jj fix`↴](#jj-fix) * [`jj git`↴](#jj-git) * [`jj git clone`↴](#jj-git-clone) +* [`jj git colocate`↴](#jj-git-colocate) * [`jj git export`↴](#jj-git-export) * [`jj git fetch`↴](#jj-git-fetch) * [`jj git import`↴](#jj-git-import) @@ -1034,6 +1034,7 @@ For a comparison with Git, including a table of commands, see https://martinvonz ###### **Subcommands:** * `clone` — Create a new repo backed by a clone of a Git repo +* `colocate` — Make the current workspace colocated * `export` — Update the underlying Git repo with changes made in the repo * `fetch` — Fetch from a Git remote * `import` — Update repo with changes made in the underlying Git repo @@ -1066,6 +1067,14 @@ The Git repo will be a bare git repo stored inside the `.jj/` directory. +## `jj git colocate` + +Make the current workspace colocated + +**Usage:** `jj git colocate` + + + ## `jj git export` Update the underlying Git repo with changes made in the repo diff --git a/cli/tests/test_git_colocated.rs b/cli/tests/test_git_colocated.rs index a8c94c282d..1078c4c860 100644 --- a/cli/tests/test_git_colocated.rs +++ b/cli/tests/test_git_colocated.rs @@ -888,3 +888,39 @@ fn get_bookmark_output(test_env: &TestEnvironment, repo_path: &Path) -> String { // --quiet to suppress deleted bookmarks hint test_env.jj_cmd_success(repo_path, &["bookmark", "list", "--all-remotes", "--quiet"]) } + +#[test] +fn test_colocate_primary_workspace() { + let test_env = TestEnvironment::default(); + let repo_path = test_env.env_root().join("repo"); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + test_env.jj_cmd_ok(&repo_path, &["commit", "-mempty"]); + test_env.jj_cmd_ok(&repo_path, &["git", "colocate"]); + assert!(repo_path.join(".git").exists()); + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r#" + @ 33a8e41e1f142f207e08b9853c959f983cdf0ddf + ○ 1e9d9f0fff69aa7d6b71b6ab1eb542bdc09a7173 git_head() empty + ◆ 0000000000000000000000000000000000000000 + "#); +} + +#[test] +fn test_colocate_primary_workspace_at_root() { + let test_env = TestEnvironment::default(); + let repo_path = test_env.env_root().join("repo"); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + // Don't do an initial commit + let (_, stderr) = test_env.jj_cmd_ok(&repo_path, &["git", "colocate"]); + insta::assert_snapshot!(stderr, @""); + assert!(repo_path.join(".git").exists()); + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r" + @ 230dd059e1b059aefc0da06a2e5a7dbf22362f22 + ◆ 0000000000000000000000000000000000000000 + "); + test_env.jj_cmd_ok(&repo_path, &["commit", "-mempty"]); + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r" + @ a3ff8b1dfd5dc5686564cf761d5b9ce96d7fd967 + ○ 6a23f77161e78397716aac30d5000f67a17a83ff git_head() empty + ◆ 0000000000000000000000000000000000000000 + "); +} diff --git a/docs/git-compatibility.md b/docs/git-compatibility.md index a5d1adcef3..75a8c2464d 100644 --- a/docs/git-compatibility.md +++ b/docs/git-compatibility.md @@ -164,27 +164,14 @@ repos may require you to deal with more involved Jujutsu and Git concepts. report any new ones you find, or if any of the known bugs are less minor than they appear. -### Converting a repo into a co-located repo +### Converting a repo into a colocated repo -A Jujutsu repo backed by a Git repo has a full Git repo inside, so it is -technically possible (though not officially supported) to convert it into a -co-located repo like so: +You can convert a non-colocated repo into a colocated repo like so: ```bash -# Move the Git repo -mv .jj/repo/store/git .git -# Tell jj where to find it -echo -n '../../../.git' > .jj/repo/store/git_target -# Ignore the .jj directory in Git -echo '/*' > .jj/.gitignore -# Make the Git repository non-bare and set HEAD -git config --unset core.bare -jj new @- +jj git colocate ``` -We may officially support this in the future. If you try this, we would -appreciate feedback and bug reports. - ## Branches TODO: Describe how branches are mapped diff --git a/lib/src/workspace.rs b/lib/src/workspace.rs index c479750949..b69fd7977b 100644 --- a/lib/src/workspace.rs +++ b/lib/src/workspace.rs @@ -357,6 +357,30 @@ impl Workspace { ) } + /// Reloads the repo and the working copy from the file system. + pub fn reload( + &self, + workspace_loader: &dyn WorkspaceLoader, + user_settings: &UserSettings, + working_copy_factory: &dyn WorkingCopyFactory, + store_factories: &StoreFactories, + ) -> Result { + let repo_loader = + RepoLoader::init_from_file_system(user_settings, self.repo_path(), store_factories)?; + + let working_copy = + workspace_loader.load_working_copy(repo_loader.store(), working_copy_factory)?; + + let new_workspace = Workspace::new( + self.workspace_root(), + self.repo_path.clone(), + working_copy, + repo_loader, + self.is_primary_workspace, + )?; + Ok(new_workspace) + } + pub fn init_workspace_with_existing_repo( user_settings: &UserSettings, workspace_root: &Path,