Implement advance-branches for jj commit

## Feature Description

If enabled in the user or repository settings, the local branches pointing to the
parents of the revision targeted by `jj commit` will be advanced to the newly
created commit. Support for `jj new` will be added in a future change.

This behavior can be enabled by default for all branches by setting
the following in the config.toml:

```
[experimental-advance-branches]
enabled-branches = ["glob:*"]
```

Specific branches can also be disabled:
```
[experimental-advance-branches]
enabled-branches = ["glob:*"]
disabled-branches = ["main"]
```

Branches that match a disabled pattern will not be advanced, even if they also
match an enabled pattern.

This implements feature request #2338.
This commit is contained in:
Evan Mesterhazy 2024-02-16 14:01:34 -05:00 committed by Evan Mesterhazy
parent cce1b9f00a
commit bbd9c7c7cb
6 changed files with 413 additions and 1 deletions

View file

@ -45,7 +45,7 @@ use jj_lib::matchers::Matcher;
use jj_lib::merge::MergedTreeValue;
use jj_lib::merged_tree::MergedTree;
use jj_lib::object_id::ObjectId;
use jj_lib::op_store::{OpStoreError, OperationId, WorkspaceId};
use jj_lib::op_store::{OpStoreError, OperationId, RefTarget, WorkspaceId};
use jj_lib::op_walk::OpsetEvaluationError;
use jj_lib::operation::Operation;
use jj_lib::repo::{
@ -393,6 +393,77 @@ impl ReadonlyUserRepo {
}
}
/// A branch that should be advanced to satisfy the "advance-branches" feature.
/// This is a helper for `WorkspaceCommandTransaction`. It provides a type-safe
/// way to separate the work of checking whether a branch can be advanced and
/// actually advancing it. Advancing the branch never fails, but can't be done
/// until the new `CommitId` is available. Splitting the work in this way also
/// allows us to identify eligible branches without actually moving them and
/// return config errors to the user early.
pub struct AdvanceableBranch {
name: String,
old_commit_id: CommitId,
}
/// Helper for parsing and evaluating settings for the advance-branches feature.
/// Settings are configured in the jj config.toml as lists of [`StringPattern`]s
/// for enabled and disabled branches. Example:
/// ```toml
/// [experimental-advance-branches]
/// # Enable the feature for all branches except "main".
/// enabled-branches = ["glob:*"]
/// disabled-branches = ["main"]
/// ```
struct AdvanceBranchesSettings {
enabled_branches: Vec<StringPattern>,
disabled_branches: Vec<StringPattern>,
}
impl AdvanceBranchesSettings {
fn from_config(config: &config::Config) -> Result<Self, CommandError> {
let get_setting = |setting_key| {
let setting = format!("experimental-advance-branches.{setting_key}");
match config.get::<Vec<String>>(&setting).optional()? {
Some(patterns) => patterns
.into_iter()
.map(|s| {
StringPattern::parse(&s).map_err(|e| {
config_error_with_message(
format!("Error parsing '{s}' for {setting}"),
e,
)
})
})
.collect(),
None => Ok(Vec::new()),
}
};
Ok(Self {
enabled_branches: get_setting("enabled-branches")?,
disabled_branches: get_setting("disabled-branches")?,
})
}
/// Returns true if the advance-branches feature is enabled for
/// `branch_name`.
fn branch_is_eligible(&self, branch_name: &str) -> bool {
if self
.disabled_branches
.iter()
.any(|d| d.matches(branch_name))
{
return false;
}
self.enabled_branches.iter().any(|e| e.matches(branch_name))
}
/// Returns true if the config includes at least one "enabled-branches"
/// pattern.
fn feature_enabled(&self) -> bool {
!self.enabled_branches.is_empty()
}
}
/// Provides utilities for writing a command that works on a [`Workspace`]
/// (which most commands do).
pub struct WorkspaceCommandHelper {
@ -1375,6 +1446,44 @@ Then run `jj squash` to move the resolution into the conflicted commit."#,
Ok(())
}
/// Identifies branches which are eligible to be moved automatically during
/// `jj commit` and `jj new`. Whether a branch is eligible is determined by
/// its target and the user and repo config for "advance-branches".
///
/// Returns a Vec of branches in `repo` that point to any of the `from`
/// commits and that are eligible to advance. The `from` commits are
/// typically the parents of the target commit of `jj commit` or `jj new`.
///
/// Branches are not moved until
/// `WorkspaceCommandTransaction::advance_branches()` is called with the
/// `AdvanceableBranch`s returned by this function.
///
/// Returns an empty `std::Vec` if no branches are eligible to advance.
pub fn get_advanceable_branches<'a>(
&self,
from: impl IntoIterator<Item = &'a CommitId>,
) -> Result<Vec<AdvanceableBranch>, CommandError> {
let ab_settings = AdvanceBranchesSettings::from_config(self.settings.config())?;
if !ab_settings.feature_enabled() {
// Return early if we know that there's no work to do.
return Ok(Vec::new());
}
let mut advanceable_branches = Vec::new();
for from_commit in from {
for (name, _) in self.repo().view().local_branches_for_commit(from_commit) {
if ab_settings.branch_is_eligible(name) {
advanceable_branches.push(AdvanceableBranch {
name: name.to_owned(),
old_commit_id: from_commit.clone(),
});
}
}
}
Ok(advanceable_branches)
}
}
/// A [`Transaction`] tied to a particular workspace.
@ -1461,6 +1570,23 @@ impl WorkspaceCommandTransaction<'_> {
pub fn into_inner(self) -> Transaction {
self.tx
}
/// Moves each branch in `branches` from an old commit it's associated with
/// (configured by `get_advanceable_branches`) to the `move_to` commit. If
/// the branch is conflicted before the update, it will remain conflicted
/// after the update, but the conflict will involve the `move_to` commit
/// instead of the old commit.
pub fn advance_branches(&mut self, branches: Vec<AdvanceableBranch>, move_to: &CommitId) {
for branch in branches {
// This removes the old commit ID from the branch's RefTarget and
// replaces it with the `move_to` ID.
self.mut_repo().merge_local_branch(
&branch.name,
&RefTarget::normal(branch.old_commit_id),
&RefTarget::normal(move_to.clone()),
);
}
}
}
fn find_workspace_dir(cwd: &Path) -> &Path {

View file

@ -57,6 +57,7 @@ pub(crate) fn cmd_commit(
let matcher = workspace_command
.parse_file_patterns(&args.paths)?
.to_matcher();
let advanceable_branches = workspace_command.get_advanceable_branches(commit.parent_ids())?;
let diff_selector =
workspace_command.diff_selector(ui, args.tool.as_deref(), args.interactive)?;
let mut tx = workspace_command.start_transaction();
@ -121,6 +122,10 @@ new working-copy commit.
commit.tree_id().clone(),
)
.write()?;
// Does nothing if there's no branches to advance.
tx.advance_branches(advanceable_branches, new_commit.id());
for workspace_id in workspace_ids {
tx.mut_repo().edit(workspace_id, &new_wc_commit).unwrap();
}

View file

@ -405,6 +405,26 @@
}
}
},
"experimental-advance-branches": {
"type": "object",
"description": "Settings controlling the 'advance-branches' feature which moves branches forward when new commits are created.",
"properties": {
"enabled-branches": {
"type": "array",
"description": "Patterns used to identify branches which may be advanced.",
"items": {
"type": "string"
}
},
"disabled-branches": {
"type": "array",
"description": "Patterns used to identify branches which are not advanced. Takes precedence over 'enabled-branches'.",
"items": {
"type": "string"
}
}
}
},
"signing": {
"type": "object",
"description": "Settings for verifying and creating cryptographic commit signatures",

View file

@ -9,6 +9,7 @@ fn test_no_forgotten_test_files() {
}
mod test_abandon_command;
mod test_advance_branches;
mod test_alias;
mod test_branch_command;
mod test_builtin_aliases;

View file

@ -0,0 +1,250 @@
// Copyright 2024 The Jujutsu Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::path::Path;
use itertools::Itertools;
use crate::common::TestEnvironment;
fn get_log_output_with_branches(test_env: &TestEnvironment, cwd: &Path) -> String {
let template = r#"commit_id.short() ++ " br:{" ++ local_branches ++ "} dsc: " ++ description"#;
test_env.jj_cmd_success(cwd, &["log", "-T", template])
}
fn enable_advance_branches_for_patterns(test_env: &TestEnvironment, cwd: &Path, patterns: &[&str]) {
#[rustfmt::skip]
let pattern_string: String = patterns.iter().map(|x| format!("\"{}\"", x)).join(",");
test_env.jj_cmd_success(
cwd,
&[
"config",
"set",
"--repo",
"experimental-advance-branches.enabled-branches",
&format!("[{}]", pattern_string),
],
);
}
fn set_advance_branches(test_env: &TestEnvironment, cwd: &Path, value: bool) {
if value {
enable_advance_branches_for_patterns(test_env, cwd, &["glob:*"]);
} else {
enable_advance_branches_for_patterns(test_env, cwd, &[""]);
}
}
// Check that enabling and disabling advance-branches works as expected.
#[test]
fn test_advance_branches_enabled() {
let test_env = TestEnvironment::default();
test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]);
let workspace_path = test_env.env_root().join("repo");
// First, test with advance-branches enabled. Start by creating a branch on the
// root commit.
set_advance_branches(&test_env, &workspace_path, true);
test_env.jj_cmd_ok(
&workspace_path,
&["branch", "create", "-r", "@-", "test_branch"],
);
// Check the initial state of the repo.
insta::assert_snapshot!(get_log_output_with_branches(&test_env, &workspace_path), @r###"
@ 230dd059e1b0 br:{} dsc:
000000000000 br:{test_branch} dsc:
"###);
// Run jj commit, which will advance the branch pointing to @-.
test_env.jj_cmd_ok(&workspace_path, &["commit", "-m=first"]);
insta::assert_snapshot!(get_log_output_with_branches(&test_env, &workspace_path), @r###"
@ 24bb7f9da598 br:{} dsc:
95f2456c4bbd br:{test_branch} dsc: first
000000000000 br:{} dsc:
"###);
// Now disable advance branches and commit again. The branch shouldn't move.
set_advance_branches(&test_env, &workspace_path, false);
test_env.jj_cmd_ok(&workspace_path, &["commit", "-m=second"]);
insta::assert_snapshot!(get_log_output_with_branches(&test_env, &workspace_path), @r###"
@ b29edd893970 br:{} dsc:
ebf7d96fb6ad br:{} dsc: second
95f2456c4bbd br:{test_branch} dsc: first
000000000000 br:{} dsc:
"###);
}
// Check that only a branch pointing to @- advances. Branches pointing to @ are
// not advanced.
#[test]
fn test_advance_branches_at_minus() {
let test_env = TestEnvironment::default();
test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]);
let workspace_path = test_env.env_root().join("repo");
set_advance_branches(&test_env, &workspace_path, true);
test_env.jj_cmd_ok(&workspace_path, &["branch", "create", "test_branch"]);
insta::assert_snapshot!(get_log_output_with_branches(&test_env, &workspace_path), @r###"
@ 230dd059e1b0 br:{test_branch} dsc:
000000000000 br:{} dsc:
"###);
test_env.jj_cmd_ok(&workspace_path, &["commit", "-m=first"]);
insta::assert_snapshot!(get_log_output_with_branches(&test_env, &workspace_path), @r###"
@ 24bb7f9da598 br:{} dsc:
95f2456c4bbd br:{test_branch} dsc: first
000000000000 br:{} dsc:
"###);
// Create a second branch pointing to @. On the next commit, only the first
// branch, which points to @-, will advance.
test_env.jj_cmd_ok(&workspace_path, &["branch", "create", "test_branch2"]);
test_env.jj_cmd_ok(&workspace_path, &["commit", "-m=second"]);
insta::assert_snapshot!(get_log_output_with_branches(&test_env, &workspace_path), @r###"
@ b29edd893970 br:{} dsc:
ebf7d96fb6ad br:{test_branch test_branch2} dsc: second
95f2456c4bbd br:{} dsc: first
000000000000 br:{} dsc:
"###);
}
// Test that per-branch overrides invert the behavior of
// experimental-advance-branches.enabled.
#[test]
fn test_advance_branches_overrides() {
let test_env = TestEnvironment::default();
test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]);
let workspace_path = test_env.env_root().join("repo");
// advance-branches is disabled by default.
test_env.jj_cmd_ok(
&workspace_path,
&["branch", "create", "-r", "@-", "test_branch"],
);
// Check the initial state of the repo.
insta::assert_snapshot!(get_log_output_with_branches(&test_env, &workspace_path), @r###"
@ 230dd059e1b0 br:{} dsc:
000000000000 br:{test_branch} dsc:
"###);
// Commit will not advance the branch since advance-branches is disabled.
test_env.jj_cmd_ok(&workspace_path, &["commit", "-m=first"]);
insta::assert_snapshot!(get_log_output_with_branches(&test_env, &workspace_path), @r###"
@ 7e3a6f5e0f15 br:{} dsc:
307e33f70413 br:{} dsc: first
000000000000 br:{test_branch} dsc:
"###);
// Now enable advance branches for "test_branch", move the branch, and commit
// again.
test_env.add_config(
r#"[experimental-advance-branches]
enabled-branches = ["test_branch"]
"#,
);
test_env.jj_cmd_ok(
&workspace_path,
&["branch", "set", "test_branch", "-r", "@-"],
);
insta::assert_snapshot!(get_log_output_with_branches(&test_env, &workspace_path), @r###"
@ 7e3a6f5e0f15 br:{} dsc:
307e33f70413 br:{test_branch} dsc: first
000000000000 br:{} dsc:
"###);
test_env.jj_cmd_ok(&workspace_path, &["commit", "-m=second"]);
insta::assert_snapshot!(get_log_output_with_branches(&test_env, &workspace_path), @r###"
@ 8c1bd3e7de60 br:{} dsc:
468d1ab20fb3 br:{test_branch} dsc: second
307e33f70413 br:{} dsc: first
000000000000 br:{} dsc:
"###);
// Now disable advance branches for "test_branch" and "second_branch", which
// we will use later. Disabling always takes precedence over enabling.
test_env.add_config(
r#"[experimental-advance-branches]
enabled-branches = ["test_branch", "second_branch"]
disabled-branches = ["test_branch"]
"#,
);
test_env.jj_cmd_ok(&workspace_path, &["commit", "-m=third"]);
insta::assert_snapshot!(get_log_output_with_branches(&test_env, &workspace_path), @r###"
@ 5888a83948dd br:{} dsc:
50e9c28e6d85 br:{} dsc: third
468d1ab20fb3 br:{test_branch} dsc: second
307e33f70413 br:{} dsc: first
000000000000 br:{} dsc:
"###);
// If we create a new branch at @- and move test_branch there as well. When
// we commit, only "second_branch" will advance since "test_branch" is disabled.
test_env.jj_cmd_ok(
&workspace_path,
&["branch", "create", "second_branch", "-r", "@-"],
);
test_env.jj_cmd_ok(
&workspace_path,
&["branch", "set", "test_branch", "-r", "@-"],
);
insta::assert_snapshot!(get_log_output_with_branches(&test_env, &workspace_path), @r###"
@ 5888a83948dd br:{} dsc:
50e9c28e6d85 br:{second_branch test_branch} dsc: third
468d1ab20fb3 br:{} dsc: second
307e33f70413 br:{} dsc: first
000000000000 br:{} dsc:
"###);
test_env.jj_cmd_ok(&workspace_path, &["commit", "-m=fourth"]);
insta::assert_snapshot!(get_log_output_with_branches(&test_env, &workspace_path), @r###"
@ 666d42aedca7 br:{} dsc:
f23aa63eeb99 br:{second_branch} dsc: fourth
50e9c28e6d85 br:{test_branch} dsc: third
468d1ab20fb3 br:{} dsc: second
307e33f70413 br:{} dsc: first
000000000000 br:{} dsc:
"###);
}
// If multiple eligible branches point to @-, all of them will be advanced.
#[test]
fn test_advance_branches_multiple_branches() {
let test_env = TestEnvironment::default();
test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]);
let workspace_path = test_env.env_root().join("repo");
set_advance_branches(&test_env, &workspace_path, true);
test_env.jj_cmd_ok(
&workspace_path,
&["branch", "create", "-r", "@-", "first_branch"],
);
test_env.jj_cmd_ok(
&workspace_path,
&["branch", "create", "-r", "@-", "second_branch"],
);
// Check the initial state of the repo.
insta::assert_snapshot!(get_log_output_with_branches(&test_env, &workspace_path), @r###"
@ 230dd059e1b0 br:{} dsc:
000000000000 br:{first_branch second_branch} dsc:
"###);
// Both branches are eligible and both will advance.
test_env.jj_cmd_ok(&workspace_path, &["commit", "-m=first"]);
insta::assert_snapshot!(get_log_output_with_branches(&test_env, &workspace_path), @r###"
@ f307e5d9f90b br:{} dsc:
0fca5c9228e6 br:{first_branch second_branch} dsc: first
000000000000 br:{} dsc:
"###);
}

View file

@ -123,6 +123,16 @@ impl View {
.map(|(name, target)| (name.as_ref(), target))
}
/// Iterates local branches `(name, target)` in lexicographical order where
/// the target adds `commit_id`.
pub fn local_branches_for_commit<'a: 'b, 'b>(
&'a self,
commit_id: &'b CommitId,
) -> impl Iterator<Item = (&'a str, &'a RefTarget)> + 'b {
self.local_branches()
.filter(|(_, target)| target.added_ids().contains(commit_id))
}
/// Iterates local branch `(name, target)`s matching the given pattern.
/// Entries are sorted by `name`.
pub fn local_branches_matching<'a: 'b, 'b>(