From e0fbae5dc3fc2345383ec373b384fcca10e64f24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Konrad=20K=C3=BCgler?= Date: Mon, 19 May 2014 21:47:27 +0200 Subject: [PATCH] Rebase: Add --preserve-merges support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With --preserve-merges C Git re-does merges using the rewritten merge parents, discarding the old merge commit. For the common use-case of pull with rebase this is unfortunate, as it loses the merge conflict resolution (and other fixes in the merge), which may have taken quite some time to get right in the first place. To overcome this we use a two-fold approach: If any of the (non-first) merge parents of a merge were rewritten, we also redo the merge, to include the (potential) new changes in those commits. If only the first parent was rewritten, i.e. we are merging a branch that is otherwise unaffected by the rebase, we instead cherry-pick the merge commit at hand. This is done with the --mainline 1 and --no-commit options to apply the changes introduced by the merge. Then we set up an appropriate MERGE_HEAD and commit the result, thus effectively forging a merge. Apart from the approach taken to rebase merge commits, this implementation closely follows C Git. As a result, both Git implementations can continue rebases of each other. Preserving merges works for both interactive and non-interactive rebase, but as in C Git it is easy do get undesired outcomes with interactive rebase. CommitCommand supports committing merges during rebase now. Bug: 439421 Change-Id: I4cf69b9d4ec6109d130ab8e3f42fcbdac25a13b2 Signed-off-by: Konrad Kügler --- .../eclipse/jgit/api/RebaseCommandTest.java | 278 +++++++++++++++++ .../org/eclipse/jgit/api/CommitCommand.java | 19 +- .../org/eclipse/jgit/api/RebaseCommand.java | 293 ++++++++++++++++-- 3 files changed, 560 insertions(+), 30 deletions(-) diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/RebaseCommandTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/RebaseCommandTest.java index c5829ec96..8e64776f7 100644 --- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/RebaseCommandTest.java +++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/RebaseCommandTest.java @@ -56,6 +56,7 @@ import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStreamReader; +import java.util.Collections; import java.util.Iterator; import java.util.List; @@ -78,6 +79,7 @@ import org.eclipse.jgit.lib.AbbreviatedObjectId; import org.eclipse.jgit.lib.ConfigConstants; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectLoader; import org.eclipse.jgit.lib.PersonIdent; import org.eclipse.jgit.lib.RebaseTodoLine; import org.eclipse.jgit.lib.RebaseTodoLine.Action; @@ -86,6 +88,7 @@ import org.eclipse.jgit.lib.ReflogEntry; import org.eclipse.jgit.lib.RepositoryState; import org.eclipse.jgit.merge.MergeStrategy; import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevSort; import org.eclipse.jgit.revwalk.RevWalk; import org.eclipse.jgit.treewalk.TreeWalk; import org.eclipse.jgit.treewalk.filter.TreeFilter; @@ -321,6 +324,281 @@ public class RebaseCommandTest extends RepositoryTestCase { assertEquals(original.getFullMessage(), derived.getFullMessage()); } + @Test + public void testRebasePreservingMerges1() throws Exception { + doTestRebasePreservingMerges(true); + } + + @Test + public void testRebasePreservingMerges2() throws Exception { + doTestRebasePreservingMerges(false); + } + + /** + * Transforms the same before-state as in + * {@link #testRebaseShouldIgnoreMergeCommits()} to the following. + *

+ * This test should always rewrite E. + * + *

+	 * A - B (master) - - -  C' - D' - F' (topic')
+	 *   \                    \       /
+	 *    C - D - F (topic)      - E'
+	 *     \     /
+	 *       - E (side)
+	 * 
+ * + * @param testConflict + * @throws Exception + */ + private void doTestRebasePreservingMerges(boolean testConflict) + throws Exception { + RevWalk rw = new RevWalk(db); + + // create file1 on master + writeTrashFile(FILE1, FILE1); + git.add().addFilepattern(FILE1).call(); + RevCommit a = git.commit().setMessage("commit a").call(); + + // create a topic branch + createBranch(a, "refs/heads/topic"); + + // update FILE1 on master + writeTrashFile(FILE1, "blah"); + writeTrashFile("conflict", "b"); + git.add().addFilepattern(".").call(); + RevCommit b = git.commit().setMessage("commit b").call(); + + checkoutBranch("refs/heads/topic"); + writeTrashFile("file3", "more changess"); + git.add().addFilepattern("file3").call(); + RevCommit c = git.commit().setMessage("commit c").call(); + + // create a branch from the topic commit + createBranch(c, "refs/heads/side"); + + // second commit on topic + writeTrashFile("file2", "file2"); + if (testConflict) + writeTrashFile("conflict", "d"); + git.add().addFilepattern(".").call(); + RevCommit d = git.commit().setMessage("commit d").call(); + assertTrue(new File(db.getWorkTree(), "file2").exists()); + + // switch to side branch and update file2 + checkoutBranch("refs/heads/side"); + writeTrashFile("file3", "more change"); + if (testConflict) + writeTrashFile("conflict", "e"); + git.add().addFilepattern(".").call(); + RevCommit e = git.commit().setMessage("commit e").call(); + + // switch back to topic and merge in side, creating f + checkoutBranch("refs/heads/topic"); + MergeResult result = git.merge().include(e.getId()) + .setStrategy(MergeStrategy.RESOLVE).call(); + final RevCommit f; + if (testConflict) { + assertEquals(MergeStatus.CONFLICTING, result.getMergeStatus()); + assertEquals(Collections.singleton("conflict"), git.status().call() + .getConflicting()); + // resolve + writeTrashFile("conflict", "f resolved"); + git.add().addFilepattern("conflict").call(); + f = git.commit().setMessage("commit f").call(); + } else { + assertEquals(MergeStatus.MERGED, result.getMergeStatus()); + f = rw.parseCommit(result.getNewHead()); + } + + RebaseResult res = git.rebase().setUpstream("refs/heads/master") + .setPreserveMerges(true).call(); + if (testConflict) { + // first there is a conflict whhen applying d + assertEquals(Status.STOPPED, res.getStatus()); + assertEquals(Collections.singleton("conflict"), git.status().call() + .getConflicting()); + assertTrue(read("conflict").contains("\nb\n=======\nd\n")); + // resolve + writeTrashFile("conflict", "d new"); + git.add().addFilepattern("conflict").call(); + res = git.rebase().setOperation(Operation.CONTINUE).call(); + + // then there is a conflict when applying e + assertEquals(Status.STOPPED, res.getStatus()); + assertEquals(Collections.singleton("conflict"), git.status().call() + .getConflicting()); + assertTrue(read("conflict").contains("\nb\n=======\ne\n")); + // resolve + writeTrashFile("conflict", "e new"); + git.add().addFilepattern("conflict").call(); + res = git.rebase().setOperation(Operation.CONTINUE).call(); + + // finally there is a conflict merging e' + assertEquals(Status.STOPPED, res.getStatus()); + assertEquals(Collections.singleton("conflict"), git.status().call() + .getConflicting()); + assertTrue(read("conflict").contains("\nd new\n=======\ne new\n")); + // resolve + writeTrashFile("conflict", "f new resolved"); + git.add().addFilepattern("conflict").call(); + res = git.rebase().setOperation(Operation.CONTINUE).call(); + } + assertEquals(Status.OK, res.getStatus()); + + if (testConflict) + assertEquals("f new resolved", read("conflict")); + assertEquals("blah", read(FILE1)); + assertEquals("file2", read("file2")); + assertEquals("more change", read("file3")); + + rw.markStart(rw.parseCommit(db.resolve("refs/heads/topic"))); + RevCommit newF = rw.next(); + assertDerivedFrom(newF, f); + assertEquals(2, newF.getParentCount()); + RevCommit newD = rw.next(); + assertDerivedFrom(newD, d); + if (testConflict) + assertEquals("d new", readFile("conflict", newD)); + RevCommit newE = rw.next(); + assertDerivedFrom(newE, e); + if (testConflict) + assertEquals("e new", readFile("conflict", newE)); + assertEquals(newD, newF.getParent(0)); + assertEquals(newE, newF.getParent(1)); + assertDerivedFrom(rw.next(), c); + assertEquals(b, rw.next()); + assertEquals(a, rw.next()); + } + + private String readFile(String path, RevCommit commit) throws IOException { + TreeWalk walk = TreeWalk.forPath(db, path, commit.getTree()); + ObjectLoader loader = db.open(walk.getObjectId(0), Constants.OBJ_BLOB); + String result = RawParseUtils.decode(loader.getCachedBytes()); + walk.release(); + return result; + } + + @Test + public void testRebasePreservingMergesWithUnrelatedSide1() throws Exception { + doTestRebasePreservingMergesWithUnrelatedSide(true); + } + + @Test + public void testRebasePreservingMergesWithUnrelatedSide2() throws Exception { + doTestRebasePreservingMergesWithUnrelatedSide(false); + } + + /** + * Rebase topic onto master, not rewriting E. The merge resulting in D is + * confliicting to show that the manual merge resolution survives the + * rebase. + * + *
+	 * A - B - G (master)
+	 *  \   \
+	 *   \   C - D - F (topic)
+	 *    \     /
+	 *      E (side)
+	 * 
+ * + *
+	 * A - B - G (master)
+	 *  \       \
+	 *   \       C' - D' - F' (topic')
+	 *    \          /
+	 *      E (side)
+	 * 
+ * + * @param testConflict + * @throws Exception + */ + private void doTestRebasePreservingMergesWithUnrelatedSide( + boolean testConflict) throws Exception { + RevWalk rw = new RevWalk(db); + rw.sort(RevSort.TOPO); + + writeTrashFile(FILE1, FILE1); + git.add().addFilepattern(FILE1).call(); + RevCommit a = git.commit().setMessage("commit a").call(); + + writeTrashFile("file2", "blah"); + git.add().addFilepattern("file2").call(); + RevCommit b = git.commit().setMessage("commit b").call(); + + // create a topic branch + createBranch(b, "refs/heads/topic"); + checkoutBranch("refs/heads/topic"); + + writeTrashFile("file3", "more changess"); + writeTrashFile(FILE1, "preparing conflict"); + git.add().addFilepattern("file3").addFilepattern(FILE1).call(); + RevCommit c = git.commit().setMessage("commit c").call(); + + createBranch(a, "refs/heads/side"); + checkoutBranch("refs/heads/side"); + writeTrashFile("conflict", "e"); + writeTrashFile(FILE1, FILE1 + "\n" + "line 2"); + git.add().addFilepattern(".").call(); + RevCommit e = git.commit().setMessage("commit e").call(); + + // switch back to topic and merge in side, creating d + checkoutBranch("refs/heads/topic"); + MergeResult result = git.merge().include(e) + .setStrategy(MergeStrategy.RESOLVE).call(); + + assertEquals(MergeStatus.CONFLICTING, result.getMergeStatus()); + assertEquals(result.getConflicts().keySet(), + Collections.singleton(FILE1)); + writeTrashFile(FILE1, "merge resolution"); + git.add().addFilepattern(FILE1).call(); + RevCommit d = git.commit().setMessage("commit d").call(); + + RevCommit f = commitFile("file2", "new content two", "topic"); + + checkoutBranch("refs/heads/master"); + writeTrashFile("fileg", "fileg"); + if (testConflict) + writeTrashFile("conflict", "g"); + git.add().addFilepattern(".").call(); + RevCommit g = git.commit().setMessage("commit g").call(); + + checkoutBranch("refs/heads/topic"); + RebaseResult res = git.rebase().setUpstream("refs/heads/master") + .setPreserveMerges(true).call(); + if (testConflict) { + assertEquals(Status.STOPPED, res.getStatus()); + assertEquals(Collections.singleton("conflict"), git.status().call() + .getConflicting()); + // resolve + writeTrashFile("conflict", "e"); + git.add().addFilepattern("conflict").call(); + res = git.rebase().setOperation(Operation.CONTINUE).call(); + } + assertEquals(Status.OK, res.getStatus()); + + assertEquals("merge resolution", read(FILE1)); + assertEquals("new content two", read("file2")); + assertEquals("more changess", read("file3")); + assertEquals("fileg", read("fileg")); + + rw.markStart(rw.parseCommit(db.resolve("refs/heads/topic"))); + RevCommit newF = rw.next(); + assertDerivedFrom(newF, f); + RevCommit newD = rw.next(); + assertDerivedFrom(newD, d); + assertEquals(2, newD.getParentCount()); + RevCommit newC = rw.next(); + assertDerivedFrom(newC, c); + RevCommit newE = rw.next(); + assertEquals(e, newE); + assertEquals(newC, newD.getParent(0)); + assertEquals(e, newD.getParent(1)); + assertEquals(g, rw.next()); + assertEquals(b, rw.next()); + assertEquals(a, rw.next()); + } + @Test public void testRebaseParentOntoHeadShouldBeUptoDate() throws Exception { writeTrashFile(FILE1, FILE1); diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/api/CommitCommand.java b/org.eclipse.jgit/src/org/eclipse/jgit/api/CommitCommand.java index 21d7138c9..f8406e023 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/api/CommitCommand.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/api/CommitCommand.java @@ -245,7 +245,8 @@ public class CommitCommand extends GitCommand { case FORCED: case FAST_FORWARD: { setCallable(false); - if (state == RepositoryState.MERGING_RESOLVED) { + if (state == RepositoryState.MERGING_RESOLVED + || isMergeDuringRebase(state)) { // Commit was successful. Now delete the files // used for merge commits repo.writeMergeCommitMsg(null); @@ -489,7 +490,8 @@ public class CommitCommand extends GitCommand { author = committer; // when doing a merge commit parse MERGE_HEAD and MERGE_MSG files - if (state == RepositoryState.MERGING_RESOLVED) { + if (state == RepositoryState.MERGING_RESOLVED + || isMergeDuringRebase(state)) { try { parents = repo.readMergeHeads(); if (parents != null) @@ -530,6 +532,19 @@ public class CommitCommand extends GitCommand { throw new NoMessageException(JGitText.get().commitMessageNotSpecified); } + private boolean isMergeDuringRebase(RepositoryState state) { + if (state != RepositoryState.REBASING_INTERACTIVE + && state != RepositoryState.REBASING_MERGE) + return false; + try { + return repo.readMergeHeads() != null; + } catch (IOException e) { + throw new JGitInternalException(MessageFormat.format( + JGitText.get().exceptionOccurredDuringReadingOfGIT_DIR, + Constants.MERGE_HEAD, e), e); + } + } + /** * @param message * the commit message used for the {@code commit} diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/api/RebaseCommand.java b/org.eclipse.jgit/src/org/eclipse/jgit/api/RebaseCommand.java index 8277ead84..9ba741029 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/api/RebaseCommand.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/api/RebaseCommand.java @@ -52,6 +52,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -77,6 +78,7 @@ import org.eclipse.jgit.diff.DiffFormatter; import org.eclipse.jgit.dircache.DirCache; import org.eclipse.jgit.dircache.DirCacheCheckout; import org.eclipse.jgit.dircache.DirCacheIterator; +import org.eclipse.jgit.errors.RevisionSyntaxException; import org.eclipse.jgit.internal.JGitText; import org.eclipse.jgit.lib.AbbreviatedObjectId; import org.eclipse.jgit.lib.AnyObjectId; @@ -96,6 +98,7 @@ import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.merge.MergeStrategy; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.revwalk.filter.RevFilter; import org.eclipse.jgit.treewalk.TreeWalk; import org.eclipse.jgit.treewalk.filter.TreeFilter; import org.eclipse.jgit.util.FileUtils; @@ -167,6 +170,20 @@ public class RebaseCommand extends GitCommand { private static final String AUTOSTASH_MSG = "On {0}: autostash"; //$NON-NLS-1$ + /** + * The folder containing the hashes of (potentially) rewritten commits when + * --preserve-merges is used. + */ + private static final String REWRITTEN = "rewritten"; //$NON-NLS-1$ + + /** + * File containing the current commit(s) to cherry pick when --preserve-merges + * is used. + */ + private static final String CURRENT_COMMIT = "current-commit"; //$NON-NLS-1$ + + private static final String REFLOG_PREFIX = "rebase:"; //$NON-NLS-1$ + /** * The available operations */ @@ -216,6 +233,8 @@ public class RebaseCommand extends GitCommand { private MergeStrategy strategy = MergeStrategy.RECURSIVE; + private boolean preserveMerges = false; + /** * @param repo */ @@ -266,6 +285,7 @@ public class RebaseCommand extends GitCommand { } this.upstreamCommit = walk.parseCommit(repo .resolve(upstreamCommitId)); + preserveMerges = rebaseState.getRewrittenDir().exists(); break; case BEGIN: autoStash(); @@ -412,6 +432,12 @@ public class RebaseCommand extends GitCommand { throws IOException, GitAPIException { if (Action.COMMENT.equals(step.getAction())) return null; + if (preserveMerges + && shouldPick + && (Action.EDIT.equals(step.getAction()) || Action.PICK + .equals(step.getAction()))) { + writeRewrittenHashes(); + } ObjectReader or = repo.newObjectReader(); Collection ids = or.resolve(step.getCommit()); @@ -468,19 +494,87 @@ public class RebaseCommand extends GitCommand { monitor.beginTask(MessageFormat.format( JGitText.get().applyingCommit, commitToPick.getShortMessage()), ProgressMonitor.UNKNOWN); - // if the first parent of commitToPick is the current HEAD, - // we do a fast-forward instead of cherry-pick to avoid - // unnecessary object rewriting - newHead = tryFastForward(commitToPick); - lastStepWasForward = newHead != null; - if (!lastStepWasForward) { - // TODO if the content of this commit is already merged - // here we should skip this step in order to avoid - // confusing pseudo-changed + if (preserveMerges) + return cherryPickCommitPreservingMerges(commitToPick); + else + return cherryPickCommitFlattening(commitToPick); + } finally { + monitor.endTask(); + } + } + + private RebaseResult cherryPickCommitFlattening(RevCommit commitToPick) + throws IOException, GitAPIException, NoMessageException, + UnmergedPathsException, ConcurrentRefUpdateException, + WrongRepositoryStateException, NoHeadException { + // If the first parent of commitToPick is the current HEAD, + // we do a fast-forward instead of cherry-pick to avoid + // unnecessary object rewriting + newHead = tryFastForward(commitToPick); + lastStepWasForward = newHead != null; + if (!lastStepWasForward) { + // TODO if the content of this commit is already merged + // here we should skip this step in order to avoid + // confusing pseudo-changed + String ourCommitName = getOurCommitName(); + CherryPickResult cherryPickResult = new Git(repo).cherryPick() + .include(commitToPick).setOurCommitName(ourCommitName) + .setReflogPrefix(REFLOG_PREFIX).setStrategy(strategy) + .call(); + switch (cherryPickResult.getStatus()) { + case FAILED: + if (operation == Operation.BEGIN) + return abort(RebaseResult.failed(cherryPickResult + .getFailingPaths())); + else + return stop(commitToPick, Status.STOPPED); + case CONFLICTING: + return stop(commitToPick, Status.STOPPED); + case OK: + newHead = cherryPickResult.getNewHead(); + } + } + return null; + } + + private RebaseResult cherryPickCommitPreservingMerges(RevCommit commitToPick) + throws IOException, GitAPIException, NoMessageException, + UnmergedPathsException, ConcurrentRefUpdateException, + WrongRepositoryStateException, NoHeadException { + + writeCurrentCommit(commitToPick); + + List newParents = getNewParents(commitToPick); + boolean otherParentsUnchanged = true; + for (int i = 1; i < commitToPick.getParentCount(); i++) + otherParentsUnchanged &= newParents.get(i).equals( + commitToPick.getParent(i)); + // If the first parent of commitToPick is the current HEAD, + // we do a fast-forward instead of cherry-pick to avoid + // unnecessary object rewriting + newHead = otherParentsUnchanged ? tryFastForward(commitToPick) : null; + lastStepWasForward = newHead != null; + if (!lastStepWasForward) { + ObjectId headId = getHead().getObjectId(); + if (!AnyObjectId.equals(headId, newParents.get(0))) + checkoutCommit(headId.getName(), newParents.get(0)); + + // Use the cherry-pick strategy if all non-first parents did not + // change. This is different from C Git, which always uses the merge + // strategy (see below). + if (otherParentsUnchanged) { + boolean isMerge = commitToPick.getParentCount() > 1; String ourCommitName = getOurCommitName(); - CherryPickResult cherryPickResult = new Git(repo).cherryPick() + CherryPickCommand pickCommand = new Git(repo).cherryPick() .include(commitToPick).setOurCommitName(ourCommitName) - .setReflogPrefix("rebase:").setStrategy(strategy).call(); //$NON-NLS-1$ + .setReflogPrefix(REFLOG_PREFIX).setStrategy(strategy); + if (isMerge) { + pickCommand.setMainlineParentNumber(1); + // We write a MERGE_HEAD and later commit explicitly + pickCommand.setNoCommit(true); + writeMergeInfo(commitToPick, newParents); + } + CherryPickResult cherryPickResult = pickCommand.call(); switch (cherryPickResult.getStatus()) { case FAILED: if (operation == Operation.BEGIN) @@ -491,13 +585,91 @@ public class RebaseCommand extends GitCommand { case CONFLICTING: return stop(commitToPick, Status.STOPPED); case OK: - newHead = cherryPickResult.getNewHead(); + if (isMerge) { + // Commit the merge (setup above using writeMergeInfo()) + CommitCommand commit = new Git(repo).commit(); + commit.setAuthor(commitToPick.getAuthorIdent()); + commit.setReflogComment(REFLOG_PREFIX + " " //$NON-NLS-1$ + + commitToPick.getShortMessage()); + newHead = commit.call(); + } else + newHead = cherryPickResult.getNewHead(); + break; + } + } else { + // Use the merge strategy to redo merges, which had some of + // their non-first parents rewritten + MergeCommand merge = new Git(repo).merge() + .setFastForward(MergeCommand.FastForwardMode.NO_FF) + .setCommit(false); + for (int i = 1; i < commitToPick.getParentCount(); i++) + merge.include(newParents.get(i)); + MergeResult mergeResult = merge.call(); + if (mergeResult.getMergeStatus().isSuccessful()) { + CommitCommand commit = new Git(repo).commit(); + commit.setAuthor(commitToPick.getAuthorIdent()); + commit.setMessage(commitToPick.getFullMessage()); + commit.setReflogComment(REFLOG_PREFIX + " " //$NON-NLS-1$ + + commitToPick.getShortMessage()); + newHead = commit.call(); + } else { + if (operation == Operation.BEGIN + && mergeResult.getMergeStatus() == MergeResult.MergeStatus.FAILED) + return abort(RebaseResult.failed(mergeResult + .getFailingPaths())); + return stop(commitToPick, Status.STOPPED); } } - return null; - } finally { - monitor.endTask(); } + return null; + } + + // Prepare MERGE_HEAD and message for the next commit + private void writeMergeInfo(RevCommit commitToPick, + List newParents) throws IOException { + repo.writeMergeHeads(newParents.subList(1, newParents.size())); + repo.writeMergeCommitMsg(commitToPick.getFullMessage()); + } + + // Get the rewritten equivalents for the parents of the given commit + private List getNewParents(RevCommit commitToPick) + throws IOException { + List newParents = new ArrayList(); + for (int p = 0; p < commitToPick.getParentCount(); p++) { + String parentHash = commitToPick.getParent(p).getName(); + if (!new File(rebaseState.getRewrittenDir(), parentHash).exists()) + newParents.add(commitToPick.getParent(p)); + else { + String newParent = RebaseState.readFile( + rebaseState.getRewrittenDir(), parentHash); + if (newParent.length() == 0) + newParents.add(walk.parseCommit(repo + .resolve(Constants.HEAD))); + else + newParents.add(walk.parseCommit(ObjectId + .fromString(newParent))); + } + } + return newParents; + } + + private void writeCurrentCommit(RevCommit commit) throws IOException { + RebaseState.appendToFile(rebaseState.getFile(CURRENT_COMMIT), + commit.name()); + } + + private void writeRewrittenHashes() throws RevisionSyntaxException, + IOException { + File currentCommitFile = rebaseState.getFile(CURRENT_COMMIT); + if (!currentCommitFile.exists()) + return; + + String head = repo.resolve(Constants.HEAD).getName(); + String currentCommits = rebaseState.readFile(CURRENT_COMMIT); + for (String current : currentCommits.split("\n")) //$NON-NLS-1$ + RebaseState + .createFile(rebaseState.getRewrittenDir(), current, head); + FileUtils.delete(currentCommitFile); } private RebaseResult finishRebase(RevCommit newHead, @@ -908,19 +1080,6 @@ public class RebaseCommand extends GitCommand { monitor.beginTask(JGitText.get().obtainingCommitsForCherryPick, ProgressMonitor.UNKNOWN); - // determine the commits to be applied - LogCommand cmd = new Git(repo).log().addRange(upstreamCommit, - headCommit); - Iterable commitsToUse = cmd.call(); - - List cherryPickList = new ArrayList(); - for (RevCommit commit : commitsToUse) { - if (commit.getParentCount() != 1) - continue; - cherryPickList.add(commit); - } - - Collections.reverse(cherryPickList); // create the folder for the meta information FileUtils.mkdir(rebaseState.getDir(), true); @@ -935,6 +1094,8 @@ public class RebaseCommand extends GitCommand { ArrayList toDoSteps = new ArrayList(); toDoSteps.add(new RebaseTodoLine("# Created by EGit: rebasing " + headId.name() //$NON-NLS-1$ + " onto " + upstreamCommit.name())); //$NON-NLS-1$ + // determine the commits to be applied + List cherryPickList = calculatePickList(headCommit); ObjectReader reader = walk.getObjectReader(); for (RevCommit commit : cherryPickList) toDoSteps.add(new RebaseTodoLine(Action.PICK, reader @@ -959,6 +1120,50 @@ public class RebaseCommand extends GitCommand { return null; } + private List calculatePickList(RevCommit headCommit) + throws GitAPIException, NoHeadException, IOException { + LogCommand cmd = new Git(repo).log().addRange(upstreamCommit, + headCommit); + Iterable commitsToUse = cmd.call(); + List cherryPickList = new ArrayList(); + for (RevCommit commit : commitsToUse) { + if (preserveMerges || commit.getParentCount() == 1) + cherryPickList.add(commit); + } + Collections.reverse(cherryPickList); + + if (preserveMerges) { + // When preserving merges we only rewrite commits which have at + // least one parent that is itself rewritten (or a merge base) + File rewrittenDir = rebaseState.getRewrittenDir(); + FileUtils.mkdir(rewrittenDir, false); + walk.reset(); + walk.setRevFilter(RevFilter.MERGE_BASE); + walk.markStart(upstreamCommit); + walk.markStart(headCommit); + RevCommit base; + while ((base = walk.next()) != null) + RebaseState.createFile(rewrittenDir, base.getName(), + upstreamCommit.getName()); + + Iterator iterator = cherryPickList.iterator(); + pickLoop: while(iterator.hasNext()){ + RevCommit commit = iterator.next(); + for (int i = 0; i < commit.getParentCount(); i++) { + boolean parentRewritten = new File(rewrittenDir, commit + .getParent(i).getName()).exists(); + if (parentRewritten) { + new File(rewrittenDir, commit.getName()).createNewFile(); + continue pickLoop; + } + } + // commit is only merged in, needs not be rewritten + iterator.remove(); + } + } + return cherryPickList; + } + private static String getHeadName(Ref head) { String headName; if (head.isSymbolic()) @@ -1139,6 +1344,7 @@ public class RebaseCommand extends GitCommand { // cleanup the files FileUtils.delete(rebaseState.getDir(), FileUtils.RECURSIVE); repo.writeCherryPickHead(null); + repo.writeMergeHeads(null); if (stashConflicts) return RebaseResult.STASH_APPLY_CONFLICTS_RESULT; return result; @@ -1320,6 +1526,18 @@ public class RebaseCommand extends GitCommand { return this; } + /** + * @param preserve + * True to re-create merges during rebase. Defaults to false, a + * flattening rebase. + * @return {@code this} + * @since 3.5 + */ + public RebaseCommand setPreserveMerges(boolean preserve) { + this.preserveMerges = preserve; + return this; + } + /** * Allows configure rebase interactive process and modify commit message */ @@ -1408,6 +1626,14 @@ public class RebaseCommand extends GitCommand { return dir; } + /** + * @return Directory with rewritten commit hashes, usually exists if + * {@link RebaseCommand#preserveMerges} is true + **/ + public File getRewrittenDir() { + return new File(getDir(), REWRITTEN); + } + public String readFile(String name) throws IOException { return readFile(getDir(), name); } @@ -1444,5 +1670,16 @@ public class RebaseCommand extends GitCommand { fos.close(); } } + + private static void appendToFile(File file, String content) + throws IOException { + FileOutputStream fos = new FileOutputStream(file, true); + try { + fos.write(content.getBytes(Constants.CHARACTER_ENCODING)); + fos.write('\n'); + } finally { + fos.close(); + } + } } }