Skip to content

Commit

Permalink
cli: git sync - Proof of concept
Browse files Browse the repository at this point in the history
This is not meant to be merged in this form.
I am bashing everything on the head with a large bat, until it works.
Once everything works nicely in this draft, I will break it up into
smaller tested pieces that are intended to be merged.

* Grab current heads and build a set of [(Parent, Child)...]
* Start a transaction.
* Fetch in the transaction:
    It's extremely  important to fetch *all* branches from *all* remotes.
    This is because if we don't fetch all branches, when we start rebasing,
    a root branch may be merged upstream with contents of a branch we didn't
    fetch, so we end up with conflicts. It's safest to always fetch all branches
    and then only rebase what we need.
* Grab current heads on transaction:
  * Transaction MutableRepo should be updated after fetch.
  * Build a Map where old heads are the keys and the new heads are values.
    * If old == new, skip it.
    * This way if nothing changed, we end up with an empty map and avoid bugs
      downstream (index.is_ancestor returns true if old == new).
  * Relationship is figured out by index.is_ancestor(old_head_id, new_head_id) in a loop.
* Check if rebase is needed.
  * old_heads.set_diffence(new_heads) gives old heads that are no longer heads, which would
    mean, their descendants need to be rebased.
* Find children needing rebase:
  * Build list of rebase specs with: (commit, new_parent)
    * commit is the immdiate child of old_heads needing rebase.
* call repo_mut.transform_descendants with a rewritter closure.
  * simplify_merge
  * rewrite commits matching the rebase_specs
  * for all other commits, if any new_parents match old_heads in our old -> new mapping,
    update those to point at the updated heads.
  * rebase with emptying newly emptied commits.


Issue: #1039
  • Loading branch information
essiene committed Nov 17, 2024
1 parent 9b0981d commit d91c6b3
Show file tree
Hide file tree
Showing 7 changed files with 930 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .cargo/insta.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
test:
runner: "nextest"
297 changes: 297 additions & 0 deletions 2
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::fmt;

use itertools::Itertools;
use jj_lib::backend::CommitId;
use jj_lib::commit::Commit;
use jj_lib::repo::Repo;
use jj_lib::revset::FailingSymbolResolver;
use jj_lib::revset::RevsetExpression;
use jj_lib::revset::RevsetIteratorExt;
use jj_lib::rewrite::EmptyBehaviour;
use jj_lib::str_util::StringPattern;

use crate::cli_util::short_change_hash;
use crate::cli_util::short_commit_hash;
use crate::cli_util::CommandHelper;
use crate::cli_util::WorkspaceCommandTransaction;
use crate::commands::CommandError;
use crate::git_util::get_fetch_remotes;
use crate::git_util::get_git_repo;
use crate::git_util::git_fetch;
use crate::git_util::FetchArgs;
use crate::ui::Ui;

/// Sync the local JJ repo to specified Git remote branch(es).
///
/// The sync command will first fetch from the Git remote, then
/// rebase all local changes onto the appropriate updated
/// heads that were fetched.
///
/// Changes that are made empty by the rebase are dropped.
#[derive(clap::Args, Clone, Debug)]
pub struct GitSyncArgs {
#[command(flatten)]
fetch: FetchArgs,
}

pub fn cmd_git_sync(
ui: &mut Ui,
command: &CommandHelper,
args: &GitSyncArgs,
) -> Result<(), CommandError> {
let mut workspace_command = command.workspace_helper(ui)?;

let git_repo = get_git_repo(workspace_command.repo().store())?;
let remotes = get_fetch_remotes(
ui,
command.settings(),
&git_repo,
&FetchArgs {
branch: vec![StringPattern::everything()],
remotes: vec![],
all_remotes: true,
},
)?;

let remote_patterns = remotes
.iter()
.map(|p| StringPattern::Exact(p.to_string()))
.collect_vec();

let mut tx = workspace_command.start_transaction();

let old_heads = get_branch_heads(tx.repo(), &args.fetch.branch)?;
let parent_child_pairs = get_parent_child_commit_pairs(
tx.repo(),
&old_heads.iter().cloned().collect_vec(),
&args.fetch.branch,
&remote_patterns,
)?;

// prep to git fetch
git_fetch(
ui,
&mut tx,
&git_repo,
&FetchArgs {
branch: vec![StringPattern::everything()],
remotes: remotes.clone(),
all_remotes: true,
},
)?;

// is rebase needed?
let updated_heads = get_branch_heads(tx.repo(), &args.fetch.branch)?;
let new_heads = updated_heads.difference(&old_heads).cloned().collect_vec();
if new_heads.is_empty() {
// rebase not needed
tx.finish(ui, "sync completed; no rebase".to_string())?;
return Ok(());
}

// find rebase targets
let needs_rebase = old_heads.difference(&updated_heads).cloned().collect_vec();

let old_to_new = map_old_to_new_heads(&tx, &needs_rebase[..], &new_heads[..])?;

let rebase_specs = parent_child_pairs
.iter()
.filter_map(|pair| {
if needs_rebase.contains(&pair.parent) {
old_to_new.get(&pair.parent.id()).map(|new|
RebaseSpec {
commit: pair.child.id().clone(),
new_parent: new.clone(),
}
}
} else {
None
}
})
.collect_vec();

let settings = tx.settings().clone();

tx.repo_mut().transform_descendants(
&settings,
rebase_specs
.iter()
.map(|spec| spec.commit.clone())
.collect::<Vec<_>>(),
|mut rewriter| {
rewriter.simplify_ancestor_merge();
for spec in &rebase_specs {
// move children of updated branches to the latest branch head
if rewriter.old_commit().id() == &spec.commit {
rewriter.set_new_parents(vec![spec.new_parent.clone()]);
} else {
// if new parents point at old heads, update them to point
// at the updated heads.
let mut updated_parents: Vec<CommitId> = vec![];

let old_parents = rewriter.new_parents().clone();

for parent in &old_parents {
if let Some(updated) = maybe_update_commit(
rewriter.repo(),
&parent,
&new_heads.iter().map(|c| c.id().clone()).collect_vec(),
) {
updated_parents.push(updated.clone());
} else {
updated_parents.push(parent.clone());
}
}

rewriter.set_new_parents(updated_parents);
}
}

if let Some(builder) =
rewriter.rebase_with_empty_behavior(&settings, EmptyBehaviour::AbandonNewlyEmpty)?
{
builder.write()?;
}

Ok(())
},
)?;

tx.finish(ui, format!("sync completed; commits rebased to new heads"))?;

Ok(())
}

fn get_branch_heads(
repo: &dyn Repo,
branches: &[StringPattern],
) -> Result<BTreeSet<Commit>, CommandError> {
let mut commits: BTreeSet<Commit> = BTreeSet::from([]);

for branch in branches {
let mut branch_commits: BTreeSet<Commit> = RevsetExpression::bookmarks(branch.clone())
.resolve_user_expression(repo, &FailingSymbolResolver)?
.evaluate(repo)?
.iter()
.commits(repo.store())
.try_collect()?;

commits.append(&mut branch_commits);
}

Ok(commits)
}

fn maybe_update_commit(
repo: &dyn Repo,
commit: &CommitId,
new_heads: &[CommitId],
) -> Option<CommitId> {
new_heads
.iter()
.filter_map(|new| {
if new != commit && repo.index().is_ancestor(commit, new) {
Some(new.clone())
} else {
None
}
})
.next()
}

fn map_old_to_new_heads(
tx: &WorkspaceCommandTransaction,
old_heads: &[Commit],
new_heads: &[Commit],
) -> Result<BTreeMap<CommitId, CommitId>, CommandError> {
let mut out: BTreeMap<CommitId, CommitId> = BTreeMap::from([]);
let index = tx.repo().index();
new_heads.iter().for_each(|new| {
old_heads.iter().for_each(|old| {
if old != new && index.is_ancestor(old.id(), new.id()) {
out.insert(old.id().clone(), new.id().clone());
}
});
});

Ok(out)
}

fn format_commit(commit: &Commit) -> String {
let change_hash = short_change_hash(commit.change_id());
let commit_hash = short_commit_hash(commit.id());

format!("{commit_hash}:{change_hash}")
}

#[derive(Eq, Ord, PartialEq, PartialOrd)]
pub struct CommitPair {
parent: Commit,
child: Commit,
}

impl fmt::Display for CommitPair {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let parent = format_commit(&self.parent);
let child = format_commit(&self.child);
write!(f, "=> {parent} --> {child}")
}
}

pub struct RebaseSpec {
commit: CommitId,
new_parent: CommitId,
}

fn get_parent_child_commit_pairs(
repo: &dyn Repo,
start: &[Commit],
branches: &[StringPattern],
remotes: &[StringPattern],
) -> Result<Vec<CommitPair>, CommandError> {
let store = repo.store();
let mut pairs: Vec<CommitPair> = vec![];

for commit in start {
for parent_id in commit.parent_ids() {
pairs.push(CommitPair {
parent: store.get_commit(parent_id)?,
child: commit.clone(),
});
}
}

let start = start.iter().map(|c| c.id().clone()).collect_vec();

for remote in remotes {
for branch in branches {
let commits: Vec<Commit> = RevsetExpression::commits(start.to_vec())
.descendants()
.minus(&RevsetExpression::commits(start.to_vec()))
.minus(&RevsetExpression::remote_bookmarks(
branch.clone(),
remote.clone(),
None,
))
.resolve_user_expression(repo, &FailingSymbolResolver)?
.evaluate(repo)?
.iter()
.commits(repo.store())
.try_collect()?;

for commit in commits {
for parent_id in commit.parent_ids() {
pairs.push(CommitPair {
parent: store.get_commit(parent_id)?,
child: commit.clone(),
});
}
}
}
}

pairs.sort();
Ok(pairs)
}
Loading

0 comments on commit d91c6b3

Please sign in to comment.