From 6b4cec1b4961dbff60ee5d2e33cacc6894587d50 Mon Sep 17 00:00:00 2001 From: Jonathan Tan Date: Fri, 2 Feb 2024 21:26:23 -0800 Subject: [PATCH] workspace: recover from missing operation If the operation corresponding to a workspace is missing for some reason (the specific situation in the test in this commit is that an operation was abandoned and garbage-collected from another workspace), currently, jj fails with a 255 error code. Teach jj a way to recover from this situation. When jj detects such a situation, it prints a message and stops operation, similar to when a workspace is stale. The message tells the user what command to run. When that command is run, jj loads the repo at the @ operation (instead of the operation of the workspace), creates a new commit on the @ commit with an empty tree, and then proceeds as usual - in particular, including the auto-snapshotting of the working tree, which creates another commit that obsoletes the newly created commit. There are several design points I considered. 1) Whether the recovery should be automatic, or (as in this commit) manual in that the user should be prompted to run a command. The user might prefer to recover in another way (e.g. by simply deleting the workspace) and this situation is (hopefully) rare enough that I think it's better to prompt the user. 2) Which command the user should be prompted to run (and thus, which command should be taught to perform the recovery). I chose "workspace update-stale" because the circumstances are very similar to it: it's symptom is that the regular jj operation is blocked somewhere at the beginning, and "workspace update-stale" already does some special work before the blockage (this commit adds more of such special work). But it might be better for something more explicitly named, or even a sequence of commands (e.g. "create a new operation that becomes @ that no workspace points to", "low-level command that makes a workspace point to the operation @") but I can see how this can be unnecessarily confusing for the user. 3) How we recover. I can think of several ways: a) Always create a commit, and allow the automatic snapshotting to create another commit that obsoletes this commit. b) Create a commit but somehow teach the automatic snapshotting to replace the created commit in-place (so it has no predecessor, as viewed in "obslog"). c) Do either a) or b), with the added improvement that if there is no diff between the newly created commit and the former @, to behave as if no new commit was created (@ remains as the former @). I chose a) since it was the simplest and most easily reasoned about, which I think is the best way to go when recovering from a rare situation. --- cli/examples/custom-working-copy/main.rs | 4 + cli/src/cli_util.rs | 19 +++- cli/src/commands/workspace.rs | 114 ++++++++++++++++++----- cli/tests/test_workspaces.rs | 92 ++++++++++++++++++ lib/src/local_working_copy.rs | 21 +++++ lib/src/working_copy.rs | 3 + 6 files changed, 225 insertions(+), 28 deletions(-) diff --git a/cli/examples/custom-working-copy/main.rs b/cli/examples/custom-working-copy/main.rs index ba19f14ac5..4f3ef0098c 100644 --- a/cli/examples/custom-working-copy/main.rs +++ b/cli/examples/custom-working-copy/main.rs @@ -240,6 +240,10 @@ impl LockedWorkingCopy for LockedConflictsWorkingCopy { self.inner.reset(commit) } + fn reset_to_empty(&mut self) -> Result<(), ResetError> { + self.inner.reset_to_empty() + } + fn sparse_patterns(&self) -> Result<&[RepoPathBuf], WorkingCopyStateError> { self.inner.sparse_patterns() } diff --git a/cli/src/cli_util.rs b/cli/src/cli_util.rs index e6ea276511..3fe91854a4 100644 --- a/cli/src/cli_util.rs +++ b/cli/src/cli_util.rs @@ -1423,7 +1423,17 @@ Set which revision the branch points to with `jj branch set {branch_name} -r user_error_with_hint( + "Could not read working copy's operation.", + "Run `jj workspace update-stale` to recover. +See https://github.com/martinvonz/jj/blob/main/docs/working-copy.md#stale-working-copy \ + for more information.", + ), + e => e.into(), + }, + )? { WorkingCopyFreshness::Fresh => (repo, wc_commit), WorkingCopyFreshness::Updated(wc_operation) => { let repo = repo.reload_at(&wc_operation)?; @@ -1953,9 +1963,10 @@ pub fn check_stale_working_copy( // The working copy isn't stale, and no need to reload the repo. Ok(WorkingCopyFreshness::Fresh) } else { - let wc_operation_data = repo - .op_store() - .read_operation(locked_wc.old_operation_id())?; + let wc_operation_data = match repo.op_store().read_operation(locked_wc.old_operation_id()) { + Ok(o) => o, + Err(e) => return Err(e), + }; let wc_operation = Operation::new( repo.op_store().clone(), locked_wc.old_operation_id().clone(), diff --git a/cli/src/commands/workspace.rs b/cli/src/commands/workspace.rs index 6950665914..15db965c73 100644 --- a/cli/src/commands/workspace.rs +++ b/cli/src/commands/workspace.rs @@ -15,21 +15,23 @@ use std::fmt::Debug; use std::fs; use std::io::Write; +use std::sync::Arc; use clap::Subcommand; use itertools::Itertools; use jj_lib::file_util; use jj_lib::object_id::ObjectId; -use jj_lib::op_store::WorkspaceId; +use jj_lib::op_store::{OpStoreError, WorkspaceId}; use jj_lib::operation::Operation; -use jj_lib::repo::Repo; +use jj_lib::repo::{ReadonlyRepo, Repo}; use jj_lib::rewrite::merge_commit_trees; use jj_lib::workspace::Workspace; use tracing::instrument; use crate::cli_util::{ - self, check_stale_working_copy, internal_error_with_message, print_checkout_stats, user_error, CommandError, CommandHelper, - RevisionArg, WorkingCopyFreshness, WorkspaceCommandHelper, + self, check_stale_working_copy, internal_error_with_message, print_checkout_stats, + short_commit_hash, user_error, CommandError, CommandHelper, RevisionArg, WorkingCopyFreshness, + WorkspaceCommandHelper, }; use crate::ui::Ui; @@ -280,20 +282,74 @@ fn cmd_workspace_root( Ok(()) } +fn create_and_check_out_recovery_commit( + command: &CommandHelper, + ui: &mut Ui, + workspace: Workspace, +) -> Result, CommandError> { + let operation = command.resolve_operation(ui, workspace.repo_loader())?; + let repo = workspace.repo_loader().load_at(&operation)?; + let workspace_id = workspace.workspace_id().clone(); + let tree_id = repo.store().empty_merged_tree_id(); + + let mut workspace_command = command.for_loaded_repo(ui, workspace, repo.clone())?; + let (mut locked_workspace, commit) = + workspace_command.unchecked_start_working_copy_mutation()?; + let commit_id = commit.id(); + + let mut tx = repo.start_transaction(command.settings()); + let mut_repo = tx.mut_repo(); + let new_commit = mut_repo + .new_commit(command.settings(), vec![commit_id.clone()], tree_id.clone()) + .write()?; + mut_repo.set_wc_commit(workspace_id, new_commit.id().clone())?; + let ret = Ok(tx.commit("recovery commit")); + + locked_workspace.locked_wc().reset_to_empty()?; + locked_workspace.finish(operation.id().clone())?; + + writeln!( + ui.stderr(), + "Created and checked out recovery commit {}", + short_commit_hash(new_commit.id()) + )?; + + ret +} + /// Loads workspace that will diverge from the last working-copy operation. fn for_stale_working_copy( ui: &mut Ui, command: &CommandHelper, -) -> Result { +) -> Result<(WorkspaceCommandHelper, bool), CommandError> { let workspace = command.load_workspace()?; let op_store = workspace.repo_loader().op_store(); - let op_id = workspace.working_copy().operation_id(); - let op_data = op_store - .read_operation(op_id) - .map_err(|e| internal_error_with_message("Failed to read operation", e))?; - let operation = Operation::new(op_store.clone(), op_id.clone(), op_data); - let repo = workspace.repo_loader().load_at(&operation)?; - command.for_loaded_repo(ui, workspace, repo) + let (repo, recovered) = { + let op_id = workspace.working_copy().operation_id(); + match op_store.read_operation(op_id) { + Ok(op_data) => ( + workspace.repo_loader().load_at(&Operation::new( + op_store.clone(), + op_id.clone(), + op_data, + ))?, + false, + ), + Err(e @ OpStoreError::ObjectNotFound { .. }) => { + writeln!( + ui.stderr(), + "Failed to read working copy's current operation; attempting recovery. Error \ + message from read attempt: {e}" + )?; + ( + create_and_check_out_recovery_commit(command, ui, command.load_workspace()?)?, + true, + ) + } + Err(e) => return Err(e.into()), + } + }; + Ok((command.for_loaded_repo(ui, workspace, repo)?, recovered)) } #[instrument(skip_all)] @@ -307,8 +363,16 @@ fn cmd_workspace_update_stale( // merged repo wouldn't change because the old one wins, but it's probably // fine if we picked the new wc_commit_id. let known_wc_commit = { - let mut workspace_command = for_stale_working_copy(ui, command)?; + let (mut workspace_command, recovered) = for_stale_working_copy(ui, command)?; workspace_command.maybe_snapshot(ui)?; + + if recovered { + // We have already recovered from the situation that prompted the user to run + // this command, and it is known that the workspace is not stale + // (since we just updated it), so we can return early. + return Ok(()); + } + let wc_commit_id = workspace_command.get_wc_commit_id().unwrap(); workspace_command.repo().store().get_commit(wc_commit_id)? }; @@ -318,10 +382,12 @@ fn cmd_workspace_update_stale( let (mut locked_ws, desired_wc_commit) = workspace_command.unchecked_start_working_copy_mutation()?; match check_stale_working_copy(locked_ws.locked_wc(), &desired_wc_commit, &repo)? { - WorkingCopyFreshness::Fresh | WorkingCopyFreshness::Updated(_) => writeln!( - ui.stderr(), - "Nothing to do (the working copy is not stale)." - )?, + WorkingCopyFreshness::Fresh | WorkingCopyFreshness::Updated(_) => { + writeln!( + ui.stderr(), + "Nothing to do (the working copy is not stale)." + )?; + } WorkingCopyFreshness::WorkingCopyStale | WorkingCopyFreshness::SiblingOperation => { // The same check as start_working_copy_mutation(), but with the stale // working-copy commit. @@ -332,13 +398,13 @@ fn cmd_workspace_update_stale( .locked_wc() .check_out(&desired_wc_commit) .map_err(|err| { - internal_error_with_message( - format!( - "Failed to check out commit {}", - desired_wc_commit.id().hex() - ), - err, - ) + internal_error_with_message( + format!( + "Failed to check out commit {}", + desired_wc_commit.id().hex() + ), + err, + ) })?; locked_ws.finish(repo.op_id().clone())?; write!(ui.stderr(), "Working copy now at: ")?; diff --git a/cli/tests/test_workspaces.rs b/cli/tests/test_workspaces.rs index d4fc312703..45ae5379c0 100644 --- a/cli/tests/test_workspaces.rs +++ b/cli/tests/test_workspaces.rs @@ -382,6 +382,98 @@ fn test_workspaces_updated_by_other() { "###); } +#[test] +fn test_workspaces_current_op_discarded_by_other() { + let test_env = TestEnvironment::default(); + // Use the local backend because GitBackend::gc() depends on the git CLI. + test_env.jj_cmd_ok( + test_env.env_root(), + &["init", "main", "--config-toml=ui.allow-init-native=true"], + ); + let main_path = test_env.env_root().join("main"); + let secondary_path = test_env.env_root().join("secondary"); + + std::fs::write(main_path.join("file"), "contents\n").unwrap(); + test_env.jj_cmd_ok(&main_path, &["new"]); + + test_env.jj_cmd_ok(&main_path, &["workspace", "add", "../secondary"]); + + // Create an op by abandoning the parent commit. Importantly, that commit also + // changes the target tree in the secondary workspace. + test_env.jj_cmd_ok(&main_path, &["abandon", "@-"]); + + let stdout = test_env.jj_cmd_success( + &main_path, + &[ + "operation", + "log", + "--template", + r#"id.short(10) ++ " " ++ description"#, + ], + ); + insta::assert_snapshot!(stdout, @r###" + @ 6c22dbc43c abandon commit 479653b3216ad2af6a8ffcbe5885203b7bb3d0d49175ffd2be58a454da95493b0e8e44885ac4f7caacbeb79b407c66d68991f5a01666e976687c6732b3311780 + ◉ 72b8975c75 Create initial working-copy commit in workspace secondary + ◉ b816f120a4 add workspace 'secondary' + ◉ 332e901b2a new empty commit + ◉ c07b7d7796 snapshot working copy + ◉ c5295044c8 add workspace 'default' + ◉ 0c73edb541 initialize repo + ◉ 0000000000 + "###); + + // Abandon ops, including the one the secondary workspace is currently on. + test_env.jj_cmd_ok(&main_path, &["operation", "abandon", "..@-"]); + test_env.jj_cmd_ok(&main_path, &["util", "gc", "--expire=now"]); + + insta::assert_snapshot!(get_log_output(&test_env, &main_path), @r###" + @ 3bebcd0f8335f735c00063290247d6c3332106261d0dd4810105906cdf29878990ee717690750ec203a7c4763a36968a0d9de799f46bc37fcf62dde13a52b73f default@ + │ ◉ 1d558d136f4d90ec2fe9beac8eb638eb509d0e378c4546f7b2634f93d0c86cfdd08b7efe4fd3aeb383f41fb7ebb2311a14754f617e71389c3aa9519c05c17dd8 secondary@ + ├─╯ + ◉ 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 + "###); + + let stderr = test_env.jj_cmd_failure(&secondary_path, &["st"]); + insta::assert_snapshot!(stderr, @r###" + Error: Could not read working copy's operation. + Hint: Run `jj workspace update-stale` to recover. + See https://github.com/martinvonz/jj/blob/main/docs/working-copy.md#stale-working-copy for more information. + "###); + + let (stdout, stderr) = test_env.jj_cmd_ok(&secondary_path, &["workspace", "update-stale"]); + insta::assert_snapshot!(stderr, @r###" + Failed to read working copy's current operation; attempting recovery. Error message from read attempt: Object 72b8975c7573303a258c292ad69f93dad1922532a2839000c217102da736ed7c6235872bdfa8c747a723df78547666686722d0d301da2d70d815798634c8eedc of type operation not found + Created and checked out recovery commit 84e67dd875e0 + "###); + insta::assert_snapshot!(stdout, @""); + + insta::assert_snapshot!(get_log_output(&test_env, &main_path), @r###" + ◉ be7e513bfb77cde552e363f947ecc5757557d0d24caf5c627eb9631f38215334722c31b4266c3627111210b1c5eb00f4cd7af4299510cb8c2fe39b405d31b45b secondary@ + ◉ 1d558d136f4d90ec2fe9beac8eb638eb509d0e378c4546f7b2634f93d0c86cfdd08b7efe4fd3aeb383f41fb7ebb2311a14754f617e71389c3aa9519c05c17dd8 + │ @ 3bebcd0f8335f735c00063290247d6c3332106261d0dd4810105906cdf29878990ee717690750ec203a7c4763a36968a0d9de799f46bc37fcf62dde13a52b73f default@ + ├─╯ + ◉ 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 + "###); + + let (stdout, stderr) = test_env.jj_cmd_ok(&secondary_path, &["st"]); + insta::assert_snapshot!(stderr, @""); + insta::assert_snapshot!(stdout, @r###" + Working copy changes: + A file + Working copy : znkkpsqq be7e513b (no description set) + Parent commit: pmmvwywv 1d558d13 (empty) (no description set) + "###); + + let (stdout, stderr) = test_env.jj_cmd_ok(&secondary_path, &["obslog"]); + insta::assert_snapshot!(stderr, @""); + insta::assert_snapshot!(stdout, @r###" + @ znkkpsqq test.user@example.com 2001-02-03 04:05:16.000 +07:00 secondary@ be7e513b + │ (no description set) + ◉ znkkpsqq hidden test.user@example.com 2001-02-03 04:05:16.000 +07:00 84e67dd8 + (empty) (no description set) + "###); +} + #[test] fn test_workspaces_update_stale_noop() { let test_env = TestEnvironment::default(); diff --git a/lib/src/local_working_copy.rs b/lib/src/local_working_copy.rs index 9914cfa864..4121f0e374 100644 --- a/lib/src/local_working_copy.rs +++ b/lib/src/local_working_copy.rs @@ -197,6 +197,10 @@ impl FileStatesMap { .collect(); } + fn clear(&mut self) { + self.data.clear(); + } + /// Returns read-only map containing all file states. fn all(&self) -> FileStates<'_> { FileStates::from_sorted(&self.data) @@ -1436,6 +1440,11 @@ impl TreeState { self.tree_id = new_tree.id(); Ok(()) } + + pub fn reset_to_empty(&mut self) { + self.file_states.clear(); + self.tree_id = self.store.empty_merged_tree_id(); + } } fn checkout_error_for_stat_error(err: std::io::Error, path: &Path) -> CheckoutError { @@ -1753,6 +1762,18 @@ impl LockedWorkingCopy for LockedLocalWorkingCopy { Ok(()) } + fn reset_to_empty(&mut self) -> Result<(), ResetError> { + self.wc + .tree_state_mut() + .map_err(|err| ResetError::Other { + message: "Failed to read the working copy state".to_string(), + err: err.into(), + })? + .reset_to_empty(); + self.tree_state_dirty = true; + Ok(()) + } + fn sparse_patterns(&self) -> Result<&[RepoPathBuf], WorkingCopyStateError> { self.wc.sparse_patterns() } diff --git a/lib/src/working_copy.rs b/lib/src/working_copy.rs index cb0c97fdef..2eb12384ed 100644 --- a/lib/src/working_copy.rs +++ b/lib/src/working_copy.rs @@ -107,6 +107,9 @@ pub trait LockedWorkingCopy { /// Update to another commit without touching the files in the working copy. fn reset(&mut self, commit: &Commit) -> Result<(), ResetError>; + /// Update to the empty tree without touching the files in the working copy. + fn reset_to_empty(&mut self) -> Result<(), ResetError>; + /// See `WorkingCopy::sparse_patterns()` fn sparse_patterns(&self) -> Result<&[RepoPathBuf], WorkingCopyStateError>;