feat: refresh diffs when selected jj bookmarks move
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
- inline scope labels with sticky continuation, chosen before implementation
|
||||
- each side uses its own syntax tree; scope changes within a hunk get separate labels
|
||||
- [x] diff view: annotate folder with checkmark if all items under given folder has been checked AND the folder is collapsed.
|
||||
- [ ] diff view: automatically detect changes and update the view when relevant bookmarks move change ids (regards bookmark-based diff view; i.e., user selected at least one bookmark in the before/after picker)
|
||||
- [x] diff view: automatically detect changes and update the view when relevant bookmarks move change ids (regards bookmark-based diff view; i.e., user selected at least one bookmark in the before/after picker)
|
||||
- [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc.
|
||||
- [ ] diff view: local review comments (same features as for github reviews) that allows review of local changes to deliver specific reviews to ai agents used for coding.
|
||||
- design question: would this require a server that the agent can interact with, or would local review files be fine? don't want to use server client stuff, if it would be easier and better to just use local files.
|
||||
|
||||
+3
-1
@@ -406,9 +406,11 @@ Conflicted jj revisions also discover files without reading every blob. Ordinary
|
||||
|
||||
**Working checkout After follows forward checkouts automatically**, checked asynchronously once per second, including inactive tabs. A descendant jj change or Git HEAD rebuilds the changed-file catalog and captures current working bytes plus eligible unsaved buffers, while keeping the original Before frozen. jj tracking uses change IDs, so snapshot/description rewrites of the same change do not advance the endpoint or prevent following its later descendants. Same, backward, and unrelated checkouts do not rebuild the catalog; live-buffer editing and external reload still work normally. Historical After never follows. Set `config.diff_follow_working_copy = false` to disable following.
|
||||
|
||||
**Selected jj bookmarks follow their locally known targets automatically**, checked on the same one-second interval, including inactive tabs and with `config.diff_follow_working_copy = false`. Local bookmarks and known remote-tracking bookmarks retain their names; explicit revision selections remain pinned. A changed commit ID, including an amendment of the same change ID, rebuilds the comparison. Branch changes also recomputes the merge base. Unrelated endpoints stay frozen, except for the existing Working After forward-follow behavior. Deleted or conflicted bookmarks keep the last successful comparison visible with an error; a later successful check clears the error. These checks never fetch, contact remotes, or snapshot jj's working copy; privately loaded remote history and advertised remote selections remain frozen.
|
||||
|
||||
**Pinned After editability tracks the checkout without moving either endpoint**, checked asynchronously once per second, including inactive tabs and when `config.diff_follow_working_copy = false`. Git matches the selected commit to HEAD; jj matches its change ID so snapshot/description rewrites of the same change remain editable. A matching After uses the shared live buffer. Switching away restores the original read-only After snapshot; switching back reuses the live buffer's unsaved edits and undo history. A failed checkout check revokes pinned After editing until a successful check restores eligibility.
|
||||
|
||||
Refresh retains the selected path when still present, explorer query/view/visibility, logical text-side focus, and split/unified/orientation/wrap choices; moving the Working endpoint clears review marks, while pinned checkout-only transitions retain them. Removed selections fall back to the first changed file. Failed refreshes keep the previous comparison visible. Superseded file loads and LSP navigation cannot apply to the new comparison; snapshots still displayed in other panes or tabs remain read-only.
|
||||
Refresh retains the selected path when still present, explorer query/view/visibility, logical text-side focus, and split/unified/orientation/wrap choices; moving a Working or bookmark endpoint clears review marks, while pinned checkout-only transitions retain them. Removed selections fall back to the first changed file. Bookmark replacements preserve/clamp cursor and scroll positions as replacement content loads. Failed refreshes keep the previous comparison visible. Superseded file loads and LSP navigation cannot apply to the new comparison; snapshots still displayed in other panes or tabs remain read-only.
|
||||
|
||||
The changed-file explorer is **docked on the right**. In the default split layout, modified files show Before on the left and After on the right. Added files show only After, using the full text workspace; deleted files show only Before. Rename-only files show one empty pane, with the old and new paths in the explorer; renames with text or final-newline changes retain both source sides. Before and unmatched pinned After snapshots are read-only. **Working checkout** or a checkout-matched pinned After binds eligible regular UTF-8 files to normal editor buffers, reusing already-open dirty buffers and their undo history. Editable After supports native edits, completion, undo/redo, and `:w`. Removed files, rename-only files, binary files, symlinks, and submodules remain read-only. Closing the diff keeps its buffers and unsaved edits.
|
||||
|
||||
|
||||
+42
-10
@@ -63,6 +63,10 @@ impl ComparisonMode {
|
||||
pub enum RevisionTarget {
|
||||
Working,
|
||||
Commit(String),
|
||||
Bookmark {
|
||||
name: String,
|
||||
remote: Option<String>,
|
||||
},
|
||||
Remote {
|
||||
remote: String,
|
||||
reference: String,
|
||||
@@ -178,6 +182,7 @@ pub enum Outcome {
|
||||
},
|
||||
Comparison(Comparison),
|
||||
CheckoutRefresh(Option<Comparison>),
|
||||
BookmarkRefresh(Comparison),
|
||||
File {
|
||||
comparison_id: u64,
|
||||
file_index: usize,
|
||||
@@ -299,6 +304,7 @@ pub struct DiffState {
|
||||
max_before: usize,
|
||||
max_after: usize,
|
||||
after_widths: Vec<usize>,
|
||||
pub(crate) refresh_view: Option<(usize, usize, usize)>,
|
||||
}
|
||||
|
||||
impl DiffState {
|
||||
@@ -355,6 +361,7 @@ impl DiffState {
|
||||
max_before: 0,
|
||||
max_after: 0,
|
||||
after_widths: Vec::new(),
|
||||
refresh_view: None,
|
||||
}
|
||||
}
|
||||
pub(crate) fn set_resolver_paths(&mut self, root: PathBuf, paths: Vec<PathBuf>, file: usize) {
|
||||
@@ -516,10 +523,15 @@ impl DiffState {
|
||||
|| self.request_kind.is_some()
|
||||
|| self.pending.is_some()
|
||||
|| self.comparison.is_none()
|
||||
|| self
|
||||
.after
|
||||
.as_ref()
|
||||
.is_none_or(|after| after.target == RevisionTarget::Working && !follow_working)
|
||||
|| self.after.as_ref().is_none_or(|after| {
|
||||
after.target == RevisionTarget::Working
|
||||
&& !follow_working
|
||||
&& !self
|
||||
.before
|
||||
.iter()
|
||||
.chain(self.after.iter())
|
||||
.any(|revision| matches!(revision.target, RevisionTarget::Bookmark { .. }))
|
||||
})
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -635,15 +647,19 @@ impl DiffState {
|
||||
}
|
||||
self.request_kind = None;
|
||||
let deferred = self.pending.take();
|
||||
let bookmark_refresh = matches!(&result, Ok(Outcome::BookmarkRefresh(_)));
|
||||
match result {
|
||||
Ok(Outcome::CheckoutRefresh(None)) => {
|
||||
let recovered = self.error.take().is_some();
|
||||
if let Some(operation) = deferred {
|
||||
self.queue(operation);
|
||||
}
|
||||
return false;
|
||||
return recovered;
|
||||
}
|
||||
Ok(Outcome::CheckoutRefresh(Some(mut comparison))) => {
|
||||
let pinned = self
|
||||
Ok(Outcome::CheckoutRefresh(Some(mut comparison)))
|
||||
| Ok(Outcome::BookmarkRefresh(mut comparison)) => {
|
||||
let pinned = !bookmark_refresh
|
||||
&& self
|
||||
.after
|
||||
.as_ref()
|
||||
.is_some_and(|after| after.target != RevisionTarget::Working);
|
||||
@@ -690,12 +706,17 @@ impl DiffState {
|
||||
self.live_files.clear();
|
||||
self.live_buffer = None;
|
||||
self.live_revision = None;
|
||||
self.refresh_view = None;
|
||||
self.follow_caret = false;
|
||||
self.prepare_selected_file();
|
||||
if pinned {
|
||||
if pinned || bookmark_refresh {
|
||||
if self.selected_file_loaded() {
|
||||
self.top = view.0.min(self.display_rows.len().saturating_sub(1));
|
||||
self.left_before = view.1.min(self.max_before);
|
||||
self.left_after = view.2.min(self.max_after);
|
||||
} else {
|
||||
self.refresh_view = Some(view);
|
||||
}
|
||||
}
|
||||
self.normalize_focus();
|
||||
}
|
||||
@@ -713,7 +734,10 @@ impl DiffState {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if matches!(result, Ok(Outcome::CheckoutRefresh(_))) {
|
||||
if matches!(
|
||||
result,
|
||||
Ok(Outcome::CheckoutRefresh(_) | Outcome::BookmarkRefresh(_))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if let Ok(Outcome::File {
|
||||
@@ -758,6 +782,7 @@ impl DiffState {
|
||||
Ok(Outcome::Comparison(comparison)) if expected == Some(RequestKind::Comparison) => {
|
||||
self.live_files.clear();
|
||||
self.frozen_after.clear();
|
||||
self.refresh_view = None;
|
||||
self.reviewed_files.clear();
|
||||
self.live_buffer = None;
|
||||
self.live_revision = None;
|
||||
@@ -811,6 +836,11 @@ impl DiffState {
|
||||
*selected = file;
|
||||
if self.file == file_index {
|
||||
self.align_file();
|
||||
if let Some(view) = self.refresh_view.take() {
|
||||
self.top = view.0.min(self.display_rows.len().saturating_sub(1));
|
||||
self.left_before = view.1.min(self.max_before);
|
||||
self.left_after = view.2.min(self.max_after);
|
||||
}
|
||||
self.normalize_focus();
|
||||
}
|
||||
self.error = None;
|
||||
@@ -1488,6 +1518,7 @@ impl DiffState {
|
||||
return true;
|
||||
}
|
||||
self.file = index;
|
||||
self.refresh_view = None;
|
||||
self.normalize_focus();
|
||||
self.live_buffer = None;
|
||||
self.live_revision = None;
|
||||
@@ -4267,7 +4298,8 @@ mod tests {
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(!state.apply_result(8, Ok(Outcome::CheckoutRefresh(None))));
|
||||
assert!(state.apply_result(8, Ok(Outcome::CheckoutRefresh(None))));
|
||||
assert!(state.error.is_none());
|
||||
assert_eq!(state.comparison_id, Some(7));
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||
|
||||
|
||||
+166
-8
@@ -4188,11 +4188,8 @@ impl Editor {
|
||||
{
|
||||
Some(buffers)
|
||||
}
|
||||
Operation::RefreshCheckout {
|
||||
follow_working: true,
|
||||
buffers,
|
||||
..
|
||||
} if self
|
||||
Operation::RefreshCheckout { buffers, .. }
|
||||
if self
|
||||
.diff
|
||||
.as_ref()
|
||||
.or(self.parked_diff.as_ref())
|
||||
@@ -4275,11 +4272,14 @@ impl Editor {
|
||||
}
|
||||
let preserved_view = (self.diff.as_ref().is_some_and(|diff| {
|
||||
diff.refresh_pending()
|
||||
&& diff
|
||||
&& (matches!(&result, Ok(Outcome::BookmarkRefresh(_)))
|
||||
|| diff
|
||||
.after
|
||||
.as_ref()
|
||||
.is_some_and(|after| after.target != RevisionTarget::Working)
|
||||
}) && !matches!(&result, Ok(Outcome::CheckoutRefresh(None))))
|
||||
.is_some_and(|after| after.target != RevisionTarget::Working))
|
||||
&& !matches!(&result, Ok(Outcome::CheckoutRefresh(None)))
|
||||
|| matches!(&result, Ok(Outcome::File { .. })) && diff.refresh_view.is_some()
|
||||
}))
|
||||
.then(|| self.active.view.inherit());
|
||||
let Some(diff) = &mut self.diff else {
|
||||
return false;
|
||||
@@ -4288,11 +4288,14 @@ impl Editor {
|
||||
return false;
|
||||
}
|
||||
let refresh = diff.refresh_pending();
|
||||
let previous_comparison = diff.comparison_id;
|
||||
if !diff.apply_result(id, result) {
|
||||
return false;
|
||||
}
|
||||
if refresh {
|
||||
if diff.comparison_id != previous_comparison {
|
||||
self.pending_lsp_navigation = None;
|
||||
}
|
||||
self.diff_bound = None;
|
||||
} else if let Some(files) = &mut diff.files {
|
||||
files.set_view(self.file_explorer_view);
|
||||
@@ -15573,6 +15576,161 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmark_refresh_reloads_paths_and_preserves_view_until_lazy_content_arrives() {
|
||||
use crate::diff::{ChangedFile, Comparison, FileStatus};
|
||||
let directory = Directory::new();
|
||||
let mut editor = editor();
|
||||
historical_diff(
|
||||
&mut editor,
|
||||
&directory.0,
|
||||
vec!["old before".into(); 4],
|
||||
vec!["old after".into(); 4],
|
||||
);
|
||||
editor.diff.as_mut().unwrap().after.as_mut().unwrap().target = RevisionTarget::Bookmark {
|
||||
name: "topic".into(),
|
||||
remote: None,
|
||||
};
|
||||
editor.diff.as_mut().unwrap().focus = Focus::After;
|
||||
editor.bind_diff_buffer();
|
||||
editor.handle(Input::Char('j'));
|
||||
editor.diff.as_mut().unwrap().top = 2;
|
||||
editor.diff.as_mut().unwrap().left_after = 3;
|
||||
editor.diff.as_mut().unwrap().reviewed_files.insert(0);
|
||||
editor.diff.as_mut().unwrap().view_mode = ViewMode::Unified;
|
||||
let old_comparison = editor.diff().unwrap().comparison_id.unwrap();
|
||||
let old_snapshot = editor.buffer_id();
|
||||
let cursor = editor.cursor();
|
||||
let file = |path: &str, loaded| ChangedFile {
|
||||
path: path.into(),
|
||||
old_path: None,
|
||||
status: FileStatus::Modified,
|
||||
loaded,
|
||||
before: if loaded {
|
||||
vec!["new before".into(); 3]
|
||||
} else {
|
||||
vec![]
|
||||
},
|
||||
after: if loaded {
|
||||
vec!["new after".into(); 3]
|
||||
} else {
|
||||
vec![]
|
||||
},
|
||||
before_eof: false,
|
||||
after_eof: false,
|
||||
hunks: if loaded {
|
||||
crate::diff::line_hunks(
|
||||
&vec!["new before".into(); 3],
|
||||
false,
|
||||
&vec!["new after".into(); 3],
|
||||
false,
|
||||
)
|
||||
} else {
|
||||
vec![]
|
||||
},
|
||||
notice: None,
|
||||
};
|
||||
editor.queue_checkout_refreshes(false);
|
||||
let (_, refresh, _) = editor.take_next_diff_request().unwrap();
|
||||
assert!(editor.apply_diff_result(
|
||||
refresh,
|
||||
Ok(Outcome::BookmarkRefresh(Comparison {
|
||||
root: directory.0.clone(),
|
||||
before: "base".into(),
|
||||
after: "topic".into(),
|
||||
editable_paths: Default::default(),
|
||||
files: vec![file("a-new.txt", false), file("file.txt", false)],
|
||||
}))
|
||||
));
|
||||
let diff = editor.diff().unwrap();
|
||||
assert_eq!(diff.file, 1);
|
||||
assert_eq!(diff.focus, Focus::After);
|
||||
assert_eq!(diff.view_mode, ViewMode::Unified);
|
||||
assert!(diff.reviewed_files.is_empty());
|
||||
assert!(!diff.selected_file_loaded());
|
||||
assert!(!diff.comparison.as_ref().unwrap().files[0].loaded);
|
||||
assert_eq!(editor.buffer_id(), old_snapshot);
|
||||
let (_, load, operation) = editor.take_next_diff_request().unwrap();
|
||||
assert!(
|
||||
matches!(operation, Operation::LoadFile { comparison_id, file_index: 1 } if comparison_id == refresh)
|
||||
);
|
||||
assert!(editor.apply_diff_result(
|
||||
load,
|
||||
Ok(Outcome::File {
|
||||
comparison_id: refresh,
|
||||
file_index: 1,
|
||||
file: file("file.txt", true),
|
||||
})
|
||||
));
|
||||
assert_eq!(editor.lines(), ["new after"; 3]);
|
||||
assert_eq!(editor.cursor(), cursor);
|
||||
assert_eq!(editor.diff().unwrap().top, 2);
|
||||
assert_eq!(editor.diff().unwrap().left_after, 3);
|
||||
assert_ne!(editor.buffer_id(), old_snapshot);
|
||||
assert!(editor.mutation_blocked());
|
||||
assert!(!editor.apply_diff_result(refresh, Ok(Outcome::CheckoutRefresh(None))));
|
||||
assert_ne!(editor.diff().unwrap().comparison_id, Some(old_comparison));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmark_refresh_errors_recover_and_hidden_replacements_preserve_dirty_buffers() {
|
||||
let directory = Directory::new();
|
||||
let path = directory.path("file.txt");
|
||||
fs::write(&path, "original").unwrap();
|
||||
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
|
||||
working_diff(&mut editor, &directory.0, &["file.txt"]);
|
||||
editor.diff.as_mut().unwrap().after.as_mut().unwrap().target = RevisionTarget::Bookmark {
|
||||
name: "topic".into(),
|
||||
remote: None,
|
||||
};
|
||||
editor.diff.as_mut().unwrap().focus = Focus::After;
|
||||
editor.bind_diff_buffer();
|
||||
let dirty_buffer = editor.buffer_id();
|
||||
type_text(&mut editor, "ix");
|
||||
editor.diff.as_mut().unwrap().reviewed_files.insert(0);
|
||||
let comparison_id = editor.diff().unwrap().comparison_id;
|
||||
editor.queue_checkout_refreshes(false);
|
||||
let (_, request, _) = editor.take_next_diff_request().unwrap();
|
||||
assert!(editor.apply_diff_result(request, Err("bookmark is conflicted".into())));
|
||||
assert_eq!(editor.diff().unwrap().comparison_id, comparison_id);
|
||||
assert_eq!(editor.lines(), ["original"]);
|
||||
assert!(editor.diff().unwrap().reviewed_files.contains(&0));
|
||||
assert_eq!(editor.buffer(dirty_buffer).unwrap().lines, ["xoriginal"]);
|
||||
assert!(editor.buffer(dirty_buffer).unwrap().dirty());
|
||||
editor.queue_checkout_refreshes(false);
|
||||
let (_, recovery, _) = editor.take_next_diff_request().unwrap();
|
||||
assert!(editor.apply_diff_result(recovery, Ok(Outcome::CheckoutRefresh(None))));
|
||||
assert!(editor.diff().unwrap().error.is_none());
|
||||
assert_eq!(editor.diff().unwrap().comparison_id, comparison_id);
|
||||
assert!(editor.diff().unwrap().reviewed_files.contains(&0));
|
||||
|
||||
let hidden_tab = editor.tab_id();
|
||||
let owner = editor.pane_id();
|
||||
let old_snapshot = editor.buffer_id();
|
||||
editor.execute("tab.new");
|
||||
let visible_tab = editor.tab_id();
|
||||
editor.queue_checkout_refreshes(false);
|
||||
let (tab, request, _) = editor.take_next_diff_request().unwrap();
|
||||
assert_eq!(tab, hidden_tab);
|
||||
let mut replacement = editor.with_pane(owner, |editor| {
|
||||
editor.diff.as_mut().unwrap().comparison.take().unwrap()
|
||||
});
|
||||
replacement.files[0].after = vec!["moved bookmark".into()];
|
||||
assert!(editor.apply_diff_result(request, Ok(Outcome::BookmarkRefresh(replacement))));
|
||||
assert_eq!(editor.tab_id(), visible_tab);
|
||||
assert_eq!(editor.buffer_id(), old_snapshot);
|
||||
assert_eq!(editor.lines(), ["original"]);
|
||||
assert!(editor.mutation_blocked());
|
||||
assert_eq!(editor.buffer(dirty_buffer).unwrap().lines, ["xoriginal"]);
|
||||
assert!(editor.buffer(dirty_buffer).unwrap().dirty());
|
||||
editor.with_pane(owner, |editor| {
|
||||
assert_eq!(editor.lines(), ["moved bookmark"]);
|
||||
assert!(editor.diff().unwrap().reviewed_files.is_empty());
|
||||
assert!(editor.mutation_blocked());
|
||||
});
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "original");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_working_refresh_serializes_lazy_selection_and_invalidates_pending_navigation() {
|
||||
for request_kind in [
|
||||
|
||||
+231
-22
@@ -1056,6 +1056,9 @@ type Snapshot = BTreeMap<PathBuf, FileData>;
|
||||
|
||||
struct PreparedComparison {
|
||||
id: u64,
|
||||
selected: [Revision; 2],
|
||||
selected_ids: [Option<String>; 2],
|
||||
mode: ComparisonMode,
|
||||
left: Snapshot,
|
||||
right: Snapshot,
|
||||
files: Vec<(PathBuf, Option<PathBuf>, FileStatus)>,
|
||||
@@ -1068,6 +1071,12 @@ struct PreparedComparison {
|
||||
editable_after: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CheckoutRefresh {
|
||||
Checkout(Comparison),
|
||||
Bookmark(Comparison),
|
||||
}
|
||||
|
||||
pub struct Repository {
|
||||
root: PathBuf,
|
||||
kind: RepoKind,
|
||||
@@ -1145,7 +1154,7 @@ impl Repository {
|
||||
notices: &mut Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
if self.kind == RepoKind::Jujutsu {
|
||||
let template = r#""[" ++ json(commit_id) ++ "," ++ json(change_id) ++ "," ++ json(description) ++ "," ++ json(local_bookmarks.map(|b| b.name())) ++ "," ++ json(remote_bookmarks.map(|b| stringify(b.name() ++ "@" ++ b.remote()))) ++ "]\n""#;
|
||||
let template = r#""[" ++ json(commit_id) ++ "," ++ json(change_id) ++ "," ++ json(description) ++ "," ++ json(local_bookmarks.map(|b| b.name())) ++ "," ++ json(remote_bookmarks.map(|b| b.name())) ++ "," ++ json(remote_bookmarks.map(|b| b.remote())) ++ "]\n""#;
|
||||
let revset = if root == self.root {
|
||||
"all()"
|
||||
} else {
|
||||
@@ -1154,15 +1163,15 @@ impl Repository {
|
||||
let output =
|
||||
text(self.jj(root, &["log", "--no-graph", "-r", revset, "-T", template])?)?;
|
||||
for line in output.lines() {
|
||||
let (id, change, description, mut bookmarks, remote_bookmarks): (
|
||||
let (id, change, description, bookmarks, remote_names, remotes): (
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Vec<String>,
|
||||
Vec<String>,
|
||||
Vec<String>,
|
||||
) = serde_json::from_str(line)
|
||||
.map_err(|e| format!("Cannot decode Jujutsu revision: {e}"))?;
|
||||
bookmarks.extend(remote_bookmarks);
|
||||
let target = RevisionTarget::Commit(id.clone());
|
||||
entries.push(Revision {
|
||||
target: target.clone(),
|
||||
@@ -1170,10 +1179,22 @@ impl Repository {
|
||||
description: description.trim_end_matches('\n').into(),
|
||||
category: Category::Revisions,
|
||||
});
|
||||
for name in bookmarks {
|
||||
for (name, remote) in bookmarks.into_iter().map(|name| (name, None)).chain(
|
||||
remote_names
|
||||
.into_iter()
|
||||
.zip(remotes)
|
||||
.map(|(name, remote)| (name, Some(remote))),
|
||||
) {
|
||||
let label = remote
|
||||
.as_ref()
|
||||
.map_or_else(|| name.clone(), |remote| format!("{name}@{remote}"));
|
||||
entries.push(Revision {
|
||||
target: target.clone(),
|
||||
label: name,
|
||||
target: if root == self.root {
|
||||
RevisionTarget::Bookmark { name, remote }
|
||||
} else {
|
||||
target.clone()
|
||||
},
|
||||
label,
|
||||
description: format!("{change} {id} {}", description.trim_end()),
|
||||
category: Category::Bookmarks,
|
||||
});
|
||||
@@ -1651,8 +1672,63 @@ impl Repository {
|
||||
};
|
||||
Ok((before, after))
|
||||
}
|
||||
fn bookmark_ids(&self, targets: [&RevisionTarget; 2]) -> Result<[Option<String>; 2], String> {
|
||||
let mut ids = [None, None];
|
||||
if !targets
|
||||
.iter()
|
||||
.any(|target| matches!(target, RevisionTarget::Bookmark { .. }))
|
||||
{
|
||||
return Ok(ids);
|
||||
}
|
||||
if self.kind != RepoKind::Jujutsu {
|
||||
return Err("Bookmarks require a Jujutsu repository".into());
|
||||
}
|
||||
// Resolve both endpoints from one operation, comparing raw names rather
|
||||
// than interpreting bookmark names as revsets or splitting on '@'.
|
||||
let template = r#""[" ++ json(name) ++ "," ++ json(remote) ++ "," ++ json(conflict) ++ "," ++ if(conflict, "null", json(normal_target.commit_id())) ++ "]\n""#;
|
||||
let output = text(self.jj(
|
||||
&self.root,
|
||||
&["bookmark", "list", "--all-remotes", "-T", template],
|
||||
)?)?;
|
||||
for line in output.lines() {
|
||||
let (name, remote, conflict, id): (String, Option<String>, bool, Option<String>) =
|
||||
serde_json::from_str(line)
|
||||
.map_err(|e| format!("Cannot decode Jujutsu bookmark: {e}"))?;
|
||||
for (index, target) in targets.iter().enumerate() {
|
||||
if let RevisionTarget::Bookmark {
|
||||
name: selected_name,
|
||||
remote: selected_remote,
|
||||
} = target
|
||||
&& selected_name == &name
|
||||
&& selected_remote == &remote
|
||||
{
|
||||
if conflict {
|
||||
return Err(format!("Bookmark {name} is conflicted"));
|
||||
}
|
||||
ids[index] = id.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
for (index, target) in targets.iter().enumerate() {
|
||||
if let RevisionTarget::Bookmark { name, remote } = target
|
||||
&& ids[index].is_none()
|
||||
{
|
||||
let label = remote
|
||||
.as_ref()
|
||||
.map_or_else(|| name.clone(), |remote| format!("{name}@{remote}"));
|
||||
return Err(format!("Bookmark {label} is missing or deleted"));
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
fn resolve(&mut self, target: &RevisionTarget) -> Result<(PathBuf, String), String> {
|
||||
let id = match target {
|
||||
RevisionTarget::Bookmark { .. } => {
|
||||
return Ok((
|
||||
self.store.clone(),
|
||||
self.bookmark_ids([target, target])?[0].take().unwrap(),
|
||||
));
|
||||
}
|
||||
RevisionTarget::Commit(id) | RevisionTarget::RemoteCommit { id, .. } => {
|
||||
if !matches!(id.len(), 40 | 64)
|
||||
|| (self.kind == RepoKind::Jujutsu && id.len() != 40)
|
||||
@@ -2321,27 +2397,34 @@ impl Repository {
|
||||
buffers: &[(PathBuf, String)],
|
||||
) -> Result<Comparison, String> {
|
||||
self.check_cancelled()?;
|
||||
let bookmarks = self.bookmark_ids([&before.target, &after.target])?;
|
||||
let mut selected_ids = bookmarks.clone();
|
||||
for (index, revision) in [before, after].iter().enumerate() {
|
||||
if revision.target != RevisionTarget::Working && selected_ids[index].is_none() {
|
||||
selected_ids[index] = Some(self.resolve(&revision.target)?.1);
|
||||
}
|
||||
}
|
||||
let targets = selected_ids
|
||||
.clone()
|
||||
.map(|id| id.map_or(RevisionTarget::Working, RevisionTarget::Commit));
|
||||
let resolved = if mode == ComparisonMode::BranchChanges
|
||||
&& before.target != RevisionTarget::Working
|
||||
&& after.target != RevisionTarget::Working
|
||||
{
|
||||
let (_, before_id) = self.resolve(&before.target)?;
|
||||
let (_, after_id) = self.resolve(&after.target)?;
|
||||
let base = self.merge_base(&before_id, &after_id)?;
|
||||
let base = self.merge_base(
|
||||
selected_ids[0].as_deref().unwrap(),
|
||||
selected_ids[1].as_deref().unwrap(),
|
||||
)?;
|
||||
let label = format!(
|
||||
"merge base of {} and {} · {base}",
|
||||
before.label, after.label
|
||||
);
|
||||
Some((
|
||||
RevisionTarget::Commit(base),
|
||||
RevisionTarget::Commit(after_id),
|
||||
label,
|
||||
))
|
||||
Some((RevisionTarget::Commit(base), targets[1].clone(), label))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (before_target, after_target, before_label) = resolved.as_ref().map_or(
|
||||
(&before.target, &after.target, before.label.as_str()),
|
||||
(&targets[0], &targets[1], before.label.as_str()),
|
||||
|(before, after, label)| (before, after, label.as_str()),
|
||||
);
|
||||
let working_after = *after_target == RevisionTarget::Working;
|
||||
@@ -2360,7 +2443,9 @@ impl Repository {
|
||||
let editable_after =
|
||||
working_after || (pinned_checkout.is_some() && pinned_checkout == checkout);
|
||||
let left = self.snapshot(before_target)?;
|
||||
let same = before_target == after_target && (!working_after || buffers.is_empty());
|
||||
let same = bookmarks.iter().all(Option::is_none)
|
||||
&& before_target == after_target
|
||||
&& (!working_after || buffers.is_empty());
|
||||
let right = if same {
|
||||
BTreeMap::new()
|
||||
} else if working_after {
|
||||
@@ -2371,6 +2456,9 @@ impl Repository {
|
||||
};
|
||||
let mut prepared = PreparedComparison {
|
||||
id: comparison_id,
|
||||
selected: [before.clone(), after.clone()],
|
||||
selected_ids,
|
||||
mode,
|
||||
left,
|
||||
right,
|
||||
files: vec![],
|
||||
@@ -2386,19 +2474,22 @@ impl Repository {
|
||||
if self.working_anchor()? != checkout {
|
||||
return Err("Checkout moved during comparison".into());
|
||||
}
|
||||
if self.bookmark_ids([&before.target, &after.target])? != bookmarks {
|
||||
return Err("Bookmark moved during comparison".into());
|
||||
}
|
||||
self.comparison = Some(prepared);
|
||||
Ok(comparison)
|
||||
}
|
||||
|
||||
/// Follow Working forward when enabled, or update a pinned After's eligibility.
|
||||
/// Pinned sources and path catalogs never change when checkout eligibility does.
|
||||
/// Resolve active bookmarks, follow Working forward when enabled, or update
|
||||
/// pinned After eligibility without changing its frozen contents.
|
||||
pub fn refresh_checkout(
|
||||
&mut self,
|
||||
comparison_id: u64,
|
||||
new_id: u64,
|
||||
follow_working: bool,
|
||||
buffers: &[(PathBuf, String)],
|
||||
) -> Result<Option<Comparison>, String> {
|
||||
) -> Result<Option<CheckoutRefresh>, String> {
|
||||
let result = self.refresh_checkout_inner(comparison_id, new_id, follow_working, buffers);
|
||||
if result.is_err()
|
||||
&& let Some(prepared) = self.comparison.as_mut()
|
||||
@@ -2418,7 +2509,7 @@ impl Repository {
|
||||
new_id: u64,
|
||||
follow_working: bool,
|
||||
buffers: &[(PathBuf, String)],
|
||||
) -> Result<Option<Comparison>, String> {
|
||||
) -> Result<Option<CheckoutRefresh>, String> {
|
||||
let Some(prepared) = self
|
||||
.comparison
|
||||
.as_ref()
|
||||
@@ -2427,6 +2518,17 @@ impl Repository {
|
||||
return Ok(None);
|
||||
};
|
||||
self.check_cancelled()?;
|
||||
let bookmarks =
|
||||
self.bookmark_ids([&prepared.selected[0].target, &prepared.selected[1].target])?;
|
||||
if bookmarks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(index, id)| id.is_some() && id != &prepared.selected_ids[index])
|
||||
{
|
||||
return self
|
||||
.refresh_bookmarks(new_id, bookmarks, follow_working, buffers)
|
||||
.map(|comparison| Some(CheckoutRefresh::Bookmark(comparison)));
|
||||
}
|
||||
if prepared.working_after && !follow_working {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -2454,13 +2556,19 @@ impl Repository {
|
||||
if self.working_anchor()? != anchor {
|
||||
return Err("Checkout moved during comparison".into());
|
||||
}
|
||||
if self
|
||||
.bookmark_ids([&prepared.selected[0].target, &prepared.selected[1].target])?
|
||||
!= bookmarks
|
||||
{
|
||||
return Err("Bookmark moved during comparison".into());
|
||||
}
|
||||
Ok(comparison)
|
||||
});
|
||||
if result.is_ok() {
|
||||
prepared.id = new_id;
|
||||
}
|
||||
self.comparison = Some(prepared);
|
||||
return result.map(Some);
|
||||
return result.map(|comparison| Some(CheckoutRefresh::Checkout(comparison)));
|
||||
}
|
||||
if !self.working_advanced(prepared.working_anchor.as_deref(), anchor.as_deref())? {
|
||||
return Ok(None);
|
||||
@@ -2476,6 +2584,12 @@ impl Repository {
|
||||
if self.working_anchor()? != anchor {
|
||||
return Err("Working checkout moved during comparison".into());
|
||||
}
|
||||
if self
|
||||
.bookmark_ids([&prepared.selected[0].target, &prepared.selected[1].target])?
|
||||
!= bookmarks
|
||||
{
|
||||
return Err("Bookmark moved during comparison".into());
|
||||
}
|
||||
Ok(comparison)
|
||||
});
|
||||
if result.is_ok() {
|
||||
@@ -2486,7 +2600,102 @@ impl Repository {
|
||||
prepared.files = previous_files;
|
||||
}
|
||||
self.comparison = Some(prepared);
|
||||
result.map(Some)
|
||||
result.map(|comparison| Some(CheckoutRefresh::Checkout(comparison)))
|
||||
}
|
||||
|
||||
fn refresh_bookmarks(
|
||||
&mut self,
|
||||
new_id: u64,
|
||||
bookmarks: [Option<String>; 2],
|
||||
follow_working: bool,
|
||||
buffers: &[(PathBuf, String)],
|
||||
) -> Result<Comparison, String> {
|
||||
let prepared = self.comparison.as_ref().unwrap();
|
||||
let mut ids = prepared.selected_ids.clone();
|
||||
for (index, id) in bookmarks.iter().enumerate() {
|
||||
if id.is_some() {
|
||||
ids[index] = id.clone();
|
||||
}
|
||||
}
|
||||
let branch =
|
||||
prepared.mode == ComparisonMode::BranchChanges && ids.iter().all(Option::is_some);
|
||||
let label = prepared.selected[0].label.clone();
|
||||
let after_label = prepared.selected[1].label.clone();
|
||||
let working_after = prepared.working_after;
|
||||
let changed_before = ids[0] != prepared.selected_ids[0];
|
||||
let changed_after = ids[1] != prepared.selected_ids[1];
|
||||
let anchor = self.working_anchor()?;
|
||||
let advance_working = working_after
|
||||
&& follow_working
|
||||
&& self.working_advanced(prepared.working_anchor.as_deref(), anchor.as_deref())?;
|
||||
let (left, before_label) = if branch {
|
||||
let base = self.merge_base(ids[0].as_deref().unwrap(), ids[1].as_deref().unwrap())?;
|
||||
let label = format!("merge base of {label} and {after_label} · {base}");
|
||||
(Some(self.snapshot(&RevisionTarget::Commit(base))?), label)
|
||||
} else if changed_before {
|
||||
(
|
||||
Some(self.snapshot(&RevisionTarget::Commit(ids[0].clone().unwrap()))?),
|
||||
label,
|
||||
)
|
||||
} else {
|
||||
(None, label)
|
||||
};
|
||||
let right = if advance_working {
|
||||
Some(self.working_snapshot(buffers)?)
|
||||
} else if changed_after {
|
||||
Some(self.snapshot(&RevisionTarget::Commit(ids[1].clone().unwrap()))?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let pinned_checkout = ids[1]
|
||||
.as_deref()
|
||||
.map(|id| self.pinned_checkout(id))
|
||||
.transpose()?
|
||||
.flatten();
|
||||
let mut prepared = self.comparison.take().unwrap();
|
||||
let previous_left = left.map(|left| std::mem::replace(&mut prepared.left, left));
|
||||
let previous_right = right.map(|right| std::mem::replace(&mut prepared.right, right));
|
||||
let previous_files = std::mem::take(&mut prepared.files);
|
||||
let previous_before = std::mem::replace(&mut prepared.before, before_label);
|
||||
let previous_editable = prepared.editable_after;
|
||||
prepared.editable_after =
|
||||
working_after || (pinned_checkout.is_some() && pinned_checkout == anchor);
|
||||
let result = self
|
||||
.build_comparison(&mut prepared, false)
|
||||
.and_then(|comparison| {
|
||||
self.check_cancelled()?;
|
||||
if self.working_anchor()? != anchor {
|
||||
return Err("Checkout moved during comparison".into());
|
||||
}
|
||||
if self
|
||||
.bookmark_ids([&prepared.selected[0].target, &prepared.selected[1].target])?
|
||||
!= bookmarks
|
||||
{
|
||||
return Err("Bookmark moved during comparison".into());
|
||||
}
|
||||
Ok(comparison)
|
||||
});
|
||||
if result.is_ok() {
|
||||
prepared.id = new_id;
|
||||
prepared.selected_ids = ids;
|
||||
prepared.pinned_commit = prepared.selected_ids[1].clone();
|
||||
prepared.pinned_checkout = pinned_checkout;
|
||||
if advance_working {
|
||||
prepared.working_anchor = anchor;
|
||||
}
|
||||
} else {
|
||||
if let Some(left) = previous_left {
|
||||
prepared.left = left;
|
||||
}
|
||||
if let Some(right) = previous_right {
|
||||
prepared.right = right;
|
||||
}
|
||||
prepared.files = previous_files;
|
||||
prepared.before = previous_before;
|
||||
prepared.editable_after = previous_editable;
|
||||
}
|
||||
self.comparison = Some(prepared);
|
||||
result
|
||||
}
|
||||
|
||||
fn build_comparison(
|
||||
|
||||
+20
-5
@@ -23,7 +23,7 @@ use crate::{
|
||||
DocumentEvent, DocumentEventKind, DocumentSnapshot, PluginEvent, PluginUpdate,
|
||||
TextPosition, UpdateEffect,
|
||||
},
|
||||
repository::Repository,
|
||||
repository::{CheckoutRefresh, Repository},
|
||||
ruby::{Config, RubyPlugin, load_config},
|
||||
};
|
||||
|
||||
@@ -321,7 +321,15 @@ impl DiffWorker {
|
||||
buffers,
|
||||
} => repository
|
||||
.refresh_checkout(comparison_id, id, follow_working, &buffers)
|
||||
.map(DiffOutcome::CheckoutRefresh),
|
||||
.map(|refresh| match refresh {
|
||||
Some(CheckoutRefresh::Bookmark(comparison)) => {
|
||||
DiffOutcome::BookmarkRefresh(comparison)
|
||||
}
|
||||
Some(CheckoutRefresh::Checkout(comparison)) => {
|
||||
DiffOutcome::CheckoutRefresh(Some(comparison))
|
||||
}
|
||||
None => DiffOutcome::CheckoutRefresh(None),
|
||||
}),
|
||||
DiffOperation::LoadFile {
|
||||
comparison_id,
|
||||
file_index,
|
||||
@@ -2177,7 +2185,7 @@ mod tests {
|
||||
use crate::diff::{Catalog, Category, Comparison, RepoKind, Revision, RevisionTarget};
|
||||
let fixture = Fixture::new();
|
||||
fixture.file("buffer.txt", "base");
|
||||
for working in [true, false] {
|
||||
for (working, bookmark) in [(true, false), (false, false), (true, true), (false, true)] {
|
||||
let mut session = fixture.open("");
|
||||
session.editor.execute("diff");
|
||||
let (id, _) = session.editor.take_diff_request().unwrap();
|
||||
@@ -2188,7 +2196,14 @@ mod tests {
|
||||
notice: None,
|
||||
entries: [
|
||||
RevisionTarget::Working,
|
||||
RevisionTarget::Commit("base".into()),
|
||||
if bookmark {
|
||||
RevisionTarget::Bookmark {
|
||||
name: "base".into(),
|
||||
remote: None,
|
||||
}
|
||||
} else {
|
||||
RevisionTarget::Commit("base".into())
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.map(|target| Revision {
|
||||
@@ -2224,7 +2239,7 @@ mod tests {
|
||||
assert!(session.editor.take_next_diff_request().is_none());
|
||||
session.refresh_checkout_diffs_at(now + Duration::from_secs(tick));
|
||||
let request = session.editor.take_next_diff_request();
|
||||
assert_eq!(request.is_some(), !working || follow);
|
||||
assert_eq!(request.is_some(), !working || follow || bookmark);
|
||||
if let Some((_, refresh, operation)) = request {
|
||||
assert!(
|
||||
matches!(operation, DiffOperation::RefreshCheckout { comparison_id, follow_working, .. }
|
||||
|
||||
@@ -49,6 +49,23 @@ impl Fixture {
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap().trim().to_owned()
|
||||
}
|
||||
|
||||
fn jj(&self, args: &[&str]) -> String {
|
||||
let output = Command::new("jj")
|
||||
.args(args)
|
||||
.current_dir(self.0.join("repo"))
|
||||
.env("JJ_CONFIG", "/dev/null")
|
||||
.env("JJ_USER", "Diff Fixture")
|
||||
.env("JJ_EMAIL", "fixture@example.invalid")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap().trim().to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
@@ -610,3 +627,58 @@ fn branch_changes_default_and_mode_switches_compare_the_selected_divergent_tips(
|
||||
"Working checkout requires Snapshots"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jj_bookmark_picker_keeps_symbolic_targets_searchable_by_resolved_commit_id() {
|
||||
use vamp::diff::RevisionTarget;
|
||||
let fixture = Fixture::new();
|
||||
fixture.jj(&["git", "init", "--no-colocate"]);
|
||||
let source = fixture.0.join("repo/source.txt");
|
||||
fs::write(&source, "base\n").unwrap();
|
||||
fixture.jj(&["describe", "-m", "base"]);
|
||||
let before = fixture.jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||
fixture.jj(&["new"]);
|
||||
fs::write(&source, "topic\n").unwrap();
|
||||
fixture.jj(&["describe", "-m", "topic"]);
|
||||
fixture.jj(&["bookmark", "create", "topic"]);
|
||||
let after = fixture.jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||
fixture.jj(&["new"]);
|
||||
let mut session = EditorSession::open(Some(source), vec![], None).unwrap();
|
||||
session.execute("diff");
|
||||
wait_until(&mut session, |session| {
|
||||
!session.editor().diff().unwrap().loading
|
||||
});
|
||||
type_query(&mut session, &before);
|
||||
session.handle(Input::Down);
|
||||
session.handle(Input::Enter);
|
||||
session.handle(Input::Char('\t'));
|
||||
type_query(&mut session, &after);
|
||||
let target = RevisionTarget::Bookmark {
|
||||
name: "topic".into(),
|
||||
remote: None,
|
||||
};
|
||||
assert_eq!(
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.unwrap()
|
||||
.entries()
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.target,
|
||||
target
|
||||
);
|
||||
session.handle(Input::Down);
|
||||
session.handle(Input::Enter);
|
||||
wait_until(&mut session, |session| {
|
||||
session.editor().diff().unwrap().selected_file_loaded()
|
||||
});
|
||||
let diff = session.editor().diff().unwrap();
|
||||
assert_eq!(
|
||||
diff.before.as_ref().unwrap().target,
|
||||
RevisionTarget::Commit(before)
|
||||
);
|
||||
assert_eq!(diff.after.as_ref().unwrap().target, target);
|
||||
assert_eq!(diff.comparison.as_ref().unwrap().files[0].after, ["topic"]);
|
||||
assert!(!diff.after_editable());
|
||||
}
|
||||
|
||||
+442
-1
@@ -17,7 +17,7 @@ use std::{
|
||||
};
|
||||
use vamp::{
|
||||
diff::{Category, Comparison, ComparisonMode, FileStatus, RepoKind, Revision, RevisionTarget},
|
||||
repository::Repository,
|
||||
repository::{CheckoutRefresh, Repository},
|
||||
};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
@@ -128,6 +128,13 @@ fn working(kind: RepoKind) -> Revision {
|
||||
r
|
||||
}
|
||||
|
||||
fn checkout_comparison(refresh: CheckoutRefresh) -> Comparison {
|
||||
let CheckoutRefresh::Checkout(comparison) = refresh else {
|
||||
panic!("Expected checkout-only refresh");
|
||||
};
|
||||
comparison
|
||||
}
|
||||
|
||||
fn compare_loaded(
|
||||
repository: &mut Repository,
|
||||
before: &Revision,
|
||||
@@ -549,6 +556,7 @@ fn git_remote_refs_and_annotated_tags_are_discovered_before_fetch_and_history_st
|
||||
let readonly = checked_out
|
||||
.refresh_checkout(20, 21, false, &[])
|
||||
.unwrap()
|
||||
.map(checkout_comparison)
|
||||
.unwrap();
|
||||
assert!(readonly.editable_paths.is_empty());
|
||||
assert_eq!(checked_out.load_file(21, 0).unwrap().after, ["remote tip"]);
|
||||
@@ -556,6 +564,7 @@ fn git_remote_refs_and_annotated_tags_are_discovered_before_fetch_and_history_st
|
||||
let promoted = checked_out
|
||||
.refresh_checkout(21, 22, false, &[])
|
||||
.unwrap()
|
||||
.map(checkout_comparison)
|
||||
.unwrap();
|
||||
assert_eq!(promoted.editable_paths, pinned.editable_paths);
|
||||
assert_eq!(checked_out.load_file(22, 0).unwrap().after, ["remote tip"]);
|
||||
@@ -662,6 +671,427 @@ fn jj_catalog_and_working_comparisons_prefer_jj_and_never_snapshot_or_merge_oper
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jj_bookmark_refresh_tracks_movement_and_same_change_rewrites_without_touching_source() {
|
||||
for colocate in [false, true] {
|
||||
let fixture = Fixture::new();
|
||||
let root = fixture.dir("bookmarks");
|
||||
init_jj(&root, colocate);
|
||||
fs::write(root.join("file"), "base\n").unwrap();
|
||||
jj(&root, &["describe", "-m", "base"]);
|
||||
let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||
jj(&root, &["bookmark", "create", "topic"]);
|
||||
jj(&root, &["new"]);
|
||||
fs::write(root.join("file"), "next\n").unwrap();
|
||||
jj(&root, &["describe", "-m", "next"]);
|
||||
let next = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||
let mut repository = open(&root);
|
||||
let catalog = repository.catalog().unwrap();
|
||||
let topic = catalog
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| entry.label == "topic")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
topic.target,
|
||||
RevisionTarget::Bookmark {
|
||||
name: "topic".into(),
|
||||
remote: None
|
||||
}
|
||||
);
|
||||
repository
|
||||
.compare(
|
||||
1,
|
||||
&revision(&"0".repeat(40), RepoKind::Jujutsu),
|
||||
topic,
|
||||
ComparisonMode::Snapshots,
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
jj(&root, &["bookmark", "set", "topic", "-r", &next]);
|
||||
let state = disk_state(&root);
|
||||
let Some(CheckoutRefresh::Bookmark(comparison)) =
|
||||
repository.refresh_checkout(1, 2, false, &[]).unwrap()
|
||||
else {
|
||||
panic!("Bookmark movement must replace contents even when follow is disabled");
|
||||
};
|
||||
assert_eq!(comparison.after, topic.label);
|
||||
assert_eq!(repository.load_file(2, 0).unwrap().after, ["next"]);
|
||||
assert!(repository.load_file(1, 0).is_err());
|
||||
assert!(
|
||||
repository
|
||||
.refresh_checkout(2, 3, false, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(disk_state(&root), state);
|
||||
let change = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "change_id"]);
|
||||
fs::write(root.join("file"), "rewritten\n").unwrap();
|
||||
jj(&root, &["describe", "-m", "rewritten"]);
|
||||
assert_eq!(
|
||||
jj(&root, &["log", "--no-graph", "-r", "@", "-T", "change_id"]),
|
||||
change
|
||||
);
|
||||
let state = disk_state(&root);
|
||||
assert!(matches!(
|
||||
repository.refresh_checkout(2, 3, false, &[]).unwrap(),
|
||||
Some(CheckoutRefresh::Bookmark(_))
|
||||
));
|
||||
assert_eq!(repository.load_file(3, 0).unwrap().after, ["rewritten"]);
|
||||
assert_eq!(disk_state(&root), state);
|
||||
jj(&root, &["describe", "-m", "description-only rewrite"]);
|
||||
assert!(matches!(
|
||||
repository.refresh_checkout(3, 4, false, &[]).unwrap(),
|
||||
Some(CheckoutRefresh::Bookmark(_))
|
||||
));
|
||||
assert_eq!(repository.load_file(4, 0).unwrap().after, ["rewritten"]);
|
||||
repository
|
||||
.compare(
|
||||
5,
|
||||
&revision(&"0".repeat(40), RepoKind::Jujutsu),
|
||||
&revision(&base, RepoKind::Jujutsu),
|
||||
ComparisonMode::Snapshots,
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
repository
|
||||
.refresh_checkout(5, 6, false, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(repository.load_file(5, 0).unwrap().after, ["base"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jj_bookmark_refresh_retains_frozen_working_before_and_working_after_overlays() {
|
||||
let fixture = Fixture::new();
|
||||
let root = fixture.dir("bookmark-working").canonicalize().unwrap();
|
||||
init_jj(&root, false);
|
||||
fs::write(root.join("file"), "base\n").unwrap();
|
||||
jj(&root, &["describe", "-m", "base"]);
|
||||
jj(&root, &["bookmark", "create", "topic"]);
|
||||
jj(&root, &["new"]);
|
||||
fs::write(root.join("file"), "frozen disk\n").unwrap();
|
||||
let mut repository = open(&root);
|
||||
let catalog = repository.catalog().unwrap();
|
||||
let topic = catalog
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| entry.label == "topic")
|
||||
.unwrap();
|
||||
repository
|
||||
.compare(
|
||||
1,
|
||||
&working(RepoKind::Jujutsu),
|
||||
topic,
|
||||
ComparisonMode::Snapshots,
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(root.join("file"), "new topic\n").unwrap();
|
||||
jj(&root, &["describe", "-m", "next"]);
|
||||
jj(&root, &["bookmark", "set", "topic", "-r", "@"]);
|
||||
assert!(matches!(
|
||||
repository.refresh_checkout(1, 2, false, &[]).unwrap(),
|
||||
Some(CheckoutRefresh::Bookmark(_))
|
||||
));
|
||||
let file = repository.load_file(2, 0).unwrap();
|
||||
assert_eq!(file.before, ["frozen disk"]);
|
||||
assert_eq!(file.after, ["new topic"]);
|
||||
repository
|
||||
.compare(
|
||||
3,
|
||||
topic,
|
||||
&working(RepoKind::Jujutsu),
|
||||
ComparisonMode::Snapshots,
|
||||
&[(root.join("file"), "old overlay\n".into())],
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(root.join("file"), "rewritten topic\n").unwrap();
|
||||
jj(&root, &["describe", "-m", "rewrite"]);
|
||||
let state = disk_state(&root);
|
||||
assert!(matches!(
|
||||
repository
|
||||
.refresh_checkout(3, 4, false, &[(root.join("file"), "dirty buffer\n".into())])
|
||||
.unwrap(),
|
||||
Some(CheckoutRefresh::Bookmark(_))
|
||||
));
|
||||
let file = repository.load_file(4, 0).unwrap();
|
||||
assert_eq!(file.before, ["rewritten topic"]);
|
||||
// Live buffer rebinding is a frontend concern; a bookmark refresh must not
|
||||
// advance the unrelated frozen Working endpoint while follow is disabled.
|
||||
assert_eq!(file.after, ["old overlay"]);
|
||||
assert_eq!(disk_state(&root), state);
|
||||
jj(&root, &["new"]);
|
||||
fs::write(root.join("file"), "advanced checkout\n").unwrap();
|
||||
jj(&root, &["describe", "-m", "advance"]);
|
||||
jj(&root, &["bookmark", "set", "topic", "-r", "@"]);
|
||||
assert!(matches!(
|
||||
repository.refresh_checkout(4, 5, false, &[]).unwrap(),
|
||||
Some(CheckoutRefresh::Bookmark(_))
|
||||
));
|
||||
assert_eq!(repository.load_file(5, 0).unwrap().after, ["old overlay"]);
|
||||
assert!(matches!(
|
||||
repository
|
||||
.refresh_checkout(5, 6, true, &[(root.join("file"), "new overlay\n".into())])
|
||||
.unwrap(),
|
||||
Some(CheckoutRefresh::Checkout(_))
|
||||
));
|
||||
let file = repository.load_file(6, 0).unwrap();
|
||||
assert_eq!(file.before, ["advanced checkout"]);
|
||||
assert_eq!(file.after, ["new overlay"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jj_bookmark_refresh_recomputes_branch_merge_base_and_recovers_after_deletion() {
|
||||
let fixture = Fixture::new();
|
||||
let root = fixture.dir("bookmark-branch");
|
||||
init_jj(&root, false);
|
||||
fs::write(root.join("file"), "base\n").unwrap();
|
||||
jj(&root, &["describe", "-m", "base"]);
|
||||
let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||
jj(&root, &["bookmark", "create", "before"]);
|
||||
jj(&root, &["new"]);
|
||||
fs::write(root.join("file"), "tip\n").unwrap();
|
||||
jj(&root, &["describe", "-m", "tip"]);
|
||||
let tip = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||
jj(&root, &["bookmark", "create", "after"]);
|
||||
let mut repository = open(&root);
|
||||
let catalog = repository.catalog().unwrap();
|
||||
let before = catalog
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| entry.label == "before")
|
||||
.unwrap();
|
||||
let after = catalog
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| entry.label == "after")
|
||||
.unwrap();
|
||||
let original = repository
|
||||
.compare(1, before, after, ComparisonMode::BranchChanges, &[])
|
||||
.unwrap();
|
||||
assert!(original.before.ends_with(&base));
|
||||
jj(&root, &["bookmark", "set", "before", "-r", &tip]);
|
||||
let Some(CheckoutRefresh::Bookmark(comparison)) =
|
||||
repository.refresh_checkout(1, 2, false, &[]).unwrap()
|
||||
else {
|
||||
panic!("Moved Before must recompute merge base");
|
||||
};
|
||||
assert!(comparison.before.ends_with(&tip));
|
||||
assert!(comparison.files.is_empty());
|
||||
jj(&root, &["bookmark", "delete", "after"]);
|
||||
let state = disk_state(&root);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_checkout(2, 3, false, &[])
|
||||
.unwrap_err()
|
||||
.contains("missing or deleted")
|
||||
);
|
||||
assert!(repository.load_file(3, 0).is_err());
|
||||
assert_eq!(disk_state(&root), state);
|
||||
jj(&root, &["bookmark", "create", "after", "-r", &tip]);
|
||||
// A restored bookmark at the same ID is a successful no-op (or eligibility
|
||||
// promotion), not another comparison-content replacement.
|
||||
assert!(!matches!(
|
||||
repository.refresh_checkout(2, 3, false, &[]).unwrap(),
|
||||
Some(CheckoutRefresh::Bookmark(_))
|
||||
));
|
||||
jj(
|
||||
&root,
|
||||
&["bookmark", "set", "after", "-r", &base, "--allow-backwards"],
|
||||
);
|
||||
let Some(CheckoutRefresh::Bookmark(comparison)) =
|
||||
repository.refresh_checkout(3, 4, false, &[]).unwrap()
|
||||
else {
|
||||
panic!("Backward bookmark movement must refresh");
|
||||
};
|
||||
assert!(comparison.before.ends_with(&base));
|
||||
assert!(comparison.files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jj_bookmark_refresh_uses_exact_names_and_keeps_last_view_through_conflicts() {
|
||||
let fixture = Fixture::new();
|
||||
let root = fixture.dir("bookmark-conflict");
|
||||
init_jj(&root, false);
|
||||
fs::write(root.join("file"), "base\n").unwrap();
|
||||
jj(&root, &["describe", "-m", "base"]);
|
||||
let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||
let name = "topic'quoted";
|
||||
let argument = "\"topic'quoted\"";
|
||||
jj(&root, &["bookmark", "create", argument]);
|
||||
jj(&root, &["bookmark", "create", "topic"]);
|
||||
jj(&root, &["new"]);
|
||||
fs::write(root.join("file"), "left\n").unwrap();
|
||||
jj(&root, &["describe", "-m", "left"]);
|
||||
let left = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||
jj(&root, &["bookmark", "create", "keep-left"]);
|
||||
jj(&root, &["new", &base]);
|
||||
fs::write(root.join("file"), "right\n").unwrap();
|
||||
jj(&root, &["describe", "-m", "right"]);
|
||||
let right = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||
let mut repository = open(&root);
|
||||
let catalog = repository.catalog().unwrap();
|
||||
let selected = catalog
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| entry.label == name)
|
||||
.unwrap();
|
||||
repository
|
||||
.compare(
|
||||
1,
|
||||
&revision(&"0".repeat(40), RepoKind::Jujutsu),
|
||||
selected,
|
||||
ComparisonMode::Snapshots,
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
jj(&root, &["bookmark", "set", "topic", "-r", &left]);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_checkout(1, 2, false, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let operation = jj(
|
||||
&root,
|
||||
&["op", "log", "--no-graph", "--limit", "1", "-T", "id"],
|
||||
);
|
||||
jj(&root, &["bookmark", "set", argument, "-r", &left]);
|
||||
jj(
|
||||
&root,
|
||||
&[
|
||||
&format!("--at-operation={operation}"),
|
||||
"bookmark",
|
||||
"set",
|
||||
argument,
|
||||
"-r",
|
||||
&right,
|
||||
],
|
||||
);
|
||||
// Merge the fixture's concurrent operations, not the viewer's read queries.
|
||||
jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||
let state = disk_state(&root);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_checkout(1, 2, false, &[])
|
||||
.unwrap_err()
|
||||
.contains("conflicted")
|
||||
);
|
||||
assert_eq!(repository.load_file(1, 0).unwrap().after, ["base"]);
|
||||
assert!(repository.load_file(2, 0).is_err());
|
||||
assert_eq!(disk_state(&root), state);
|
||||
jj(
|
||||
&root,
|
||||
&[
|
||||
"bookmark",
|
||||
"set",
|
||||
argument,
|
||||
"-r",
|
||||
&left,
|
||||
"--allow-backwards",
|
||||
],
|
||||
);
|
||||
assert!(matches!(
|
||||
repository.refresh_checkout(1, 2, false, &[]).unwrap(),
|
||||
Some(CheckoutRefresh::Bookmark(_))
|
||||
));
|
||||
assert_eq!(repository.load_file(2, 0).unwrap().after, ["left"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jj_known_remote_bookmark_refreshes_locally_but_private_catalog_entries_stay_frozen() {
|
||||
let fixture = Fixture::new();
|
||||
let source = fixture.dir("source");
|
||||
init_jj(&source, false);
|
||||
fs::write(source.join("file"), "first\n").unwrap();
|
||||
jj(&source, &["describe", "-m", "first"]);
|
||||
jj(&source, &["bookmark", "create", "topic"]);
|
||||
jj(&source, &["git", "export"]);
|
||||
let root = fixture.dir("active");
|
||||
init_jj(&root, false);
|
||||
jj(
|
||||
&root,
|
||||
&[
|
||||
"git",
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
source.join(".jj/repo/store/git").to_str().unwrap(),
|
||||
],
|
||||
);
|
||||
let mut repository = open(&root);
|
||||
let private = repository.fetch_remote("origin").unwrap();
|
||||
let frozen = private
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| {
|
||||
entry.label == "topic@origin" && matches!(entry.target, RevisionTarget::Commit(_))
|
||||
})
|
||||
.unwrap();
|
||||
jj(&root, &["git", "fetch", "--remote", "origin"]);
|
||||
let catalog = repository.catalog().unwrap();
|
||||
let remote = catalog
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| {
|
||||
entry.target
|
||||
== (RevisionTarget::Bookmark {
|
||||
name: "topic".into(),
|
||||
remote: Some("origin".into()),
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
repository
|
||||
.compare(
|
||||
1,
|
||||
&revision(&"0".repeat(40), RepoKind::Jujutsu),
|
||||
remote,
|
||||
ComparisonMode::Snapshots,
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
jj(&source, &["new"]);
|
||||
fs::write(source.join("file"), "second\n").unwrap();
|
||||
jj(&source, &["describe", "-m", "second"]);
|
||||
jj(&source, &["bookmark", "set", "topic", "-r", "@"]);
|
||||
jj(&source, &["git", "export"]);
|
||||
// Remote movement alone does not cause a network poll by the viewer.
|
||||
assert!(
|
||||
repository
|
||||
.refresh_checkout(1, 2, false, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
jj(&root, &["git", "fetch", "--remote", "origin"]);
|
||||
let state = disk_state(&root);
|
||||
assert!(matches!(
|
||||
repository.refresh_checkout(1, 2, false, &[]).unwrap(),
|
||||
Some(CheckoutRefresh::Bookmark(_))
|
||||
));
|
||||
assert_eq!(repository.load_file(2, 0).unwrap().after, ["second"]);
|
||||
assert_eq!(disk_state(&root), state);
|
||||
repository
|
||||
.compare(
|
||||
3,
|
||||
&revision(&"0".repeat(40), RepoKind::Jujutsu),
|
||||
frozen,
|
||||
ComparisonMode::Snapshots,
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
repository
|
||||
.refresh_checkout(3, 4, false, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(repository.load_file(3, 0).unwrap().after, ["first"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_change_history() {
|
||||
let fixture = Fixture::new();
|
||||
@@ -823,6 +1253,7 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
|
||||
&[(active.join("file"), "dirty live\n".into())],
|
||||
)
|
||||
.unwrap()
|
||||
.map(checkout_comparison)
|
||||
.unwrap();
|
||||
assert_eq!(promoted.before, branch.before);
|
||||
assert_eq!(promoted.after, branch.after);
|
||||
@@ -1519,6 +1950,7 @@ fn git_working_after_advances_only_to_descendants_with_new_paths_and_unsaved_tex
|
||||
let mut refreshed = repository
|
||||
.refresh_checkout(1, 2, true, &[(root.join("file"), "unsaved\n".into())])
|
||||
.unwrap()
|
||||
.map(checkout_comparison)
|
||||
.unwrap();
|
||||
assert_eq!(refreshed.before, original.before);
|
||||
assert_eq!(refreshed.after, original.after);
|
||||
@@ -1616,6 +2048,7 @@ fn working_after_refresh_preserves_frozen_working_before_and_sources_on_failure(
|
||||
let refreshed = repository
|
||||
.refresh_checkout(10, 11, true, &[])
|
||||
.unwrap()
|
||||
.map(checkout_comparison)
|
||||
.unwrap();
|
||||
assert_eq!(refreshed.files.len(), 1);
|
||||
let file = repository.load_file(11, 0).unwrap();
|
||||
@@ -1638,6 +2071,7 @@ fn working_after_refresh_preserves_frozen_working_before_and_sources_on_failure(
|
||||
let readonly = repository
|
||||
.refresh_checkout(12, 13, false, &[(root.join("file"), "dirty live\n".into())])
|
||||
.unwrap()
|
||||
.map(checkout_comparison)
|
||||
.unwrap();
|
||||
assert_eq!(readonly.before, pinned.before);
|
||||
assert_eq!(readonly.after, pinned.after);
|
||||
@@ -1662,6 +2096,7 @@ fn working_after_refresh_preserves_frozen_working_before_and_sources_on_failure(
|
||||
let promoted = repository
|
||||
.refresh_checkout(13, 14, false, &[])
|
||||
.unwrap()
|
||||
.map(checkout_comparison)
|
||||
.unwrap();
|
||||
assert_eq!(promoted.editable_paths, pinned.editable_paths);
|
||||
fs::write(root.join("file"), "dirty disk\n").unwrap();
|
||||
@@ -1675,6 +2110,7 @@ fn working_after_refresh_preserves_frozen_working_before_and_sources_on_failure(
|
||||
let recovered = repository
|
||||
.refresh_checkout(14, 15, false, &[])
|
||||
.unwrap()
|
||||
.map(checkout_comparison)
|
||||
.unwrap();
|
||||
assert_eq!(recovered.editable_paths, pinned.editable_paths);
|
||||
assert_eq!(
|
||||
@@ -1748,6 +2184,7 @@ fn unborn_git_working_after_safely_advances_to_its_first_commit() {
|
||||
let comparison = repository
|
||||
.refresh_checkout(1, 2, true, &[])
|
||||
.unwrap()
|
||||
.map(checkout_comparison)
|
||||
.unwrap();
|
||||
assert_eq!(comparison.files.len(), 1);
|
||||
assert_eq!(repository.load_file(2, 0).unwrap().after, ["initial"]);
|
||||
@@ -1791,6 +2228,7 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
|
||||
let mut comparison = repository
|
||||
.refresh_checkout(1, 2, true, &[(root.join("file"), "unsaved\n".into())])
|
||||
.unwrap()
|
||||
.map(checkout_comparison)
|
||||
.unwrap();
|
||||
for (index, file) in comparison.files.iter_mut().enumerate() {
|
||||
*file = repository.load_file(2, index).unwrap();
|
||||
@@ -1847,6 +2285,7 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
|
||||
let refreshed = repository
|
||||
.refresh_checkout(10, 11, true, &[])
|
||||
.unwrap()
|
||||
.map(checkout_comparison)
|
||||
.unwrap();
|
||||
assert_eq!(refreshed.files.len(), 1);
|
||||
let file = repository.load_file(11, 0).unwrap();
|
||||
@@ -1900,6 +2339,7 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
|
||||
let readonly = repository
|
||||
.refresh_checkout(21, 22, false, &[(root.join("file"), "dirty live\n".into())])
|
||||
.unwrap()
|
||||
.map(checkout_comparison)
|
||||
.unwrap();
|
||||
assert!(readonly.editable_paths.is_empty());
|
||||
assert_eq!(readonly.before, pinned.before);
|
||||
@@ -1913,6 +2353,7 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
|
||||
let promoted = repository
|
||||
.refresh_checkout(22, 23, false, &[])
|
||||
.unwrap()
|
||||
.map(checkout_comparison)
|
||||
.unwrap();
|
||||
assert_eq!(promoted.editable_paths, pinned.editable_paths);
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user