diff --git a/CHANGELOG.md b/CHANGELOG.md index 50ef5f51c4..3d3bc2caa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). (inherit from parent; default), `full` (full working copy), or `empty` (the empty working copy). +* New command `jj workspace rename` that can rename the current workspace. + * `jj op log` gained an option to include operation diffs. * `jj git clone` now accepts a `--remote ` option, which diff --git a/cli/examples/custom-working-copy/main.rs b/cli/examples/custom-working-copy/main.rs index 431c5c7209..276ad1fd83 100644 --- a/cli/examples/custom-working-copy/main.rs +++ b/cli/examples/custom-working-copy/main.rs @@ -250,6 +250,10 @@ impl LockedWorkingCopy for LockedConflictsWorkingCopy { self.inner.check_out(commit) } + fn rename_workspace(&mut self, new_workspace_id: WorkspaceId) { + self.inner.rename_workspace(new_workspace_id); + } + fn reset(&mut self, commit: &Commit) -> Result<(), ResetError> { self.inner.reset(commit) } diff --git a/cli/src/command_error.rs b/cli/src/command_error.rs index 8993eb2f14..9bd78b396d 100644 --- a/cli/src/command_error.rs +++ b/cli/src/command_error.rs @@ -46,6 +46,7 @@ use jj_lib::revset::RevsetParseErrorKind; use jj_lib::revset::RevsetResolutionError; use jj_lib::signing::SignInitError; use jj_lib::str_util::StringPatternParseError; +use jj_lib::view::RenameWorkspaceError; use jj_lib::working_copy::ResetError; use jj_lib::working_copy::SnapshotError; use jj_lib::working_copy::WorkingCopyStateError; @@ -264,6 +265,12 @@ impl From for CommandError { } } +impl From for CommandError { + fn from(err: RenameWorkspaceError) -> Self { + user_error_with_message("Failed to rename a workspace", err) + } +} + impl From for CommandError { fn from(err: BackendError) -> Self { match &err { diff --git a/cli/src/commands/workspace/mod.rs b/cli/src/commands/workspace/mod.rs index 37dacefcda..bcbd08f7eb 100644 --- a/cli/src/commands/workspace/mod.rs +++ b/cli/src/commands/workspace/mod.rs @@ -15,6 +15,7 @@ mod add; mod forget; mod list; +mod rename; mod root; mod update_stale; @@ -27,6 +28,8 @@ use self::forget::cmd_workspace_forget; use self::forget::WorkspaceForgetArgs; use self::list::cmd_workspace_list; use self::list::WorkspaceListArgs; +use self::rename::cmd_workspace_rename; +use self::rename::WorkspaceRenameArgs; use self::root::cmd_workspace_root; use self::root::WorkspaceRootArgs; use self::update_stale::cmd_workspace_update_stale; @@ -51,6 +54,7 @@ pub(crate) enum WorkspaceCommand { Add(WorkspaceAddArgs), Forget(WorkspaceForgetArgs), List(WorkspaceListArgs), + Rename(WorkspaceRenameArgs), Root(WorkspaceRootArgs), UpdateStale(WorkspaceUpdateStaleArgs), } @@ -65,6 +69,7 @@ pub(crate) fn cmd_workspace( WorkspaceCommand::Add(args) => cmd_workspace_add(ui, command, args), WorkspaceCommand::Forget(args) => cmd_workspace_forget(ui, command, args), WorkspaceCommand::List(args) => cmd_workspace_list(ui, command, args), + WorkspaceCommand::Rename(args) => cmd_workspace_rename(ui, command, args), WorkspaceCommand::Root(args) => cmd_workspace_root(ui, command, args), WorkspaceCommand::UpdateStale(args) => cmd_workspace_update_stale(ui, command, args), } diff --git a/cli/src/commands/workspace/rename.rs b/cli/src/commands/workspace/rename.rs new file mode 100644 index 0000000000..53d79c1b0d --- /dev/null +++ b/cli/src/commands/workspace/rename.rs @@ -0,0 +1,78 @@ +// Copyright 2020 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 jj_lib::op_store::WorkspaceId; +use tracing::instrument; + +use crate::cli_util::CommandHelper; +use crate::command_error::user_error; +use crate::command_error::CommandError; +use crate::ui::Ui; + +/// Renames the current workspace +#[derive(clap::Args, Clone, Debug)] +pub struct WorkspaceRenameArgs { + /// The name of the workspace to update to. + new_workspace_name: String, +} + +#[instrument(skip_all)] +pub fn cmd_workspace_rename( + ui: &mut Ui, + command: &CommandHelper, + args: &WorkspaceRenameArgs, +) -> Result<(), CommandError> { + if args.new_workspace_name.is_empty() { + return Err(user_error("New workspace name cannot be empty")); + } + + let mut workspace_command = command.workspace_helper(ui)?; + + let old_workspace_id = workspace_command.working_copy().workspace_id().clone(); + let new_workspace_id = WorkspaceId::new(args.new_workspace_name.clone()); + if new_workspace_id == old_workspace_id { + writeln!(ui.status(), "Nothing changed.")?; + return Ok(()); + } + + if workspace_command + .repo() + .view() + .get_wc_commit_id(&old_workspace_id) + .is_none() + { + return Err(user_error(format!( + "The current workspace '{}' is not tracked in the repo.", + old_workspace_id.as_str() + ))); + } + + let mut tx = workspace_command.start_transaction().into_inner(); + let (mut locked_ws, _wc_commit) = workspace_command.start_working_copy_mutation()?; + + locked_ws + .locked_wc() + .rename_workspace(new_workspace_id.clone()); + + tx.repo_mut() + .rename_workspace(&old_workspace_id, new_workspace_id)?; + let repo = tx.commit(format!( + "Renamed workspace '{}' to '{}'", + old_workspace_id.as_str(), + args.new_workspace_name + )); + locked_ws.finish(repo.op_id().clone())?; + + Ok(()) +} diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap index e8c52c6525..e7559a12b6 100644 --- a/cli/tests/cli-reference@.md.snap +++ b/cli/tests/cli-reference@.md.snap @@ -98,6 +98,7 @@ This document contains the help content for the `jj` command-line program. * [`jj workspace add`↴](#jj-workspace-add) * [`jj workspace forget`↴](#jj-workspace-forget) * [`jj workspace list`↴](#jj-workspace-list) +* [`jj workspace rename`↴](#jj-workspace-rename) * [`jj workspace root`↴](#jj-workspace-root) * [`jj workspace update-stale`↴](#jj-workspace-update-stale) @@ -2144,6 +2145,7 @@ Each workspace also has own sparse patterns. * `add` — Add a workspace * `forget` — Stop tracking a workspace's working-copy commit in the repo * `list` — List workspaces +* `rename` — Renames the current workspace * `root` — Show the current workspace root directory * `update-stale` — Update a workspace that has become stale @@ -2208,6 +2210,18 @@ List workspaces +## `jj workspace rename` + +Renames the current workspace + +**Usage:** `jj workspace rename ` + +###### **Arguments:** + +* `` — The name of the workspace to update to + + + ## `jj workspace root` Show the current workspace root directory diff --git a/cli/tests/test_workspaces.rs b/cli/tests/test_workspaces.rs index f50626082a..3c6cfe337b 100644 --- a/cli/tests/test_workspaces.rs +++ b/cli/tests/test_workspaces.rs @@ -1078,6 +1078,93 @@ fn test_debug_snapshot() { "###); } +#[test] +fn test_workspaces_rename_nothing_changed() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "main"]); + let main_path = test_env.env_root().join("main"); + let (stdout, stderr) = test_env.jj_cmd_ok(&main_path, &["workspace", "rename", "default"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Nothing changed. + "###); +} + +#[test] +fn test_workspaces_rename_new_workspace_name_already_used() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "main"]); + let main_path = test_env.env_root().join("main"); + test_env.jj_cmd_ok( + &main_path, + &["workspace", "add", "--name", "second", "../secondary"], + ); + let stderr = test_env.jj_cmd_failure(&main_path, &["workspace", "rename", "second"]); + insta::assert_snapshot!(stderr, @r###" + Error: Failed to rename a workspace + Caused by: Workspace second already exists + "###); +} + +#[test] +fn test_workspaces_rename_forgotten_workspace() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "main"]); + let main_path = test_env.env_root().join("main"); + test_env.jj_cmd_ok( + &main_path, + &["workspace", "add", "--name", "second", "../secondary"], + ); + test_env.jj_cmd_ok(&main_path, &["workspace", "forget", "second"]); + let secondary_path = test_env.env_root().join("secondary"); + let stderr = test_env.jj_cmd_failure(&secondary_path, &["workspace", "rename", "third"]); + insta::assert_snapshot!(stderr, @r###" + Error: The current workspace 'second' is not tracked in the repo. + "###); +} + +#[test] +fn test_workspaces_rename_workspace() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "main"]); + let main_path = test_env.env_root().join("main"); + test_env.jj_cmd_ok( + &main_path, + &["workspace", "add", "--name", "second", "../secondary"], + ); + let secondary_path = test_env.env_root().join("secondary"); + + // Both workspaces show up when we list them + let stdout = test_env.jj_cmd_success(&main_path, &["workspace", "list"]); + insta::assert_snapshot!(stdout, @r###" + default: qpvuntsm 230dd059 (empty) (no description set) + second: uuqppmxq 57d63245 (empty) (no description set) + "###); + + let stdout = test_env.jj_cmd_success(&secondary_path, &["workspace", "rename", "third"]); + insta::assert_snapshot!(stdout, @""); + + let stdout = test_env.jj_cmd_success(&main_path, &["workspace", "list"]); + insta::assert_snapshot!(stdout, @r###" + default: qpvuntsm 230dd059 (empty) (no description set) + third: uuqppmxq 57d63245 (empty) (no description set) + "###); + + // Can see the working-copy commit in each workspace in the log output. + insta::assert_snapshot!(get_log_output(&test_env, &main_path), @r###" + ○ 57d63245a308 third@ + │ @ 230dd059e1b0 default@ + ├─╯ + ◆ 000000000000 + "###); + insta::assert_snapshot!(get_log_output(&test_env, &secondary_path), @r###" + @ 57d63245a308 third@ + │ ○ 230dd059e1b0 default@ + ├─╯ + ◆ 000000000000 + "###); +} + fn get_log_output(test_env: &TestEnvironment, cwd: &Path) -> String { let template = r#" separate(" ", diff --git a/lib/src/local_working_copy.rs b/lib/src/local_working_copy.rs index 1e5398f408..da2f550814 100644 --- a/lib/src/local_working_copy.rs +++ b/lib/src/local_working_copy.rs @@ -1626,6 +1626,7 @@ impl WorkingCopy for LocalWorkingCopy { old_operation_id, old_tree_id, tree_state_dirty: false, + new_workspace_id: None, })) } } @@ -1825,6 +1826,7 @@ pub struct LockedLocalWorkingCopy { old_operation_id: OperationId, old_tree_id: MergedTreeId, tree_state_dirty: bool, + new_workspace_id: Option, } impl LockedWorkingCopy for LockedLocalWorkingCopy { @@ -1872,6 +1874,10 @@ impl LockedWorkingCopy for LockedLocalWorkingCopy { Ok(stats) } + fn rename_workspace(&mut self, new_workspace_id: WorkspaceId) { + self.new_workspace_id = Some(new_workspace_id); + } + fn reset(&mut self, commit: &Commit) -> Result<(), ResetError> { let new_tree = commit.tree()?; self.wc @@ -1937,7 +1943,10 @@ impl LockedWorkingCopy for LockedLocalWorkingCopy { err: Box::new(err), })?; } - if self.old_operation_id != operation_id { + if self.old_operation_id != operation_id || self.new_workspace_id.is_some() { + if let Some(new_workspace_id) = self.new_workspace_id { + self.wc.checkout_state_mut().workspace_id = new_workspace_id; + } self.wc.checkout_state_mut().operation_id = operation_id; self.wc.save(); } diff --git a/lib/src/repo.rs b/lib/src/repo.rs index 312c8afcff..10da444d6d 100644 --- a/lib/src/repo.rs +++ b/lib/src/repo.rs @@ -92,6 +92,7 @@ use crate::simple_op_store::SimpleOpStore; use crate::store::Store; use crate::submodule_store::SubmoduleStore; use crate::transaction::Transaction; +use crate::view::RenameWorkspaceError; use crate::view::View; pub trait Repo { @@ -1353,6 +1354,15 @@ impl MutableRepo { Ok(()) } + pub fn rename_workspace( + &mut self, + old_workspace_id: &WorkspaceId, + new_workspace_id: WorkspaceId, + ) -> Result<(), RenameWorkspaceError> { + self.view_mut() + .rename_workspace(old_workspace_id, new_workspace_id) + } + pub fn check_out( &mut self, workspace_id: WorkspaceId, diff --git a/lib/src/view.rs b/lib/src/view.rs index ace5e378cd..63b29cbdbd 100644 --- a/lib/src/view.rs +++ b/lib/src/view.rs @@ -19,6 +19,7 @@ use std::collections::HashMap; use std::collections::HashSet; use itertools::Itertools; +use thiserror::Error; use crate::backend::CommitId; use crate::op_store; @@ -95,6 +96,29 @@ impl View { self.data.wc_commit_ids.remove(workspace_id); } + pub fn rename_workspace( + &mut self, + old_workspace_id: &WorkspaceId, + new_workspace_id: WorkspaceId, + ) -> Result<(), RenameWorkspaceError> { + if self.data.wc_commit_ids.contains_key(&new_workspace_id) { + return Err(RenameWorkspaceError::WorkspaceAlreadyExists { + workspace_id: new_workspace_id.as_str().to_owned(), + }); + } + let wc_commit_id = self + .data + .wc_commit_ids + .remove(old_workspace_id) + .ok_or_else(|| RenameWorkspaceError::WorkspaceDoesNotExist { + workspace_id: old_workspace_id.as_str().to_owned(), + })?; + self.data + .wc_commit_ids + .insert(new_workspace_id, wc_commit_id); + Ok(()) + } + pub fn add_head(&mut self, head_id: &CommitId) { self.data.head_ids.insert(head_id.clone()); } @@ -369,3 +393,13 @@ impl View { &mut self.data } } + +/// Error from attempts to rename a workspace +#[derive(Debug, Error)] +pub enum RenameWorkspaceError { + #[error("Workspace {workspace_id} not found")] + WorkspaceDoesNotExist { workspace_id: String }, + + #[error("Workspace {workspace_id} already exists")] + WorkspaceAlreadyExists { workspace_id: String }, +} diff --git a/lib/src/working_copy.rs b/lib/src/working_copy.rs index f038e884c8..cdd65247ff 100644 --- a/lib/src/working_copy.rs +++ b/lib/src/working_copy.rs @@ -107,6 +107,9 @@ pub trait LockedWorkingCopy { /// Check out the specified commit in the working copy. fn check_out(&mut self, commit: &Commit) -> Result; + /// Update the workspace name. + fn rename_workspace(&mut self, new_workspace_name: WorkspaceId); + /// Update to another commit without touching the files in the working copy. fn reset(&mut self, commit: &Commit) -> Result<(), ResetError>;