Skip to content

Commit

Permalink
op_walk: add function that reparents (and abandons) operation range
Browse files Browse the repository at this point in the history
This will be used in "jj op abandon ..op_id" command. The "op_id..@" range will
be reparented onto the root operation.

The current implementation is good enough for local repos, but it won't scale.
We might want to extract it as a trait method or introduce OpIndex for
efficient DAG operation.
  • Loading branch information
yuja committed Jan 4, 2024
1 parent 392e83b commit e525513
Show file tree
Hide file tree
Showing 2 changed files with 320 additions and 1 deletion.
69 changes: 69 additions & 0 deletions lib/src/op_walk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
//! Utility for operation id resolution and traversal.
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use std::slice;
use std::sync::Arc;

use itertools::Itertools as _;
Expand Down Expand Up @@ -230,3 +232,70 @@ pub fn walk_ancestors(head_ops: &[Operation]) -> impl Iterator<Item = OpStoreRes
)
.map_ok(|OperationByEndTime(op)| op)
}

/// Stats about `reparent_range()`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReparentStats {
/// New head operation ids.
pub new_head_ids: Vec<OperationId>,
/// The number of rewritten operations.
pub rewritten_count: usize,
/// The number of ancestor operations that become unreachable from the
/// rewritten heads.
pub unreachable_count: usize,
}

/// Reparents the operation range `root_ops..head_ops` onto the `dest_op`.
///
/// Returns the new head operation ids as well as some stats. If the old
/// operation heads are remapped to the new heads, the operations within the
/// range `dest_op..root_ops` become unreachable.
///
/// If the source operation range `root_ops..head_ops` was empty, the
/// `new_head_ids` will be `[dest_op.id()]`, meaning the `dest_op` is the head.
// TODO: Find better place to host this function. It might be an OpStore method.
pub fn reparent_range(
op_store: &dyn OpStore,
root_ops: &[Operation],
head_ops: &[Operation],
dest_op: &Operation,
) -> OpStoreResult<ReparentStats> {
// Calculate ::root_ops to exclude them from the source range and count the
// number of operations that become unreachable.
let mut unwanted_ids: HashSet<_> = walk_ancestors(root_ops)
.map_ok(|op| op.id().clone())
.try_collect()?;
let ops_to_reparent: Vec<_> = walk_ancestors(head_ops)
.filter_ok(|op| !unwanted_ids.contains(op.id()))
.try_collect()?;
for op in walk_ancestors(slice::from_ref(dest_op)) {
unwanted_ids.remove(op?.id());
}
let unreachable_ids = unwanted_ids;

let mut rewritten_ids = HashMap::new();
for old_op in ops_to_reparent.into_iter().rev() {
let mut data = old_op.store_operation().clone();
let mut dest_once = Some(dest_op.id());
data.parents = data
.parents
.iter()
.filter_map(|id| rewritten_ids.get(id).or_else(|| dest_once.take()))
.cloned()
.collect();
let new_id = op_store.write_operation(&data)?;
rewritten_ids.insert(old_op.id().clone(), new_id);
}

let mut dest_once = Some(dest_op.id());
let new_head_ids = head_ops
.iter()
.filter_map(|op| rewritten_ids.get(op.id()).or_else(|| dest_once.take()))
.cloned()
.collect();
Ok(ReparentStats {
new_head_ids,
rewritten_count: rewritten_ids.len(),
unreachable_count: unreachable_ids.len(),
})
}
252 changes: 251 additions & 1 deletion lib/tests/test_operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@
// limitations under the License.

use std::path::Path;
use std::slice;
use std::sync::Arc;

use assert_matches::assert_matches;
use itertools::Itertools as _;
use jj_lib::backend::{CommitId, ObjectId};
use jj_lib::op_walk::{self, OpsetEvaluationError, OpsetResolutionError};
use jj_lib::repo::Repo;
use jj_lib::operation::Operation;
use jj_lib::repo::{ReadonlyRepo, Repo};
use jj_lib::settings::UserSettings;
use testutils::{create_random_commit, write_random_commit, TestRepo};

Expand Down Expand Up @@ -182,6 +185,253 @@ fn test_isolation() {
assert_heads(repo.as_ref(), vec![rewrite1.id(), rewrite2.id()]);
}

#[test]
fn test_reparent_range_linear() {
let settings = testutils::user_settings();
let test_repo = TestRepo::init();
let repo_0 = test_repo.repo;
let op_store = repo_0.op_store();

let read_op = |id| {
let data = op_store.read_operation(id).unwrap();
Operation::new(op_store.clone(), id.clone(), data)
};

fn op_parents<const N: usize>(op: &Operation) -> [Operation; N] {
let parents: Vec<_> = op.parents().try_collect().unwrap();
parents.try_into().unwrap()
}

// Set up linear operation graph:
// D
// C
// B
// A
// 0 (initial)
let random_tx = |repo: &Arc<ReadonlyRepo>| {
let mut tx = repo.start_transaction(&settings);
write_random_commit(tx.mut_repo(), &settings);
tx
};
let repo_a = random_tx(&repo_0).commit("op A");
let repo_b = random_tx(&repo_a).commit("op B");
let repo_c = random_tx(&repo_b).commit("op C");
let repo_d = random_tx(&repo_c).commit("op D");

// Reparent B..D (=C|D) onto A:
// D'
// C'
// A
// 0 (initial)
let stats = op_walk::reparent_range(
op_store.as_ref(),
slice::from_ref(repo_b.operation()),
slice::from_ref(repo_d.operation()),
repo_a.operation(),
)
.unwrap();
assert_eq!(stats.new_head_ids.len(), 1);
assert_eq!(stats.rewritten_count, 2);
assert_eq!(stats.unreachable_count, 1);
let new_op_d = read_op(&stats.new_head_ids[0]);
assert_eq!(
new_op_d.store_operation().metadata,
repo_d.operation().store_operation().metadata
);
assert_eq!(
new_op_d.store_operation().view_id,
repo_d.operation().store_operation().view_id
);
let [new_op_c] = op_parents(&new_op_d);
assert_eq!(
new_op_c.store_operation().metadata,
repo_c.operation().store_operation().metadata
);
assert_eq!(
new_op_c.store_operation().view_id,
repo_c.operation().store_operation().view_id
);
assert_eq!(new_op_c.parent_ids(), slice::from_ref(repo_a.op_id()));

// Reparent empty range onto A
let stats = op_walk::reparent_range(
op_store.as_ref(),
slice::from_ref(repo_d.operation()),
slice::from_ref(repo_d.operation()),
repo_a.operation(),
)
.unwrap();
assert_eq!(stats.new_head_ids, vec![repo_a.op_id().clone()]);
assert_eq!(stats.rewritten_count, 0);
assert_eq!(stats.unreachable_count, 3);
}

#[test]
fn test_reparent_range_branchy() {
let settings = testutils::user_settings();
let test_repo = TestRepo::init();
let repo_0 = test_repo.repo;
let op_store = repo_0.op_store();

let read_op = |id| {
let data = op_store.read_operation(id).unwrap();
Operation::new(op_store.clone(), id.clone(), data)
};

fn op_parents<const N: usize>(op: &Operation) -> [Operation; N] {
let parents: Vec<_> = op.parents().try_collect().unwrap();
parents.try_into().unwrap()
}

// Set up branchy operation graph:
// G
// |\
// | F
// E |
// D |
// |/
// C
// B
// A
// 0 (initial)
let random_tx = |repo: &Arc<ReadonlyRepo>| {
let mut tx = repo.start_transaction(&settings);
write_random_commit(tx.mut_repo(), &settings);
tx
};
let repo_a = random_tx(&repo_0).commit("op A");
let repo_b = random_tx(&repo_a).commit("op B");
let repo_c = random_tx(&repo_b).commit("op C");
let repo_d = random_tx(&repo_c).commit("op D");
let tx_e = random_tx(&repo_d);
let tx_f = random_tx(&repo_c);
let repo_g = testutils::commit_transactions(&settings, vec![tx_e, tx_f]);
let [op_e, op_f] = op_parents(repo_g.operation());

// Reparent D..G (= E|F|G) onto B:
// G'
// |\
// | F'
// E'|
// |/
// B
// A
// 0 (initial)
let stats = op_walk::reparent_range(
op_store.as_ref(),
slice::from_ref(repo_d.operation()),
slice::from_ref(repo_g.operation()),
repo_b.operation(),
)
.unwrap();
assert_eq!(stats.new_head_ids.len(), 1);
assert_eq!(stats.rewritten_count, 3);
assert_eq!(stats.unreachable_count, 2);
let new_op_g = read_op(&stats.new_head_ids[0]);
assert_eq!(
new_op_g.store_operation().metadata,
repo_g.operation().store_operation().metadata
);
assert_eq!(
new_op_g.store_operation().view_id,
repo_g.operation().store_operation().view_id
);
let [new_op_e, new_op_f] = op_parents(&new_op_g);
assert_eq!(new_op_e.parent_ids(), slice::from_ref(repo_b.op_id()));
assert_eq!(new_op_f.parent_ids(), slice::from_ref(repo_b.op_id()));

// Reparent B..G (=C|D|E|F|G) onto A:
// G'
// |\
// | F'
// E'|
// D'|
// |/
// C'
// A
// 0 (initial)
let stats = op_walk::reparent_range(
op_store.as_ref(),
slice::from_ref(repo_b.operation()),
slice::from_ref(repo_g.operation()),
repo_a.operation(),
)
.unwrap();
assert_eq!(stats.new_head_ids.len(), 1);
assert_eq!(stats.rewritten_count, 5);
assert_eq!(stats.unreachable_count, 1);
let new_op_g = read_op(&stats.new_head_ids[0]);
assert_eq!(
new_op_g.store_operation().metadata,
repo_g.operation().store_operation().metadata
);
assert_eq!(
new_op_g.store_operation().view_id,
repo_g.operation().store_operation().view_id
);
let [new_op_e, new_op_f] = op_parents(&new_op_g);
let [new_op_d] = op_parents(&new_op_e);
assert_eq!(new_op_d.parent_ids(), new_op_f.parent_ids());
let [new_op_c] = op_parents(&new_op_d);
assert_eq!(new_op_c.parent_ids(), slice::from_ref(repo_a.op_id()));

// Reparent (E|F)..G (=G) onto D:
// G'
// D
// C
// B
// A
// 0 (initial)
let stats = op_walk::reparent_range(
op_store.as_ref(),
&[op_e.clone(), op_f.clone()],
slice::from_ref(repo_g.operation()),
repo_d.operation(),
)
.unwrap();
assert_eq!(stats.new_head_ids.len(), 1);
assert_eq!(stats.rewritten_count, 1);
assert_eq!(stats.unreachable_count, 2);
let new_op_g = read_op(&stats.new_head_ids[0]);
assert_eq!(
new_op_g.store_operation().metadata,
repo_g.operation().store_operation().metadata
);
assert_eq!(
new_op_g.store_operation().view_id,
repo_g.operation().store_operation().view_id
);
assert_eq!(new_op_g.parent_ids(), slice::from_ref(repo_d.op_id()));

// Reparent C..F (=F) onto D (ignoring G):
// F'
// D
// C
// B
// A
// 0 (initial)
let stats = op_walk::reparent_range(
op_store.as_ref(),
slice::from_ref(repo_c.operation()),
slice::from_ref(&op_f),
repo_d.operation(),
)
.unwrap();
assert_eq!(stats.new_head_ids.len(), 1);
assert_eq!(stats.rewritten_count, 1);
assert_eq!(stats.unreachable_count, 0);
let new_op_f = read_op(&stats.new_head_ids[0]);
assert_eq!(
new_op_f.store_operation().metadata,
op_f.store_operation().metadata
);
assert_eq!(
new_op_f.store_operation().view_id,
op_f.store_operation().view_id
);
assert_eq!(new_op_f.parent_ids(), slice::from_ref(repo_d.op_id()));
}

fn stable_op_id_settings() -> UserSettings {
UserSettings::from_config(
testutils::base_config()
Expand Down

0 comments on commit e525513

Please sign in to comment.