use std::sync::Arc;
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use itertools::Itertools;
use jj_lib::{
backend::{CommitId, CopyId, FileId, TreeValue},
commit::{Commit, conflict_label_for_commits},
matchers::{EverythingMatcher, FilesMatcher, Matcher},
merge::Merge,
merged_tree::MergedTree,
merged_tree_builder::MergedTreeBuilder,
object_id::ObjectId as ObjectIdTrait,
repo::Repo,
repo_path::RepoPath,
rewrite::{self, RebaseOptions, RebasedCommit},
};
use super::{precondition, read_file_content};
pub use crate::{
messages::{
ChangeHunk, TreePath,
mutations::{
CopyChanges, CopyHunk, MoveChanges, MoveHunk, MutationOptions, MutationResult,
},
},
worker::{Mutation, gui_util::WorkspaceSession},
};
#[async_trait(?Send)]
impl Mutation for MoveChanges {
async fn execute(
self: Box<Self>,
ws: &mut WorkspaceSession,
options: &MutationOptions,
) -> Result<MutationResult> {
let mut tx = ws.start_transaction().await?;
let to_id = CommitId::try_from_hex(&self.to_id.hex).expect("frontend-validated id");
if ws.check_immutable([to_id.clone()])? && !options.ignore_immutable {
precondition!("Destination revision is immutable");
}
let mut to = ws.get_commit(&to_id)?;
let (from_commits, is_immutable) = ws.resolve_change_set(&self.from, true)?;
if is_immutable && !options.ignore_immutable {
precondition!("Some source revisions are immutable");
}
let from_newest = from_commits
.first()
.ok_or_else(|| anyhow!("empty revset"))?;
let from_oldest = from_commits.last().ok_or_else(|| anyhow!("empty revset"))?;
let matcher = build_matcher(&self.paths)?;
let from_tree = from_newest.tree();
let oldest_parents = from_oldest.parents().await?;
let parent_tree = rewrite::merge_commit_trees(tx.repo(), &oldest_parents).await?;
let split_tree = rewrite::restore_tree(
&from_tree,
&parent_tree,
from_newest.conflict_label(),
conflict_label_for_commits(&oldest_parents),
matcher.as_ref(),
)
.await?;
let abandon_all = split_tree.tree_ids() == from_tree.tree_ids();
for commit in &from_commits {
let commit_tree = commit.tree();
let commit_parents = commit.parents().await?;
let commit_parent_tree =
rewrite::merge_commit_trees(tx.repo(), &commit_parents).await?;
let commit_remainder = rewrite::restore_tree(
&commit_parent_tree,
&commit_tree,
conflict_label_for_commits(&commit_parents),
commit.conflict_label(),
matcher.as_ref(),
)
.await?;
if commit_remainder.tree_ids() == commit_tree.tree_ids() {
} else if commit_remainder.tree_ids() == commit_parent_tree.tree_ids() {
tx.repo_mut().record_abandoned_commit(commit);
} else {
tx.repo_mut()
.rewrite_commit(commit)
.set_tree(commit_remainder)
.write()
.await?;
}
}
if tx.repo().index().is_ancestor(from_oldest.id(), to.id())? {
let mut rebase_map = std::collections::HashMap::new();
tx.repo_mut()
.rebase_descendants_with_options(
&RebaseOptions::default(),
|old_commit, rebased_commit| {
rebase_map.insert(
old_commit.id().clone(),
match rebased_commit {
RebasedCommit::Rewritten(new_commit) => new_commit.id().clone(),
RebasedCommit::Abandoned { parent_id } => parent_id,
},
);
},
)
.await?;
let rebased_to_id = rebase_map
.get(to.id())
.ok_or_else(|| anyhow!("descendant to_commit not found in rebase map"))?
.clone();
to = tx.repo().store().get_commit(&rebased_to_id)?;
}
let to_tree = to.tree();
let new_to_tree = MergedTree::merge(Merge::from_vec(vec![
(
to_tree,
format!("{} (move destination)", to.conflict_label()),
),
(
parent_tree,
format!(
"{} (parents of moved revision)",
from_oldest.conflict_label()
),
),
(
split_tree,
format!("{} (moved changes)", from_newest.conflict_label()),
),
]))
.await?;
let source_refs: Vec<_> = from_commits.iter().collect();
let description = combine_messages(&source_refs, &to, abandon_all);
tx.repo_mut()
.rewrite_commit(&to)
.set_tree(new_to_tree)
.set_description(description)
.write()
.await?;
match ws
.finish_transaction(
tx,
format!(
"move changes from {}::{} to {}",
from_oldest.id().hex(),
from_newest.id().hex(),
to.id().hex()
),
)
.await?
{
Some(new_status) => Ok(MutationResult::Updated {
new_status,
new_selection: None,
}),
None => Ok(MutationResult::Unchanged),
}
}
}
#[async_trait(?Send)]
impl Mutation for CopyChanges {
async fn execute(
self: Box<Self>,
ws: &mut WorkspaceSession,
options: &MutationOptions,
) -> Result<MutationResult> {
let mut tx = ws.start_transaction().await?;
let from = ws.resolve_commit_id(&self.from_id)?;
let from_tree = from.tree();
let matcher = build_matcher(&self.paths)?;
let (commits, is_immutable) = ws.resolve_change_set(&self.to_set, true)?;
if is_immutable && !options.ignore_immutable {
if commits.len() == 1 {
precondition!("Destination revision is immutable");
} else {
precondition!("Some destination revisions are immutable");
}
}
if commits.is_empty() {
return Ok(MutationResult::Unchanged);
}
let mut any_changed = false;
for commit in commits.iter().rev() {
let to_tree = commit.tree();
let new_tree = rewrite::restore_tree(
&from_tree,
&to_tree,
from.conflict_label(),
commit.conflict_label(),
matcher.as_ref(),
)
.await?;
if new_tree.tree_ids() != to_tree.tree_ids() {
any_changed = true;
tx.repo_mut()
.rewrite_commit(commit)
.set_tree(new_tree)
.write()
.await?;
}
}
if !any_changed {
return Ok(MutationResult::Unchanged);
}
tx.repo_mut().rebase_descendants().await?;
let description = if commits.len() == 1 {
format!("restore into commit {}", commits[0].id().hex())
} else {
format!("restore into {} commits", commits.len())
};
match ws.finish_transaction(tx, description).await? {
Some(new_status) => Ok(MutationResult::Updated {
new_status,
new_selection: None,
}),
None => Ok(MutationResult::Unchanged),
}
}
}
#[async_trait(?Send)]
impl Mutation for MoveHunk {
async fn execute(
self: Box<Self>,
ws: &mut WorkspaceSession,
options: &MutationOptions,
) -> Result<MutationResult> {
let from = ws.resolve_change_id(&self.from_id)?;
let mut to = ws.resolve_commit_id(&self.to_id)?;
if ws.check_immutable(vec![from.id().clone(), to.id().clone()])?
&& !options.ignore_immutable
{
precondition!("Some revisions are immutable");
}
let mut tx: jj_lib::transaction::Transaction = ws.start_transaction().await?;
let repo_path = RepoPath::from_internal_string(&self.path.repo_path)?;
let from_tree = from.tree();
let from_parents = from.parents().await?;
if from_parents.len() != 1 {
precondition!("Cannot move hunk from a merge commit");
}
let base_tree = from_parents[0].tree();
let store = tx.repo().store();
let base_content = read_file_content(store, &base_tree, repo_path).await?;
let sibling_content = apply_hunk_to_base(&base_content, &self.hunk)?;
let sibling_blob_id = store
.write_file(repo_path, &mut sibling_content.as_slice())
.await?;
let sibling_executable = match from_tree.path_value(repo_path).await?.into_resolved() {
Ok(Some(TreeValue::File { executable, .. })) => executable,
Ok(_) => false,
Err(_) => false,
};
let sibling_tree = update_tree_entry(
store,
&base_tree,
repo_path,
sibling_blob_id,
sibling_executable,
)
.await?;
let remainder_tree = MergedTree::merge(Merge::from_vec(vec![
(
from_tree.clone(),
format!("{} (hunk source)", from.conflict_label()),
),
(
sibling_tree.clone(),
format!("{} (moved hunk)", from.conflict_label()),
),
(
base_tree.clone(),
format!(
"{} (parent of hunk source)",
from_parents[0].conflict_label()
),
),
]))
.await?;
let to_tree = to.tree();
let mut new_to_tree = MergedTree::merge(Merge::from_vec(vec![
(
to_tree,
format!("{} (hunk destination)", to.conflict_label()),
),
(
base_tree.clone(),
format!(
"{} (parent of hunk source)",
from_parents[0].conflict_label()
),
),
(
sibling_tree.clone(),
format!("{} (moved hunk)", from.conflict_label()),
),
]))
.await?;
let abandon_source = remainder_tree.tree_ids() == base_tree.tree_ids();
let description = combine_messages(&[&from], &to, abandon_source);
let from_is_ancestor = tx.repo().index().is_ancestor(from.id(), to.id())?;
let to_is_ancestor = tx.repo().index().is_ancestor(to.id(), from.id())?;
if to_is_ancestor {
tx.repo_mut()
.rewrite_commit(&to)
.set_tree(new_to_tree)
.set_description(description)
.write()
.await?;
if abandon_source {
tx.repo_mut().record_abandoned_commit(&from);
} else {
tx.repo_mut()
.rewrite_commit(&from)
.set_tree(remainder_tree)
.write()
.await?;
}
tx.repo_mut().rebase_descendants().await?;
} else {
if abandon_source {
tx.repo_mut().record_abandoned_commit(&from);
} else {
tx.repo_mut()
.rewrite_commit(&from)
.set_tree(remainder_tree)
.write()
.await?;
}
if from_is_ancestor {
let mut rebase_map = std::collections::HashMap::new();
tx.repo_mut()
.rebase_descendants_with_options(
&RebaseOptions::default(),
|old_commit, rebased_commit| {
rebase_map.insert(
old_commit.id().clone(),
match rebased_commit {
RebasedCommit::Rewritten(new_commit) => new_commit.id().clone(),
RebasedCommit::Abandoned { parent_id } => parent_id,
},
);
},
)
.await?;
let rebased_to_id = rebase_map
.get(to.id())
.ok_or_else(|| anyhow!("descendant to_commit not found in rebase map"))?
.clone();
to = tx.repo().store().get_commit(&rebased_to_id)?;
new_to_tree = MergedTree::merge(Merge::from_vec(vec![
(
to.tree(),
format!("{} (rebased hunk destination)", to.conflict_label()),
),
(
base_tree.clone(),
format!(
"{} (parent of hunk source)",
from_parents[0].conflict_label()
),
),
(
sibling_tree.clone(),
format!("{} (moved hunk)", from.conflict_label()),
),
]))
.await?;
}
tx.repo_mut()
.rewrite_commit(&to)
.set_tree(new_to_tree)
.set_description(description)
.write()
.await?;
tx.repo_mut().rebase_descendants().await?;
}
match ws
.finish_transaction(
tx,
format!(
"move hunk in {} from {} to {}",
self.path.repo_path,
from.id().hex(),
to.id().hex()
),
)
.await?
{
Some(new_status) => Ok(MutationResult::Updated {
new_status,
new_selection: None,
}),
None => Ok(MutationResult::Unchanged),
}
}
}
#[async_trait(?Send)]
impl Mutation for CopyHunk {
async fn execute(
self: Box<Self>,
ws: &mut WorkspaceSession,
options: &MutationOptions,
) -> Result<MutationResult> {
let mut tx = ws.start_transaction().await?;
let from = ws.resolve_commit_id(&self.from_id)?;
let to = ws.resolve_change_id(&self.to_id)?;
let repo_path = RepoPath::from_internal_string(&self.path.repo_path)?;
if ws.check_immutable(vec![to.id().clone()])? && !options.ignore_immutable {
precondition!("Revision is immutable");
}
let store = tx.repo().store();
let to_tree = to.tree();
let to_path_value = to_tree.path_value(repo_path).await?;
if to_path_value.into_resolved().is_err() {
precondition!("Cannot restore hunk: destination file has conflicts");
}
let to_content = read_file_content(store, &to_tree, repo_path).await?;
let to_text = String::from_utf8_lossy(&to_content);
let to_lines: Vec<&str> = to_text.lines().collect();
let to_start_0based = self.hunk.location.to_file.start.saturating_sub(1);
let to_end_0based = to_start_0based + self.hunk.location.to_file.len;
if to_end_0based > to_lines.len() {
precondition!(
"Hunk location out of bounds: file has {} lines, hunk requires lines {}-{}",
to_lines.len(),
self.hunk.location.to_file.start,
to_end_0based
);
}
let expected_to_lines: Vec<&str> = self
.hunk
.lines
.lines
.iter()
.filter(|line| line.starts_with(' ') || line.starts_with('+'))
.map(|line| line[1..].trim_end())
.collect();
let actual_to_lines: Vec<&str> = to_lines[to_start_0based..to_end_0based]
.iter()
.map(|line| line.trim_end())
.collect();
if expected_to_lines.len() != actual_to_lines.len() {
return Err(anyhow!(
"Hunk validation failed: expected {} lines, found {} lines at destination",
expected_to_lines.len(),
actual_to_lines.len()
));
}
for (i, (expected, actual)) in expected_to_lines
.iter()
.zip(actual_to_lines.iter())
.enumerate()
{
if expected != actual {
return Err(anyhow!(
"Hunk validation failed at line {}: expected '{}', found '{}'",
to_start_0based + i + 1,
expected,
actual
));
}
}
let from_tree = from.tree();
let from_content = read_file_content(store, &from_tree, repo_path).await?;
let from_text = String::from_utf8_lossy(&from_content);
let from_lines: Vec<&str> = from_text.lines().collect();
let from_start_0based = self.hunk.location.from_file.start.saturating_sub(1);
let from_end_0based = from_start_0based + self.hunk.location.from_file.len;
if from_end_0based > from_lines.len() {
precondition!(
"Source hunk location out of bounds: file has {} lines, hunk requires lines {}-{}",
from_lines.len(),
self.hunk.location.from_file.start,
from_end_0based
);
}
let source_region_lines = &from_lines[from_start_0based..from_end_0based];
let mut new_to_lines = Vec::new();
new_to_lines.extend(to_lines[..to_start_0based].iter().map(|s| s.to_string()));
new_to_lines.extend(source_region_lines.iter().map(|s| s.to_string()));
new_to_lines.extend(to_lines[to_end_0based..].iter().map(|s| s.to_string()));
let ends_with_newline = to_content.ends_with(b"\n");
let mut new_to_content = Vec::new();
let num_lines = new_to_lines.len();
for (i, line) in new_to_lines.iter().enumerate() {
new_to_content.extend_from_slice(line.as_bytes());
if i < num_lines - 1 {
new_to_content.push(b'\n');
}
}
if ends_with_newline && !new_to_content.is_empty() && !new_to_content.ends_with(b"\n") {
new_to_content.push(b'\n');
}
if new_to_content == to_content {
return Ok(MutationResult::Unchanged);
}
let new_to_blob_id = store
.write_file(repo_path, &mut new_to_content.as_slice())
.await?;
let to_executable = match to_tree.path_value(repo_path).await?.into_resolved() {
Ok(Some(TreeValue::File { executable, .. })) => executable,
_ => false,
};
let new_to_tree =
update_tree_entry(store, &to_tree, repo_path, new_to_blob_id, to_executable).await?;
tx.repo_mut()
.rewrite_commit(&to)
.set_tree(new_to_tree)
.write()
.await?;
tx.repo_mut().rebase_descendants().await?;
match ws
.finish_transaction(
tx,
format!(
"restore hunk in {} from {} into {}",
self.path.repo_path, self.from_id.hex, self.to_id.commit.hex
),
)
.await?
{
Some(new_status) => Ok(MutationResult::Updated {
new_status,
new_selection: None,
}),
None => Ok(MutationResult::Unchanged),
}
}
}
#[allow(clippy::manual_strip)]
fn apply_hunk_to_base(base_content: &[u8], hunk: &ChangeHunk) -> Result<Vec<u8>> {
let base_text = String::from_utf8_lossy(base_content);
let base_lines: Vec<&str> = base_text.lines().collect();
let ends_with_newline = base_content.ends_with(b"\n");
let mut result_lines: Vec<String> = Vec::new();
let hunk_lines = hunk.lines.lines.iter().peekable();
let hunk_start = hunk.location.from_file.start.saturating_sub(1);
result_lines.extend(base_lines[..hunk_start].iter().map(|s| s.to_string()));
let mut base_idx = hunk_start;
for diff_line in hunk_lines {
if diff_line.starts_with(' ') || diff_line.starts_with('-') {
let expected = &diff_line[1..];
if base_idx < base_lines.len() && base_lines[base_idx].trim_end() == expected.trim_end()
{
if diff_line.starts_with(' ') {
result_lines.push(base_lines[base_idx].to_string());
}
base_idx += 1;
} else {
anyhow::bail!(
"Hunk mismatch at line {}: expected '{}', found '{}'",
base_idx + 1,
expected.trim_end(),
base_lines.get(base_idx).map_or("<EOF>", |l| l.trim_end())
);
}
} else if diff_line.starts_with('+') {
let added = diff_line[1..].trim_end_matches('\n');
result_lines.push(added.to_string());
} else {
anyhow::bail!("Malformed diff line: {}", diff_line);
}
}
result_lines.extend(base_lines[base_idx..].iter().map(|s| s.to_string()));
let mut result_bytes = Vec::new();
let num_lines = result_lines.len();
for (i, line) in result_lines.iter().enumerate() {
result_bytes.extend_from_slice(line.as_bytes());
if i < num_lines - 1 {
result_bytes.push(b'\n');
}
}
if ends_with_newline && !result_bytes.is_empty() && !result_bytes.ends_with(b"\n") {
result_bytes.push(b'\n');
}
Ok(result_bytes)
}
fn build_matcher(paths: &[TreePath]) -> Result<Box<dyn Matcher>> {
if paths.is_empty() {
Ok(Box::new(EverythingMatcher))
} else {
let repo_paths: Vec<_> = paths
.iter()
.map(|p| RepoPath::from_internal_string(&p.repo_path))
.try_collect()?;
Ok(Box::new(FilesMatcher::new(&repo_paths)))
}
}
fn combine_messages(sources: &[&Commit], destination: &Commit, abandon_source: bool) -> String {
if abandon_source {
let descriptions: Vec<_> = std::iter::once(destination.description())
.chain(sources.iter().map(|c| c.description()))
.filter(|d| !d.is_empty())
.collect();
descriptions.join("\n")
} else {
destination.description().to_owned()
}
}
async fn update_tree_entry(
_store: &Arc<jj_lib::store::Store>,
original_tree: &MergedTree,
path: &RepoPath,
new_blob: FileId,
executable: bool,
) -> Result<MergedTree, anyhow::Error> {
let mut builder = MergedTreeBuilder::new(original_tree.clone());
builder.set_or_remove(
path.to_owned(),
Merge::normal(TreeValue::File {
id: new_blob,
executable,
copy_id: CopyId::placeholder(),
}),
);
let new_tree = builder.write_tree().await?;
Ok(new_tree)
}
#[cfg(all(test, not(feature = "ts-rs")))]
mod tests {
use super::*;
use crate::{
messages::{
ChangeLocation, ChangeRange, MultilineString, RevSet,
mutations::{CreateRevision, DescribeRevision},
queries::RevsResult,
},
worker::{
WorkerSession, queries,
tests::{get_by_chid, mkrepo, query_by_chid, query_by_id, revs},
},
};
use anyhow::Result;
use assert_matches::assert_matches;
use std::fs;
use tokio::io::AsyncReadExt;
#[tokio::test]
async fn move_changes_all_paths() -> Result<()> {
let repo = mkrepo();
let mut session = WorkerSession::default();
let mut ws = session.load_workspace(repo.path()).await?;
let parent_header =
queries::query_revision(&ws, &revs::conflict_bookmark())?.expect("exists");
assert!(parent_header.has_conflict);
let result = MoveChanges {
from: RevSet::singleton(revs::resolve_conflict()),
to_id: revs::conflict_bookmark().commit,
paths: vec![],
}
.execute_unboxed(&mut ws)
.await?;
assert_matches!(result, MutationResult::Updated { .. });
let parent_header =
queries::query_revision(&ws, &revs::conflict_bookmark())?.expect("exists");
assert!(!parent_header.has_conflict);
Ok(())
}
#[tokio::test]
async fn move_changes_single_path() -> Result<()> {
let repo = mkrepo();
let mut session = WorkerSession::default();
let mut ws = session.load_workspace(repo.path()).await?;
let from_rev = query_by_id(&ws, revs::main_bookmark()).await?;
let to_rev = query_by_id(&ws, revs::working_copy()).await?;
assert_matches!(from_rev, RevsResult::Detail { changes, .. } if changes.len() == 2);
assert_matches!(to_rev, RevsResult::Detail { changes, .. } if changes.is_empty());
let result = MoveChanges {
from: RevSet::singleton(revs::main_bookmark()),
to_id: revs::working_copy().commit,
paths: vec![TreePath {
repo_path: "c.txt".to_owned(),
relative_path: "".into(),
}],
}
.execute_unboxed(&mut ws)
.await?;
assert_matches!(result, MutationResult::Updated { .. });
let from_rev = query_by_id(&ws, revs::main_bookmark()).await?;
let to_rev = query_by_id(&ws, revs::working_copy()).await?;
assert_matches!(from_rev, RevsResult::Detail { changes, .. } if changes.len() == 1);
assert_matches!(to_rev, RevsResult::Detail { changes, .. } if changes.len() == 1);
Ok(())
}
#[tokio::test]
async fn move_changes_range_partial() -> Result<()> {
let repo = mkrepo();
let mut session = WorkerSession::default();
let mut ws = session.load_workspace(repo.path()).await?;
fs::write(repo.path().join("x.txt"), "x content").unwrap();
fs::write(repo.path().join("y.txt"), "y content").unwrap();
DescribeRevision {
id: revs::working_copy(),
new_description: "commit A".to_owned(),
reset_author: false,
}
.execute_unboxed(&mut ws)
.await?;
let a = ws.get_commit(ws.wc_id())?;
let a_id = ws.format_id(&a);
let result = CreateRevision {
set: RevSet::singleton(a_id.clone()),
}
.execute_unboxed(&mut ws)
.await?;
let b_id = match result {
MutationResult::Updated {
new_selection: Some(sel),
..
} => sel.id,
_ => panic!("expected new revision"),
};
fs::write(repo.path().join("y.txt"), "y modified").unwrap();
fs::write(repo.path().join("z.txt"), "z content").unwrap();
DescribeRevision {
id: b_id.clone(),
new_description: "commit B".to_owned(),
reset_author: false,
}
.execute_unboxed(&mut ws)
.await?;
let b = get_by_chid(&ws, &b_id)?;
let b_id = ws.format_id(&b);
let c_base = revs::main_bookmark();
let result = CreateRevision {
set: RevSet::singleton(c_base.clone()),
}
.execute_unboxed(&mut ws)
.await?;
let c_id = match result {
MutationResult::Updated {
new_selection: Some(sel),
..
} => sel.id,
_ => panic!("expected new revision"),
};
let result = MoveChanges {
from: RevSet::sequence(a_id.clone(), b_id.clone()),
to_id: c_id.commit.clone(),
paths: vec![TreePath {
repo_path: "z.txt".to_owned(),
relative_path: "".into(),
}],
}
.execute_unboxed(&mut ws)
.await?;
assert_matches!(result, MutationResult::Updated { .. });
let a_rev = query_by_chid(&ws, &a_id.change.hex).await?;
assert_matches!(a_rev, RevsResult::Detail { changes, .. } if changes.len() == 2);
let b_rev = query_by_chid(&ws, &b_id.change.hex).await?;
assert_matches!(b_rev, RevsResult::Detail { changes, .. } if changes.len() == 1);
let c_rev = query_by_chid(&ws, &c_id.change.hex).await?;
assert_matches!(c_rev, RevsResult::Detail { changes, .. } if changes.len() == 1);
Ok(())
}
#[tokio::test]
async fn move_changes_range_partial_multi_touch() -> Result<()> {
let repo = mkrepo();
let mut session = WorkerSession::default();
let mut ws = session.load_workspace(repo.path()).await?;
fs::write(repo.path().join("z.txt"), "version 1").unwrap();
DescribeRevision {
id: revs::working_copy(),
new_description: "commit A: create z.txt".to_owned(),
reset_author: false,
}
.execute_unboxed(&mut ws)
.await?;
let a = ws.get_commit(ws.wc_id())?;
let a_id = ws.format_id(&a);
let result = CreateRevision {
set: RevSet::singleton(a_id.clone()),
}
.execute_unboxed(&mut ws)
.await?;
let b_id = match result {
MutationResult::Updated {
new_selection: Some(sel),
..
} => sel.id,
_ => panic!("expected new revision"),
};
fs::write(repo.path().join("z.txt"), "version 2").unwrap();
fs::write(repo.path().join("y.txt"), "y content").unwrap();
DescribeRevision {
id: b_id.clone(),
new_description: "commit B: modify z.txt, add y.txt".to_owned(),
reset_author: false,
}
.execute_unboxed(&mut ws)
.await?;
let b = get_by_chid(&ws, &b_id)?;
let b_id = ws.format_id(&b);
let a_rev = query_by_id(&ws, a_id.clone()).await?;
assert_matches!(a_rev, RevsResult::Detail { changes, .. } if changes.len() == 1);
let b_rev = query_by_id(&ws, b_id.clone()).await?;
assert_matches!(b_rev, RevsResult::Detail { changes, .. } if changes.len() == 2);
let c_base = revs::main_bookmark();
let result = CreateRevision {
set: RevSet::singleton(c_base.clone()),
}
.execute_unboxed(&mut ws)
.await?;
let c_id = match result {
MutationResult::Updated {
new_selection: Some(sel),
..
} => sel.id,
_ => panic!("expected new revision"),
};
let result = MoveChanges {
from: RevSet::sequence(a_id.clone(), b_id.clone()),
to_id: c_id.commit.clone(),
paths: vec![TreePath {
repo_path: "z.txt".to_owned(),
relative_path: "".into(),
}],
}
.execute_unboxed(&mut ws)
.await?;
assert_matches!(result, MutationResult::Updated { .. });
let a_exists = ws.evaluate_revset_str(&a_id.change.hex);
assert!(
a_exists.is_err() || a_exists.unwrap().is_empty(),
"commit A should be abandoned"
);
let b_rev = query_by_chid(&ws, &b_id.change.hex).await?;
assert_matches!(b_rev, RevsResult::Detail { changes, .. } if changes.len() == 1);
let c_rev = query_by_chid(&ws, &c_id.change.hex).await?;
assert_matches!(c_rev, RevsResult::Detail { changes, .. } if changes.len() == 1);
let c = get_by_chid(&ws, &c_id)?;
let tree = c.tree();
let path = jj_lib::repo_path::RepoPath::from_internal_string("z.txt")?;
let value = tree.path_value(&path).await?;
assert!(value.is_resolved());
Ok(())
}
#[tokio::test]
async fn move_changes_descendant_target() -> Result<()> {
let repo = mkrepo();
let mut session = WorkerSession::default();
let mut ws = session.load_workspace(repo.path()).await?;
let base = ws.get_commit(ws.wc_id())?;
let base_id = ws.format_id(&base);
let result = CreateRevision {
set: RevSet::singleton(base_id.clone()),
}
.execute_unboxed(&mut ws)
.await?;
let source_id = match result {
MutationResult::Updated {
new_selection: Some(sel),
..
} => sel.id,
_ => panic!("expected new revision"),
};
fs::write(repo.path().join("move_changes_source.txt"), "source").unwrap();
DescribeRevision {
id: source_id.clone(),
new_description: "source".to_owned(),
reset_author: false,
}
.execute_unboxed(&mut ws)
.await?;
let source = get_by_chid(&ws, &source_id)?;
let source_id = ws.format_id(&source);
let result = CreateRevision {
set: RevSet::singleton(source_id.clone()),
}
.execute_unboxed(&mut ws)
.await?;
let middle_id = match result {
MutationResult::Updated {
new_selection: Some(sel),
..
} => sel.id,
_ => panic!("expected new revision"),
};
fs::write(repo.path().join("move_changes_middle.txt"), "middle").unwrap();
DescribeRevision {
id: middle_id.clone(),
new_description: "middle".to_owned(),
reset_author: false,
}
.execute_unboxed(&mut ws)
.await?;
let middle = get_by_chid(&ws, &middle_id)?;
let middle_id = ws.format_id(&middle);
let result = CreateRevision {
set: RevSet::singleton(middle_id.clone()),
}
.execute_unboxed(&mut ws)
.await?;
let target_id = match result {
MutationResult::Updated {
new_selection: Some(sel),
..
} => sel.id,
_ => panic!("expected new revision"),
};
fs::write(repo.path().join("move_changes_target.txt"), "target").unwrap();
DescribeRevision {
id: target_id.clone(),
new_description: "target".to_owned(),
reset_author: false,
}
.execute_unboxed(&mut ws)
.await?;
let target = get_by_chid(&ws, &target_id)?;
let target_id = ws.format_id(&target);
let result = MoveChanges {
from: RevSet::singleton(source_id.clone()),
to_id: target_id.commit.clone(),
paths: vec![TreePath {
repo_path: "move_changes_source.txt".to_owned(),
relative_path: "".into(),
}],
}
.execute_unboxed(&mut ws)
.await?;
assert_matches!(result, MutationResult::Updated { .. });
let source_header = queries::query_revision(&ws, &source_id)?;
assert!(source_header.is_none(), "source should be abandoned");
let middle_after = get_by_chid(&ws, &middle_id)?;
let target_after = get_by_chid(&ws, &target_id)?;
assert_eq!(middle_after.parent_ids()[0], base.id().clone());
assert_eq!(target_after.parent_ids()[0], middle_after.id().clone());
for (label, commit) in [("middle", &middle_after), ("target", &target_after)] {
let targets = ws
.repo()
.resolve_change_id(commit.change_id())?
.expect("commit should resolve");
assert!(
!targets.is_divergent(),
"{label} should not be divergent after move"
);
}
let middle_rev = query_by_chid(&ws, &middle_id.change.hex).await?;
assert_matches!(&middle_rev, RevsResult::Detail { changes, .. } if changes.iter().any(|c| c.path.repo_path == "move_changes_middle.txt")
&& !changes.iter().any(|c| c.path.repo_path == "move_changes_source.txt"));
let target_rev = query_by_chid(&ws, &target_id.change.hex).await?;
assert_matches!(&target_rev, RevsResult::Detail { changes, .. } if changes.iter().any(|c| c.path.repo_path == "move_changes_target.txt")
&& changes.iter().any(|c| c.path.repo_path == "move_changes_source.txt"));
Ok(())
}
#[tokio::test]
async fn copy_changes() -> Result<()> {
let repo = mkrepo();
let mut session = WorkerSession::default();
let mut ws = session.load_workspace(repo.path()).await?;
let from_rev = query_by_id(&ws, revs::resolve_conflict()).await?;
let to_rev = query_by_id(&ws, revs::working_copy()).await?;
assert_matches!(from_rev, RevsResult::Detail { changes, .. } if changes.len() == 1);
assert_matches!(to_rev, RevsResult::Detail { changes, .. } if changes.is_empty());
let result = CopyChanges {
from_id: revs::resolve_conflict().commit,
to_set: RevSet::singleton(revs::working_copy()),
paths: vec![TreePath {
repo_path: "b.txt".to_owned(),
relative_path: "".into(),
}],
}
.execute_unboxed(&mut ws)
.await?;
assert_matches!(result, MutationResult::Updated { .. });
let from_rev = query_by_id(&ws, revs::resolve_conflict()).await?;
let to_rev = query_by_id(&ws, revs::working_copy()).await?;
assert_matches!(from_rev, RevsResult::Detail { changes, .. } if changes.len() == 1);
assert_matches!(to_rev, RevsResult::Detail { changes, .. } if changes.len() == 1);
Ok(())
}
#[tokio::test]
async fn copy_changes_range() -> Result<()> {
let repo = mkrepo();
let mut session = WorkerSession::default();
let mut ws = session.load_workspace(repo.path()).await?;
let child_rev = query_by_id(&ws, revs::hunk_child_single()).await?;
let grandchild_rev = query_by_id(&ws, revs::hunk_grandchild()).await?;
assert_matches!(&child_rev, RevsResult::Detail { changes, .. } if changes.iter().any(|c| c.path.repo_path == "hunk_test.txt"));
assert_matches!(&grandchild_rev, RevsResult::Detail { changes, .. } if changes.iter().any(|c| c.path.repo_path == "hunk_test.txt"));
let result = CopyChanges {
from_id: revs::hunk_base().commit,
to_set: RevSet::sequence(revs::hunk_child_single(), revs::hunk_grandchild()),
paths: vec![TreePath {
repo_path: "hunk_test.txt".to_owned(),
relative_path: "".into(),
}],
}
.execute_unboxed(&mut ws)
.await?;
assert_matches!(result, MutationResult::Updated { .. });
let new_child_rev = query_by_id(&ws, revs::hunk_child_single()).await?;
let new_grandchild_rev = query_by_id(&ws, revs::hunk_grandchild()).await?;
assert_matches!(&new_child_rev, RevsResult::Detail { changes, .. } if !changes.iter().any(|c| c.path.repo_path == "hunk_test.txt"));
assert_matches!(&new_grandchild_rev, RevsResult::Detail { changes, .. } if !changes.iter().any(|c| c.path.repo_path == "hunk_test.txt"));
Ok(())
}
#[tokio::test]
async fn copy_changes_range_immutable() -> Result<()> {
let repo = mkrepo();
let mut session = WorkerSession::default();
let mut ws = session.load_workspace(repo.path()).await?;
let result = CopyChanges {
from_id: revs::immutable_grandparent().commit,
to_set: RevSet::sequence(revs::immutable_parent(), revs::immutable_bookmark()),
paths: vec![],
}
.execute_unboxed(&mut ws)
.await?;
assert_matches!(result, MutationResult::PreconditionError { .. });
Ok(())
}
#[tokio::test]
async fn move_hunk_descendant_partial() -> anyhow::Result<()> {
use jj_lib::repo::Repo;
let repo = mkrepo();
let mut session = WorkerSession::default();
let mut ws = session.load_workspace(repo.path()).await?;
let hunk = ChangeHunk {
location: ChangeLocation {
from_file: ChangeRange { start: 1, len: 3 },
to_file: ChangeRange { start: 1, len: 3 },
},
lines: MultilineString {
lines: vec![
" line1".to_owned(),
"-line2".to_owned(),
"+changed2".to_owned(),
" line3".to_owned(),
],
},
};
let mutation = MoveHunk {
from_id: revs::hunk_child_multi(),
to_id: revs::hunk_base().commit,
path: TreePath {
repo_path: "hunk_test.txt".to_owned(),
relative_path: "".into(),
},
hunk,
};
let result = mutation.execute_unboxed(&mut ws).await?;
assert_matches!(result, MutationResult::Updated { .. });
let source_commit = get_by_chid(&ws, &revs::hunk_child_multi())?;
let source_tree = source_commit.tree();
let repo_path = jj_lib::repo_path::RepoPath::from_internal_string("hunk_test.txt")?;
match source_tree.path_value(&repo_path).await?.into_resolved() {
Ok(Some(jj_lib::backend::TreeValue::File { id, .. })) => {
let mut reader = ws.repo().store().read_file(&repo_path, &id).await?;
let mut content = Vec::new();
reader.read_to_end(&mut content).await?;
let content_str = String::from_utf8_lossy(&content);
assert_eq!(
content_str, "line1\nchanged2\nline3\nchanged4\nline5\n",
"Source should have both changes after rebase (parent now has changed2)"
);
}
_ => panic!("Expected hunk_test.txt to be a file in source commit"),
}
let target_commit = get_by_chid(&ws, &revs::hunk_base())?;
let target_tree = target_commit.tree();
match target_tree.path_value(&repo_path).await?.into_resolved() {
Ok(Some(jj_lib::backend::TreeValue::File { id, .. })) => {
let mut reader = ws.repo().store().read_file(&repo_path, &id).await?;
let mut content = Vec::new();
reader.read_to_end(&mut content).await?;
let content_str = String::from_utf8_lossy(&content);
assert_eq!(
content_str, "line1\nchanged2\nline3\nline4\nline5\n",
"Target should have line 2 changed but not line 4"
);
}
_ => panic!("Expected hunk_test.txt to be a file in target commit"),
}
Ok(())
}
#[tokio::test]
async fn move_hunk_message() -> anyhow::Result<()> {
let repo = mkrepo();
let mut session = WorkerSession::default();
let mut ws = session.load_workspace(repo.path()).await?;
let hunk = ChangeHunk {
location: ChangeLocation {
from_file: ChangeRange { start: 2, len: 1 },
to_file: ChangeRange { start: 2, len: 1 },
},
lines: MultilineString {
lines: vec!["-line2".to_owned(), "+modified2".to_owned()],
},
};
let mutation = MoveHunk {
from_id: revs::hunk_child_single(),
to_id: revs::hunk_sibling().commit,
path: TreePath {
repo_path: "hunk_test.txt".to_owned(),
relative_path: "".into(),
},
hunk,
};
let result = mutation.execute_unboxed(&mut ws).await?;
assert_matches!(result, MutationResult::Updated { .. });
let source_header = queries::query_revision(&ws, &revs::hunk_child_single())?;
assert!(source_header.is_none(), "Source should be abandoned");
let target_header =
queries::query_revision(&ws, &revs::hunk_sibling())?.expect("target exists");
let desc = target_header.description.lines.join("\n");
assert!(
desc.contains("hunk sibling") && desc.contains("hunk child single"),
"Target description should combine both: got '{}'",
desc
);
Ok(())
}
#[tokio::test]
async fn move_hunk_invalid() -> anyhow::Result<()> {
let repo = mkrepo();
let mut session = WorkerSession::default();
let mut ws = session.load_workspace(repo.path()).await?;
let hunk = ChangeHunk {
location: ChangeLocation {
from_file: ChangeRange { start: 1, len: 1 },
to_file: ChangeRange { start: 1, len: 1 },
},
lines: MultilineString {
lines: vec!["-nonexistent".to_owned(), "+something".to_owned()],
},
};
let mutation = MoveHunk {
from_id: revs::hunk_source(),
to_id: revs::working_copy().commit,
path: TreePath {
repo_path: "b.txt".to_owned(),
relative_path: "".into(),
},
hunk,
};
let result = mutation.execute_unboxed(&mut ws).await;
assert!(result.is_err(), "Should fail with invalid hunk");
Ok(())
}
#[tokio::test]
async fn move_hunk_descendant_abandons_source() -> anyhow::Result<()> {
use jj_lib::repo::Repo;
let repo = mkrepo();
let mut session = WorkerSession::default();
let mut ws = session.load_workspace(repo.path()).await?;
let hunk = ChangeHunk {
location: ChangeLocation {
from_file: ChangeRange { start: 1, len: 3 },
to_file: ChangeRange { start: 1, len: 3 },
},
lines: MultilineString {
lines: vec![
" line1".to_owned(),
"-line2".to_owned(),
"+modified2".to_owned(),
" line3".to_owned(),
],
},
};
let mutation = MoveHunk {
from_id: revs::hunk_child_single(),
to_id: revs::hunk_base().commit,
path: TreePath {
repo_path: "hunk_test.txt".to_owned(),
relative_path: "".into(),
},
hunk,
};
let result = mutation.execute_unboxed(&mut ws).await?;
assert_matches!(result, MutationResult::Updated { .. });
let source_header = queries::query_revision(&ws, &revs::hunk_child_single())?;
assert!(source_header.is_none(), "Source should be abandoned");
let target_commit = get_by_chid(&ws, &revs::hunk_base())?;
let target_tree = target_commit.tree();
let repo_path = jj_lib::repo_path::RepoPath::from_internal_string("hunk_test.txt")?;
match target_tree.path_value(&repo_path).await?.into_resolved() {
Ok(Some(jj_lib::backend::TreeValue::File { id, .. })) => {
let mut reader = ws.repo().store().read_file(&repo_path, &id).await?;
let mut content = Vec::new();
reader.read_to_end(&mut content).await?;
let content_str = String::from_utf8_lossy(&content);
assert_eq!(
content_str, "line1\nmodified2\nline3\nline4\nline5\n",
"Target should have the hunk applied"
);
}
_ => panic!("Expected hunk_test.txt to be a file in target commit"),
}
Ok(())
}
#[tokio::test]
async fn move_hunk_unrelated() -> anyhow::Result<()> {
use jj_lib::repo::Repo;
let repo = mkrepo();
let mut session = WorkerSession::default();
let mut ws = session.load_workspace(repo.path()).await?;
let hunk = ChangeHunk {
location: ChangeLocation {
from_file: ChangeRange { start: 1, len: 3 },
to_file: ChangeRange { start: 1, len: 3 },
},
lines: MultilineString {
lines: vec![
" line1".to_owned(),
"-line2".to_owned(),
"+modified2".to_owned(),
" line3".to_owned(),
],
},
};
let mutation = MoveHunk {
from_id: revs::hunk_child_single(),
to_id: revs::hunk_sibling().commit,
path: TreePath {
repo_path: "hunk_test.txt".to_owned(),
relative_path: "".into(),
},
hunk,
};
let result = mutation.execute_unboxed(&mut ws).await?;
assert_matches!(result, MutationResult::Updated { .. });
let from_header = queries::query_revision(&ws, &revs::hunk_child_single())?;
if from_header.is_some() {
let from_rev = query_by_id(&ws, revs::hunk_child_single()).await?;
assert_matches!(from_rev, RevsResult::Detail { changes, .. } if changes.is_empty(),
"Expected source commit to have no changes after hunk move");
}
let sibling_commit = get_by_chid(&ws, &revs::hunk_sibling())?;
let sibling_tree = sibling_commit.tree();
let repo_path = jj_lib::repo_path::RepoPath::from_internal_string("hunk_test.txt")?;
match sibling_tree.path_value(&repo_path).await?.into_resolved() {
Ok(Some(jj_lib::backend::TreeValue::File { id, .. })) => {
let mut reader = ws.repo().store().read_file(&repo_path, &id).await?;
let mut content = Vec::new();
reader.read_to_end(&mut content).await?;
let content_str = String::from_utf8_lossy(&content);
assert_eq!(
content_str, "line1\nmodified2\nline3\nline4\nline5\nnew6\nnew7\nnew8\n",
"Sibling should have modified2 plus the new lines"
);
}
_ => panic!("Expected hunk_test.txt to be a file in sibling commit"),
}
Ok(())
}
#[tokio::test]
async fn move_hunk_unrelated_different_structure_creates_conflict() -> anyhow::Result<()> {
let repo = mkrepo();
let mut session = WorkerSession::default();
let mut ws = session.load_workspace(repo.path()).await?;
let hunk = ChangeHunk {
location: ChangeLocation {
from_file: ChangeRange { start: 1, len: 1 },
to_file: ChangeRange { start: 1, len: 1 },
},
lines: MultilineString {
lines: vec!["-1".to_owned(), "+11".to_owned()],
},
};
let mutation = MoveHunk {
from_id: revs::hunk_source(),
to_id: revs::working_copy().commit,
path: TreePath {
repo_path: "b.txt".to_owned(),
relative_path: "".into(),
},
hunk,
};
let result = mutation.execute_unboxed(&mut ws).await?;
assert_matches!(result, MutationResult::Updated { .. });
let to_header =
queries::query_revision(&ws, &revs::working_copy())?.expect("working copy exists");
assert!(
to_header.has_conflict,
"Expected conflict when moving hunk to file with different structure"
);
Ok(())
}
#[tokio::test]
async fn move_hunk_ancestor_to_descendant() -> anyhow::Result<()> {
use jj_lib::repo::Repo;
let repo = mkrepo();
let mut session = WorkerSession::default();
let mut ws = session.load_workspace(repo.path()).await?;
let repo_path = jj_lib::repo_path::RepoPath::from_internal_string("hunk_test.txt")?;
let child_before = get_by_chid(&ws, &revs::hunk_child_single())?;
let child_tree_before = child_before.tree();
match child_tree_before
.path_value(&repo_path)
.await?
.into_resolved()
{
Ok(Some(jj_lib::backend::TreeValue::File { id, .. })) => {
let mut reader = ws.repo().store().read_file(&repo_path, &id).await?;
let mut content = Vec::new();
reader.read_to_end(&mut content).await?;
assert_eq!(
String::from_utf8_lossy(&content),
"line1\nmodified2\nline3\nline4\nline5\n",
"hunk_child_single initial state"
);
}
_ => panic!("Expected hunk_test.txt in hunk_child_single"),
}
let grandchild_before = get_by_chid(&ws, &revs::hunk_grandchild())?;
let grandchild_tree_before = grandchild_before.tree();
match grandchild_tree_before
.path_value(&repo_path)
.await?
.into_resolved()
{
Ok(Some(jj_lib::backend::TreeValue::File { id, .. })) => {
let mut reader = ws.repo().store().read_file(&repo_path, &id).await?;
let mut content = Vec::new();
reader.read_to_end(&mut content).await?;
assert_eq!(
String::from_utf8_lossy(&content),
"line1\nmodified2\ngrandchild3\nline4\nline5\n",
"hunk_grandchild initial state"
);
}
_ => panic!("Expected hunk_test.txt in hunk_grandchild"),
}
let hunk = ChangeHunk {
location: ChangeLocation {
from_file: ChangeRange { start: 1, len: 3 },
to_file: ChangeRange { start: 1, len: 3 },
},
lines: MultilineString {
lines: vec![
" line1".to_owned(),
"-line2".to_owned(),
"+modified2".to_owned(),
" line3".to_owned(),
],
},
};
let mutation = MoveHunk {
from_id: revs::hunk_child_single(),
to_id: revs::hunk_grandchild().commit,
path: TreePath {
repo_path: "hunk_test.txt".to_owned(),
relative_path: "".into(),
},
hunk,
};
let result = mutation.execute_unboxed(&mut ws).await?;
assert_matches!(result, MutationResult::Updated { .. });
let source_header = queries::query_revision(&ws, &revs::hunk_child_single())?;
assert!(
source_header.is_none(),
"Source should be abandoned (its only change was moved)"
);
let dest_after = get_by_chid(&ws, &revs::hunk_grandchild())?;
let dest_tree = dest_after.tree();
let dest_targets = ws
.repo()
.resolve_change_id(dest_after.change_id())?
.expect("destination should resolve");
assert!(
!dest_targets.is_divergent(),
"destination should not be divergent after move"
);
let path_value = dest_tree.path_value(&repo_path).await?;
match path_value.into_resolved() {
Ok(Some(jj_lib::backend::TreeValue::File { id, .. })) => {
let mut reader = ws.repo().store().read_file(&repo_path, &id).await?;
let mut content = Vec::new();
reader.read_to_end(&mut content).await?;
let content_str = String::from_utf8_lossy(&content);
assert_eq!(
content_str, "line1\nmodified2\ngrandchild3\nline4\nline5\n",
"Destination should have modified2 (moved) and grandchild3 (own)"
);
}
Ok(None) => panic!("hunk_test.txt does not exist in destination"),
Ok(other) => panic!("hunk_test.txt has unexpected type: {:?}", other),
Err(_conflict) => {
panic!("Destination should not have a conflict after move");
}
}
let grandchild_parents = dest_after.parents().await?;
assert_eq!(
grandchild_parents.len(),
1,
"Grandchild should have one parent"
);
let parent = &grandchild_parents[0];
let base = get_by_chid(&ws, &revs::hunk_base())?;
assert_eq!(
parent.id(),
base.id(),
"Grandchild's parent should now be hunk_base (skipping abandoned hunk_child_single)"
);
Ok(())
}
#[tokio::test]
async fn move_hunk_between_siblings() -> anyhow::Result<()> {
use jj_lib::repo::Repo;
let repo = mkrepo();
let mut session = WorkerSession::default();
let mut ws = session.load_workspace(repo.path()).await?;
let hunk = ChangeHunk {
location: ChangeLocation {
from_file: ChangeRange { start: 1, len: 3 },
to_file: ChangeRange { start: 1, len: 3 },
},
lines: MultilineString {
lines: vec![
" line1".to_owned(),
"-line2".to_owned(),
"+changed2".to_owned(),
" line3".to_owned(),
],
},
};
let mutation = MoveHunk {
from_id: revs::hunk_child_multi(),
to_id: revs::hunk_sibling().commit,
path: TreePath {
repo_path: "hunk_test.txt".to_owned(),
relative_path: "".into(),
},
hunk,
};
let result = mutation.execute_unboxed(&mut ws).await?;
assert_matches!(result, MutationResult::Updated { .. });
let source_commit = get_by_chid(&ws, &revs::hunk_child_multi())?;
let source_tree = source_commit.tree();
let repo_path = jj_lib::repo_path::RepoPath::from_internal_string("hunk_test.txt")?;
match source_tree.path_value(&repo_path).await?.into_resolved() {
Ok(Some(jj_lib::backend::TreeValue::File { id, .. })) => {
let mut reader = ws.repo().store().read_file(&repo_path, &id).await?;
let mut content = Vec::new();
reader.read_to_end(&mut content).await?;
let content_str = String::from_utf8_lossy(&content);
assert_eq!(
content_str, "line1\nline2\nline3\nchanged4\nline5\n",
"Source should have only changed4 (changed2 was moved)"
);
}
_ => panic!("Expected hunk_test.txt in source"),
}
let target_commit = get_by_chid(&ws, &revs::hunk_sibling())?;
let target_tree = target_commit.tree();
match target_tree.path_value(&repo_path).await?.into_resolved() {
Ok(Some(jj_lib::backend::TreeValue::File { id, .. })) => {
let mut reader = ws.repo().store().read_file(&repo_path, &id).await?;
let mut content = Vec::new();
reader.read_to_end(&mut content).await?;
let content_str = String::from_utf8_lossy(&content);
assert_eq!(
content_str, "line1\nchanged2\nline3\nline4\nline5\nnew6\nnew7\nnew8\n",
"Target should have changed2 (moved) plus its own new lines"
);
}
_ => panic!("Expected hunk_test.txt in target"),
}
Ok(())
}
#[tokio::test]
async fn move_hunk_does_not_affect_other_files() -> anyhow::Result<()> {
use jj_lib::repo::Repo;
let repo = mkrepo();
let mut session = WorkerSession::default();
let mut ws = session.load_workspace(repo.path()).await?;
let child_before = get_by_chid(&ws, &revs::hunk_child_multi())?;
let child_tree_before = child_before.tree();
let a_txt_path = jj_lib::repo_path::RepoPath::from_internal_string("a.txt")?;
let a_txt_content_before = match child_tree_before
.path_value(&a_txt_path)
.await?
.into_resolved()
{
Ok(Some(jj_lib::backend::TreeValue::File { id, .. })) => {
let mut reader = ws.repo().store().read_file(&a_txt_path, &id).await?;
let mut content = Vec::new();
reader.read_to_end(&mut content).await?;
String::from_utf8_lossy(&content).to_string()
}
Ok(None) => String::new(), _ => panic!("Unexpected state for a.txt"),
};
let parent_before = get_by_chid(&ws, &revs::hunk_base())?;
let parent_tree_before = parent_before.tree();
let parent_a_txt_before = match parent_tree_before
.path_value(&a_txt_path)
.await?
.into_resolved()
{
Ok(Some(jj_lib::backend::TreeValue::File { id, .. })) => {
let mut reader = ws.repo().store().read_file(&a_txt_path, &id).await?;
let mut content = Vec::new();
reader.read_to_end(&mut content).await?;
String::from_utf8_lossy(&content).to_string()
}
Ok(None) => String::new(),
_ => panic!("Unexpected state for a.txt in parent"),
};
let hunk = ChangeHunk {
location: ChangeLocation {
from_file: ChangeRange { start: 1, len: 3 },
to_file: ChangeRange { start: 1, len: 3 },
},
lines: MultilineString {
lines: vec![
" line1".to_owned(),
"-line2".to_owned(),
"+changed2".to_owned(),
" line3".to_owned(),
],
},
};
let mutation = MoveHunk {
from_id: revs::hunk_child_multi(),
to_id: revs::hunk_base().commit,
path: TreePath {
repo_path: "hunk_test.txt".to_owned(),
relative_path: "".into(),
},
hunk,
};
let result = mutation.execute_unboxed(&mut ws).await?;
assert_matches!(result, MutationResult::Updated { .. });
let child_after = get_by_chid(&ws, &revs::hunk_child_multi())?;
let child_tree_after = child_after.tree();
let a_txt_content_after = match child_tree_after
.path_value(&a_txt_path)
.await?
.into_resolved()
{
Ok(Some(jj_lib::backend::TreeValue::File { id, .. })) => {
let mut reader = ws.repo().store().read_file(&a_txt_path, &id).await?;
let mut content = Vec::new();
reader.read_to_end(&mut content).await?;
String::from_utf8_lossy(&content).to_string()
}
Ok(None) => String::new(),
_ => panic!("Unexpected state for a.txt after move"),
};
assert_eq!(
a_txt_content_before, a_txt_content_after,
"a.txt in child should be unchanged after moving hunk in hunk_test.txt"
);
let parent_after = get_by_chid(&ws, &revs::hunk_base())?;
let parent_tree_after = parent_after.tree();
let parent_a_txt_after = match parent_tree_after
.path_value(&a_txt_path)
.await?
.into_resolved()
{
Ok(Some(jj_lib::backend::TreeValue::File { id, .. })) => {
let mut reader = ws.repo().store().read_file(&a_txt_path, &id).await?;
let mut content = Vec::new();
reader.read_to_end(&mut content).await?;
String::from_utf8_lossy(&content).to_string()
}
Ok(None) => String::new(),
_ => panic!("Unexpected state for a.txt in parent after move"),
};
assert_eq!(
parent_a_txt_before, parent_a_txt_after,
"a.txt in parent should be unchanged after moving hunk in hunk_test.txt"
);
Ok(())
}
#[tokio::test]
async fn move_hunk_second_of_two_hunks() -> anyhow::Result<()> {
use jj_lib::repo::Repo;
let repo = mkrepo();
let mut session = WorkerSession::default();
let mut ws = session.load_workspace(repo.path()).await?;
let hunk = ChangeHunk {
location: ChangeLocation {
from_file: ChangeRange { start: 3, len: 3 },
to_file: ChangeRange { start: 3, len: 3 },
},
lines: MultilineString {
lines: vec![
" line3".to_owned(),
"-line4".to_owned(),
"+changed4".to_owned(),
" line5".to_owned(),
],
},
};
let mutation = MoveHunk {
from_id: revs::hunk_child_multi(),
to_id: revs::hunk_sibling().commit,
path: TreePath {
repo_path: "hunk_test.txt".to_owned(),
relative_path: "".into(),
},
hunk,
};
let result = mutation.execute_unboxed(&mut ws).await?;
assert_matches!(result, MutationResult::Updated { .. });
let source_commit = get_by_chid(&ws, &revs::hunk_child_multi())?;
let source_tree = source_commit.tree();
let repo_path = jj_lib::repo_path::RepoPath::from_internal_string("hunk_test.txt")?;
match source_tree.path_value(&repo_path).await?.into_resolved() {
Ok(Some(jj_lib::backend::TreeValue::File { id, .. })) => {
let mut reader = ws.repo().store().read_file(&repo_path, &id).await?;
let mut content = Vec::new();
reader.read_to_end(&mut content).await?;
let content_str = String::from_utf8_lossy(&content);
assert_eq!(
content_str, "line1\nchanged2\nline3\nline4\nline5\n",
"Source should have first hunk (changed2) but not second"
);
}
_ => panic!("Expected hunk_test.txt to be a file in source commit"),
}
let target_commit = get_by_chid(&ws, &revs::hunk_sibling())?;
let target_tree = target_commit.tree();
match target_tree.path_value(&repo_path).await?.into_resolved() {
Ok(Some(jj_lib::backend::TreeValue::File { id, .. })) => {
let mut reader = ws.repo().store().read_file(&repo_path, &id).await?;
let mut content = Vec::new();
reader.read_to_end(&mut content).await?;
let content_str = String::from_utf8_lossy(&content);
assert_eq!(
content_str, "line1\nline2\nline3\nchanged4\nline5\nnew6\nnew7\nnew8\n",
"Target should have the second hunk (changed4) added plus new lines"
);
}
_ => panic!("Expected hunk_test.txt to be a file in target commit"),
}
Ok(())
}
#[tokio::test]
async fn copy_hunk_from_parent() -> anyhow::Result<()> {
use jj_lib::repo::Repo;
let repo = mkrepo();
let mut session = WorkerSession::default();
let mut ws = session.load_workspace(repo.path()).await?;
let hunk = ChangeHunk {
location: ChangeLocation {
from_file: ChangeRange { start: 1, len: 3 },
to_file: ChangeRange { start: 1, len: 3 },
},
lines: MultilineString {
lines: vec![
" line1".to_owned(),
"-line2".to_owned(),
"+modified2".to_owned(),
" line3".to_owned(),
],
},
};
let mutation = CopyHunk {
from_id: revs::hunk_base().commit,
to_id: revs::hunk_child_single(),
path: TreePath {
repo_path: "hunk_test.txt".to_owned(),
relative_path: "".into(),
},
hunk,
};
let result = mutation.execute_unboxed(&mut ws).await?;
assert_matches!(result, MutationResult::Updated { .. });
let child_commit = get_by_chid(&ws, &revs::hunk_child_single())?;
let child_tree = child_commit.tree();
let repo_path = jj_lib::repo_path::RepoPath::from_internal_string("hunk_test.txt")?;
match child_tree.path_value(&repo_path).await?.into_resolved() {
Ok(Some(jj_lib::backend::TreeValue::File { id, .. })) => {
let mut reader = ws.repo().store().read_file(&repo_path, &id).await?;
let mut content = Vec::new();
reader.read_to_end(&mut content).await?;
let content_str = String::from_utf8_lossy(&content);
assert_eq!(
content_str, "line1\nline2\nline3\nline4\nline5\n",
"Child should have parent's content after restore"
);
}
_ => panic!("Expected hunk_test.txt to be a file"),
}
Ok(())
}
#[tokio::test]
async fn copy_hunk_to_conflict() -> anyhow::Result<()> {
let repo = mkrepo();
let mut session = WorkerSession::default();
let mut ws = session.load_workspace(repo.path()).await?;
let conflict_commit = revs::conflict_bookmark();
let parent_commit = revs::main_bookmark();
let hunk = ChangeHunk {
location: ChangeLocation {
from_file: ChangeRange { start: 1, len: 1 },
to_file: ChangeRange { start: 1, len: 1 },
},
lines: MultilineString {
lines: vec!["-original".to_owned(), "+changed".to_owned()],
},
};
let mutation = CopyHunk {
from_id: parent_commit.commit.clone(),
to_id: conflict_commit.clone(),
path: TreePath {
repo_path: "b.txt".to_owned(), relative_path: "".into(),
},
hunk,
};
let result = mutation.execute_unboxed(&mut ws).await;
match result {
Ok(MutationResult::PreconditionError { message }) => {
assert!(
message.contains("conflict"),
"Expected error message about conflicts, got: {}",
message
);
}
Ok(_) => panic!("Expected precondition error for conflicted file"),
Err(e) => panic!("Expected precondition error, got hard error: {}", e),
}
Ok(())
}
#[tokio::test]
async fn copy_hunk_out_of_bounds() -> anyhow::Result<()> {
let repo = mkrepo();
let mut session = WorkerSession::default();
let mut ws = session.load_workspace(repo.path()).await?;
let hunk = ChangeHunk {
location: ChangeLocation {
from_file: ChangeRange { start: 1, len: 1 },
to_file: ChangeRange { start: 10, len: 5 }, },
lines: MultilineString {
lines: vec!["-something".to_owned(), "+else".to_owned()],
},
};
let mutation = CopyHunk {
from_id: revs::small_parent().commit,
to_id: revs::small_child(),
path: TreePath {
repo_path: "small.txt".to_owned(),
relative_path: "".into(),
},
hunk,
};
let result = mutation.execute_unboxed(&mut ws).await;
match result {
Ok(MutationResult::PreconditionError { message }) => {
assert!(
message.contains("out of bounds"),
"Expected error about bounds, got: {}",
message
);
}
Ok(_) => panic!("Expected precondition error for out of bounds"),
Err(e) => panic!("Expected precondition error, got hard error: {}", e),
}
Ok(())
}
#[tokio::test]
async fn copy_hunk_unchanged() -> anyhow::Result<()> {
let repo = mkrepo();
let mut session = WorkerSession::default();
let mut ws = session.load_workspace(repo.path()).await?;
let hunk = ChangeHunk {
location: ChangeLocation {
from_file: ChangeRange { start: 1, len: 3 },
to_file: ChangeRange { start: 1, len: 3 },
},
lines: MultilineString {
lines: vec![
" line1".to_owned(),
" line2".to_owned(),
" line3".to_owned(),
],
},
};
let mutation = CopyHunk {
from_id: revs::hunk_base().commit,
to_id: revs::hunk_sibling(),
path: TreePath {
repo_path: "hunk_test.txt".to_owned(),
relative_path: "".into(),
},
hunk,
};
let result = mutation.execute_unboxed(&mut ws).await?;
assert_matches!(result, MutationResult::Unchanged);
Ok(())
}
#[tokio::test]
async fn copy_hunk_multiple_hunks() -> anyhow::Result<()> {
use jj_lib::repo::Repo;
let repo = mkrepo();
let mut session = WorkerSession::default();
let mut ws = session.load_workspace(repo.path()).await?;
let hunk = ChangeHunk {
location: ChangeLocation {
from_file: ChangeRange { start: 3, len: 3 },
to_file: ChangeRange { start: 3, len: 3 },
},
lines: MultilineString {
lines: vec![
" line3".to_owned(),
"-line4".to_owned(),
"+changed4".to_owned(),
" line5".to_owned(),
],
},
};
let mutation = CopyHunk {
from_id: revs::hunk_base().commit,
to_id: revs::hunk_child_multi(),
path: TreePath {
repo_path: "hunk_test.txt".to_owned(),
relative_path: "".into(),
},
hunk,
};
let result = mutation.execute_unboxed(&mut ws).await?;
assert_matches!(result, MutationResult::Updated { .. });
let child_commit = get_by_chid(&ws, &revs::hunk_child_multi())?;
let child_tree = child_commit.tree();
let repo_path = jj_lib::repo_path::RepoPath::from_internal_string("hunk_test.txt")?;
match child_tree.path_value(&repo_path).await?.into_resolved() {
Ok(Some(jj_lib::backend::TreeValue::File { id, .. })) => {
let mut reader = ws.repo().store().read_file(&repo_path, &id).await?;
let mut content = Vec::new();
reader.read_to_end(&mut content).await?;
let content_str = String::from_utf8_lossy(&content);
assert_eq!(
content_str, "line1\nchanged2\nline3\nline4\nline5\n",
"Line 2 should remain modified (changed2), line 4 should be restored"
);
}
_ => panic!("Expected hunk_test.txt to be a file"),
}
Ok(())
}
}