Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Don't detatch Git HEAD when advance-branches is enabled for a branch #3405

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 149 additions & 4 deletions cli/src/cli_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ use jj_lib::id_prefix::IdPrefixContext;
use jj_lib::matchers::{EverythingMatcher, Matcher, PrefixMatcher};
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::{
Expand Down Expand Up @@ -389,6 +389,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 {
Expand Down Expand Up @@ -1195,11 +1266,30 @@ See https://github.com/martinvonz/jj/blob/main/docs/working-copy.md#stale-workin
.transpose()?;
if self.working_copy_shared_with_git {
let git_repo = self.git_backend().unwrap().open_git_repo()?;
if let Some(wc_commit) = &maybe_new_wc_commit {
git::reset_head(tx.mut_repo(), &git_repo, wc_commit)?;
}
// TODO(emesterhazy): Is it problematic that we're exporting these
// refs before resetting head? If the ref export fails, the head
// won't be reset. We could defer returning the error until after
// HEAD is reset, but we need to try to export the refs first so
// that we can set HEAD to an advanceable branch if one exists.
let failed_branches = git::export_refs(tx.mut_repo())?;
print_failed_git_export(ui, &failed_branches)?;
if let Some(wc_commit) = &maybe_new_wc_commit {
// If there's a single branch pointing to one of the working
// copy's parents and advance-branches is enabled for it, then
// set the Git HEAD to that branch instead of detaching at the
// commit. Ignore errors since it's too late to bail out without
// losing any work.
let parent_branch = match self.get_advanceable_branches(wc_commit.parent_ids()) {
Ok(branches) if branches.len() == 1 => Some(branches[0].name.clone()),
_ => None,
};
git::reset_head(
tx.mut_repo(),
&git_repo,
wc_commit,
parent_branch.as_deref(),
)?;
}
}
self.user_repo = ReadonlyUserRepo::new(tx.commit(description));
self.report_repo_changes(ui, &old_repo)?;
Expand Down Expand Up @@ -1362,6 +1452,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.
Expand Down Expand Up @@ -1448,6 +1576,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 {
Expand Down
5 changes: 5 additions & 0 deletions cli/src/commands/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ pub(crate) fn cmd_commit(
.get_wc_commit_id()
.ok_or_else(|| user_error("This command requires a working copy"))?;
let commit = workspace_command.repo().store().get_commit(commit_id)?;
let advanceable_branches = workspace_command.get_advanceable_branches(commit.parent_ids())?;
let matcher = workspace_command.matcher_from_values(&args.paths)?;
let diff_selector =
workspace_command.diff_selector(ui, args.tool.as_deref(), args.interactive)?;
Expand Down Expand Up @@ -119,6 +120,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();
}
Expand Down
28 changes: 25 additions & 3 deletions cli/src/commands/new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ use jj_lib::rewrite::{merge_commit_trees, rebase_commit};
use tracing::instrument;

use crate::cli_util::{
resolve_multiple_nonempty_revsets_default_single, short_commit_hash, CommandHelper, RevisionArg,
resolve_multiple_nonempty_revsets_default_single, short_commit_hash, AdvanceableBranch,
CommandHelper, RevisionArg, WorkspaceCommandHelper,
};
use crate::command_error::{user_error, CommandError};
use crate::description_util::join_message_paragraphs;
Expand Down Expand Up @@ -101,6 +102,7 @@ Please use `jj new 'all:x|y'` instead of `jj new --allow-large-revsets x y`.",
.into_iter()
.collect_vec();
let target_ids = target_commits.iter().map(|c| c.id().clone()).collect_vec();
let advanceable_branches = get_advanceable_branches(args, &workspace_command, &target_commits)?;
let mut tx = workspace_command.start_transaction();
let mut num_rebased;
let new_commit;
Expand Down Expand Up @@ -138,11 +140,11 @@ Please use `jj new 'all:x|y'` instead of `jj new --allow-large-revsets x y`.",
.set_description(join_message_paragraphs(&args.message_paragraphs))
.write()?;
num_rebased = target_ids.len();
for child_commit in target_commits {
for child_commit in &target_commits {
rebase_commit(
command.settings(),
tx.mut_repo(),
&child_commit,
child_commit,
&[new_commit.clone()],
)?;
}
Expand Down Expand Up @@ -199,6 +201,26 @@ Please use `jj new 'all:x|y'` instead of `jj new --allow-large-revsets x y`.",
if num_rebased > 0 {
writeln!(ui.stderr(), "Rebased {num_rebased} descendant commits")?;
}

// Does nothing if there's no branches to advance.
tx.advance_branches(advanceable_branches, target_commits[0].id());

tx.finish(ui, "new empty commit")?;
Ok(())
}

// Branches are only advanced if `jj new` has a single target commit and the
// new commit is not being inserted before or after the target.
fn get_advanceable_branches(
args: &NewArgs,
ws: &WorkspaceCommandHelper,
target_commits: &[Commit],
) -> Result<Vec<AdvanceableBranch>, CommandError> {
let should_advance_branches =
target_commits.len() == 1 && !args.insert_before && !args.insert_after;
if should_advance_branches {
ws.get_advanceable_branches(target_commits[0].parent_ids())
} else {
Ok(Vec::new())
}
}
20 changes: 20 additions & 0 deletions cli/src/config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,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",
Expand Down
1 change: 1 addition & 0 deletions cli/tests/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading