cli_util: enable automatic update of stale workspaces if config is set

This significantly reduces toil for multi-workspace users, resolving issue #3820
This commit is contained in:
dploch 2024-11-08 14:01:31 -05:00 committed by Daniel Ploch
parent 2c54848e63
commit 49890fa2d9
6 changed files with 331 additions and 115 deletions

View file

@ -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

View file

@ -372,7 +372,49 @@ impl CommandHelper {
#[instrument(skip(self, ui))]
pub fn workspace_helper(&self, ui: &Ui) -> Result<WorkspaceCommandHelper, CommandError> {
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<E>(err: E) -> SnapshotWorkingCopyError
where
E: Into<CommandError>,
{
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<Option<_>, _> {
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(())
}

View file

@ -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)?;

View file

@ -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",

View file

@ -30,3 +30,4 @@ edit = false
[snapshot]
max-new-file-size = "1MiB"
auto-track = "all()"
auto-update-stale = false

View file

@ -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
"###);
insta::allow_duplicates! {
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.
"###);
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, &["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, @"");
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.
"###);
insta::assert_snapshot!(get_log_output(&test_env, &main_path), @r###"
15df8cb57d3f secondary@
96b31dafdc41
@ 6c051bd1ccd5 default@
7c5b25a4fc8f
000000000000
"###);
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]