Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
jj workspace can have its own Git HEAD. Existing repositories are migrated
automatically.

* `jj workspace add` supports `--colocate`/`--no-colocate` flags to control
whether a Git worktree is created alongside the workspace. The default
colocates when the current workspace is colocated and the `git.colocate`
config is `true`. `jj workspace forget` removes the corresponding Git
worktree when one exists.

### Fixed bugs

* The default pager flags now include `-K` (`--quit-on-intr`), so pressing
Expand Down
72 changes: 66 additions & 6 deletions cli/src/commands/workspace/add.rs
Comment thread
calebdw marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ use itertools::Itertools as _;
use jj_lib::commit::CommitIteratorExt as _;
use jj_lib::file_util;
use jj_lib::file_util::IoResultExt as _;
#[cfg(feature = "git")]
use jj_lib::git::GitSettings;
use jj_lib::ref_name::WorkspaceNameBuf;
use jj_lib::repo::Repo as _;
use jj_lib::rewrite::merge_commit_trees;
Expand All @@ -32,6 +34,8 @@ use crate::command_error::internal_error_with_message;
use crate::command_error::user_error;
use crate::description_util::add_trailers;
use crate::description_util::join_message_paragraphs;
#[cfg(feature = "git")]
use crate::git_util::create_git_worktree;
use crate::ui::Ui;

/// How to handle sparse patterns when creating a new workspace.
Expand Down Expand Up @@ -81,6 +85,20 @@ pub struct WorkspaceAddArgs {
#[arg(long = "message", short, value_name = "MESSAGE")]
message_paragraphs: Vec<String>,

/// Create a corresponding Git worktree for this workspace
///
/// By default, a Git worktree is created when the current workspace is
/// colocated and the [git.colocate config] is `true`.
///
/// [git.colocate config]:
/// https://docs.jj-vcs.dev/latest/config/#default-colocation
#[arg(long, conflicts_with = "no_colocate")]
colocate: bool,

/// Do not create a Git worktree for this workspace
#[arg(long, conflicts_with = "colocate")]
no_colocate: bool,

/// How to handle sparse patterns when creating a new workspace.
#[arg(long, value_enum, default_value_t = SparseInheritance::Copy)]
sparse_patterns: SparseInheritance,
Expand Down Expand Up @@ -114,12 +132,54 @@ pub async fn cmd_workspace_add(
name = workspace_name.as_symbol()
)));
}
if !destination_path.exists() {
fs::create_dir(&destination_path).context(&destination_path)?;
} else if !file_util::is_empty_dir(&destination_path)? {
return Err(user_error(
"Destination path exists and is not an empty directory",
));
#[cfg(feature = "git")]
let created_git_worktree = {
if (args.colocate || args.no_colocate)
&& !jj_lib::git::get_git_backend(repo.store()).is_ok()
{
return Err(user_error(
"--colocate/--no-colocate requires a Git backend",
));
}
let should_colocate = if args.colocate {
true
} else if args.no_colocate {
false
} else {
old_workspace_command.working_copy_shared_with_git()
&& old_workspace_command.settings().get_bool("git.colocate")?
};
if should_colocate {
let git_settings = GitSettings::from_settings(old_workspace_command.settings())?;
let git_head = repo.view().git_head(old_workspace_command.workspace_name());
if git_head.is_absent() {
return Err(user_error(
"Cannot create colocated Git worktree because Git HEAD does not point to a \
commit yet. Create a commit first, then retry.",
));
}
create_git_worktree(
ui,
&git_settings,
old_workspace_command.workspace_root(),
&destination_path,
)?;
true
} else {
false
}
};
#[cfg(not(feature = "git"))]
let created_git_worktree = false;

if !created_git_worktree {
if !destination_path.exists() {
fs::create_dir(&destination_path).context(&destination_path)?;
} else if !file_util::is_empty_dir(&destination_path)? {
return Err(user_error(
"Destination path exists and is not an empty directory",
));
}
}

let working_copy_factory = command.get_working_copy_factory()?;
Expand Down
25 changes: 25 additions & 0 deletions cli/src/commands/workspace/forget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

use clap_complete::ArgValueCandidates;
use itertools::Itertools as _;
#[cfg(feature = "git")]
use jj_lib::git::GitSettings;
use jj_lib::ref_name::WorkspaceNameBuf;
use jj_lib::workspace_store::SimpleWorkspaceStore;
use jj_lib::workspace_store::WorkspaceStore as _;
Expand All @@ -22,6 +24,8 @@ use tracing::instrument;
use crate::cli_util::CommandHelper;
use crate::command_error::CommandError;
use crate::complete;
#[cfg(feature = "git")]
use crate::git_util::remove_git_worktree;
use crate::ui::Ui;

/// Stop tracking a workspace's working-copy commit in the repo
Expand Down Expand Up @@ -74,6 +78,18 @@ pub async fn cmd_workspace_forget(

let workspace_store = SimpleWorkspaceStore::load(workspace_command.repo_path())?;

#[cfg(feature = "git")]
let workspace_paths = {
let repo_path = workspace_command.repo_path();
forget_ws
.iter()
.filter_map(|ws| {
let rel_path = workspace_store.get_workspace_path(ws).ok().flatten()?;
dunce::canonicalize(repo_path.join(rel_path)).ok()
})
.collect_vec()
};

// bundle every workspace forget into a single transaction, so that e.g.
// undo correctly restores all of them at once.
let mut tx = workspace_command.start_transaction();
Expand All @@ -94,5 +110,14 @@ pub async fn cmd_workspace_forget(
};

tx.finish(ui, description).await?;

#[cfg(feature = "git")]
{
let git_settings = GitSettings::from_settings(workspace_command.settings())?;
for path in &workspace_paths {
remove_git_worktree(ui, &git_settings, workspace_command.workspace_root(), path)?;
Comment thread
calebdw marked this conversation as resolved.
}
}

Ok(())
}
133 changes: 133 additions & 0 deletions cli/src/git_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use std::io::Write as _;
use std::iter;
use std::mem;
use std::path::Path;
use std::process::Command;
use std::time::Duration;
use std::time::Instant;

Expand All @@ -28,6 +29,8 @@ use crossterm::terminal::Clear;
use crossterm::terminal::ClearType;
use indoc::writedoc;
use itertools::Itertools as _;
use jj_lib::file_util;
use jj_lib::file_util::IoResultExt as _;
use jj_lib::git;
use jj_lib::git::FailedRefExportReason;
use jj_lib::git::GitExportStats;
Expand All @@ -53,6 +56,7 @@ use crate::cli_util::print_updated_commits;
use crate::command_error::CommandError;
use crate::command_error::cli_error;
use crate::command_error::user_error;
use crate::command_error::user_error_with_message;
use crate::formatter::Formatter;
use crate::formatter::FormatterExt as _;
use crate::revset_util::parse_remote_auto_track_bookmarks_map;
Expand Down Expand Up @@ -565,6 +569,135 @@ pub fn print_push_stats(ui: &Ui, stats: &GitPushStats) -> io::Result<()> {
Ok(())
}

pub fn create_git_worktree(
ui: &Ui,
git_settings: &GitSettings,
main_workspace_root: &Path,
destination: &Path,
) -> Result<(), CommandError> {
// Use relative paths to match jj's convention for portable repositories.
// Silently ignored by git versions that don't support it.
let relative_paths_config = ["-c", "worktree.useRelativePaths=true"];

let dest_exists = destination.exists() && !file_util::is_empty_dir(destination)?;
if dest_exists {
// `git worktree add` refuses to create a worktree in a non-empty
// directory. Work around this by creating in a temporary sibling
// directory, moving the .git gitlink, and repairing the paths.
let tmp =
tempfile::TempDir::new_in(destination.parent().unwrap_or(std::path::Path::new(".")))
.map_err(|err| {
user_error_with_message(
"Failed to create temporary directory for Git worktree",
err,
)
})?;
let tmp_path = tmp.keep();

run_git_worktree_add(
git_settings,
&relative_paths_config,
main_workspace_root,
&tmp_path,
)?;

std::fs::rename(tmp_path.join(".git"), destination.join(".git")).context(destination)?;
std::fs::remove_dir_all(&tmp_path).ok();

let output = Command::new(&git_settings.executable_path)
.args(relative_paths_config)
.args(["worktree", "repair", "--"])
.arg(destination)
.current_dir(main_workspace_root)
.output()
.map_err(|err| user_error_with_message("Failed to run `git worktree repair`", err))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(user_error(format!(
"Failed to repair Git worktree paths: {stderr}"
)));
}
} else {
run_git_worktree_add(
git_settings,
&relative_paths_config,
main_workspace_root,
destination,
)?;
}

writeln!(ui.status(), "Created Git worktree for the new workspace.")?;
Ok(())
}

fn run_git_worktree_add(
git_settings: &GitSettings,
extra_config: &[&str],
main_workspace_root: &Path,
destination: &Path,
) -> Result<(), CommandError> {
let output = Command::new(&git_settings.executable_path)
.args(extra_config)
.args(["worktree", "add", "--detach", "--no-checkout", "--"])
.arg(destination)
Comment thread
calebdw marked this conversation as resolved.
.arg("HEAD")
.current_dir(main_workspace_root)
.output()
.map_err(|err| user_error_with_message("Failed to run `git worktree add`", err))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(user_error(format!(
"Failed to create Git worktree: {stderr}"
)));
}
Ok(())
}

pub fn remove_git_worktree(
ui: &Ui,
git_settings: &GitSettings,
main_workspace_root: &Path,
worktree_path: &Path,
) -> Result<(), CommandError> {
let dot_git = worktree_path.join(".git");
if !dot_git.is_file() {
return Ok(());
}
// Remove the .git gitlink file, then prune the worktree metadata.
// We don't use `git worktree remove` because it deletes the directory
// contents, and jj workspace forget should preserve workspace files.
std::fs::remove_file(&dot_git).ok();

let output = Command::new(&git_settings.executable_path)
.args(["worktree", "prune"])
.current_dir(main_workspace_root)
.output();
match output {
Ok(o) if o.status.success() => {
writeln!(
ui.status(),
r#"Removed Git worktree for "{}"."#,
worktree_path.display()
)?;
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
writeln!(
ui.warning_default(),
r#"Failed to prune Git worktree for "{}": {stderr}"#,
worktree_path.display()
)?;
}
Err(err) => {
writeln!(
ui.warning_default(),
"Failed to run `git worktree prune`: {err}"
)?;
}
}
Ok(())
}

#[cfg(test)]
mod tests {
use std::path::MAIN_SEPARATOR;
Expand Down
6 changes: 6 additions & 0 deletions cli/tests/cli-reference@.md.snap
Original file line number Diff line number Diff line change
Expand Up @@ -3800,6 +3800,12 @@ By default, the new workspace inherits the sparse patterns of the current worksp

If any revisions are specified, the new workspace will be created, and the new working-copy commit will be created with all these revisions as parents, i.e. the working-copy commit will exist as if you had run `jj new r1 r2 r3 ...`.
* `-m`, `--message <MESSAGE>` — The change description to use
* `--colocate` — Create a corresponding Git worktree for this workspace

By default, a Git worktree is created when the current workspace is colocated and the [git.colocate config] is `true`.

[git.colocate config]: https://docs.jj-vcs.dev/latest/config/#default-colocation
* `--no-colocate` — Do not create a Git worktree for this workspace
* `--sparse-patterns <SPARSE_PATTERNS>` — How to handle sparse patterns when creating a new workspace

Default value: `copy`
Expand Down
Loading