/* Copyright (c) 2012-2014 Boundless and others. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Distribution License v1.0 * which accompanies this distribution, and is available at * https://www.eclipse.org/org/documents/edl-v10.html * * Contributors: * Gabriel Roldan (Boundless) - initial implementation */ package org.locationtech.geogig.api.porcelain; import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.net.URL; import java.util.ArrayList; import java.util.Arrays; import java.util.Iterator; import java.util.List; import org.locationtech.geogig.api.AbstractGeoGigOp; import org.locationtech.geogig.api.CommitBuilder; import org.locationtech.geogig.api.ObjectId; import org.locationtech.geogig.api.Platform; import org.locationtech.geogig.api.Ref; import org.locationtech.geogig.api.RevCommit; import org.locationtech.geogig.api.SymRef; import org.locationtech.geogig.api.hooks.Hookable; import org.locationtech.geogig.api.plumbing.CatObject; import org.locationtech.geogig.api.plumbing.DiffTree; import org.locationtech.geogig.api.plumbing.FindCommonAncestor; import org.locationtech.geogig.api.plumbing.RefParse; import org.locationtech.geogig.api.plumbing.ResolveGeogigDir; import org.locationtech.geogig.api.plumbing.UpdateRef; import org.locationtech.geogig.api.plumbing.UpdateSymRef; import org.locationtech.geogig.api.plumbing.WriteTree2; import org.locationtech.geogig.api.plumbing.diff.DiffEntry; import org.locationtech.geogig.api.plumbing.merge.Conflict; import org.locationtech.geogig.api.plumbing.merge.ConflictsReadOp; import org.locationtech.geogig.api.plumbing.merge.ConflictsWriteOp; import org.locationtech.geogig.api.plumbing.merge.MergeScenarioReport; import org.locationtech.geogig.api.plumbing.merge.ReportCommitConflictsOp; import org.locationtech.geogig.api.porcelain.ResetOp.ResetMode; import org.locationtech.geogig.di.CanRunDuringConflict; import org.locationtech.geogig.repository.Repository; import org.locationtech.geogig.storage.ObjectReader; import org.locationtech.geogig.storage.text.TextSerializationFactory; import com.google.common.base.Charsets; import com.google.common.base.Joiner; import com.google.common.base.Optional; import com.google.common.base.Preconditions; import com.google.common.base.Supplier; import com.google.common.base.Suppliers; import com.google.common.io.Files; /** * * Rebase the current head to the included branch head. * * Rebasing is done following these steps: * * -Commits to apply are computed and for each one a file is created in the rebase-apply folder, * containing the ID of the commit. Files have correlative names starting on 1, indicating the order * in which they should be applied * * -HEAD is rewinded to starting point * * -Commits are applied. For each commit applied, the corresponding file is deleted * * -A file named 'next' keeps track of the next commit to apply between executions of the rebase * command, in case of conflicts * * - A file named 'branch' keeps track of the current branch name * * * */ @CanRunDuringConflict @Hookable(name = "rebase") public class RebaseOp extends AbstractGeoGigOp<Boolean> { private Supplier<ObjectId> upstream; private Supplier<ObjectId> onto; private boolean skip; private boolean continueRebase; private String currentBranch; private ObjectId rebaseHead; private boolean abort; private String squashMessage; /** * Sets the commit to replay commits onto. * * @param onto a supplier for the commit id * @return {@code this} */ public RebaseOp setOnto(final Supplier<ObjectId> onto) { this.onto = onto; return this; } /** * Sets whether to abort the current rebase operation * * @param abort * @return */ public RebaseOp setAbort(boolean abort) { this.abort = abort; return this; } /** * Sets the message to use to squash commits. If no message is provided, no squash is performed, * so this parameter acts also as a flag * * @param squash the squash message * @return */ public RebaseOp setSquashMessage(String squashMessage) { this.squashMessage = squashMessage; return this; } /** * Sets whether to continue a rebase operation aborted due to conflicts * * @param continueRebase * @return {@code this} */ public RebaseOp setContinue(boolean continueRebase) { this.continueRebase = continueRebase; return this; } /** * Sets whether to skip the current commit, which cause the rebase operation to be aborted * * @param skip * @return {@code this} */ public RebaseOp setSkip(boolean skip) { this.skip = skip; return this; } /** * Sets the upstream commit. This is used in finding the common ancestor. * * @param upstream a supplier for the upstream commit * @return {@code this} */ public RebaseOp setUpstream(final Supplier<ObjectId> upstream) { this.upstream = upstream; return this; } /** * Executes the rebase operation. * * @return always {@code true} */ @Override protected Boolean _call() { final Optional<Ref> currHead = command(RefParse.class).setName(Ref.HEAD).call(); Preconditions.checkState(currHead.isPresent(), "Repository has no HEAD, can't rebase."); if (!(continueRebase || skip || abort)) { Preconditions.checkState(currHead.get() instanceof SymRef, "Can't rebase from detached HEAD %s", currHead.get()); Preconditions.checkState(upstream != null, "No upstream target has been specified."); Preconditions.checkState(!ObjectId.NULL.equals(upstream.get()), "Upstream did not resolve to a commit."); } // Rebase can only be run in a conflicted situation if the skip or abort option is used List<Conflict> conflicts = command(ConflictsReadOp.class).call(); Preconditions.checkState(conflicts.isEmpty() || skip || abort, "Cannot run operation while merge conflicts exist."); Optional<Ref> ref = command(RefParse.class).setName(Ref.ORIG_HEAD).call(); File branchFile = new File(getRebaseFolder(), "branch"); RevCommit squashCommit = readSquashCommit(); if (abort) { Preconditions.checkState(ref.isPresent() && branchFile.exists(), "Cannot abort. You are not in the middle of a rebase process."); command(ResetOp.class).setMode(ResetMode.HARD) .setCommit(Suppliers.ofInstance(ref.get().getObjectId())).call(); command(UpdateRef.class).setDelete(true).setName(Ref.ORIG_HEAD).call(); branchFile.delete(); return true; } else if (continueRebase) { Preconditions.checkState(ref.isPresent() && branchFile.exists(), "Cannot continue. You are not in the middle of a rebase process."); try { currentBranch = Files.readFirstLine(branchFile, Charsets.UTF_8); } catch (IOException e) { throw new IllegalStateException("Cannot read current branch info file"); } rebaseHead = currHead.get().getObjectId(); if (squashCommit == null) { // Commit the manually-merged changes with the info of the commit that caused the // conflict applyNextCommit(false); // Commit files should already be prepared, so we do nothing else } else { applyCommit(squashCommit, false); } } else if (skip) { Preconditions.checkState(ref.isPresent() && branchFile.exists(), "Cannot skip. You are not in the middle of a rebase process."); try { currentBranch = Files.readFirstLine(branchFile, Charsets.UTF_8); } catch (IOException e) { throw new IllegalStateException("Cannot read current branch info file"); } rebaseHead = currHead.get().getObjectId(); command(ResetOp.class).setCommit(Suppliers.ofInstance(rebaseHead)) .setMode(ResetMode.HARD).call(); if (squashCommit == null) { skipCurrentCommit(); applyNextCommit(true); } else { return true; } } else { Preconditions .checkState(!ref.isPresent(), "You are currently in the middle of a merge or rebase project <ORIG_HEAD is present>."); getProgressListener().started(); command(UpdateRef.class).setName(Ref.ORIG_HEAD) .setNewValue(currHead.get().getObjectId()).call(); // Here we prepare the files with the info about the commits to apply or, if that's not // needed, do a fast-forward final SymRef headRef = (SymRef) currHead.get(); currentBranch = headRef.getTarget(); if (ObjectId.NULL.equals(headRef.getObjectId())) { // Fast-forward command(UpdateRef.class).setName(currentBranch).setNewValue(upstream.get()).call(); command(UpdateSymRef.class).setName(Ref.HEAD).setNewValue(currentBranch).call(); workingTree().updateWorkHead(upstream.get()); index().updateStageHead(upstream.get()); getProgressListener().complete(); return true; } Repository repository = repository(); final RevCommit headCommit = repository.getCommit(headRef.getObjectId()); final RevCommit targetCommit = repository.getCommit(upstream.get()); command(UpdateRef.class).setName(Ref.ORIG_HEAD).setNewValue(headCommit.getId()); Optional<ObjectId> ancestorCommit = command(FindCommonAncestor.class) .setLeft(headCommit).setRight(targetCommit) .setProgressListener(subProgress(10.f)).call(); Preconditions.checkState(ancestorCommit.isPresent(), "No ancestor commit could be found."); if (ancestorCommit.get().equals(headCommit.getId())) { // Fast-forward command(UpdateRef.class).setName(currentBranch).setNewValue(upstream.get()).call(); command(UpdateSymRef.class).setName(Ref.HEAD).setNewValue(currentBranch).call(); workingTree().updateWorkHead(upstream.get()); index().updateStageHead(upstream.get()); getProgressListener().complete(); return true; } // Get all commits between the head commit and the ancestor. Iterator<RevCommit> commitIterator = command(LogOp.class).call(); List<RevCommit> commitsToRebase = new ArrayList<RevCommit>(); RevCommit commit = commitIterator.next(); while (!commit.getId().equals(ancestorCommit.get())) { commitsToRebase.add(commit); commit = commitIterator.next(); } // rewind the HEAD if (onto == null) { onto = Suppliers.ofInstance(upstream.get()); } rebaseHead = onto.get(); command(ResetOp.class).setCommit(Suppliers.ofInstance(rebaseHead)) .setMode(ResetMode.HARD).call(); if (squashMessage != null) { CommitBuilder builder = new CommitBuilder(commitsToRebase.get(0)); builder.setParentIds(Arrays.asList(ancestorCommit.get())); builder.setMessage(squashMessage); squashCommit = builder.build(); // save the commit, since it does not exist in the database, and might be needed if // there is a conflict CharSequence commitString = command(CatObject.class).setObject( Suppliers.ofInstance(squashCommit)).call(); File squashFile = new File(getRebaseFolder(), "squash"); try { Files.write(commitString, squashFile, Charsets.UTF_8); } catch (IOException e) { throw new IllegalStateException("Cannot create squash commit info file"); } applyCommit(squashCommit, true); return true; } else { createRebaseCommitsInfoFiles(commitsToRebase); } // ProgressListener subProgress = subProgress(90.f); } if (squashCommit == null) { boolean ret; do { ret = applyNextCommit(true); } while (ret); } // clean up File squashFile = new File(getRebaseFolder(), "squash"); if (squashFile.exists()) { squashFile.delete(); } command(UpdateRef.class).setDelete(true).setName(Ref.ORIG_HEAD).call(); branchFile.delete(); // subProgress.complete(); getProgressListener().complete(); return true; } private File getRebaseFolder() { URL dir = command(ResolveGeogigDir.class).call().get(); File rebaseFolder = new File(dir.getFile(), "rebase-apply"); if (!rebaseFolder.exists()) { Preconditions.checkState(rebaseFolder.mkdirs(), "Cannot create 'rebase-apply' folder"); } return rebaseFolder; } private void skipCurrentCommit() { File rebaseFolder = getRebaseFolder(); File nextFile = new File(rebaseFolder, "next"); try { String idx = Files.readFirstLine(nextFile, Charsets.UTF_8); File commitFile = new File(rebaseFolder, idx); commitFile.delete(); int newIdx = Integer.parseInt(idx) + 1; Files.write(Integer.toString(newIdx), nextFile, Charsets.UTF_8); } catch (IOException e) { throw new IllegalStateException("Cannot read/write rebase commits index file"); } } private void createRebaseCommitsInfoFiles(List<RevCommit> commitsToRebase) { File rebaseFolder = getRebaseFolder(); for (int i = commitsToRebase.size() - 1, idx = 1; i >= 0; i--, idx++) { File file = new File(rebaseFolder, Integer.toString(idx)); try { Files.write(commitsToRebase.get(i).getId().toString(), file, Charsets.UTF_8); } catch (IOException e) { throw new IllegalStateException("Cannot create rebase commits info files"); } } File nextFile = new File(rebaseFolder, "next"); try { Files.write("1", nextFile, Charsets.UTF_8); } catch (IOException e) { throw new IllegalStateException("Cannot create next rebase commit info file"); } } private boolean applyNextCommit(boolean useCommitChanges) { File rebaseFolder = getRebaseFolder(); File nextFile = new File(rebaseFolder, "next"); try { String idx = Files.readFirstLine(nextFile, Charsets.UTF_8); File commitFile = new File(rebaseFolder, idx); if (commitFile.exists()) { String commitId = Files.readFirstLine(commitFile, Charsets.UTF_8); RevCommit commit = repository().getCommit(ObjectId.valueOf(commitId)); applyCommit(commit, useCommitChanges); commitFile.delete(); int newIdx = Integer.parseInt(idx) + 1; Files.write(Integer.toString(newIdx), nextFile, Charsets.UTF_8); return true; } else { return false; } } catch (IOException e) { throw new IllegalStateException("Cannot read/write rebase commits index file"); } } /** * Applies the passed command. * * @param commitToApply the commit to apply * @param useCommitChanges if true, applies the command completely, staging its changes before * committing. If false, it commits the currently staged changes, ignoring the changes in * the commit and using just its author and message */ private void applyCommit(RevCommit commitToApply, boolean useCommitChanges) { Repository repository = repository(); Platform platform = platform(); if (useCommitChanges) { ObjectId parentTreeId; ObjectId parentCommitId = ObjectId.NULL; if (commitToApply.getParentIds().size() > 0) { parentCommitId = commitToApply.getParentIds().get(0); } parentTreeId = ObjectId.NULL; if (repository.commitExists(parentCommitId)) { parentTreeId = repository.getCommit(parentCommitId).getTreeId(); } // get changes Iterator<DiffEntry> diff = command(DiffTree.class).setOldTree(parentTreeId) .setNewTree(commitToApply.getTreeId()).setReportTrees(true).call(); // see if there are conflicts MergeScenarioReport report = command(ReportCommitConflictsOp.class).setCommit( commitToApply).call(); if (report.getConflicts().isEmpty()) { // stage changes index().stage(getProgressListener(), diff, 0); // write new tree ObjectId newTreeId = command(WriteTree2.class).call(); long timestamp = platform.currentTimeMillis(); // Create new commit CommitBuilder builder = new CommitBuilder(commitToApply); builder.setParentIds(Arrays.asList(rebaseHead)); builder.setTreeId(newTreeId); builder.setCommitterTimestamp(timestamp); builder.setCommitterTimeZoneOffset(platform.timeZoneOffset(timestamp)); RevCommit newCommit = builder.build(); repository.objectDatabase().put(newCommit); rebaseHead = newCommit.getId(); command(UpdateRef.class).setName(currentBranch).setNewValue(rebaseHead).call(); command(UpdateSymRef.class).setName(Ref.HEAD).setNewValue(currentBranch).call(); workingTree().updateWorkHead(newTreeId); index().updateStageHead(newTreeId); } else { Iterator<DiffEntry> unconflicted = report.getUnconflicted().iterator(); // stage unconflicted changes index().stage(getProgressListener(), unconflicted, 0); workingTree().updateWorkHead(index().getTree().getId()); // mark conflicted elements command(ConflictsWriteOp.class).setConflicts(report.getConflicts()).call(); // created exception message StringBuilder msg = new StringBuilder(); msg.append("error: could not apply "); msg.append(commitToApply.getId().toString().substring(0, 7)); msg.append(" " + commitToApply.getMessage() + "\n"); for (Conflict conflict : report.getConflicts()) { msg.append("CONFLICT: conflict in " + conflict.getPath() + "\n"); } File branchFile = new File(getRebaseFolder(), "branch"); try { Files.write(currentBranch, branchFile, Charsets.UTF_8); } catch (IOException e) { throw new IllegalStateException("Cannot create current branch info file"); } throw new RebaseConflictsException(msg.toString()); } } else { // write new tree ObjectId newTreeId = command(WriteTree2.class).call(); long timestamp = platform.currentTimeMillis(); // Create new commit CommitBuilder builder = new CommitBuilder(commitToApply); builder.setParentIds(Arrays.asList(rebaseHead)); builder.setTreeId(newTreeId); builder.setCommitterTimestamp(timestamp); builder.setCommitterTimeZoneOffset(platform.timeZoneOffset(timestamp)); RevCommit newCommit = builder.build(); repository.objectDatabase().put(newCommit); rebaseHead = newCommit.getId(); command(UpdateRef.class).setName(currentBranch).setNewValue(rebaseHead).call(); command(UpdateSymRef.class).setName(Ref.HEAD).setNewValue(currentBranch).call(); workingTree().updateWorkHead(newTreeId); index().updateStageHead(newTreeId); } } /** * Return the commit that is the squashed version of all the commits to apply, reading it from * the 'squash' file. If the file does not exist (that is, we are not in the middle of a rebase * with squash operation), returns null * * @return */ private RevCommit readSquashCommit() { File file = new File(getRebaseFolder(), "squash"); if (!file.exists()) { return null; } List<String> lines; try { lines = Files.readLines(file, Charsets.UTF_8); } catch (IOException e) { throw new IllegalStateException("Cannot create squash commit info file"); } ObjectId id = ObjectId.valueOf(lines.get(0).split("\t")[1].trim()); String commitString = Joiner.on("\n").join(lines.subList(1, lines.size())); ByteArrayInputStream stream = new ByteArrayInputStream( commitString.getBytes(Charsets.UTF_8)); ObjectReader<RevCommit> reader = new TextSerializationFactory().createCommitReader(); RevCommit revCommit = reader.read(id, stream); return revCommit; } }