feat: track checkout identity for pinned diff editability

This commit is contained in:
2026-10-13 17:04:15 +02:00
parent e3e087f16f
commit 99fa5a2aff
14 changed files with 705 additions and 170 deletions
+131 -25
View File
@@ -311,6 +311,9 @@ struct PreparedComparison {
after: String,
working_after: bool,
working_anchor: Option<String>,
pinned_commit: Option<String>,
pinned_checkout: Option<String>,
editable_after: bool,
}
pub struct Repository {
@@ -933,7 +936,7 @@ impl Repository {
"log",
"--no-graph",
"-r",
&format!("commit_id({})", serde_json::to_string(id).unwrap()),
&format!("present(commit_id({}))", serde_json::to_string(id).unwrap()),
"-T",
"conflict",
],
@@ -1335,6 +1338,19 @@ impl Repository {
}
Ok(base.into())
}
fn pinned_checkout(&self, id: &str) -> Result<Option<String>, String> {
if self.kind == RepoKind::Git {
return Ok(Some(id.into()));
}
// Private remote history need not exist in the active jj operation.
let revset = format!("present(commit_id({}))", serde_json::to_string(id).unwrap());
let identity = text(self.jj(
&self.root,
&["log", "--no-graph", "-r", &revset, "-T", "change_id"],
)?)?;
let identity = identity.trim();
Ok((!identity.is_empty()).then(|| identity.into()))
}
fn working_anchor(&self) -> Result<Option<String>, String> {
if self.kind == RepoKind::Jujutsu {
return Ok(Some(
@@ -1417,11 +1433,20 @@ impl Repository {
|(before, after, label)| (before, after, label.as_str()),
);
let working_after = *after_target == RevisionTarget::Working;
let working_anchor = if working_after {
self.working_anchor()?
} else {
let checkout = self.working_anchor()?;
let pinned = if working_after {
None
} else {
let (store, id) = self.resolve(after_target)?;
Some((store, id))
};
let pinned_checkout = pinned
.as_ref()
.map(|(_, id)| self.pinned_checkout(id))
.transpose()?
.flatten();
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 right = if same {
@@ -1429,7 +1454,8 @@ impl Repository {
} else if working_after {
self.working_snapshot(buffers)?
} else {
self.snapshot(after_target)?
let (store, id) = pinned.as_ref().unwrap();
self.commit_snapshot(store, id)?
};
let mut prepared = PreparedComparison {
id: comparison_id,
@@ -1439,31 +1465,91 @@ impl Repository {
before: before_label.into(),
after: after.label.clone(),
working_after,
working_anchor,
working_anchor: working_after.then(|| checkout.clone()).flatten(),
pinned_commit: pinned.map(|(_, id)| id),
pinned_checkout,
editable_after,
};
let comparison = self.build_comparison(&mut prepared, same)?;
if working_after && self.working_anchor()? != prepared.working_anchor {
return Err("Working checkout moved during comparison".into());
if self.working_anchor()? != checkout {
return Err("Checkout moved during comparison".into());
}
self.comparison = Some(prepared);
Ok(comparison)
}
/// Advance only a selected Working After, keeping its original Before frozen.
pub fn refresh_working_after(
/// Follow Working forward when enabled, or update a pinned After's eligibility.
/// Pinned sources and path catalogs never change when checkout eligibility does.
pub fn refresh_checkout(
&mut self,
comparison_id: u64,
new_id: u64,
follow_working: bool,
buffers: &[(PathBuf, String)],
) -> Result<Option<Comparison>, 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()
&& prepared.id == comparison_id
&& !prepared.working_after
{
// The frontend revokes on error; a recovered checkout must emit a
// fresh promotion even if its identity did not change meanwhile.
prepared.editable_after = false;
}
result
}
fn refresh_checkout_inner(
&mut self,
comparison_id: u64,
new_id: u64,
follow_working: bool,
buffers: &[(PathBuf, String)],
) -> Result<Option<Comparison>, String> {
let Some(prepared) = self
.comparison
.as_ref()
.filter(|prepared| prepared.id == comparison_id && prepared.working_after)
.filter(|prepared| prepared.id == comparison_id)
else {
return Ok(None);
};
self.check_cancelled()?;
if prepared.working_after && !follow_working {
return Ok(None);
}
if prepared.pinned_checkout.is_none()
&& let Some(id) = prepared.pinned_commit.as_deref()
{
// A privately loaded endpoint may later be imported into active jj.
// Retry only its frozen commit ID, never the mutable remote alias.
let identity = self.pinned_checkout(id)?;
self.comparison.as_mut().unwrap().pinned_checkout = identity;
}
let prepared = self.comparison.as_ref().unwrap();
let anchor = self.working_anchor()?;
if !prepared.working_after {
let editable = prepared.pinned_checkout.is_some() && prepared.pinned_checkout == anchor;
if editable == prepared.editable_after {
return Ok(None);
}
// No live contents enter the frozen comparison, including dirty
// frontend buffer overlays.
let mut prepared = self.comparison.take().unwrap();
prepared.editable_after = editable;
let comparison = self.comparison_catalog(&prepared);
let result = self.check_cancelled().and_then(|()| {
if self.working_anchor()? != anchor {
return Err("Checkout moved during comparison".into());
}
Ok(comparison)
});
if result.is_ok() {
prepared.id = new_id;
}
self.comparison = Some(prepared);
return result.map(Some);
}
if !self.working_advanced(prepared.working_anchor.as_deref(), anchor.as_deref())? {
return Ok(None);
}
@@ -1560,35 +1646,55 @@ impl Repository {
removed.insert(j);
}
}
let files: Vec<_> = files
prepared.files = files
.into_iter()
.enumerate()
.filter_map(|(i, f)| (!removed.contains(&i)).then_some(f))
.filter_map(|(i, file)| {
(!removed.contains(&i)).then_some((file.path, file.old_path, file.status))
})
.collect();
let editable_paths = if prepared.working_after {
files
Ok(self.comparison_catalog(prepared))
}
fn comparison_catalog(&self, prepared: &PreparedComparison) -> Comparison {
let files = prepared
.files
.iter()
.map(|(path, old_path, status)| ChangedFile {
path: path.clone(),
old_path: old_path.clone(),
status: *status,
loaded: false,
before: vec![],
after: vec![],
before_eof: false,
after_eof: false,
hunks: vec![],
notice: None,
})
.collect();
let editable_paths = if prepared.editable_after {
prepared
.files
.iter()
.filter(|file| {
right_view
.get(&file.path)
.filter(|(path, _, _)| {
prepared
.right
.get(path)
.is_some_and(|data| matches!(data.mode.as_str(), "100644" | "100755"))
})
.map(|file| file.path.clone())
.map(|(path, _, _)| path.clone())
.collect()
} else {
BTreeSet::new()
};
prepared.files = files
.iter()
.map(|file| (file.path.clone(), file.old_path.clone(), file.status))
.collect();
Ok(Comparison {
Comparison {
before: prepared.before.clone(),
after: prepared.after.clone(),
files,
root: self.root.clone(),
editable_paths,
})
}
}
/// Prepare one catalog entry using its original comparison's frozen sources.