Skip to content

Commit

Permalink
cli: add jj git colocate
Browse files Browse the repository at this point in the history
Initially, we can only colocate the primary workspace.

See <https://martinvonz.github.io/jj/latest/git-compatibility/#converting-a-repo-into-a-co-located-repo> for the steps.

Adds a Workspace::reload() function to reload the workspace from the
file system, which is technically not necessary for this diff, but will
become necessary soon, when we make the GitBackend colocation-aware.
  • Loading branch information
cormacrelf committed Nov 12, 2024
1 parent 879288c commit 335ac54
Show file tree
Hide file tree
Showing 8 changed files with 227 additions and 17 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions cli/src/cli_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()?;

Expand Down
142 changes: 142 additions & 0 deletions cli/src/commands/git/colocate.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
5 changes: 5 additions & 0 deletions cli/src/commands/git/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
// limitations under the License.

pub mod clone;
pub mod colocate;
pub mod export;
pub mod fetch;
pub mod import;
Expand All @@ -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;
Expand Down Expand Up @@ -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),
Expand All @@ -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),
Expand Down
11 changes: 10 additions & 1 deletion cli/tests/[email protected]
Original file line number Diff line number Diff line change
@@ -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
---
<!-- BEGIN MARKDOWN-->

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions cli/tests/test_git_colocated.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
");
}
19 changes: 3 additions & 16 deletions docs/git-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions lib/src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self, WorkspaceLoadError> {
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,
Expand Down

0 comments on commit 335ac54

Please sign in to comment.