summaryrefslogtreecommitdiff
path: root/plugins/git4idea/src/git4idea/rebase/GitRebaser.java
blob: aabf913fbf61cf1183b0b0c0d4e4055d2f359f48 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
/*
 * Copyright 2000-2011 JetBrains s.r.o.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package git4idea.rebase;

import com.intellij.openapi.components.ServiceManager;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.progress.ProgressIndicator;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.io.FileUtil;
import com.intellij.openapi.vcs.ProjectLevelVcsManager;
import com.intellij.openapi.vcs.VcsException;
import com.intellij.openapi.vfs.VirtualFile;
import git4idea.GitPlatformFacade;
import git4idea.GitUtil;
import git4idea.GitVcs;
import git4idea.commands.*;
import git4idea.merge.GitConflictResolver;
import git4idea.update.GitUpdateResult;
import git4idea.util.GitUIUtil;
import git4idea.util.StringScanner;
import git4idea.util.UntrackedFilesNotifier;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;

/**
 * @author Kirill Likhodedov
 */
public class GitRebaser {

  private final Project myProject;
  private GitVcs myVcs;
  private List<GitRebaseUtils.CommitInfo> mySkippedCommits;
  private static final Logger LOG = Logger.getInstance(GitRebaser.class);
  @NotNull private final Git myGit;
  private @Nullable ProgressIndicator myProgressIndicator;

  public GitRebaser(Project project, @NotNull Git git, @Nullable ProgressIndicator progressIndicator) {
    myProject = project;
    myGit = git;
    myProgressIndicator = progressIndicator;
    myVcs = GitVcs.getInstance(project);
    mySkippedCommits = new ArrayList<GitRebaseUtils.CommitInfo>();
  }

  public GitUpdateResult rebase(@NotNull VirtualFile root,
                                @NotNull List<String> parameters,
                                @Nullable final Runnable onCancel,
                                @Nullable GitLineHandlerListener lineListener) {
    final GitLineHandler rebaseHandler = createHandler(root);
    rebaseHandler.setStdoutSuppressed(false);
    rebaseHandler.addParameters(parameters);
    if (lineListener != null) {
      rebaseHandler.addLineListener(lineListener);
    }

    final GitRebaseProblemDetector rebaseConflictDetector = new GitRebaseProblemDetector();
    rebaseHandler.addLineListener(rebaseConflictDetector);
    GitUntrackedFilesOverwrittenByOperationDetector untrackedFilesDetector = new GitUntrackedFilesOverwrittenByOperationDetector(root);
    rebaseHandler.addLineListener(untrackedFilesDetector);

    String progressTitle = "Rebasing";
    GitTask rebaseTask = new GitTask(myProject, rebaseHandler, progressTitle);
    rebaseTask.setProgressIndicator(myProgressIndicator);
    rebaseTask.setProgressAnalyzer(new GitStandardProgressAnalyzer());
    final AtomicReference<GitUpdateResult> updateResult = new AtomicReference<GitUpdateResult>();
    final AtomicBoolean failure = new AtomicBoolean();
    try {
      GitUtil.workingTreeChangeStarted(myProject);
      rebaseTask.executeInBackground(true, new GitTaskResultHandlerAdapter() {
        @Override
        protected void onSuccess() {
          updateResult.set(GitUpdateResult.SUCCESS);
        }

        @Override
        protected void onCancel() {
          if (onCancel != null) {
            onCancel.run();
          }
          updateResult.set(GitUpdateResult.CANCEL);
        }

        @Override
        protected void onFailure() {
          failure.set(true);
        }
      });

      if (failure.get()) {
        updateResult.set(handleRebaseFailure(rebaseHandler, root, rebaseConflictDetector, untrackedFilesDetector));
      }
    }
    finally {
      GitUtil.workingTreeChangeFinished(myProject);
    }
    return updateResult.get();
  }

  protected GitLineHandler createHandler(VirtualFile root) {
    return new GitLineHandler(myProject, root, GitCommand.REBASE);
  }

  public void abortRebase(@NotNull VirtualFile root) {
    LOG.info("abortRebase " + root);
    final GitLineHandler rh = new GitLineHandler(myProject, root, GitCommand.REBASE);
    rh.setStdoutSuppressed(false);
    rh.addParameters("--abort");
    GitTask task = new GitTask(myProject, rh, "Aborting rebase");
    task.setProgressIndicator(myProgressIndicator);
    task.executeAsync(new GitTaskResultNotificationHandler(myProject, "Rebase aborted", "Abort rebase cancelled", "Error aborting rebase"));
  }

  public boolean continueRebase(@NotNull VirtualFile root) {
    return continueRebase(root, "--continue");
  }

  /**
   * Runs 'git rebase --continue' on several roots consequently.
   * @return true if rebase successfully finished.
   */
  public boolean continueRebase(@NotNull Collection<VirtualFile> rebasingRoots) {
    GitUtil.workingTreeChangeStarted(myProject);
    try {
      boolean success = true;
      for (VirtualFile root : rebasingRoots) {
        success &= continueRebase(root);
      }
      return success;
    }
    finally {
      GitUtil.workingTreeChangeFinished(myProject);
    }
  }

  // start operation may be "--continue" or "--skip" depending on the situation.
  private boolean continueRebase(final @NotNull VirtualFile root, @NotNull String startOperation) {
    LOG.info("continueRebase " + root + " " + startOperation);
    final GitLineHandler rh = new GitLineHandler(myProject, root, GitCommand.REBASE);
    rh.setStdoutSuppressed(false);
    rh.addParameters(startOperation);
    final GitRebaseProblemDetector rebaseConflictDetector = new GitRebaseProblemDetector();
    rh.addLineListener(rebaseConflictDetector);

    makeContinueRebaseInteractiveEditor(root, rh);

    final GitTask rebaseTask = new GitTask(myProject, rh, "git rebase " + startOperation);
    rebaseTask.setProgressAnalyzer(new GitStandardProgressAnalyzer());
    rebaseTask.setProgressIndicator(myProgressIndicator);
    return executeRebaseTaskInBackground(root, rh, rebaseConflictDetector, rebaseTask);
  }

  protected void makeContinueRebaseInteractiveEditor(VirtualFile root, GitLineHandler rh) {
    GitRebaseEditorService rebaseEditorService = GitRebaseEditorService.getInstance();
    // TODO If interactive rebase with commit rewording was invoked, this should take the reworded message
    GitRebaser.TrivialEditor editor = new GitRebaser.TrivialEditor(rebaseEditorService, myProject, root, rh);
    Integer rebaseEditorNo = editor.getHandlerNo();
    rebaseEditorService.configureHandler(rh, rebaseEditorNo);
  }

  /**
   * @return Roots which have unfinished rebase process. May be empty.
   */
  public @NotNull Collection<VirtualFile> getRebasingRoots() {
    final Collection<VirtualFile> rebasingRoots = new HashSet<VirtualFile>();
    for (VirtualFile root : ProjectLevelVcsManager.getInstance(myProject).getRootsUnderVcs(myVcs)) {
      if (GitRebaseUtils.isRebaseInTheProgress(root)) {
        rebasingRoots.add(root);
      }
    }
    return rebasingRoots;
  }

  /**
   * Reorders commits so that the given commits go before others, just after the given parentCommit.
   * For example, if A->B->C->D are unpushed commits and B and D are supplied to this method, then after rebase the commits will
   * look like that: B->D->A->C.
   * NB: If there are merges in the unpushed commits being reordered, a conflict would happen. The calling code should probably
   * prohibit reordering merge commits.
   */
  public boolean reoderCommitsIfNeeded(@NotNull final VirtualFile root, @NotNull String parentCommit, @NotNull List<String> olderCommits) throws VcsException {
    List<String> allCommits = new ArrayList<String>(); //TODO
    if (olderCommits.isEmpty() || olderCommits.size() == allCommits.size()) {
      LOG.info("Nothing to reorder. olderCommits: " + olderCommits + " allCommits: " + allCommits);
      return true;
    }

    final GitLineHandler h = new GitLineHandler(myProject, root, GitCommand.REBASE);
    h.setStdoutSuppressed(false);
    Integer rebaseEditorNo = null;
    GitRebaseEditorService rebaseEditorService = GitRebaseEditorService.getInstance();
    try {
      h.addParameters("-i", "-m", "-v");
      h.addParameters(parentCommit);

      final GitRebaseProblemDetector rebaseConflictDetector = new GitRebaseProblemDetector();
      h.addLineListener(rebaseConflictDetector);

      final PushRebaseEditor pushRebaseEditor = new PushRebaseEditor(rebaseEditorService, root, olderCommits, false, h);
      rebaseEditorNo = pushRebaseEditor.getHandlerNo();
      rebaseEditorService.configureHandler(h, rebaseEditorNo);

      final GitTask rebaseTask = new GitTask(myProject, h, "Reordering commits");
      rebaseTask.setProgressIndicator(myProgressIndicator);
      return executeRebaseTaskInBackground(root, h, rebaseConflictDetector, rebaseTask);
    } finally { // TODO should be unregistered in the task.success
      // unregistering rebase service
      if (rebaseEditorNo != null) {
        rebaseEditorService.unregisterHandler(rebaseEditorNo);
      }
    }
  }

  private boolean executeRebaseTaskInBackground(VirtualFile root, GitLineHandler h, GitRebaseProblemDetector rebaseConflictDetector, GitTask rebaseTask) {
    final AtomicBoolean result = new AtomicBoolean();
    final AtomicBoolean failure = new AtomicBoolean();
    rebaseTask.executeInBackground(true, new GitTaskResultHandlerAdapter() {
      @Override protected void onSuccess() {
        result.set(true);
      }

      @Override protected void onCancel() {
        result.set(false);
      }

      @Override protected void onFailure() {
        failure.set(true);
      }
    });
    if (failure.get()) {
      result.set(handleRebaseFailure(root, h, rebaseConflictDetector));
    }
    return result.get();
  }

  /**
   * @return true if the failure situation was resolved successfully, false if we failed to resolve the problem.
   */
  private boolean handleRebaseFailure(final VirtualFile root, final GitLineHandler h, GitRebaseProblemDetector rebaseConflictDetector) {
    if (rebaseConflictDetector.isMergeConflict()) {
      LOG.info("handleRebaseFailure merge conflict");
      return new GitConflictResolver(myProject, myGit, ServiceManager.getService(GitPlatformFacade.class), Collections.singleton(root), makeParamsForRebaseConflict()) {
        @Override protected boolean proceedIfNothingToMerge() {
          return continueRebase(root, "--continue");
        }

        @Override protected boolean proceedAfterAllMerged() {
          return continueRebase(root, "--continue");
        }
      }.merge();
    }
    else if (rebaseConflictDetector.isNoChangeError()) {
      LOG.info("handleRebaseFailure no changes error detected");
      try {
        if (GitUtil.hasLocalChanges(true, myProject, root)) {
          LOG.error("The rebase detector incorrectly detected 'no changes' situation. Attempting to continue rebase.");
          return continueRebase(root);
        }
        else if (GitUtil.hasLocalChanges(false, myProject, root)) {
          LOG.warn("No changes from patch were not added to the index. Adding all changes from tracked files.");
          stageEverything(root);
          return continueRebase(root);
        }
        else {
          GitRebaseUtils.CommitInfo commit = GitRebaseUtils.getCurrentRebaseCommit(root);
          LOG.info("no changes confirmed. Skipping commit " + commit);
          mySkippedCommits.add(commit);
          return continueRebase(root, "--skip");
        }
      }
      catch (VcsException e) {
        LOG.info("Failed to work around 'no changes' error.", e);
        String message = "Couldn't proceed with rebase. " + e.getMessage();
        GitUIUtil.notifyImportantError(myProject, "Error rebasing", message);
        return false;
      }
    }
    else {
      LOG.info("handleRebaseFailure error " + h.errors());
      GitUIUtil.notifyImportantError(myProject, "Error rebasing", GitUIUtil.stringifyErrors(h.errors()));
      return false;
    }
  }

  private void stageEverything(@NotNull VirtualFile root) throws VcsException {
    GitSimpleHandler handler = new GitSimpleHandler(myProject, root, GitCommand.ADD);
    handler.setSilent(false);
    handler.addParameters("--update");
    handler.run();
  }

  private static GitConflictResolver.Params makeParamsForRebaseConflict() {
    return new GitConflictResolver.Params().
      setReverse(true).
      setErrorNotificationTitle("Can't continue rebase").
      setMergeDescription("Merge conflicts detected. Resolve them before continuing rebase.").
      setErrorNotificationAdditionalDescription("Then you may <b>continue rebase</b>. <br/> " +
                                                "You also may <b>abort rebase</b> to restore the original branch and stop rebasing.");
  }

  public static class TrivialEditor extends GitInteractiveRebaseEditorHandler{
    public TrivialEditor(@NotNull GitRebaseEditorService service,
                         @NotNull Project project,
                         @NotNull VirtualFile root,
                         @NotNull GitHandler handler) {
      super(service, project, root, handler);
    }

    @Override
    public int editCommits(String path) {
      return 0;
    }
  }

  @NotNull
  public GitUpdateResult handleRebaseFailure(@NotNull GitLineHandler handler, @NotNull VirtualFile root,
                                             @NotNull GitRebaseProblemDetector rebaseConflictDetector,
                                             @NotNull GitMessageWithFilesDetector untrackedWouldBeOverwrittenDetector) {
    if (rebaseConflictDetector.isMergeConflict()) {
      LOG.info("handleRebaseFailure merge conflict");
      final boolean allMerged = new GitRebaser.ConflictResolver(myProject, myGit, root, this).merge();
      return allMerged ? GitUpdateResult.SUCCESS_WITH_RESOLVED_CONFLICTS : GitUpdateResult.INCOMPLETE;
    } else if (untrackedWouldBeOverwrittenDetector.wasMessageDetected()) {
      LOG.info("handleRebaseFailure: untracked files would be overwritten by checkout");
      UntrackedFilesNotifier.notifyUntrackedFilesOverwrittenBy(myProject, root,
                                                               untrackedWouldBeOverwrittenDetector.getRelativeFilePaths(), "rebase", null);
      return GitUpdateResult.ERROR;
    } else {
      LOG.info("handleRebaseFailure error " + handler.errors());
      GitUIUtil.notifyImportantError(myProject, "Rebase error", GitUIUtil.stringifyErrors(handler.errors()));
      return GitUpdateResult.ERROR;
    }
  }

  public static class ConflictResolver extends GitConflictResolver {
    @NotNull private final GitRebaser myRebaser;
    @NotNull private final VirtualFile myRoot;

    public ConflictResolver(@NotNull Project project, @NotNull Git git, @NotNull VirtualFile root, @NotNull GitRebaser rebaser) {
      super(project, git, ServiceManager.getService(GitPlatformFacade.class), Collections.singleton(root), makeParams());
      myRebaser = rebaser;
      myRoot = root;
    }

    private static Params makeParams() {
      Params params = new Params();
      params.setReverse(true);
      params.setMergeDescription("Merge conflicts detected. Resolve them before continuing rebase.");
      params.setErrorNotificationTitle("Can't continue rebase");
      params.setErrorNotificationAdditionalDescription("Then you may <b>continue rebase</b>. <br/> You also may <b>abort rebase</b> to restore the original branch and stop rebasing.");
      return params;
    }

    @Override protected boolean proceedIfNothingToMerge() throws VcsException {
      return myRebaser.continueRebase(myRoot);
    }

    @Override protected boolean proceedAfterAllMerged() throws VcsException {
      return myRebaser.continueRebase(myRoot);
    }
  }

  /**
   * The rebase editor that just overrides the list of commits
   */
  class PushRebaseEditor extends GitInteractiveRebaseEditorHandler {
    private final Logger LOG = Logger.getInstance(PushRebaseEditor.class);
    private final List<String> myCommits; // The reordered commits
    private final boolean myHasMerges; // true means that the root has merges

    /**
     * The constructor from fields that is expected to be
     * accessed only from {@link git4idea.rebase.GitRebaseEditorService}.
     *
     * @param rebaseEditorService
     * @param root      the git repository root
     * @param commits   the reordered commits
     * @param hasMerges if true, the vcs root has merges
     */
    public PushRebaseEditor(GitRebaseEditorService rebaseEditorService,
                            final VirtualFile root,
                            List<String> commits,
                            boolean hasMerges,
                            GitHandler h) {
      super(rebaseEditorService, myProject, root, h);
      myCommits = commits;
      myHasMerges = hasMerges;
    }

    public int editCommits(String path) {
      if (!myRebaseEditorShown) {
        myRebaseEditorShown = true;
        if (myHasMerges) {
          return 0;
        }
        try {
          TreeMap<String, String> pickLines = new TreeMap<String, String>();
          StringScanner s = new StringScanner(new String(FileUtil.loadFileText(new File(path), GitUtil.UTF8_ENCODING)));
          while (s.hasMoreData()) {
            if (!s.tryConsume("pick ")) {
              s.line();
              continue;
            }
            String commit = s.spaceToken();
            pickLines.put(commit, "pick " + commit + " " + s.line());
          }
          PrintWriter w = new PrintWriter(new OutputStreamWriter(new FileOutputStream(path), GitUtil.UTF8_ENCODING));
          try {
            for (String commit : myCommits) {
              String key = pickLines.headMap(commit + "\u0000").lastKey();
              if (key == null || !commit.startsWith(key)) {
                continue; // commit from merged branch
              }
              w.print(pickLines.get(key) + "\n");
            }
          }
          finally {
            w.close();
          }
          return 0;
        }
        catch (Exception ex) {
          LOG.error("Editor failed: ", ex);
          return 1;
        }
      }
      else {
        return super.editCommits(path);
      }
    }
  }


}