Skip to content

Commit

Permalink
cli: git sync - Proof of concept
Browse files Browse the repository at this point in the history
* map old heads -> new heads
* rebase
  * roots are candidates with parents with updated heads.
  * simplify parent merge
  * drop newly emptied commits
  * update new parents from the updated heads set if the old parents are ancestors.

Issue: #1039
  • Loading branch information
essiene committed Nov 19, 2024
1 parent aa12d04 commit e8ef1e2
Show file tree
Hide file tree
Showing 3 changed files with 181 additions and 0 deletions.
152 changes: 152 additions & 0 deletions cli/src/commands/git/sync.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
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::op_store::RemoteRefState;
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_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
Expand Down Expand Up @@ -36,17 +59,74 @@ pub fn cmd_git_sync(
) -> Result<(), CommandError> {
let mut workspace_command = command.workspace_helper(ui)?;
let mut tx = workspace_command.start_transaction();

let guard = tracing::debug_span!("git.sync.pre-fetch").entered();
let prefetch_heads = get_branch_heads(tx.base_repo().as_ref(), &args.branch)?;
let candidates = CandidateCommit::get(tx.repo(), &prefetch_heads)?;
drop(guard);

let guard = tracing::debug_span!("git.sync.fetch").entered();
git_fetch_all(ui, &mut tx, args.all_remotes)?;
drop(guard);

let guard = tracing::debug_span!("git.sync.post-fetch").entered();

let postfetch_heads = get_branch_heads(tx.repo(), &args.branch)?;

let update_record = UpdateRecord::new(
&tx,
&BranchHeads {
prefetch: &prefetch_heads,
postfetch: &postfetch_heads,
},
);

drop(guard);

let guard = tracing::debug_span!("git.sync.rebase").entered();
let settings = tx.settings().clone();
let mut num_rebased = 0;

tx.repo_mut().transform_descendants(
&settings,
update_record.get_rebase_roots(&candidates),
|mut rewriter| {
rewriter.simplify_ancestor_merge();
let mut updated_parents: Vec<CommitId> = vec![];

let old_parents = rewriter.new_parents().iter().cloned().collect_vec();

let old_commit = short_commit_hash(rewriter.old_commit().id());
for parent in &old_parents {
let old = short_commit_hash(parent);
if let Some(updated) = update_record.maybe_update_commit(rewriter.repo(), parent) {
let new = short_commit_hash(&updated);
tracing::debug!("rebase {old_commit} from {old} to {new}");
updated_parents.push(updated.clone());
} else {
tracing::debug!("not rebasing {old_commit} from {old}");
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()?;
num_rebased += 1;
}

Ok(())
},
)?;

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

drop(guard);

Ok(())
Expand Down Expand Up @@ -88,6 +168,78 @@ fn get_branch_heads(

Ok(commits)
}

fn set_diff(lhs: &[CommitId], rhs: &[CommitId]) -> Vec<CommitId> {
BTreeSet::from_iter(lhs.to_vec())
.difference(&BTreeSet::from_iter(rhs.to_vec()))
.cloned()
.collect_vec()
}

struct BranchHeads<'a> {
prefetch: &'a [CommitId],
postfetch: &'a [CommitId],
}

struct UpdateRecord {
old_to_new: BTreeMap<CommitId, CommitId>,
}

impl UpdateRecord {
fn new(tx: &WorkspaceCommandTransaction, heads: &BranchHeads) -> Self {
let new_heads = set_diff(heads.postfetch, heads.prefetch);
let needs_rebase = set_diff(heads.prefetch, heads.postfetch);

let mut old_to_new: BTreeMap<CommitId, CommitId> = BTreeMap::from([]);

for new in &new_heads {
for old in &needs_rebase {
if old != new && tx.repo().index().is_ancestor(old, new) {
old_to_new.insert(old.clone(), new.clone());
}
}
}

for (k, v) in &old_to_new {
let old = short_commit_hash(k);
let new = short_commit_hash(v);
tracing::debug!("rebase children of {old} to {new}");
}

UpdateRecord { old_to_new }
}

/// Returns commits that need to be rebased.
///
/// The returned commits all have parents in the `old_to_new` mapping, which
/// means that the branch their parents belong to, have advanced to new commits.
fn get_rebase_roots(&self, candidates: &[CandidateCommit]) -> Vec<CommitId> {
candidates
.iter()
.filter_map(|candidate| {
if self.old_to_new.contains_key(&candidate.parent) {
Some(candidate.child.clone())
} else {
None
}
})
.collect_vec()
}

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

#[derive(Eq, Ord, PartialEq, PartialOrd)]
pub struct CandidateCommit {
parent: CommitId,
Expand Down
24 changes: 24 additions & 0 deletions cli/tests/[email protected]
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ This document contains the help content for the `jj` command-line program.
* [`jj git remote remove`↴](#jj-git-remote-remove)
* [`jj git remote rename`↴](#jj-git-remote-rename)
* [`jj git remote set-url`↴](#jj-git-remote-set-url)
* [`jj git sync`↴](#jj-git-sync)
* [`jj help`↴](#jj-help)
* [`jj init`↴](#jj-init)
* [`jj interdiff`↴](#jj-interdiff)
Expand Down Expand Up @@ -1049,6 +1050,7 @@ For a comparison with Git, including a table of commands, see https://martinvonz
* `init` — Create a new Git backed repo
* `push` — Push to a Git remote
* `remote` — Manage Git remotes
* `sync` — Sync the local JJ repo to specified Git remote branch(es)
Expand Down Expand Up @@ -1260,6 +1262,28 @@ Set the URL of a Git remote
## `jj git sync`
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.
**Usage:** `jj git sync [OPTIONS]`
###### **Options:**
* `-b`, `--branch <BRANCH>` — Fetch only some of the branches
By default, the specified name matches exactly. Use `glob:` prefix to expand `*` as a glob. The other wildcard characters aren't supported.
Default value: `glob:*`
* `--remote <REMOTE>` — The remote to fetch from (only named remotes are supported, can be repeated)
* `--all-remotes` — Fetch from all remotes
## `jj help`
Print this message or the help of the given subcommand(s)
Expand Down
5 changes: 5 additions & 0 deletions lib/src/rewrite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ impl<'repo> CommitRewriter<'repo> {
self.mut_repo
}

/// Returns an immutable reference to `MutableRepo`.
pub fn repo(&mut self) -> &MutableRepo {
self.mut_repo
}

/// The commit we're rewriting.
pub fn old_commit(&self) -> &Commit {
&self.old_commit
Expand Down

0 comments on commit e8ef1e2

Please sign in to comment.