feat: refresh diffs when selected jj bookmarks move

This commit is contained in:
Valdemar Lorenzen
2026-10-15 15:18:13 +02:00
parent 1a02502488
commit c32d4a7590
8 changed files with 991 additions and 62 deletions
+231 -22
View File
@@ -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(