Skip to content

Commit 32ef2c8

Browse files
committed
git: add git colocate command
This new command lets you turn a non colocated git repo into a colocated repo and back (by passing and --undo flag). The command simply implements the instructions found in https://github.com/jj-vcs/jj/blob/main/docs/git-compatibility.md#converting-a-repo-into-a-co-located-repo
1 parent 5ff828b commit 32ef2c8

File tree

7 files changed

+444
-3
lines changed

7 files changed

+444
-3
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5656

5757
### New features
5858

59+
* The new command `jj git colocate` can convert a non-colocated git repo into
60+
a colocated repo and vice-versa (using the `--undo` flag).
61+
5962
* The new command `jj redo` can progressively redo operations that were
6063
previously undone by multiple calls to `jj undo`.
6164

cli/src/commands/git/colocate.rs

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
// Copyright 2025 The Jujutsu Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
use std::path::Path;
16+
17+
use crate::cli_util::CommandHelper;
18+
use crate::cli_util::WorkspaceCommandHelper;
19+
use crate::command_error::CommandError;
20+
use crate::command_error::user_error;
21+
use crate::command_error::user_error_with_message;
22+
use crate::git_util::is_colocated_git_workspace;
23+
use crate::ui::Ui;
24+
25+
/// Convert a Jujutsu repository to a co-located Jujutsu/git repository
26+
#[derive(clap::Args, Clone, Debug)]
27+
pub struct GitColocateArgs {
28+
/// Undo a previous colocate operation, reverting the repository back to a
29+
/// non-colocated git repository
30+
#[arg(long)]
31+
undo: bool,
32+
}
33+
34+
pub fn cmd_git_colocate(
35+
ui: &mut Ui,
36+
command: &CommandHelper,
37+
args: &GitColocateArgs,
38+
) -> Result<(), CommandError> {
39+
let mut workspace_command = command.workspace_helper(ui)?;
40+
41+
if args.undo {
42+
undo_colocate(ui, &mut workspace_command)
43+
} else {
44+
colocate_repository(ui, &mut workspace_command)
45+
}
46+
}
47+
48+
fn colocate_repository(
49+
ui: &mut Ui,
50+
workspace_command: &mut WorkspaceCommandHelper,
51+
) -> Result<(), CommandError> {
52+
if is_colocated_git_workspace(workspace_command.workspace(), workspace_command.repo()) {
53+
return Err(user_error("Repository is already co-located with Git"));
54+
}
55+
56+
let workspace_root = workspace_command.workspace_root();
57+
let dot_jj_path = workspace_root.join(".jj");
58+
let jj_repo_path = dot_jj_path.join("repo");
59+
let git_store_path = jj_repo_path.join("store").join("git");
60+
let git_target_path = jj_repo_path.join("store").join("git_target");
61+
let dot_git_path = workspace_root.join(".git");
62+
63+
// Bail out if a git repo already exist at the root folder
64+
if dot_git_path.exists() {
65+
return Err(user_error(
66+
"A .git directory already exists in the workspace root. Cannot colocate",
67+
));
68+
}
69+
// or if the jujutsu repo is a worktree
70+
if jj_repo_path.is_file() {
71+
return Err(user_error("Cannot collocate a jujutsu worktree"));
72+
}
73+
// or if it is not backed by git
74+
if !git_store_path.exists() {
75+
return Err(user_error(
76+
"git store not found. This repository might not be using the git back-end",
77+
));
78+
}
79+
80+
// Create a .gitignore file in the .jj directory that ensures that the root
81+
// git repo completely ignores the .jj directory
82+
// Note that if a .jj/.gitignore already exists it will be overwritten
83+
// This should be fine since it does not make sense to only ignore parts of
84+
// the .jj directory
85+
let jj_gitignore_path = dot_jj_path.join(".gitignore");
86+
std::fs::write(&jj_gitignore_path, "/*\n")
87+
.map_err(|e| user_error_with_message("Failed to create .jj/.gitignore", e))?;
88+
89+
// Create a git_target file pointing to the new location
90+
// Note that we do this first so that it is easier to revert the operation
91+
// in case there is a failure in this step or the next
92+
let git_target_content = "../../../.git";
93+
std::fs::write(&git_target_path, git_target_content)
94+
.map_err(|e| user_error_with_message("Failed to create git_target file", e))?;
95+
96+
// Move the git repository from .jj/repo/store/git to .git
97+
if let Err(e) = move_directory(&git_store_path, &dot_git_path) {
98+
// Attempt to delete git_target_path if move fails and show an error message
99+
let _ = std::fs::remove_file(&git_target_path);
100+
return Err(user_error_with_message(
101+
"Failed to move git repository from .jj/repo/store/git to repository root directory",
102+
e,
103+
));
104+
}
105+
106+
// Make the colocated git repository non-bare
107+
let output = std::process::Command::new("git")
108+
.arg("-C")
109+
.arg(&dot_git_path)
110+
.args(["config", "--unset", "core.bare"])
111+
.output();
112+
113+
match output {
114+
Ok(output) if output.status.success() => {}
115+
Ok(output) => {
116+
let stderr = String::from_utf8_lossy(&output.stderr);
117+
return Err(user_error_with_message(
118+
"Failed to unset core.bare in git config",
119+
format!("git config failed: {}", stderr.trim()),
120+
));
121+
}
122+
Err(e) => {
123+
return Err(user_error_with_message(
124+
"Failed to run git config command to unset core.bare",
125+
e,
126+
));
127+
}
128+
}
129+
130+
// Finally, update git HEAD by taking a snapshot which triggers git export
131+
// This will update .git/HEAD to point to the working-copy commit's parent
132+
workspace_command.maybe_snapshot(ui)?;
133+
134+
writeln!(
135+
ui.status(),
136+
"Repository successfully converted to a colocated Jujutsu/git repository"
137+
)?;
138+
139+
Ok(())
140+
}
141+
142+
fn undo_colocate(
143+
ui: &mut Ui,
144+
workspace_command: &mut WorkspaceCommandHelper,
145+
) -> Result<(), CommandError> {
146+
// Check if the repo is colocated before proceeding
147+
if !is_colocated_git_workspace(workspace_command.workspace(), workspace_command.repo()) {
148+
return Err(user_error(
149+
"Repository is not co-located with Git. Nothing to undo",
150+
));
151+
}
152+
153+
let workspace_root = workspace_command.workspace_root();
154+
let dot_jj_path = workspace_root.join(".jj");
155+
let git_store_path = dot_jj_path.join("repo").join("store").join("git");
156+
let git_target_path = dot_jj_path.join("repo").join("store").join("git_target");
157+
let dot_git_path = workspace_root.join(".git");
158+
let jj_gitignore_path = dot_jj_path.join(".gitignore");
159+
160+
// Do not proceed if there is no .git directory at the root folder
161+
if !dot_git_path.exists() {
162+
return Err(user_error("No .git directory found in workspace root"));
163+
}
164+
165+
// Or if a git repo already exist inside jujutsus repo store
166+
if git_store_path.exists() {
167+
return Err(user_error(
168+
"git store already exists at .jj/repo/store/git. Cannot undo colocate",
169+
));
170+
}
171+
172+
// Make the git repository bare
173+
let output = std::process::Command::new("git")
174+
.arg("-C")
175+
.arg(&dot_git_path)
176+
.args(["config", "core.bare", "true"])
177+
.output()
178+
.map_err(|e| user_error_with_message("Failed to run git config command", e))?;
179+
180+
if !output.status.success() {
181+
let stderr = String::from_utf8_lossy(&output.stderr);
182+
return Err(user_error_with_message(
183+
"Failed to set core.bare in git config",
184+
format!("git config failed: {}", stderr.trim()),
185+
));
186+
}
187+
188+
// Move the git repository from .git into .jj/repo/store/git
189+
move_directory(&dot_git_path, &git_store_path)
190+
.map_err(|e| user_error_with_message("Failed to move git repository", e))?;
191+
192+
// Update the git_target file to point to the internal git store
193+
let git_target_content = "git";
194+
std::fs::write(&git_target_path, git_target_content)
195+
.map_err(|e| user_error_with_message("Failed to update git_target file", e))?;
196+
197+
// Remove the .jj/.gitignore file if it exists
198+
if jj_gitignore_path.exists() {
199+
std::fs::remove_file(&jj_gitignore_path)
200+
.map_err(|e| user_error_with_message("Failed to remove .jj/.gitignore", e))?;
201+
}
202+
203+
writeln!(
204+
ui.status(),
205+
"Repository successfully converted into a non-colocated regular Jujutsu repository"
206+
)?;
207+
208+
Ok(())
209+
}
210+
211+
/// Cross-platform directory move operation
212+
fn move_directory(from: &Path, to: &Path) -> std::io::Result<()> {
213+
// Try a rename first, falling back to copy + remove in case of failure
214+
match std::fs::rename(from, to) {
215+
Ok(()) => Ok(()),
216+
Err(_) => {
217+
// If rename fails, do a recursive copy and delete
218+
copy_dir_recursive(from, to)?;
219+
std::fs::remove_dir_all(from)?;
220+
Ok(())
221+
}
222+
}
223+
}
224+
225+
/// Recursively copy a directory to handle cross-filesystem moves
226+
fn copy_dir_recursive(from: &Path, to: &Path) -> std::io::Result<()> {
227+
use std::fs;
228+
229+
if !to.exists() {
230+
fs::create_dir_all(to)?;
231+
}
232+
233+
for entry in fs::read_dir(from)? {
234+
let entry = entry?;
235+
let file_type = entry.file_type()?;
236+
let src_path = entry.path();
237+
let dest_path = to.join(entry.file_name());
238+
239+
if file_type.is_dir() {
240+
copy_dir_recursive(&src_path, &dest_path)?;
241+
} else {
242+
fs::copy(&src_path, &dest_path)?;
243+
}
244+
}
245+
246+
Ok(())
247+
}

cli/src/commands/git/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
// limitations under the License.
1414

1515
mod clone;
16+
mod colocate;
1617
mod export;
1718
mod fetch;
1819
mod import;
@@ -36,6 +37,8 @@ use jj_lib::store::Store;
3637

3738
use self::clone::GitCloneArgs;
3839
use self::clone::cmd_git_clone;
40+
use self::colocate::GitColocateArgs;
41+
use self::colocate::cmd_git_colocate;
3942
use self::export::GitExportArgs;
4043
use self::export::cmd_git_export;
4144
use self::fetch::GitFetchArgs;
@@ -68,6 +71,7 @@ use crate::ui::Ui;
6871
#[derive(Subcommand, Clone, Debug)]
6972
pub enum GitCommand {
7073
Clone(GitCloneArgs),
74+
Colocate(GitColocateArgs),
7175
Export(GitExportArgs),
7276
Fetch(GitFetchArgs),
7377
Import(GitImportArgs),
@@ -85,6 +89,7 @@ pub fn cmd_git(
8589
) -> Result<(), CommandError> {
8690
match subcommand {
8791
GitCommand::Clone(args) => cmd_git_clone(ui, command, args),
92+
GitCommand::Colocate(args) => cmd_git_colocate(ui, command, args),
8893
GitCommand::Export(args) => cmd_git_export(ui, command, args),
8994
GitCommand::Fetch(args) => cmd_git_fetch(ui, command, args),
9095
GitCommand::Import(args) => cmd_git_import(ui, command, args),

cli/tests/[email protected]

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ This document contains the help content for the `jj` command-line program.
4747
* [`jj fix`↴](#jj-fix)
4848
* [`jj git`↴](#jj-git)
4949
* [`jj git clone`↴](#jj-git-clone)
50+
* [`jj git colocate`↴](#jj-git-colocate)
5051
* [`jj git export`↴](#jj-git-export)
5152
* [`jj git fetch`↴](#jj-git-fetch)
5253
* [`jj git import`↴](#jj-git-import)
@@ -1192,6 +1193,7 @@ See this [comparison], including a [table of commands].
11921193
###### **Subcommands:**
11931194

11941195
* `clone` — Create a new repo backed by a clone of a Git repo
1196+
* `colocate` — Convert a Jujutsu repository to a co-located Jujutsu/git repository
11951197
* `export` — Update the underlying Git repo with changes made in the repo
11961198
* `fetch` — Fetch from a Git remote
11971199
* `import` — Update repo with changes made in the underlying Git repo
@@ -1240,6 +1242,18 @@ The Git repo will be a bare git repo stored inside the `.jj/` directory.
12401242

12411243

12421244

1245+
## `jj git colocate`
1246+
1247+
Convert a Jujutsu repository to a co-located Jujutsu/git repository
1248+
1249+
**Usage:** `jj git colocate [OPTIONS]`
1250+
1251+
###### **Options:**
1252+
1253+
* `--undo` — Undo a previous colocate operation, reverting the repository back to a non-colocated git repository
1254+
1255+
1256+
12431257
## `jj git export`
12441258

12451259
Update the underlying Git repo with changes made in the repo

cli/tests/runner.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ mod test_file_track_untrack_commands;
3838
mod test_fix_command;
3939
mod test_generate_md_cli_help;
4040
mod test_git_clone;
41+
mod test_git_colocate;
4142
mod test_git_colocated;
4243
mod test_git_fetch;
4344
mod test_git_import_export;

0 commit comments

Comments
 (0)