Skip to content

Commit

Permalink
squash: move the core functionality to jj_lib
Browse files Browse the repository at this point in the history
This allows 'squash' to be executed more easily from a programmatic context
  • Loading branch information
torquestomp committed Oct 9, 2024
1 parent ea9a4eb commit 7f39792
Show file tree
Hide file tree
Showing 2 changed files with 162 additions and 99 deletions.
139 changes: 40 additions & 99 deletions cli/src/commands/squash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ use itertools::Itertools as _;
use jj_lib::commit::Commit;
use jj_lib::commit::CommitIteratorExt;
use jj_lib::matchers::Matcher;
use jj_lib::merged_tree::MergedTree;
use jj_lib::object_id::ObjectId;
use jj_lib::repo::Repo;
use jj_lib::rewrite;
use jj_lib::settings::UserSettings;
use tracing::instrument;

Expand Down Expand Up @@ -196,12 +196,6 @@ pub fn move_diff(
tx.base_workspace_helper()
.check_rewritable(sources.iter().chain(std::iter::once(destination)).ids())?;

struct SourceCommit<'a> {
commit: &'a Commit,
parent_tree: MergedTree,
selected_tree: MergedTree,
abandon: bool,
}
let mut source_commits = vec![];
for source in sources {
let parent_tree = source.parent_tree(tx.repo())?;
Expand All @@ -227,105 +221,52 @@ from the source will be moved into the destination.
let selected_tree_id =
diff_selector.select(&parent_tree, &source_tree, matcher, format_instructions)?;
let selected_tree = tx.repo().store().get_root_tree(&selected_tree_id)?;
let abandon = !keep_emptied && selected_tree.id() == source_tree.id();
if !abandon && selected_tree_id == parent_tree.id() {
// Nothing selected from this commit. If it's abandoned (i.e. already empty), we
// still include it so `jj squash` can be used for abandoning an empty commit in
// the middle of a stack.
continue;
}
// TODO: Do we want to optimize the case of moving to the parent commit (`jj
// squash -r`)? The source tree will be unchanged in that case.
source_commits.push(SourceCommit {
commit: source,
parent_tree,

source_commits.push(rewrite::CommitToSquash {
commit: source.clone(),
selected_tree,
abandon,
parent_tree,
});
}
if source_commits.is_empty() {
if diff_selector.is_interactive() {
return Err(user_error("No changes selected"));
}

if let [only_path] = path_arg {
if no_rev_arg
&& tx
.base_workspace_helper()
.parse_revset(ui, &RevisionArg::from(only_path.to_owned()))
.is_ok()
{
writeln!(
ui.warning_default(),
"The argument {only_path:?} is being interpreted as a path. To specify a \
revset, pass -r {only_path:?} instead."
)?;
let repo_path = tx.base_workspace_helper().repo_path().to_owned();
match rewrite::squash_commits(
settings,
tx.repo_mut(),
&source_commits,
destination,
keep_emptied,
|abandoned_commits| match description {
SquashedDescription::Exact(description) => Ok(description),
SquashedDescription::UseDestination => Ok(destination.description().to_owned()),
SquashedDescription::Combine => {
let abandoned_commits = abandoned_commits.iter().map(|c| &c.commit).collect_vec();
combine_messages(&repo_path, &abandoned_commits, destination, settings)
}
},
)? {
rewrite::SquashResult::NoChanges => {
if diff_selector.is_interactive() {
return Err(user_error("No changes selected"));
}
}

return Ok(());
}
if let [only_path] = path_arg {
if no_rev_arg
&& tx
.base_workspace_helper()
.parse_revset(ui, &RevisionArg::from(only_path.to_owned()))
.is_ok()
{
writeln!(
ui.warning_default(),
"The argument {only_path:?} is being interpreted as a path. To specify a \
revset, pass -r {only_path:?} instead."
)?;
}
}

for source in &source_commits {
if source.abandon {
tx.repo_mut()
.record_abandoned_commit(source.commit.id().clone());
} else {
let source_tree = source.commit.tree()?;
// Apply the reverse of the selected changes onto the source
let new_source_tree = source_tree.merge(&source.selected_tree, &source.parent_tree)?;
tx.repo_mut()
.rewrite_commit(settings, source.commit)
.set_tree_id(new_source_tree.id().clone())
.write()?;
Ok(())
}
rewrite::SquashResult::NewCommit(_) => Ok(()),
}

let mut rewritten_destination = destination.clone();
if sources
.iter()
.any(|source| tx.repo().index().is_ancestor(source.id(), destination.id()))
{
// If we're moving changes to a descendant, first rebase descendants onto the
// rewritten sources. Otherwise it will likely already have the content
// changes we're moving, so applying them will have no effect and the
// changes will disappear.
let rebase_map = tx.repo_mut().rebase_descendants_return_map(settings)?;
let rebased_destination_id = rebase_map.get(destination.id()).unwrap().clone();
rewritten_destination = tx.repo().store().get_commit(&rebased_destination_id)?;
}
// Apply the selected changes onto the destination
let mut destination_tree = rewritten_destination.tree()?;
for source in &source_commits {
destination_tree = destination_tree.merge(&source.parent_tree, &source.selected_tree)?;
}
let description = match description {
SquashedDescription::Exact(description) => description,
SquashedDescription::UseDestination => destination.description().to_owned(),
SquashedDescription::Combine => {
let abandoned_commits = source_commits
.iter()
.filter_map(|source| source.abandon.then_some(source.commit))
.collect_vec();
combine_messages(
tx.base_workspace_helper().repo_path(),
&abandoned_commits,
destination,
settings,
)?
}
};
let mut predecessors = vec![destination.id().clone()];
predecessors.extend(
source_commits
.iter()
.map(|source| source.commit.id().clone()),
);
tx.repo_mut()
.rewrite_commit(settings, &rewritten_destination)
.set_tree_id(destination_tree.id().clone())
.set_predecessors(predecessors)
.set_description(description)
.write()?;
Ok(())
}
122 changes: 122 additions & 0 deletions lib/src/rewrite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -830,3 +830,125 @@ pub fn move_commits(
num_skipped_rebases,
})
}

pub struct CommitToSquash {
pub commit: Commit,
pub selected_tree: MergedTree,
pub parent_tree: MergedTree,
}

impl CommitToSquash {
/// Returns true if the selection contains all changes in the commit.
fn is_full_selection(&self) -> bool {
&self.selected_tree.id() == self.commit.tree_id()
}

/// Returns true if the selection matches the parent tree (contains no
/// changes from the commit).
///
/// Both `is_full_selection()` and `is_empty_selection()`
/// can be true if the commit is itself empty.
fn is_empty_selection(&self) -> bool {
self.selected_tree.id() == self.parent_tree.id()
}
}

#[derive(Clone, Debug)]
pub enum SquashResult {
/// No inputs contained actual changes.
NoChanges,
/// Destination was rewritten.
NewCommit(Commit),
}

/// Squash `sources` into `destination` and return a CommitBuilder for the
/// resulting commit. Caller is responsible for setting the description and
/// finishing the commit.
pub fn squash_commits<E>(
settings: &UserSettings,
repo: &mut MutableRepo,
sources: &[CommitToSquash],
destination: &Commit,
keep_emptied: bool,
description_fn: impl FnOnce(&[&CommitToSquash]) -> Result<String, E>,
) -> Result<SquashResult, E>
where
E: From<BackendError>,
{
struct SourceCommit<'a> {
commit: &'a CommitToSquash,
abandon: bool,
}
let mut source_commits = vec![];
for source in sources {
let abandon = !keep_emptied && source.is_full_selection();
if !abandon && source.is_empty_selection() {
// Nothing selected from this commit. If it's abandoned (i.e. already empty), we
// still include it so `jj squash` can be used for abandoning an empty commit in
// the middle of a stack.
continue;
}

// TODO: Do we want to optimize the case of moving to the parent commit (`jj
// squash -r`)? The source tree will be unchanged in that case.
source_commits.push(SourceCommit {
commit: source,
abandon,
});
}

if source_commits.is_empty() {
return Ok(SquashResult::NoChanges);
}

let mut abandoned_commits = vec![];
for source in &source_commits {
if source.abandon {
repo.record_abandoned_commit(source.commit.commit.id().clone());
abandoned_commits.push(source.commit);
} else {
let source_tree = source.commit.commit.tree()?;
// Apply the reverse of the selected changes onto the source
let new_source_tree =
source_tree.merge(&source.commit.selected_tree, &source.commit.parent_tree)?;
repo.rewrite_commit(settings, &source.commit.commit)
.set_tree_id(new_source_tree.id().clone())
.write()?;
}
}

let mut rewritten_destination = destination.clone();
if sources.iter().any(|source| {
repo.index()
.is_ancestor(source.commit.id(), destination.id())
}) {
// If we're moving changes to a descendant, first rebase descendants onto the
// rewritten sources. Otherwise it will likely already have the content
// changes we're moving, so applying them will have no effect and the
// changes will disappear.
let rebase_map = repo.rebase_descendants_return_map(settings)?;
let rebased_destination_id = rebase_map.get(destination.id()).unwrap().clone();
rewritten_destination = repo.store().get_commit(&rebased_destination_id)?;
}
// Apply the selected changes onto the destination
let mut destination_tree = rewritten_destination.tree()?;
for source in &source_commits {
destination_tree =
destination_tree.merge(&source.commit.parent_tree, &source.commit.selected_tree)?;
}
let mut predecessors = vec![destination.id().clone()];
predecessors.extend(
source_commits
.iter()
.map(|source| source.commit.commit.id().clone()),
);

let destination = repo
.rewrite_commit(settings, &rewritten_destination)
.set_tree_id(destination_tree.id().clone())
.set_predecessors(predecessors)
.set_description(description_fn(&abandoned_commits)?)
.write()?;

Ok(SquashResult::NewCommit(destination))
}

0 comments on commit 7f39792

Please sign in to comment.