diff --git a/CHANGELOG.md b/CHANGELOG.md index 880d71a306..029569e05d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. +* Added the config setting `snapshot.auto-update-stale` for automatically + running `jj workspace update-stale` when applicable. + ### Fixed bugs ## [0.23.0] - 2024-11-06 diff --git a/cli/src/cli_util.rs b/cli/src/cli_util.rs index cc64d81e89..765de113f3 100644 --- a/cli/src/cli_util.rs +++ b/cli/src/cli_util.rs @@ -372,7 +372,49 @@ impl CommandHelper { #[instrument(skip(self, ui))] pub fn workspace_helper(&self, ui: &Ui) -> Result { let mut workspace_command = self.workspace_helper_no_snapshot(ui)?; - workspace_command.maybe_snapshot(ui)?; + + let workspace_command = match workspace_command.maybe_snapshot_impl(ui) { + Ok(()) => workspace_command, + Err(SnapshotWorkingCopyError::Command(err)) => return Err(err), + Err(SnapshotWorkingCopyError::StaleWorkingCopy(err)) => { + let auto_update_stale = self + .settings() + .config() + .get_bool("snapshot.auto-update-stale")?; + if !auto_update_stale { + return Err(err); + } + + // We detected the working copy was stale and the client is configured to + // auto-update-stale, so let's do that now. We need to do it up here, not at a + // lower level (e.g. inside snapshot_working_copy()) to avoid recursive locking + // of the working copy. + match self.load_stale_working_copy_commit(ui)? { + StaleWorkingCopy::Recovered(workspace_command) => workspace_command, + StaleWorkingCopy::Snapshotted((repo, stale_commit)) => { + let mut workspace_command = self.workspace_helper_no_snapshot(ui)?; + let (locked_ws, new_commit) = + workspace_command.unchecked_start_working_copy_mutation()?; + let stats = update_stale_working_copy( + locked_ws, + repo.op_id().clone(), + &stale_commit, + &new_commit, + )?; + writeln!( + ui.warning_default(), + "Automatically updated to fresh commit {}", + short_commit_hash(stale_commit.id()) + )?; + workspace_command.write_stale_commit_stats(ui, &new_commit, stats)?; + + workspace_command.user_repo = ReadonlyUserRepo::new(repo); + workspace_command + } + } + } + }; + Ok(workspace_command) } @@ -854,6 +896,27 @@ pub struct WorkspaceCommandHelper { working_copy_shared_with_git: bool, } +enum SnapshotWorkingCopyError { + Command(CommandError), + StaleWorkingCopy(CommandError), +} + +impl SnapshotWorkingCopyError { + fn into_command_error(self) -> CommandError { + match self { + Self::Command(err) => err, + Self::StaleWorkingCopy(err) => err, + } + } +} + +fn snapshot_command_error(err: E) -> SnapshotWorkingCopyError +where + E: Into, +{ + SnapshotWorkingCopyError::Command(err.into()) +} + impl WorkspaceCommandHelper { #[instrument(skip_all)] fn new( @@ -911,27 +974,34 @@ impl WorkspaceCommandHelper { } } - /// Snapshot the working copy if allowed, and import Git refs if the working - /// copy is collocated with Git. #[instrument(skip_all)] - pub fn maybe_snapshot(&mut self, ui: &Ui) -> Result<(), CommandError> { + fn maybe_snapshot_impl(&mut self, ui: &Ui) -> Result<(), SnapshotWorkingCopyError> { if self.may_update_working_copy { if self.working_copy_shared_with_git { - self.import_git_head(ui)?; + self.import_git_head(ui).map_err(snapshot_command_error)?; } // Because the Git refs (except HEAD) aren't imported yet, the ref // pointing to the new working-copy commit might not be exported. // In that situation, the ref would be conflicted anyway, so export // failure is okay. self.snapshot_working_copy(ui)?; + // import_git_refs() can rebase the working-copy commit. if self.working_copy_shared_with_git { - self.import_git_refs(ui)?; + self.import_git_refs(ui).map_err(snapshot_command_error)?; } } Ok(()) } + /// Snapshot the working copy if allowed, and import Git refs if the working + /// copy is collocated with Git. + #[instrument(skip_all)] + pub fn maybe_snapshot(&mut self, ui: &Ui) -> Result<(), CommandError> { + self.maybe_snapshot_impl(ui) + .map_err(|err| err.into_command_error()) + } + /// Imports new HEAD from the colocated Git repo. /// /// If the Git HEAD has changed, this function checks out the new Git HEAD. @@ -1072,7 +1142,7 @@ impl WorkspaceCommandHelper { Ok((locked_ws, wc_commit)) } - pub fn create_and_check_out_recovery_commit(&mut self, ui: &Ui) -> Result<(), CommandError> { + fn create_and_check_out_recovery_commit(&mut self, ui: &Ui) -> Result<(), CommandError> { self.check_working_copy_writable()?; let workspace_id = self.workspace_id().clone(); @@ -1098,10 +1168,9 @@ to the current parents may contain changes from multiple commits. short_commit_hash(new_commit.id()) )?; locked_ws.finish(repo.op_id().clone())?; - self.user_repo = ReadonlyUserRepo::new(repo); - self.maybe_snapshot(ui)?; - Ok(()) + + self.maybe_snapshot(ui) } pub fn workspace_root(&self) -> &Path { @@ -1620,13 +1689,14 @@ to the current parents may contain changes from multiple commits. } #[instrument(skip_all)] - fn snapshot_working_copy(&mut self, ui: &Ui) -> Result<(), CommandError> { + fn snapshot_working_copy(&mut self, ui: &Ui) -> Result<(), SnapshotWorkingCopyError> { let workspace_id = self.workspace_id().to_owned(); let get_wc_commit = |repo: &ReadonlyRepo| -> Result, _> { repo.view() .get_wc_commit_id(&workspace_id) .map(|id| repo.store().get_commit(id)) .transpose() + .map_err(snapshot_command_error) }; let repo = self.repo().clone(); let Some(wc_commit) = get_wc_commit(&repo)? else { @@ -1634,20 +1704,34 @@ to the current parents may contain changes from multiple commits. // committing the working copy. return Ok(()); }; - let base_ignores = self.base_ignores()?; - let auto_tracking_matcher = self.auto_tracking_matcher(ui)?; + let base_ignores = self.base_ignores().map_err(snapshot_command_error)?; + let auto_tracking_matcher = self + .auto_tracking_matcher(ui) + .map_err(snapshot_command_error)?; // Compare working-copy tree and operation with repo's, and reload as needed. - let fsmonitor_settings = self.settings().fsmonitor_settings()?; - let max_new_file_size = self.settings().max_new_file_size()?; + let fsmonitor_settings = self + .settings() + .fsmonitor_settings() + .map_err(snapshot_command_error)?; + let max_new_file_size = self + .settings() + .max_new_file_size() + .map_err(snapshot_command_error)?; let command = self.env.command.clone(); - let mut locked_ws = self.workspace.start_working_copy_mutation()?; + let mut locked_ws = self + .workspace + .start_working_copy_mutation() + .map_err(snapshot_command_error)?; let old_op_id = locked_ws.locked_wc().old_operation_id().clone(); + let (repo, wc_commit) = match WorkingCopyFreshness::check_stale(locked_ws.locked_wc(), &wc_commit, &repo) { Ok(WorkingCopyFreshness::Fresh) => (repo, wc_commit), Ok(WorkingCopyFreshness::Updated(wc_operation)) => { - let repo = repo.reload_at(&wc_operation)?; + let repo = repo + .reload_at(&wc_operation) + .map_err(snapshot_command_error)?; let wc_commit = if let Some(wc_commit) = get_wc_commit(&repo)? { wc_commit } else { @@ -1657,43 +1741,52 @@ to the current parents may contain changes from multiple commits. (repo, wc_commit) } Ok(WorkingCopyFreshness::WorkingCopyStale) => { - return Err(user_error_with_hint( - format!( - "The working copy is stale (not updated since operation {}).", - short_operation_hash(&old_op_id) - ), - "Run `jj workspace update-stale` to update it. + return Err(SnapshotWorkingCopyError::StaleWorkingCopy( + user_error_with_hint( + format!( + "The working copy is stale (not updated since operation {}).", + short_operation_hash(&old_op_id) + ), + "Run `jj workspace update-stale` to update it. See https://martinvonz.github.io/jj/latest/working-copy/#stale-working-copy \ - for more information.", + for more information.", + ), )); } Ok(WorkingCopyFreshness::SiblingOperation) => { - return Err(internal_error(format!( - "The repo was loaded at operation {}, which seems to be a sibling of the \ - working copy's operation {}", - short_operation_hash(repo.op_id()), - short_operation_hash(&old_op_id) + return Err(SnapshotWorkingCopyError::StaleWorkingCopy(internal_error( + format!( + "The repo was loaded at operation {}, which seems to be a sibling of \ + the working copy's operation {}", + short_operation_hash(repo.op_id()), + short_operation_hash(&old_op_id) + ), ))); } Err(OpStoreError::ObjectNotFound { .. }) => { - return Err(user_error_with_hint( - "Could not read working copy's operation.", - "Run `jj workspace update-stale` to recover. + return Err(SnapshotWorkingCopyError::StaleWorkingCopy( + user_error_with_hint( + "Could not read working copy's operation.", + "Run `jj workspace update-stale` to recover. See https://martinvonz.github.io/jj/latest/working-copy/#stale-working-copy \ - for more information.", + for more information.", + ), )); } - Err(e) => return Err(e.into()), + Err(e) => return Err(snapshot_command_error(e)), }; self.user_repo = ReadonlyUserRepo::new(repo); let progress = crate::progress::snapshot_progress(ui); - let new_tree_id = locked_ws.locked_wc().snapshot(&SnapshotOptions { - base_ignores, - fsmonitor_settings, - progress: progress.as_ref().map(|x| x as _), - start_tracking_matcher: &auto_tracking_matcher, - max_new_file_size, - })?; + let new_tree_id = locked_ws + .locked_wc() + .snapshot(&SnapshotOptions { + base_ignores, + fsmonitor_settings, + progress: progress.as_ref().map(|x| x as _), + start_tracking_matcher: &auto_tracking_matcher, + max_new_file_size, + }) + .map_err(snapshot_command_error)?; drop(progress); if new_tree_id != *wc_commit.tree_id() { let mut tx = start_repo_transaction( @@ -1706,26 +1799,37 @@ See https://martinvonz.github.io/jj/latest/working-copy/#stale-working-copy \ let commit = mut_repo .rewrite_commit(command.settings(), &wc_commit) .set_tree_id(new_tree_id) - .write()?; - mut_repo.set_wc_commit(workspace_id, commit.id().clone())?; + .write() + .map_err(snapshot_command_error)?; + mut_repo + .set_wc_commit(workspace_id, commit.id().clone()) + .map_err(snapshot_command_error)?; // Rebase descendants - let num_rebased = mut_repo.rebase_descendants(command.settings())?; + let num_rebased = mut_repo + .rebase_descendants(command.settings()) + .map_err(snapshot_command_error)?; if num_rebased > 0 { writeln!( ui.status(), "Rebased {num_rebased} descendant commits onto updated working copy" - )?; + ) + .map_err(snapshot_command_error)?; } if self.working_copy_shared_with_git { - let refs = git::export_refs(mut_repo)?; - print_failed_git_export(ui, &refs)?; + let refs = git::export_refs(mut_repo).map_err(snapshot_command_error)?; + print_failed_git_export(ui, &refs).map_err(snapshot_command_error)?; } - self.user_repo = ReadonlyUserRepo::new(tx.commit("snapshot working copy")?); + let repo = tx + .commit("snapshot working copy") + .map_err(snapshot_command_error)?; + self.user_repo = ReadonlyUserRepo::new(repo); } - locked_ws.finish(self.user_repo.repo.op_id().clone())?; + locked_ws + .finish(self.user_repo.repo.op_id().clone()) + .map_err(snapshot_command_error)?; Ok(()) } diff --git a/cli/src/commands/workspace/update_stale.rs b/cli/src/commands/workspace/update_stale.rs index da52f5f964..6db2b24466 100644 --- a/cli/src/commands/workspace/update_stale.rs +++ b/cli/src/commands/workspace/update_stale.rs @@ -45,7 +45,7 @@ pub fn cmd_workspace_update_stale( // (since we just updated it), so we can return early. return Ok(()); } - StaleWorkingCopy::Snapshotted((_workspace_command, commit)) => commit, + StaleWorkingCopy::Snapshotted((_repo, commit)) => commit, }; let mut workspace_command = command.workspace_helper_no_snapshot(ui)?; diff --git a/cli/src/config-schema.json b/cli/src/config-schema.json index 0901ef2cd4..f5b3599929 100644 --- a/cli/src/config-schema.json +++ b/cli/src/config-schema.json @@ -457,6 +457,11 @@ "description": "Fileset pattern describing what new files to automatically track on snapshotting. By default all new files are tracked.", "default": "all()" }, + "auto-update-stale": { + "type": "boolean", + "description": "Whether to automatically update the working copy if it is stale. See https://martinvonz.github.io/jj/latest/working-copy/#stale-working-copy", + "default": "false" + }, "max-new-file-size": { "type": [ "integer", diff --git a/cli/src/config/misc.toml b/cli/src/config/misc.toml index aead72170b..d4f08353c5 100644 --- a/cli/src/config/misc.toml +++ b/cli/src/config/misc.toml @@ -30,3 +30,4 @@ edit = false [snapshot] max-new-file-size = "1MiB" auto-track = "all()" +auto-update-stale = false diff --git a/cli/tests/test_workspaces.rs b/cli/tests/test_workspaces.rs index 6b1db1f488..706bd4b590 100644 --- a/cli/tests/test_workspaces.rs +++ b/cli/tests/test_workspaces.rs @@ -14,6 +14,8 @@ use std::path::Path; +use test_case::test_case; + use crate::common::TestEnvironment; /// Test adding a second workspace @@ -609,9 +611,79 @@ fn test_workspaces_updated_by_other() { "###); } +/// Test a clean working copy that gets rewritten from another workspace #[test] -fn test_workspaces_current_op_discarded_by_other() { +fn test_workspaces_updated_by_other_automatic() { + let test_env = TestEnvironment::default(); + test_env.add_config("[snapshot]\nauto-update-stale = true\n"); + + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "main"]); + 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"]); + + insta::assert_snapshot!(get_log_output(&test_env, &main_path), @r###" + ○ 3224de8ae048 secondary@ + │ @ 06b57f44a3ca default@ + ├─╯ + ○ 506f4ec3c2c6 + ◆ 000000000000 + "###); + + // Rewrite the check-out commit in one workspace. + std::fs::write(main_path.join("file"), "changed in main\n").unwrap(); + let (stdout, stderr) = test_env.jj_cmd_ok(&main_path, &["squash"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Rebased 1 descendant commits + Working copy now at: mzvwutvl a58c9a9b (empty) (no description set) + Parent commit : qpvuntsm d4124476 (no description set) + "###); + + // The secondary workspace's working-copy commit was updated. + insta::assert_snapshot!(get_log_output(&test_env, &main_path), @r###" + @ a58c9a9b19ce default@ + │ ○ e82cd4ee8faa secondary@ + ├─╯ + ○ d41244767d45 + ◆ 000000000000 + "###); + + // The first working copy gets automatically updated. + let (stdout, stderr) = test_env.jj_cmd_ok(&secondary_path, &["st"]); + insta::assert_snapshot!(stdout, @r###" + The working copy is clean + Working copy : pmmvwywv 3224de8a (empty) (no description set) + Parent commit: qpvuntsm 506f4ec3 (no description set) + "###); + insta::assert_snapshot!(stderr, @r###" + Warning: Automatically updated to fresh commit 3224de8ae048 + Working copy now at: pmmvwywv e82cd4ee (empty) (no description set) + Added 0 files, modified 1 files, removed 0 files + "###); + + insta::assert_snapshot!(get_log_output(&test_env, &secondary_path), + @r###" + ○ a58c9a9b19ce default@ + │ @ e82cd4ee8faa secondary@ + ├─╯ + ○ d41244767d45 + ◆ 000000000000 + "###); +} + +#[test_case(false; "manual")] +#[test_case(true; "automatic")] +fn test_workspaces_current_op_discarded_by_other(automatic: bool) { let test_env = TestEnvironment::default(); + if automatic { + test_env.add_config("[snapshot]\nauto-update-stale = true\n"); + } + // Use the local backend because GitBackend::gc() depends on the git CLI. test_env.jj_cmd_ok( test_env.env_root(), @@ -657,84 +729,115 @@ fn test_workspaces_current_op_discarded_by_other() { r#"id.short(10) ++ " " ++ description"#, ], ); - insta::assert_snapshot!(stdout, @r#" - @ 757bc1140b abandon commit 20dd439c4bd12c6ad56c187ac490bd0141804618f638dc5c4dc92ff9aecba20f152b23160db9dcf61beb31a5cb14091d9def5a36d11c9599cc4d2e5689236af1 - ○ 8d4abed655 create initial working-copy commit in workspace secondary - ○ 3de27432e5 add workspace 'secondary' - ○ bcf69de808 new empty commit - ○ a36b99a15c snapshot working copy - ○ ddf023d319 new empty commit - ○ 829c93f6a3 snapshot working copy - ○ 2557266dd2 add workspace 'default' - ○ 0000000000 - "#); + insta::allow_duplicates! { + insta::assert_snapshot!(stdout, @r#" + @ 757bc1140b abandon commit 20dd439c4bd12c6ad56c187ac490bd0141804618f638dc5c4dc92ff9aecba20f152b23160db9dcf61beb31a5cb14091d9def5a36d11c9599cc4d2e5689236af1 + ○ 8d4abed655 create initial working-copy commit in workspace secondary + ○ 3de27432e5 add workspace 'secondary' + ○ bcf69de808 new empty commit + ○ a36b99a15c snapshot working copy + ○ ddf023d319 new empty commit + ○ 829c93f6a3 snapshot working copy + ○ 2557266dd2 add workspace 'default' + ○ 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###" - ○ 96b31dafdc41 secondary@ - │ @ 6c051bd1ccd5 default@ - ├─╯ - ○ 7c5b25a4fc8f - ◆ 000000000000 - "###); - - 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://martinvonz.github.io/jj/latest/working-copy/#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 8d4abed655badb70b1bab62aa87136619dbc3c8015a8ce8dfb7abfeca4e2f36c713d8f84e070a0613907a6cee7e1cc05323fe1205a319b93fe978f11a060c33c of type operation not found - Created and checked out recovery commit 76d0126b3e5c - "###); - insta::assert_snapshot!(stdout, @""); - - insta::assert_snapshot!(get_log_output(&test_env, &main_path), @r###" - ○ 15df8cb57d3f secondary@ - ○ 96b31dafdc41 - │ @ 6c051bd1ccd5 default@ - ├─╯ - ○ 7c5b25a4fc8f - ◆ 000000000000 - "###); + insta::allow_duplicates! { + insta::assert_snapshot!(get_log_output(&test_env, &main_path), @r###" + ○ 96b31dafdc41 secondary@ + │ @ 6c051bd1ccd5 default@ + ├─╯ + ○ 7c5b25a4fc8f + ◆ 000000000000 + "###); + } + + if automatic { + // Run a no-op command to set the randomness seed for commit hashes. + test_env.jj_cmd_success(&secondary_path, &["help"]); + + let (stdout, stderr) = test_env.jj_cmd_ok(&secondary_path, &["st"]); + insta::assert_snapshot!(stdout, @r###" + Working copy changes: + A added + D deleted + M modified + Working copy : kmkuslsw 15df8cb5 RECOVERY COMMIT FROM `jj workspace update-stale` + Parent commit: rzvqmyuk 96b31daf (empty) (no description set) + "###); + insta::assert_snapshot!(stderr, @r###" + Failed to read working copy's current operation; attempting recovery. Error message from read attempt: Object 8d4abed655badb70b1bab62aa87136619dbc3c8015a8ce8dfb7abfeca4e2f36c713d8f84e070a0613907a6cee7e1cc05323fe1205a319b93fe978f11a060c33c of type operation not found + Created and checked out recovery commit 76d0126b3e5c + "###); + } else { + 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://martinvonz.github.io/jj/latest/working-copy/#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 8d4abed655badb70b1bab62aa87136619dbc3c8015a8ce8dfb7abfeca4e2f36c713d8f84e070a0613907a6cee7e1cc05323fe1205a319b93fe978f11a060c33c of type operation not found + Created and checked out recovery commit 76d0126b3e5c + "###); + insta::assert_snapshot!(stdout, @""); + } + + insta::allow_duplicates! { + insta::assert_snapshot!(get_log_output(&test_env, &main_path), @r###" + ○ 15df8cb57d3f secondary@ + ○ 96b31dafdc41 + │ @ 6c051bd1ccd5 default@ + ├─╯ + ○ 7c5b25a4fc8f + ◆ 000000000000 + "###); + } // The sparse patterns should remain let stdout = test_env.jj_cmd_success(&secondary_path, &["sparse", "list"]); - insta::assert_snapshot!(stdout, @r###" - added - deleted - modified - "###); + insta::allow_duplicates! { + insta::assert_snapshot!(stdout, @r###" + added + deleted + modified + "###); + } let (stdout, stderr) = test_env.jj_cmd_ok(&secondary_path, &["st"]); - insta::assert_snapshot!(stderr, @""); - insta::assert_snapshot!(stdout, @r###" - Working copy changes: - A added - D deleted - M modified - Working copy : kmkuslsw 15df8cb5 RECOVERY COMMIT FROM `jj workspace update-stale` - Parent commit: rzvqmyuk 96b31daf (empty) (no description set) - "###); - // The modified file should have the same contents it had before (not reset to - // the base contents) - insta::assert_snapshot!(std::fs::read_to_string(secondary_path.join("modified")).unwrap(), @r###" - secondary - "###); + insta::allow_duplicates! { + insta::assert_snapshot!(stderr, @""); + insta::assert_snapshot!(stdout, @r###" + Working copy changes: + A added + D deleted + M modified + Working copy : kmkuslsw 15df8cb5 RECOVERY COMMIT FROM `jj workspace update-stale` + Parent commit: rzvqmyuk 96b31daf (empty) (no description set) + "###); + // The modified file should have the same contents it had before (not reset to + // the base contents) + insta::assert_snapshot!(std::fs::read_to_string(secondary_path.join("modified")).unwrap(), @r###" + secondary + "###); + } let (stdout, stderr) = test_env.jj_cmd_ok(&secondary_path, &["evolog"]); - insta::assert_snapshot!(stderr, @""); - insta::assert_snapshot!(stdout, @r###" - @ kmkuslsw test.user@example.com 2001-02-03 08:05:18 secondary@ 15df8cb5 - │ RECOVERY COMMIT FROM `jj workspace update-stale` - ○ kmkuslsw hidden test.user@example.com 2001-02-03 08:05:18 76d0126b - (empty) RECOVERY COMMIT FROM `jj workspace update-stale` - "###); + insta::allow_duplicates! { + insta::assert_snapshot!(stderr, @""); + insta::assert_snapshot!(stdout, @r###" + @ kmkuslsw test.user@example.com 2001-02-03 08:05:18 secondary@ 15df8cb5 + │ RECOVERY COMMIT FROM `jj workspace update-stale` + ○ kmkuslsw hidden test.user@example.com 2001-02-03 08:05:18 76d0126b + (empty) RECOVERY COMMIT FROM `jj workspace update-stale` + "###); + } } #[test]