diff --git a/docs/operation-log.md b/docs/operation-log.md index 3ca8e049f..81a9375c9 100644 --- a/docs/operation-log.md +++ b/docs/operation-log.md @@ -17,8 +17,13 @@ The operation log allows you to undo an operation (`jj [op] undo`), which doesn' need to be the most recent one. It also lets you restore the entire repo to the way it looked at an earlier point (`jj op restore`). -When referring to operations, you can use `@` to represent the current operation -as well as the `-` operator (e.g. `@-`) to get the parent of an operation. +When referring to operations, you can use `@` to represent the current +operation. + +The following operators are supported: + +* `x-`: Parents of `x` (e.g. `@-`) +* `x+`: Children of `x` ## Concurrent operations diff --git a/lib/src/op_walk.rs b/lib/src/op_walk.rs index 21b388573..b011db88d 100644 --- a/lib/src/op_walk.rs +++ b/lib/src/op_walk.rs @@ -101,22 +101,27 @@ fn resolve_single_op( get_current_op: impl FnOnce() -> Result, op_str: &str, ) -> Result { - let op_symbol = op_str.trim_end_matches('-'); + let op_symbol = op_str.trim_end_matches(['-', '+']); let op_postfix = &op_str[op_symbol.len()..]; + let head_ops = op_postfix + .contains('+') + .then(|| get_current_head_ops(op_store, op_heads_store)) + .transpose()?; let mut operation = match op_symbol { "@" => get_current_op(), s => resolve_single_op_from_store(op_store, op_heads_store, s), }?; - for _ in op_postfix.chars() { - let mut parent_ops = operation.parents(); - let Some(op) = parent_ops.next().transpose()? else { - return Err(OpsetResolutionError::EmptyOperations(op_str.to_owned()).into()); + for c in op_postfix.chars() { + let mut neighbor_ops = match c { + '-' => operation.parents().try_collect()?, + '+' => find_child_ops(head_ops.as_ref().unwrap(), operation.id())?, + _ => unreachable!(), + }; + operation = match neighbor_ops.len() { + 0 => Err(OpsetResolutionError::EmptyOperations(op_str.to_owned()))?, + 1 => neighbor_ops.pop().unwrap(), + _ => Err(OpsetResolutionError::MultipleOperations(op_str.to_owned()))?, }; - if parent_ops.next().is_some() { - return Err(OpsetResolutionError::MultipleOperations(op_str.to_owned()).into()); - } - drop(parent_ops); - operation = op; } Ok(operation) } @@ -178,6 +183,20 @@ fn get_current_head_ops( .try_collect() } +/// Looks up children of the `root_op_id` by traversing from the `head_ops`. +/// +/// This will be slow if the `root_op_id` is far away (or unreachable) from the +/// `head_ops`. +fn find_child_ops( + head_ops: &[Operation], + root_op_id: &OperationId, +) -> OpStoreResult> { + walk_ancestors(head_ops) + .take_while(|res| res.as_ref().map_or(true, |op| op.id() != root_op_id)) + .filter_ok(|op| op.parent_ids().iter().any(|id| id == root_op_id)) + .try_collect() +} + #[derive(Clone, Debug, Eq, Hash, PartialEq)] struct OperationByEndTime(Operation); diff --git a/lib/tests/test_operations.rs b/lib/tests/test_operations.rs index b85dcfc6a..1a9037147 100644 --- a/lib/tests/test_operations.rs +++ b/lib/tests/test_operations.rs @@ -262,7 +262,7 @@ fn test_resolve_op_id() { } #[test] -fn test_resolve_op_parents() { +fn test_resolve_op_parents_children() { // Use monotonic timestamp to stabilize merge order of transactions let settings = testutils::user_settings(); let test_repo = TestRepo::init_with_settings(&settings); @@ -275,6 +275,7 @@ fn test_resolve_op_parents() { operations.push(repo.operation().clone()); } + // Parent let op2_id_hex = operations[2].id().hex(); assert_eq!( op_walk::resolve_op_with_repo(&repo, &format!("{op2_id_hex}-")).unwrap(), @@ -292,6 +293,30 @@ fn test_resolve_op_parents() { )) ); + // Child + let op0_id_hex = operations[0].id().hex(); + assert_eq!( + op_walk::resolve_op_with_repo(&repo, &format!("{op0_id_hex}+")).unwrap(), + operations[1] + ); + assert_eq!( + op_walk::resolve_op_with_repo(&repo, &format!("{op0_id_hex}++")).unwrap(), + operations[2] + ); + assert_matches!( + op_walk::resolve_op_with_repo(&repo, &format!("{op0_id_hex}+++")), + Err(OpsetEvaluationError::OpsetResolution( + OpsetResolutionError::EmptyOperations(_) + )) + ); + + // Child of parent + assert_eq!( + op_walk::resolve_op_with_repo(&repo, &format!("{op2_id_hex}--+")).unwrap(), + operations[1] + ); + + // Merge and fork let tx1 = repo.start_transaction(&settings); let tx2 = repo.start_transaction(&settings); repo = testutils::commit_transactions(&settings, vec![tx1, tx2]); @@ -302,4 +327,11 @@ fn test_resolve_op_parents() { OpsetResolutionError::MultipleOperations(_) )) ); + let op2_id_hex = operations[2].id().hex(); + assert_matches!( + op_walk::resolve_op_with_repo(&repo, &format!("{op2_id_hex}+")), + Err(OpsetEvaluationError::OpsetResolution( + OpsetResolutionError::MultipleOperations(_) + )) + ); }