// Copyright (C) 2017 The Android Open Source Project
//
// 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 com.google.gerrit.acceptance.api.revision;

import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.TruthJUnit.assume;
import static com.google.gerrit.entities.Patch.COMMIT_MSG;
import static com.google.gerrit.entities.Patch.MERGE_LIST;
import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
import static com.google.gerrit.extensions.common.testing.DiffInfoSubject.assertThat;
import static com.google.gerrit.extensions.common.testing.FileInfoSubject.assertThat;
import static com.google.gerrit.git.ObjectIds.abbreviateName;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toMap;

import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.GitUtil;
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.PushOneCommit.Result;
import com.google.gerrit.common.RawInputUtil;
import com.google.gerrit.extensions.api.changes.FileApi;
import com.google.gerrit.extensions.api.changes.RebaseInput;
import com.google.gerrit.extensions.client.DiffPreferencesInfo;
import com.google.gerrit.extensions.common.ChangeType;
import com.google.gerrit.extensions.common.DiffInfo;
import com.google.gerrit.extensions.common.FileInfo;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.BinaryResult;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.IntStream;
import javax.imageio.ImageIO;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.revwalk.RevCommit;
import org.junit.Before;
import org.junit.Ignore;
import org.junit.Test;

public class RevisionDiffIT extends AbstractDaemonTest {
  // @RunWith(Parameterized.class) can't be used as AbstractDaemonTest is annotated with another
  // runner. Using different configs is a workaround to achieve the same.
  protected static final String TEST_PARAMETER_MARKER = "test_only_parameter";

  private static final String CURRENT = "current";
  private static final String FILE_NAME = "some_file.txt";
  private static final String FILE_NAME2 = "another_file.txt";
  private static final String FILE_CONTENT =
      IntStream.rangeClosed(1, 100)
          .mapToObj(number -> String.format("Line %d\n", number))
          .collect(joining());
  private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";

  private boolean intraline;
  private boolean useNewDiffCacheListFiles;
  private boolean useNewDiffCacheGetDiff;

  private ObjectId commit1;
  private String changeId;
  private String initialPatchSetId;

  @Before
  public void setUp() throws Exception {
    // Reduce flakiness of tests. (If tests aren't fast enough, we would use a fall-back
    // computation, which might yield different results.)
    baseConfig.setString("cache", "diff", "timeout", "1 minute");
    baseConfig.setString("cache", "diff_intraline", "timeout", "1 minute");

    intraline = baseConfig.getBoolean(TEST_PARAMETER_MARKER, "intraline", false);
    useNewDiffCacheListFiles =
        baseConfig.getBoolean("cache", "diff_cache", "runNewDiffCache_ListFiles", false);
    useNewDiffCacheGetDiff =
        baseConfig.getBoolean("cache", "diff_cache", "runNewDiffCache_GetDiff", false);

    ObjectId headCommit = testRepo.getRepository().resolve("HEAD");
    commit1 =
        addCommit(headCommit, ImmutableMap.of(FILE_NAME, FILE_CONTENT, FILE_NAME2, FILE_CONTENT2));

    Result result = createEmptyChange();
    changeId = result.getChangeId();
    initialPatchSetId = result.getPatchSetId().getId();
  }

  @Test
  public void diff() throws Exception {
    // The assertions assume that intraline is false.
    assume().that(intraline).isFalse();

    String fileName = "a_new_file.txt";
    String fileContent = "First line\nSecond line\n";
    PushOneCommit.Result result = createChange("Add a file", fileName, fileContent);
    assertDiffForNewFile(result, fileName, fileContent);
    assertDiffForNewFile(result, COMMIT_MSG, result.getCommit().getFullMessage());
  }

  @Ignore
  @Test
  public void diffWithRootCommit() throws Exception {
    // TODO(ghareeb): Implement this test
  }

  @Test
  public void patchsetLevelFileDiffIsEmpty() throws Exception {
    PushOneCommit.Result result = createChange();
    DiffInfo diffForPatchsetLevelFile =
        gApi.changes()
            .id(result.getChangeId())
            .revision(result.getCommit().name())
            .file(PATCHSET_LEVEL)
            .diff();
    // This behavior is the same as the behavior for non-existent files.
    assertThat(diffForPatchsetLevelFile).binary().isNull();
    assertThat(diffForPatchsetLevelFile).content().isEmpty();
    assertThat(diffForPatchsetLevelFile).diffHeader().isNull();
    assertThat(diffForPatchsetLevelFile).metaA().isNull();
    assertThat(diffForPatchsetLevelFile).metaB().isNull();
    assertThat(diffForPatchsetLevelFile).webLinks().isNull();
  }

  @Test
  public void deletedFileIsIncludedInDiff() throws Exception {
    gApi.changes().id(changeId).edit().deleteFile(FILE_NAME);
    gApi.changes().id(changeId).edit().publish();

    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
  }

  @Test
  public void numberOfLinesInDiffOfDeletedFileWithoutNewlineAtEndIsCorrect() throws Exception {
    String filePath = "a_new_file.txt";
    String fileContent = "Line 1\nLine 2\nLine 3";
    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
    gApi.changes().id(changeId).edit().publish();
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    gApi.changes().id(changeId).edit().deleteFile(filePath);
    gApi.changes().id(changeId).edit().publish();

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(3);
    assertThat(diffInfo).metaB().isNull();
  }

  @Test
  public void numberOfLinesInFileInfoOfDeletedFileWithoutNewlineAtEndIsCorrect() throws Exception {
    String filePath = "a_new_file.txt";
    String fileContent = "Line 1\nLine 2\nLine 3";
    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
    gApi.changes().id(changeId).edit().publish();
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    gApi.changes().id(changeId).edit().deleteFile(filePath);
    gApi.changes().id(changeId).edit().publish();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(previousPatchSetId);
    assertThat(changedFiles.get(filePath)).linesInserted().isNull();
    assertThat(changedFiles.get(filePath)).linesDeleted().isEqualTo(3);
  }

  @Test
  public void numberOfLinesInDiffOfDeletedFileWithNewlineAtEndIsCorrect() throws Exception {
    String filePath = "a_new_file.txt";
    String fileContent = "Line 1\nLine 2\nLine 3\n";
    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
    gApi.changes().id(changeId).edit().publish();
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    gApi.changes().id(changeId).edit().deleteFile(filePath);
    gApi.changes().id(changeId).edit().publish();

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(4);
    assertThat(diffInfo).metaB().isNull();
  }

  @Test
  public void numberOfLinesInFileInfoOfDeletedFileWithNewlineAtEndIsCorrect() throws Exception {
    String filePath = "a_new_file.txt";
    String fileContent = "Line 1\nLine 2\nLine 3\n";
    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
    gApi.changes().id(changeId).edit().publish();
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    gApi.changes().id(changeId).edit().deleteFile(filePath);
    gApi.changes().id(changeId).edit().publish();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(previousPatchSetId);
    assertThat(changedFiles.get(filePath)).linesInserted().isNull();
    // Inherited from Git: An empty last line is ignored in the count.
    assertThat(changedFiles.get(filePath)).linesDeleted().isEqualTo(3);
  }

  @Test
  public void deletedFileWithoutNewlineAtEndResultsInOneDiffEntry() throws Exception {
    String filePath = "a_new_file.txt";
    String fileContent = "Line 1\nLine 2\nLine 3";
    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
    gApi.changes().id(changeId).edit().publish();
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    gApi.changes().id(changeId).edit().deleteFile(filePath);
    gApi.changes().id(changeId).edit().publish();

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
    assertThat(diffInfo)
        .content()
        .onlyElement()
        .linesOfA()
        .containsExactly("Line 1", "Line 2", "Line 3");
  }

  @Test
  public void deletedFileWithNewlineAtEndResultsInOneDiffEntry() throws Exception {
    String filePath = "a_new_file.txt";
    String fileContent = "Line 1\nLine 2\nLine 3\n";
    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
    gApi.changes().id(changeId).edit().publish();
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    gApi.changes().id(changeId).edit().deleteFile(filePath);
    gApi.changes().id(changeId).edit().publish();

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
    assertThat(diffInfo)
        .content()
        .onlyElement()
        .linesOfA()
        .containsExactly("Line 1", "Line 2", "Line 3", "");
  }

  @Test
  public void addedFileIsIncludedInDiff() throws Exception {
    String newFilePath = "a_new_file.txt";
    String newFileContent = "arbitrary content";
    gApi.changes().id(changeId).edit().modifyFile(newFilePath, RawInputUtil.create(newFileContent));
    gApi.changes().id(changeId).edit().publish();

    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, newFilePath);
  }

  @Test
  public void numberOfLinesInDiffOfAddedFileWithoutNewlineAtEndIsCorrect() throws Exception {
    String filePath = "a_new_file.txt";
    String fileContent = "Line 1\nLine2\nLine 3";
    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
    gApi.changes().id(changeId).edit().publish();

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, filePath).withBase(initialPatchSetId).get();
    assertThat(diffInfo).metaA().isNull();
    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(3);
  }

  @Test
  public void numberOfLinesInFileInfoOfAddedFileWithoutNewlineAtEndIsCorrect() throws Exception {
    String filePath = "a_new_file.txt";
    String fileContent = "Line 1\nLine2\nLine 3";
    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
    gApi.changes().id(changeId).edit().publish();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    assertThat(changedFiles.get(filePath)).linesInserted().isEqualTo(3);
    assertThat(changedFiles.get(filePath)).linesDeleted().isNull();
  }

  @Test
  public void numberOfLinesInDiffOfAddedFileWithNewlineAtEndIsCorrect() throws Exception {
    String filePath = "a_new_file.txt";
    String fileContent = "Line 1\nLine2\nLine 3\n";
    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
    gApi.changes().id(changeId).edit().publish();

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, filePath).withBase(initialPatchSetId).get();
    assertThat(diffInfo).metaA().isNull();
    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(4);
  }

  @Test
  public void numberOfLinesInFileInfoOfAddedFileWithNewlineAtEndIsCorrect() throws Exception {
    String filePath = "a_new_file.txt";
    String fileContent = "Line 1\nLine2\nLine 3\n";
    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
    gApi.changes().id(changeId).edit().publish();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    // Inherited from Git: An empty last line is ignored in the count.
    assertThat(changedFiles.get(filePath)).linesInserted().isEqualTo(3);
    assertThat(changedFiles.get(filePath)).linesDeleted().isNull();
  }

  @Test
  public void addedFileWithoutNewlineAtEndResultsInOneDiffEntry() throws Exception {
    String filePath = "a_new_file.txt";
    String fileContent = "Line 1\nLine 2\nLine 3";
    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
    gApi.changes().id(changeId).edit().publish();

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, filePath).withBase(initialPatchSetId).get();
    assertThat(diffInfo)
        .content()
        .onlyElement()
        .linesOfB()
        .containsExactly("Line 1", "Line 2", "Line 3");
  }

  @Test
  public void addedFileWithNewlineAtEndResultsInOneDiffEntry() throws Exception {
    String filePath = "a_new_file.txt";
    String fileContent = "Line 1\nLine 2\nLine 3\n";
    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
    gApi.changes().id(changeId).edit().publish();

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, filePath).withBase(initialPatchSetId).get();
    assertThat(diffInfo)
        .content()
        .onlyElement()
        .linesOfB()
        .containsExactly("Line 1", "Line 2", "Line 3", "");
  }

  @Test
  public void renamedFileIsIncludedInDiff() throws Exception {
    String newFilePath = "a_new_file.txt";
    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath);
    gApi.changes().id(changeId).edit().publish();

    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, newFilePath);
  }

  @Test
  public void copiedFileTreatedAsAddedFileInDiff() throws Exception {
    String copyFilePath = "copy_of_some_file.txt";
    gApi.changes().id(changeId).edit().modifyFile(copyFilePath, RawInputUtil.create(FILE_CONTENT));
    gApi.changes().id(changeId).edit().publish();

    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, copyFilePath);
    // If this ever changes, please add tests which cover copied files.
    assertThat(changedFiles.get(copyFilePath)).status().isEqualTo('A');
    assertThat(changedFiles.get(copyFilePath)).linesInserted().isEqualTo(100);
    assertThat(changedFiles.get(copyFilePath)).linesDeleted().isNull();
  }

  @Test
  public void copiedFileDetectedIfOriginalFileIsRenamedInDiff() throws Exception {
    /*
     * Copies are detected when a file is deleted and more than 1 file with the same content are
     * added. In this case, the added file with the closest name to the original file is tagged as a
     * rename and the remaining files are considered copies. This implementation is done by JGit in
     * the RenameDetector component.
     */
    String renamedFileName = "renamed_some_file.txt";
    String copyFileName1 = "copy1_with_different_name.txt";
    String copyFileName2 = "copy2_with_different_name.txt";
    gApi.changes().id(changeId).edit().modifyFile(copyFileName1, RawInputUtil.create(FILE_CONTENT));
    gApi.changes().id(changeId).edit().modifyFile(copyFileName2, RawInputUtil.create(FILE_CONTENT));
    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, renamedFileName);
    gApi.changes().id(changeId).edit().publish();

    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();

    assertThat(changedFiles.keySet())
        .containsExactly("/COMMIT_MSG", renamedFileName, copyFileName1, copyFileName2);
    assertThat(changedFiles.get(renamedFileName).status).isEqualTo('R');
    assertThat(changedFiles.get(copyFileName1).status).isEqualTo('C');
    assertThat(changedFiles.get(copyFileName2).status).isEqualTo('C');
  }

  @Test
  public void addedBinaryFileIsIncludedInDiff() throws Exception {
    String imageFileName = "an_image.png";
    byte[] imageBytes = createRgbImage(255, 0, 0);
    gApi.changes().id(changeId).edit().modifyFile(imageFileName, RawInputUtil.create(imageBytes));
    gApi.changes().id(changeId).edit().publish();

    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, imageFileName);
  }

  @Test
  public void modifiedBinaryFileIsIncludedInDiff() throws Exception {
    String imageFileName = "an_image.png";
    byte[] imageBytes1 = createRgbImage(255, 100, 0);
    ObjectId commit2 = addCommit(commit1, imageFileName, imageBytes1);

    rebaseChangeOn(changeId, commit2);
    byte[] imageBytes2 = createRgbImage(0, 100, 255);
    gApi.changes().id(changeId).edit().modifyFile(imageFileName, RawInputUtil.create(imageBytes2));
    gApi.changes().id(changeId).edit().publish();

    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, imageFileName);
  }

  @Test
  public void diffOnMergeCommitChange() throws Exception {
    PushOneCommit.Result r = createMergeCommitChange("refs/for/master");

    DiffInfo diff;

    // automerge
    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "foo").get();
    assertThat(diff.metaA.lines).isEqualTo(6);
    assertThat(diff.metaB.lines).isEqualTo(1);

    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "bar").get();
    assertThat(diff.metaA.lines).isEqualTo(6);
    assertThat(diff.metaB.lines).isEqualTo(1);

    // parent 1
    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "bar").withParent(1).get();
    assertThat(diff.metaA.lines).isEqualTo(1);
    assertThat(diff.metaB.lines).isEqualTo(1);

    // parent 2
    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "foo").withParent(2).get();
    assertThat(diff.metaA.lines).isEqualTo(1);
    assertThat(diff.metaB.lines).isEqualTo(1);
  }

  @Test
  public void diffWithThreeParentsMergeCommitChange() throws Exception {
    // Create a merge commit of 3 files: foo, bar, baz. The merge commit is pointing to 3 different
    // parents: the merge commit contains foo of parent1, bar of parent2 and baz of parent3.
    PushOneCommit.Result r =
        createNParentsMergeCommitChange("refs/for/master", ImmutableList.of("foo", "bar", "baz"));

    DiffInfo diff;

    // parent 1
    Map<String, FileInfo> changedFiles = gApi.changes().id(r.getChangeId()).current().files(1);
    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, MERGE_LIST, "bar", "baz");
    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "foo").withParent(1).get();
    assertThat(diff.diffHeader).isNull();
    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "bar").withParent(1).get();
    assertThat(diff.diffHeader).hasSize(4);
    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "baz").withParent(1).get();
    assertThat(diff.diffHeader).hasSize(4);

    // parent 2
    changedFiles = gApi.changes().id(r.getChangeId()).current().files(2);
    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, MERGE_LIST, "foo", "baz");
    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "foo").withParent(2).get();
    assertThat(diff.diffHeader).hasSize(4);
    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "bar").withParent(2).get();
    assertThat(diff.diffHeader).isNull();
    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "baz").withParent(2).get();
    assertThat(diff.diffHeader).hasSize(4);

    // parent 3
    changedFiles = gApi.changes().id(r.getChangeId()).current().files(3);
    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, MERGE_LIST, "foo", "bar");
    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "foo").withParent(3).get();
    assertThat(diff.diffHeader).hasSize(4);
    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "bar").withParent(3).get();
    assertThat(diff.diffHeader).hasSize(4);
    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "baz").withParent(3).get();
    assertThat(diff.diffHeader).isNull();
  }

  @Test
  public void diffWithThreeParentsMergeCommitAgainstAutoMergeReturnsCommitMsgAndMergeListOnly()
      throws Exception {
    PushOneCommit.Result r =
        createNParentsMergeCommitChange("refs/for/master", ImmutableList.of("foo", "bar", "baz"));

    // Diff against auto-merge returns COMMIT_MSG and MERGE_LIST only
    Map<String, FileInfo> changedFiles = gApi.changes().id(r.getChangeId()).current().files();
    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, MERGE_LIST);
  }

  @Test
  public void diffBetweenPatchSetsOfMergeCommitCanBeRetrievedForCommitMessageAndMergeList()
      throws Exception {
    PushOneCommit.Result result = createMergeCommitChange("refs/for/master", "my_file.txt");
    String changeId = result.getChangeId();
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(changeId, "my_file.txt", content -> content.concat("Line I\nLine II\n"));

    // Call both of them in succession to ensure that they don't share the same cache keys.
    DiffInfo commitMessageDiffInfo =
        getDiffRequest(changeId, CURRENT, COMMIT_MSG).withBase(previousPatchSetId).get();
    DiffInfo mergeListDiffInfo =
        getDiffRequest(changeId, CURRENT, MERGE_LIST).withBase(previousPatchSetId).get();

    assertThat(commitMessageDiffInfo).content().hasSize(3);
    assertThat(mergeListDiffInfo).content().hasSize(1);
  }

  @Test
  public void diffAgainstAutoMergeCanBeRetrievedForCommitMessageAndMergeList() throws Exception {
    PushOneCommit.Result result = createMergeCommitChange("refs/for/master", "my_file.txt");
    String changeId = result.getChangeId();
    addModifiedPatchSet(changeId, "my_file.txt", content -> content.concat("Line I\nLine II\n"));

    DiffInfo commitMessageDiffInfo =
        getDiffRequest(changeId, CURRENT, COMMIT_MSG)
            .get(); // diff latest PS against base (auto-merge)
    DiffInfo mergeListDiffInfo =
        getDiffRequest(changeId, CURRENT, MERGE_LIST)
            .get(); // diff latest PS against base (auto-merge)

    assertThat(commitMessageDiffInfo).changeType().isEqualTo(ChangeType.ADDED);
    assertThat(commitMessageDiffInfo).content().hasSize(1);

    assertThat(mergeListDiffInfo).changeType().isEqualTo(ChangeType.ADDED);
    assertThat(mergeListDiffInfo).content().hasSize(1);
    assertThat(mergeListDiffInfo)
        .content()
        .element(0)
        .linesOfB()
        .element(0)
        .isEqualTo("Merge List:");
  }

  @Test
  public void diffOfUnmodifiedFileMarksAllLinesAsCommon() throws Exception {
    String filePath = "a_new_file.txt";
    String fileContent = "Line 1\nLine 2\nLine 3\n";
    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
    gApi.changes().id(changeId).edit().publish();
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    gApi.changes().id(changeId).edit().modifyCommitMessage(updatedCommitMessage());
    gApi.changes().id(changeId).edit().publish();

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
    assertThat(diffInfo)
        .content()
        .onlyElement()
        .commonLines()
        .containsExactly("Line 1", "Line 2", "Line 3", "")
        .inOrder();
    assertThat(diffInfo).content().onlyElement().linesOfA().isNull();
    assertThat(diffInfo).content().onlyElement().linesOfB().isNull();
  }

  @Test
  public void diffOfUnmodifiedFileWithNewlineAtEndHasEmptyLineAtEnd() throws Exception {
    String filePath = "a_new_file.txt";
    String fileContent = "Line 1\nLine 2\nLine 3\n";
    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
    gApi.changes().id(changeId).edit().publish();
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    gApi.changes().id(changeId).edit().modifyCommitMessage(updatedCommitMessage());
    gApi.changes().id(changeId).edit().publish();

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
    assertThat(diffInfo).content().onlyElement().commonLines().lastElement().isEqualTo("");

    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(4);
    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(4);
  }

  @Test
  public void diffOfUnmodifiedFileWithoutNewlineAtEndEndsWithLastLineContent() throws Exception {
    String filePath = "a_new_file.txt";
    String fileContent = "Line 1\nLine 2\nLine 3";
    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
    gApi.changes().id(changeId).edit().publish();
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    gApi.changes().id(changeId).edit().modifyCommitMessage(updatedCommitMessage());
    gApi.changes().id(changeId).edit().publish();

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
    assertThat(diffInfo).content().onlyElement().commonLines().lastElement().isEqualTo("Line 3");

    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(3);
    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(3);
  }

  @Test
  public void diffOfModifiedFileWithNewlineAtEndHasEmptyLineAtEnd() throws Exception {
    String filePath = "a_new_file.txt";
    String fileContent = "Line 1\nLine 2\nLine 3\n";
    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
    gApi.changes().id(changeId).edit().publish();
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(changeId, filePath, content -> content.replace("Line 1\n", "Line one\n"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
    assertThat(diffInfo).content().lastElement().commonLines().lastElement().isEqualTo("");

    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(4);
    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(4);
  }

  @Test
  public void diffOfModifiedFileWithoutNewlineAtEndEndsWithLastLineContent() throws Exception {
    String filePath = "a_new_file.txt";
    String fileContent = "Line 1\nLine 2\nLine 3";
    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
    gApi.changes().id(changeId).edit().publish();
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(changeId, filePath, content -> content.replace("Line 1\n", "Line one\n"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
    assertThat(diffInfo).content().lastElement().commonLines().lastElement().isEqualTo("Line 3");

    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(3);
    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(3);
  }

  @Test
  public void diffOfModifiedLastLineWithNewlineAtEndHasEmptyLineAtEnd() throws Exception {
    String filePath = "a_new_file.txt";
    String fileContent = "Line 1\nLine 2\nLine 3\n";
    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
    gApi.changes().id(changeId).edit().publish();
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(changeId, filePath, content -> content.replace("Line 3\n", "Line three\n"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
    assertThat(diffInfo).content().lastElement().commonLines().lastElement().isEqualTo("");

    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(4);
    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(4);
  }

  @Test
  public void diffOfModifiedLastLineWithoutNewlineAtEndEndsWithLastLineContent() throws Exception {
    String filePath = "a_new_file.txt";
    String fileContent = "Line 1\nLine 2\nLine 3";
    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
    gApi.changes().id(changeId).edit().publish();
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(changeId, filePath, content -> content.replace("Line 3", "Line three"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
    assertThat(diffInfo).content().lastElement().linesOfA().containsExactly("Line 3");
    assertThat(diffInfo).content().lastElement().linesOfB().containsExactly("Line three");

    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(3);
    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(3);
  }

  @Test
  public void addedNewlineAtEndOfFileIsMarkedInDiffWhenWhitespaceIsConsidered() throws Exception {
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("\n"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME)
            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
            .withBase(previousPatchSetId)
            .get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 101");
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 101", "");

    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(102);
  }

  @Test
  public void addedNewlineAtEndOfFileIsMarkedInDiffWhenWhitespaceIsIgnored() throws Exception {
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("\n"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME)
            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_ALL)
            .withBase(previousPatchSetId)
            .get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().isNull();
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("");

    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(102);
  }

  @Test
  public void addedNewlineAtEndOfFileMeansOneModifiedLine() throws Exception {
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("\n"));

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(previousPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
  }

  @Test
  public void addedNewlineAtEndOfFileIsMarkedInDiffWhenOtherwiseOnlyEditsDueToRebaseExist()
      throws Exception {
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    String newBaseFileContent = FILE_CONTENT.replace("Line 70\n", "Line seventy\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, newBaseFileContent);
    rebaseChangeOn(changeId, commit2);
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("\n"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME)
            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
            .withBase(previousPatchSetId)
            .get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).isNotNull(); // Line 70 modification
    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 101");
    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line 101", "");

    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(102);
  }

  @Test
  // TODO: Fix this issue. This test documents the current behavior and ensures that we at least
  //  don't run into an internal server error.
  public void addedNewlineAtEndOfFileIsNotIdentifiedAsDueToRebaseEvenThoughItShould()
      throws Exception {
    String baseFileContent = FILE_CONTENT.concat("Line 101");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, baseFileContent);
    rebaseChangeOn(changeId, commit2);
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    String newBaseFileContent = baseFileContent.concat("\n");
    ObjectId commit3 = addCommit(commit2, FILE_NAME, newBaseFileContent);
    rebaseChangeOn(changeId, commit3);

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME)
            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_ALL)
            .withBase(previousPatchSetId)
            .get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().isNull();
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("");
    // This should actually be isDueToRebase().
    assertThat(diffInfo).content().element(1).isNotDueToRebase();
  }

  @Test
  public void
      addedNewlineAtEndOfFileIsMarkedWhenEditDueToRebaseIncreasedLineCountAndWhitespaceConsidered()
          throws Exception {
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    String newBaseFileContent = FILE_CONTENT.replace("Line 70\n", "Line 70\nLine 70.5\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, newBaseFileContent);
    rebaseChangeOn(changeId, commit2);
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("\n"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME)
            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
            .withBase(previousPatchSetId)
            .get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).isNotNull(); // Line 70.5 insertion
    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 101");
    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line 101", "");

    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(103);
  }

  @Test
  // TODO: Fix this issue. This test documents the current behavior and ensures that we at least
  //  don't run into an internal server error.
  public void
      addedNewlineAtEndOfFileIsNotMarkedWhenEditDueToRebaseIncreasedLineCountAndWhitespaceIgnoredEvenThoughItShould()
          throws Exception {
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    String newBaseFileContent = FILE_CONTENT.replace("Line 70\n", "Line 70\nLine 70.5\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, newBaseFileContent);
    rebaseChangeOn(changeId, commit2);
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("\n"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME)
            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_ALL)
            .withBase(previousPatchSetId)
            .get();
    assertThat(diffInfo).content().element(0).numberOfSkippedLines().isGreaterThan(0);
  }

  @Test
  public void addedLastLineWithoutNewlineBeforeAndAfterwardsIsMarkedInDiff() throws Exception {
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("\nLine 102"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME)
            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
            .withBase(previousPatchSetId)
            .get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 101");
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 101", "Line 102");

    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(102);
  }

  @Test
  public void addedLastLineWithoutNewlineBeforeAndAfterwardsMeansTwoModifiedLines()
      throws Exception {
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("\nLine 102"));

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(previousPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(2);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
  }

  @Test
  public void addedLastLineWithoutNewlineBeforeButWithOneAfterwardsIsMarkedInDiff()
      throws Exception {
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("\nLine 102\n"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME)
            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
            .withBase(previousPatchSetId)
            .get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 101");
    assertThat(diffInfo)
        .content()
        .element(1)
        .linesOfB()
        .containsExactly("Line 101", "Line 102", "");

    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(103);
  }

  @Test
  public void addedLastLineWithoutNewlineBeforeButWithOneAfterwardsMeansTwoModifiedLines()
      throws Exception {
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("\nLine 102\n"));

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(previousPatchSetId);
    // Inherited from Git: An empty last line is ignored in the count.
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(2);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
  }

  @Test
  public void addedLastLineWithNewlineBeforeAndAfterwardsIsMarkedInDiff() throws Exception {
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101\n"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().isNull();
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 101");
    assertThat(diffInfo).content().element(2).commonLines().containsExactly("");

    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(102);
  }

  @Test
  public void addedLastLineWithNewlineBeforeAndAfterwardsMeansOneInsertedLine() throws Exception {
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101\n"));

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isNull();
  }

  @Test
  public void addedLastLineWithNewlineBeforeButWithoutOneAfterwardsIsMarkedInDiff()
      throws Exception {
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("");
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 101");

    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(101);
  }

  @Test
  public void addedLastLineWithNewlineBeforeButWithoutOneAfterwardsMeansOneInsertedLine()
      throws Exception {
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
    // Inherited from Git: An empty last line is ignored in the count.
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isNull();
  }

  @Test
  public void hunkForModifiedLastLineIsCombinedWithHunkForAddedNewlineAtEnd() throws Exception {
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(
        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 101", "Line one oh one\n"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 101");
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line one oh one", "");
  }

  @Test
  public void intralineEditsAreIdentified() throws Exception {
    // TODO(ghareeb): This test asserts the wrong behavior due to the following issue
    // bugs.chromium.org/p/gerrit/issues/detail?id=13563
    // Please remove this comment and assert the correct behavior when the bug is fixed.

    assume().that(intraline).isTrue();

    String orig = "[-9999,9999]";
    String replace = "[-999,999]";

    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat(orig));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.replace(orig, replace));

    // TODO(ghareeb): remove this comment when the issue is fixed.
    // The returned diff incorrectly contains:
    // replace [-9999{,99}99] with [-999{,}999].
    // If this replace edit is done, the resulting string incorrectly becomes [-9999,99].

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();

    List<List<Integer>> editsA = diffInfo.content.get(1).editA;
    List<List<Integer>> editsB = diffInfo.content.get(1).editB;
    String reconstructed = transformStringUsingEditList(orig, replace, editsA, editsB);

    // TODO(ghareeb): assert equals when the issue is fixed.
    assertThat(reconstructed).isNotEqualTo(replace);
  }

  @Test
  public void intralineEditsForModifiedLastLineArePreservedWhenNewlineIsAlsoAddedAtEnd()
      throws Exception {
    assume().that(intraline).isTrue();

    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(
        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 101", "Line one oh one\n"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo)
        .content()
        .element(1)
        .intralineEditsOfA()
        .containsExactly(ImmutableList.of(5, 3));
    assertThat(diffInfo)
        .content()
        .element(1)
        .intralineEditsOfB()
        .containsExactly(ImmutableList.of(5, 11));
  }

  @Test
  public void hunkForModifiedSecondToLastLineIsNotCombinedWithHunkForAddedNewlineAtEnd()
      throws Exception {
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat("Line 101"));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(
        changeId,
        FILE_NAME,
        fileContent -> fileContent.replace("Line 100\n", "Line one hundred\n").concat("\n"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 100");
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line one hundred");
    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(3).linesOfA().isNull();
    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("");
  }

  @Test
  public void deletedNewlineAtEndOfFileIsMarkedInDiffWhenWhitespaceIsConsidered() throws Exception {
    addModifiedPatchSet(
        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line 100"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME)
            .withBase(initialPatchSetId)
            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
            .get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 100", "");
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 100");

    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(100);
  }

  @Test
  public void deletedNewlineAtEndOfFileIsMarkedInDiffWhenWhitespaceIsIgnored() throws Exception {
    addModifiedPatchSet(
        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line 100"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME)
            .withBase(initialPatchSetId)
            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_ALL)
            .get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("");
    assertThat(diffInfo).content().element(1).linesOfB().isNull();

    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(100);
  }

  @Test
  public void deletedNewlineAtEndOfFileMeansOneModifiedLine() throws Exception {
    addModifiedPatchSet(
        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line 100"));

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
  }

  @Test
  public void deletedLastLineWithoutNewlineBeforeAndAfterwardsIsMarkedInDiff() throws Exception {
    addModifiedPatchSet(
        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line 100"));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.replace("\nLine 100", ""));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME)
            .withBase(previousPatchSetId)
            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
            .get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 99", "Line 100");
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 99");

    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(100);
    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(99);
  }

  @Test
  public void deletedLastLineWithoutNewlineBeforeAndAfterwardsMeansTwoModifiedLines()
      throws Exception {
    addModifiedPatchSet(
        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line 100"));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.replace("\nLine 100", ""));

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(previousPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
  }

  @Test
  public void deletedLastLineWithoutNewlineBeforeButWithOneAfterwardsIsMarkedInDiff()
      throws Exception {
    addModifiedPatchSet(
        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line 100"));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100", ""));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME)
            .withBase(previousPatchSetId)
            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
            .get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 100");
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("");

    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(100);
    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(100);
  }

  @Test
  public void deletedLastLineWithoutNewlineBeforeButWithOneAfterwardsMeansOneDeletedLine()
      throws Exception {
    addModifiedPatchSet(
        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line 100"));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100", ""));

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(previousPatchSetId);
    // Inherited from Git: An empty last line is ignored in the count.
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isNull();
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
  }

  @Test
  public void deletedLastLineWithNewlineBeforeAndAfterwardsIsMarkedInDiff() throws Exception {
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", ""));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME)
            .withBase(initialPatchSetId)
            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
            .get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 100");
    assertThat(diffInfo).content().element(1).linesOfB().isNull();
    assertThat(diffInfo).content().element(2).commonLines().containsExactly("");

    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(100);
  }

  @Test
  public void deletedLastLineWithNewlineBeforeAndAfterwardsMeansOneDeletedLine() throws Exception {
    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", ""));

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isNull();
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
  }

  @Test
  public void deletedLastLineWithNewlineBeforeButWithoutOneAfterwardsIsMarkedInDiff()
      throws Exception {
    addModifiedPatchSet(
        changeId, FILE_NAME, fileContent -> fileContent.replace("\nLine 100\n", ""));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME)
            .withBase(initialPatchSetId)
            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
            .get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 99", "Line 100", "");
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 99");

    assertThat(diffInfo).metaA().totalLineCount().isEqualTo(101);
    assertThat(diffInfo).metaB().totalLineCount().isEqualTo(99);
  }

  @Test
  public void deletedLastLineWithNewlineBeforeButWithoutOneAfterwardsMeansTwoModifiedLines()
      throws Exception {
    addModifiedPatchSet(
        changeId, FILE_NAME, fileContent -> fileContent.replace("\nLine 100\n", ""));

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
  }

  @Test
  public void hunkForModifiedLastLineIsCombinedWithHunkForDeletedNewlineAtEnd() throws Exception {
    addModifiedPatchSet(
        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line one hundred"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 100", "");
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line one hundred");
  }

  @Test
  public void intralineEditsForModifiedLastLineArePreservedWhenNewlineIsAlsoDeletedAtEnd()
      throws Exception {
    assume().that(intraline).isTrue();

    addModifiedPatchSet(
        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 100\n", "Line one hundred"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo)
        .content()
        .element(1)
        .intralineEditsOfA()
        .containsExactly(ImmutableList.of(5, 4));
    assertThat(diffInfo)
        .content()
        .element(1)
        .intralineEditsOfB()
        .containsExactly(ImmutableList.of(5, 11));
  }

  @Test
  public void hunkForModifiedSecondToLastLineIsNotCombinedWithHunkForDeletedNewlineAtEnd()
      throws Exception {
    addModifiedPatchSet(
        changeId,
        FILE_NAME,
        fileContent ->
            fileContent
                .replace("Line 99\n", "Line ninety-nine\n")
                .replace("Line 100\n", "Line 100"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 99");
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line ninety-nine");
    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("");
    assertThat(diffInfo).content().element(3).linesOfB().isNull();
  }

  @Test
  public void addedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase() throws Exception {
    ObjectId commit2 = addCommit(commit1, "file_added_in_another_commit.txt", "Some file content");

    rebaseChangeOn(changeId, commit2);
    addModifiedPatchSet(changeId, FILE_NAME, "Another line\n"::concat);

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
  }

  @Test
  public void removedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase() throws Exception {
    ObjectId commit2 = addCommitRemovingFiles(commit1, FILE_NAME2);

    rebaseChangeOn(changeId, commit2);
    addModifiedPatchSet(changeId, FILE_NAME, "Another line\n"::concat);

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
  }

  @Test
  public void renamedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase() throws Exception {
    ObjectId commit2 = addCommitRenamingFile(commit1, FILE_NAME2, "a_new_file_name.txt");

    rebaseChangeOn(changeId, commit2);
    addModifiedPatchSet(changeId, FILE_NAME, "Another line\n"::concat);

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
  }

  @Test
  public void renamedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase_WhenEquallyModifiedInBoth()
      throws Exception {
    // TODO(ghareeb): fix this test for the new diff cache implementation
    assume().that(useNewDiffCacheListFiles).isFalse();

    Function<String, String> contentModification =
        fileContent -> fileContent.replace("1st line\n", "First line\n");
    addModifiedPatchSet(changeId, FILE_NAME2, contentModification);
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;

    // Revert the modification to be able to rebase.
    addModifiedPatchSet(
        changeId, FILE_NAME2, fileContent -> fileContent.replace("First line\n", "1st line\n"));

    String renamedFileName = "renamed_file.txt";
    ObjectId commit2 = addCommitRenamingFile(commit1, FILE_NAME2, renamedFileName);
    rebaseChangeOn(changeId, commit2);
    addModifiedPatchSet(changeId, renamedFileName, contentModification);
    addModifiedPatchSet(changeId, FILE_NAME, "Another line\n"::concat);

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(previousPatchSetId);
    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
  }

  @Test
  public void renamedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase_WhenModifiedDuringRebase()
      throws Exception {
    String renamedFilePath = "renamed_some_file.txt";
    ObjectId commit2 =
        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 5\n", "Line five\n"));
    ObjectId commit3 = addCommitRenamingFile(commit2, FILE_NAME, renamedFilePath);

    rebaseChangeOn(changeId, commit3);

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG);
  }

  @Test
  public void fileRenamedDuringRebaseSameAsInPatchSetIsIgnored() throws Exception {
    String renamedFileName = "renamed_file.txt";
    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, renamedFileName);
    gApi.changes().id(changeId).edit().publish();
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;

    // Revert the renaming to be able to rebase.
    gApi.changes().id(changeId).edit().renameFile(renamedFileName, FILE_NAME);
    gApi.changes().id(changeId).edit().publish();

    ObjectId commit2 = addCommitRenamingFile(commit1, FILE_NAME, renamedFileName);
    rebaseChangeOn(changeId, commit2);

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(previousPatchSetId);
    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG);
  }

  @Test
  public void fileWithRebaseHunksRenamedDuringRebaseSameAsInPatchSetIsIgnored() throws Exception {
    String renamedFileName = "renamed_file.txt";
    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, renamedFileName);
    gApi.changes().id(changeId).edit().publish();
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;

    // Revert the renaming to be able to rebase.
    gApi.changes().id(changeId).edit().renameFile(renamedFileName, FILE_NAME);
    gApi.changes().id(changeId).edit().publish();

    ObjectId commit2 =
        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 10\n", "Line ten\n"));
    ObjectId commit3 = addCommitRenamingFile(commit2, FILE_NAME, renamedFileName);
    rebaseChangeOn(changeId, commit3);

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(previousPatchSetId);
    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG);
  }

  @Test
  public void filesNotTouchedByPatchSetsAndContainingOnlyRebaseHunksAreIgnored() throws Exception {
    String newFileContent = FILE_CONTENT.replace("Line 10\n", "Line ten\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
    ObjectId commit3 = addCommitRenamingFile(commit2, FILE_NAME2, "a_new_file_name.txt");

    rebaseChangeOn(changeId, commit3);

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG);
  }

  @Test
  public void filesTouchedByPatchSetsAndContainingOnlyRebaseHunksAreIgnored() throws Exception {
    // TODO(ghareeb): fix this test for the new diff cache implementation
    assume().that(useNewDiffCacheListFiles).isFalse();

    addModifiedPatchSet(
        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 50\n", "Line fifty\n"));
    addModifiedPatchSet(
        changeId, FILE_NAME2, fileContent -> fileContent.replace("1st line\n", "First line\n"));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    // Revert the modification to allow rebasing.
    addModifiedPatchSet(
        changeId, FILE_NAME2, fileContent -> fileContent.replace("First line\n", "1st line\n"));

    String newFileContent = FILE_CONTENT.replace("Line 10\n", "Line ten\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
    String newFilePath = "a_new_file_name.txt";
    ObjectId commit3 = addCommitRenamingFile(commit2, FILE_NAME2, newFilePath);

    rebaseChangeOn(changeId, commit3);
    // Apply the modification again to bring the file into the same state as for the previous
    // patch set.
    addModifiedPatchSet(
        changeId, newFilePath, fileContent -> fileContent.replace("1st line\n", "First line\n"));

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(previousPatchSetId);
    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG);
  }

  @Test
  public void singleHunkAtBeginningIsFollowedByCorrectCommonLines() throws Exception {
    String filePath = "a_new_file.txt";
    String fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n";
    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
    gApi.changes().id(changeId).edit().publish();
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(changeId, filePath, content -> content.replace("Line 1\n", "Line one\n"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
    assertThat(diffInfo).content().element(0).linesOfA().isNotEmpty();
    assertThat(diffInfo).content().element(0).linesOfB().isNotEmpty();
    assertThat(diffInfo)
        .content()
        .element(1)
        .commonLines()
        .containsExactly("Line 2", "Line 3", "Line 4", "Line 5", "")
        .inOrder();
  }

  @Test
  public void singleHunkAtEndIsPrecededByCorrectCommonLines() throws Exception {
    String filePath = "a_new_file.txt";
    String fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n";
    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
    gApi.changes().id(changeId).edit().publish();
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(changeId, filePath, content -> content.replace("Line 5\n", "Line five\n"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
    assertThat(diffInfo)
        .content()
        .element(0)
        .commonLines()
        .containsExactly("Line 1", "Line 2", "Line 3", "Line 4")
        .inOrder();
    assertThat(diffInfo).content().element(1).linesOfA().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfB().isNotEmpty();
  }

  @Test
  public void singleHunkInTheMiddleIsSurroundedByCorrectCommonLines() throws Exception {
    String filePath = "a_new_file.txt";
    String fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\n";
    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
    gApi.changes().id(changeId).edit().publish();
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(changeId, filePath, content -> content.replace("Line 3\n", "Line three\n"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
    assertThat(diffInfo)
        .content()
        .element(0)
        .commonLines()
        .containsExactly("Line 1", "Line 2")
        .inOrder();
    assertThat(diffInfo).content().element(1).linesOfA().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfB().isNotEmpty();
    assertThat(diffInfo)
        .content()
        .element(2)
        .commonLines()
        .containsExactly("Line 4", "Line 5", "Line 6", "")
        .inOrder();
  }

  @Test
  public void twoHunksAreSeparatedByCorrectCommonLines() throws Exception {
    String filePath = "a_new_file.txt";
    String fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n";
    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
    gApi.changes().id(changeId).edit().publish();
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(
        changeId,
        filePath,
        content -> content.replace("Line 2\n", "Line two\n").replace("Line 5\n", "Line five\n"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfB().isNotEmpty();
    assertThat(diffInfo)
        .content()
        .element(2)
        .commonLines()
        .containsExactly("Line 3", "Line 4")
        .inOrder();
    assertThat(diffInfo).content().element(3).linesOfA().isNotEmpty();
    assertThat(diffInfo).content().element(3).linesOfB().isNotEmpty();
  }

  @Test
  public void rebaseHunksAtStartOfFileAreIdentified() throws Exception {
    String newFileContent =
        FILE_CONTENT.replace("Line 1\n", "Line one\n").replace("Line 5\n", "Line five\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);

    rebaseChangeOn(changeId, commit2);
    Function<String, String> contentModification =
        fileContent -> fileContent.replace("Line 50\n", "Line fifty\n");
    addModifiedPatchSet(changeId, FILE_NAME, contentModification);

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
    assertThat(diffInfo).content().element(0).isDueToRebase();
    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 5");
    assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line five");
    assertThat(diffInfo).content().element(2).isDueToRebase();
    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 50");
    assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line fifty");
    assertThat(diffInfo).content().element(4).isNotDueToRebase();
    assertThat(diffInfo).content().element(5).commonLines().isNotEmpty();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
  }

  @Test
  public void rebaseHunksAtEndOfFileAreIdentified() throws Exception {
    String newFileContent =
        FILE_CONTENT
            .replace("Line 60\n", "Line sixty\n")
            .replace("Line 100\n", "Line one hundred\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);

    rebaseChangeOn(changeId, commit2);
    Function<String, String> contentModification =
        fileContent -> fileContent.replace("Line 50\n", "Line fifty\n");
    addModifiedPatchSet(changeId, FILE_NAME, contentModification);

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 50");
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line fifty");
    assertThat(diffInfo).content().element(1).isNotDueToRebase();
    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 60");
    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line sixty");
    assertThat(diffInfo).content().element(3).isDueToRebase();
    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 100");
    assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line one hundred");
    assertThat(diffInfo).content().element(5).isDueToRebase();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
  }

  @Test
  public void rebaseHunksInBetweenRegularHunksAreIdentified() throws Exception {
    String newFileContent =
        FILE_CONTENT.replace("Line 40\n", "Line forty\n").replace("Line 45\n", "Line forty five\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);

    rebaseChangeOn(changeId, commit2);
    Function<String, String> contentModification =
        fileContent ->
            fileContent
                .replace("Line 1\n", "Line one\n")
                .replace("Line 100\n", "Line one hundred\n");
    addModifiedPatchSet(changeId, FILE_NAME, contentModification);

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
    assertThat(diffInfo).content().element(0).isNotDueToRebase();
    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 40");
    assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line forty");
    assertThat(diffInfo).content().element(2).isDueToRebase();
    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 45");
    assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line forty five");
    assertThat(diffInfo).content().element(4).isDueToRebase();
    assertThat(diffInfo).content().element(5).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(6).linesOfA().containsExactly("Line 100");
    assertThat(diffInfo).content().element(6).linesOfB().containsExactly("Line one hundred");
    assertThat(diffInfo).content().element(6).isNotDueToRebase();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(2);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
  }

  @Test
  public void rebaseHunkIsIdentifiedWhenMovedDownInPreviousPatchSet() throws Exception {
    // Move the code down by introducing additional lines (pure insert + enlarging replacement) in
    // the previous patch set.
    Function<String, String> contentModification1 =
        fileContent ->
            "Line zero\n" + fileContent.replace("Line 10\n", "Line ten\nLine ten and a half\n");
    addModifiedPatchSet(changeId, FILE_NAME, contentModification1);
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;

    String newFileContent = FILE_CONTENT.replace("Line 40\n", "Line forty\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);

    rebaseChangeOn(changeId, commit2);
    Function<String, String> contentModification2 =
        fileContent -> fileContent.replace("Line 100\n", "Line one hundred\n");
    addModifiedPatchSet(changeId, FILE_NAME, contentModification2);

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 40");
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line forty");
    assertThat(diffInfo).content().element(1).isDueToRebase();
    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 100");
    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line one hundred");
    assertThat(diffInfo).content().element(3).isNotDueToRebase();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(previousPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
  }

  @Test
  public void rebaseHunkIsIdentifiedWhenMovedDownInLatestPatchSet() throws Exception {
    String newFileContent = FILE_CONTENT.replace("Line 40\n", "Line forty\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);

    rebaseChangeOn(changeId, commit2);
    // Move the code down by introducing additional lines (pure insert + enlarging replacement) in
    // the latest patch set.
    Function<String, String> contentModification =
        fileContent ->
            "Line zero\n" + fileContent.replace("Line 10\n", "Line ten\nLine ten and a half\n");
    addModifiedPatchSet(changeId, FILE_NAME, contentModification);

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
    assertThat(diffInfo).content().element(0).linesOfA().isNull();
    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line zero");
    assertThat(diffInfo).content().element(0).isNotDueToRebase();
    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 10");
    assertThat(diffInfo)
        .content()
        .element(2)
        .linesOfB()
        .containsExactly("Line ten", "Line ten and a half");
    assertThat(diffInfo).content().element(2).isNotDueToRebase();
    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 40");
    assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line forty");
    assertThat(diffInfo).content().element(4).isDueToRebase();
    assertThat(diffInfo).content().element(5).commonLines().isNotEmpty();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(3);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
  }

  @Test
  public void rebaseHunkIsIdentifiedWhenMovedUpInPreviousPatchSet() throws Exception {
    // Move the code up by removing lines (pure deletion + shrinking replacement) in the previous
    // patch set.
    Function<String, String> contentModification1 =
        fileContent ->
            fileContent.replace("Line 1\n", "").replace("Line 10\nLine 11\n", "Line ten\n");
    addModifiedPatchSet(changeId, FILE_NAME, contentModification1);
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;

    String newFileContent = FILE_CONTENT.replace("Line 40\n", "Line forty\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);

    rebaseChangeOn(changeId, commit2);
    Function<String, String> contentModification2 =
        fileContent -> fileContent.replace("Line 100\n", "Line one hundred\n");
    addModifiedPatchSet(changeId, FILE_NAME, contentModification2);

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 40");
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line forty");
    assertThat(diffInfo).content().element(1).isDueToRebase();
    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 100");
    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line one hundred");
    assertThat(diffInfo).content().element(3).isNotDueToRebase();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(previousPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
  }

  @Test
  public void rebaseHunkIsIdentifiedWhenMovedUpInLatestPatchSet() throws Exception {
    String newFileContent = FILE_CONTENT.replace("Line 40\n", "Line forty\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);

    rebaseChangeOn(changeId, commit2);
    // Move the code up by removing lines (pure deletion + shrinking replacement) in the latest
    // patch set.
    Function<String, String> contentModification =
        fileContent ->
            fileContent.replace("Line 1\n", "").replace("Line 10\nLine 11\n", "Line ten\n");
    addModifiedPatchSet(changeId, FILE_NAME, contentModification);

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
    assertThat(diffInfo).content().element(0).linesOfB().isNull();
    assertThat(diffInfo).content().element(0).isNotDueToRebase();
    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 10", "Line 11");
    assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line ten");
    assertThat(diffInfo).content().element(2).isNotDueToRebase();
    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 40");
    assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line forty");
    assertThat(diffInfo).content().element(4).isDueToRebase();
    assertThat(diffInfo).content().element(5).commonLines().isNotEmpty();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(3);
  }

  @Test
  public void modifiedRebaseHunkWithSameRegionConsideredAsRegularHunk() throws Exception {
    String newFileContent = FILE_CONTENT.replace("Line 40\n", "Line forty\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);

    rebaseChangeOn(changeId, commit2);
    Function<String, String> contentModification =
        fileContent -> fileContent.replace("Line forty\n", "Line modified after rebase\n");
    addModifiedPatchSet(changeId, FILE_NAME, contentModification);

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 40");
    assertThat(diffInfo)
        .content()
        .element(1)
        .linesOfB()
        .containsExactly("Line modified after rebase");
    assertThat(diffInfo).content().element(1).isNotDueToRebase();
    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
  }

  @Test
  public void rebaseHunkOverlappingAtBeginningConsideredAsRegularHunk() throws Exception {
    String newFileContent =
        FILE_CONTENT.replace("Line 40\nLine 41\n", "Line forty\nLine forty one\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);

    rebaseChangeOn(changeId, commit2);
    Function<String, String> contentModification =
        fileContent ->
            fileContent
                .replace("Line 39\n", "Line thirty nine\n")
                .replace("Line forty one\n", "Line 41\n");
    addModifiedPatchSet(changeId, FILE_NAME, contentModification);

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 39", "Line 40");
    assertThat(diffInfo)
        .content()
        .element(1)
        .linesOfB()
        .containsExactly("Line thirty nine", "Line forty");
    assertThat(diffInfo).content().element(1).isNotDueToRebase();
    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(2);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
  }

  @Test
  public void rebaseHunkOverlappingAtEndConsideredAsRegularHunk() throws Exception {
    String newFileContent =
        FILE_CONTENT.replace("Line 40\nLine 41\n", "Line forty\nLine forty one\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);

    rebaseChangeOn(changeId, commit2);
    Function<String, String> contentModification =
        fileContent ->
            fileContent
                .replace("Line forty\n", "Line 40\n")
                .replace("Line 42\n", "Line forty two\n");
    addModifiedPatchSet(changeId, FILE_NAME, contentModification);

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 41", "Line 42");
    assertThat(diffInfo)
        .content()
        .element(1)
        .linesOfB()
        .containsExactly("Line forty one", "Line forty two");
    assertThat(diffInfo).content().element(1).isNotDueToRebase();
    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(2);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
  }

  @Test
  public void rebaseHunkModifiedInsideConsideredAsRegularHunk() throws Exception {
    String newFileContent =
        FILE_CONTENT.replace(
            "Line 39\nLine 40\nLine 41\n", "Line thirty nine\nLine forty\nLine forty one\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);

    rebaseChangeOn(changeId, commit2);
    Function<String, String> contentModification =
        fileContent -> fileContent.replace("Line forty\n", "A different line forty\n");
    addModifiedPatchSet(changeId, FILE_NAME, contentModification);

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo)
        .content()
        .element(1)
        .linesOfA()
        .containsExactly("Line 39", "Line 40", "Line 41");
    assertThat(diffInfo)
        .content()
        .element(1)
        .linesOfB()
        .containsExactly("Line thirty nine", "A different line forty", "Line forty one");
    assertThat(diffInfo).content().element(1).isNotDueToRebase();
    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(3);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(3);
  }

  @Test
  public void rebaseHunkAfterLineNumberChangingOverlappingHunksIsIdentified() throws Exception {
    String newFileContent =
        FILE_CONTENT
            .replace("Line 40\nLine 41\n", "Line forty\nLine forty one\n")
            .replace("Line 60\n", "Line sixty\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);

    rebaseChangeOn(changeId, commit2);
    Function<String, String> contentModification =
        fileContent ->
            fileContent
                .replace("Line forty\n", "Line 40\n")
                .replace("Line 42\n", "Line forty two\nLine forty two and a half\n");
    addModifiedPatchSet(changeId, FILE_NAME, contentModification);

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 41", "Line 42");
    assertThat(diffInfo)
        .content()
        .element(1)
        .linesOfB()
        .containsExactly("Line forty one", "Line forty two", "Line forty two and a half");
    assertThat(diffInfo).content().element(1).isNotDueToRebase();
    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 60");
    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line sixty");
    assertThat(diffInfo).content().element(3).isDueToRebase();
    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(3);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
  }

  @Test
  public void rebaseHunksOneLineApartFromRegularHunkAreIdentified() throws Exception {
    String newFileContent =
        FILE_CONTENT.replace("Line 1\n", "Line one\n").replace("Line 5\n", "Line five\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);

    rebaseChangeOn(changeId, commit2);
    Function<String, String> contentModification =
        fileContent -> fileContent.replace("Line 3\n", "Line three\n");
    addModifiedPatchSet(changeId, FILE_NAME, contentModification);

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
    assertThat(diffInfo).content().element(0).isDueToRebase();
    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 3");
    assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line three");
    assertThat(diffInfo).content().element(2).isNotDueToRebase();
    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 5");
    assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line five");
    assertThat(diffInfo).content().element(4).isDueToRebase();
    assertThat(diffInfo).content().element(5).commonLines().isNotEmpty();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
  }

  @Test
  public void rebaseHunksDirectlyTouchingHunksOfPatchSetsNotModifiedBetweenThemAreIdentified()
      throws Exception {
    // Add to hunks in a patch set and remove them in a further patch set to allow rebasing.
    Function<String, String> contentModification =
        fileContent ->
            fileContent.replace("Line 1\n", "Line one\n").replace("Line 3\n", "Line three\n");
    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    Function<String, String> reverseContentModification =
        fileContent ->
            fileContent.replace("Line one\n", "Line 1\n").replace("Line three\n", "Line 3\n");
    addModifiedPatchSet(changeId, FILE_NAME, reverseContentModification);

    String newFileContent = FILE_CONTENT.replace("Line 2\n", "Line two\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
    rebaseChangeOn(changeId, commit2);

    // Add the hunks again and modify another line so that we get a diff for the file.
    // (Files with only edits due to rebase are filtered out.)
    addModifiedPatchSet(
        changeId,
        FILE_NAME,
        contentModification.andThen(fileContent -> fileContent.replace("Line 10\n", "Line ten\n")));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 2");
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line two");
    assertThat(diffInfo).content().element(1).isDueToRebase();
    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 10");
    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line ten");
    assertThat(diffInfo).content().element(3).isNotDueToRebase();
    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(previousPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
  }

  @Test
  public void multipleRebaseEditsMixedWithRegularEditsCanBeIdentified() throws Exception {
    addModifiedPatchSet(
        changeId,
        FILE_NAME,
        fileContent -> fileContent.replace("Line 7\n", "Line seven\n").replace("Line 24\n", ""));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;

    ObjectId commit2 =
        addCommit(
            commit1,
            FILE_NAME,
            FILE_CONTENT
                .replace("Line 2\n", "Line two\n")
                .replace("Line 18\nLine 19\n", "Line eighteen\nLine nineteen\n")
                .replace("Line 50\n", "Line fifty\n"));

    rebaseChangeOn(changeId, commit2);
    addModifiedPatchSet(
        changeId,
        FILE_NAME,
        fileContent ->
            fileContent
                .replace("Line seven\n", "Line 7\n")
                .replace("Line 9\n", "Line nine\n")
                .replace("Line 60\n", "Line sixty\n"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 2");
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line two");
    assertThat(diffInfo).content().element(1).isDueToRebase();
    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line seven");
    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line 7");
    assertThat(diffInfo).content().element(3).isNotDueToRebase();
    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 9");
    assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line nine");
    assertThat(diffInfo).content().element(5).isNotDueToRebase();
    assertThat(diffInfo).content().element(6).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(7).linesOfA().containsExactly("Line 18", "Line 19");
    assertThat(diffInfo)
        .content()
        .element(7)
        .linesOfB()
        .containsExactly("Line eighteen", "Line nineteen");
    assertThat(diffInfo).content().element(7).isDueToRebase();
    assertThat(diffInfo).content().element(8).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(9).linesOfA().containsExactly("Line 50");
    assertThat(diffInfo).content().element(9).linesOfB().containsExactly("Line fifty");
    assertThat(diffInfo).content().element(9).isDueToRebase();
    assertThat(diffInfo).content().element(10).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(11).linesOfA().containsExactly("Line 60");
    assertThat(diffInfo).content().element(11).linesOfB().containsExactly("Line sixty");
    assertThat(diffInfo).content().element(11).isNotDueToRebase();
    assertThat(diffInfo).content().element(12).commonLines().isNotEmpty();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(previousPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(3);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(3);
  }

  @Test
  public void deletedFileDuringRebaseConsideredAsRegularHunkWhenModifiedInDiff() throws Exception {
    // Modify the file and revert the modifications to allow rebasing.
    addModifiedPatchSet(
        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 50\n", "Line fifty\n"));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(
        changeId, FILE_NAME, fileContent -> fileContent.replace("Line fifty\n", "Line 50\n"));

    ObjectId commit2 = addCommitRemovingFiles(commit1, FILE_NAME);

    rebaseChangeOn(changeId, commit2);

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
    assertThat(diffInfo).changeType().isEqualTo(ChangeType.DELETED);
    assertThat(diffInfo).content().element(0).linesOfA().hasSize(101);
    assertThat(diffInfo).content().element(0).linesOfB().isNull();
    assertThat(diffInfo).content().element(0).isNotDueToRebase();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(previousPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isNull();
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(100);
  }

  @Test
  public void addedFileDuringRebaseConsideredAsRegularHunkWhenModifiedInDiff() throws Exception {
    String newFilePath = "a_new_file.txt";
    ObjectId commit2 = addCommit(commit1, newFilePath, "1st line\n2nd line\n3rd line\n");

    rebaseChangeOn(changeId, commit2);
    addModifiedPatchSet(
        changeId, newFilePath, fileContent -> fileContent.replace("1st line\n", "First line\n"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, newFilePath).withBase(initialPatchSetId).get();
    assertThat(diffInfo).changeType().isEqualTo(ChangeType.ADDED);
    assertThat(diffInfo).content().element(0).linesOfA().isNull();
    assertThat(diffInfo).content().element(0).linesOfB().hasSize(4);
    assertThat(diffInfo).content().element(0).isNotDueToRebase();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    assertThat(changedFiles.get(newFilePath)).linesInserted().isEqualTo(3);
    assertThat(changedFiles.get(newFilePath)).linesDeleted().isNull();
  }

  @Test
  public void rebaseHunkInRenamedFileIsIdentified_WhenFileIsRenamedDuringRebase() throws Exception {
    String renamedFilePath = "renamed_some_file.txt";
    ObjectId commit2 =
        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 1\n", "Line one\n"));
    ObjectId commit3 = addCommitRenamingFile(commit2, FILE_NAME, renamedFilePath);

    rebaseChangeOn(changeId, commit3);
    Function<String, String> contentModification =
        fileContent -> fileContent.replace("Line 50\n", "Line fifty\n");
    addModifiedPatchSet(changeId, renamedFilePath, contentModification);

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, renamedFilePath).withBase(initialPatchSetId).get();
    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
    assertThat(diffInfo).content().element(0).isDueToRebase();
    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 50");
    assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line fifty");
    assertThat(diffInfo).content().element(2).isNotDueToRebase();
    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    assertThat(changedFiles.get(renamedFilePath)).linesInserted().isEqualTo(1);
    assertThat(changedFiles.get(renamedFilePath)).linesDeleted().isEqualTo(1);
  }

  @Test
  public void rebaseHunkInRenamedFileIsIdentified_WhenFileIsRenamedInPatchSets() throws Exception {
    String renamedFilePath = "renamed_some_file.txt";
    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, renamedFilePath);
    gApi.changes().id(changeId).edit().publish();
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;

    // Revert the renaming to be able to rebase.
    gApi.changes().id(changeId).edit().renameFile(renamedFilePath, FILE_NAME);
    gApi.changes().id(changeId).edit().publish();

    ObjectId commit2 =
        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 5\n", "Line five\n"));

    rebaseChangeOn(changeId, commit2);
    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, renamedFilePath);
    gApi.changes().id(changeId).edit().publish();
    Function<String, String> contentModification =
        fileContent -> fileContent.replace("Line 50\n", "Line fifty\n");
    addModifiedPatchSet(changeId, renamedFilePath, contentModification);

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, renamedFilePath).withBase(previousPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 5");
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line five");
    assertThat(diffInfo).content().element(1).isDueToRebase();
    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 50");
    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line fifty");
    assertThat(diffInfo).content().element(3).isNotDueToRebase();
    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(previousPatchSetId);
    assertThat(changedFiles.get(renamedFilePath)).linesInserted().isEqualTo(1);
    assertThat(changedFiles.get(renamedFilePath)).linesDeleted().isEqualTo(1);
  }

  @Test
  public void renamedFileWithOnlyRebaseHunksIsIdentified_WhenRenamedBetweenPatchSets()
      throws Exception {
    String newFilePath1 = "renamed_some_file.txt";
    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath1);
    gApi.changes().id(changeId).edit().publish();
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;

    // Revert the renaming to be able to rebase.
    gApi.changes().id(changeId).edit().renameFile(newFilePath1, FILE_NAME);
    gApi.changes().id(changeId).edit().publish();

    ObjectId commit2 =
        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 5\n", "Line five\n"));

    rebaseChangeOn(changeId, commit2);
    String newFilePath2 = "renamed_some_file_to_something_else.txt";
    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath2);
    gApi.changes().id(changeId).edit().publish();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(previousPatchSetId);
    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, newFilePath2);
    assertThat(changedFiles.get(newFilePath2)).linesInserted().isNull();
    assertThat(changedFiles.get(newFilePath2)).linesDeleted().isNull();

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, newFilePath2).withBase(previousPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 5");
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line five");
    assertThat(diffInfo).content().element(1).isDueToRebase();
    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
  }

  @Test
  public void renamedFileWithOnlyRebaseHunksIsIdentified_WhenRenamedForRebaseAndForPatchSets()
      throws Exception {
    String newFilePath1 = "renamed_some_file.txt";
    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath1);
    gApi.changes().id(changeId).edit().publish();
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;

    // Revert the renaming to be able to rebase.
    gApi.changes().id(changeId).edit().renameFile(newFilePath1, FILE_NAME);
    gApi.changes().id(changeId).edit().publish();

    ObjectId commit2 =
        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 5\n", "Line five\n"));
    String newFilePath2 = "renamed_some_file_during_rebase.txt";
    ObjectId commit3 = addCommitRenamingFile(commit2, FILE_NAME, newFilePath2);

    rebaseChangeOn(changeId, commit3);
    String newFilePath3 = "renamed_some_file_to_something_else.txt";
    gApi.changes().id(changeId).edit().renameFile(newFilePath2, newFilePath3);
    gApi.changes().id(changeId).edit().publish();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(previousPatchSetId);
    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, newFilePath3);
    assertThat(changedFiles.get(newFilePath3)).linesInserted().isNull();
    assertThat(changedFiles.get(newFilePath3)).linesDeleted().isNull();

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, newFilePath3).withBase(previousPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 5");
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line five");
    assertThat(diffInfo).content().element(1).isDueToRebase();
    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
  }

  @Test
  public void copiedAndRenamedFilesWithOnlyRebaseHunksAreIdentified() throws Exception {
    String newFileContent = FILE_CONTENT.replace("Line 5\n", "Line five\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);

    rebaseChangeOn(changeId, commit2);
    // Copies are only identified by JGit when paired with renaming.
    String copyFileName = "copy_of_some_file.txt";
    String renamedFileName = "renamed_some_file.txt";
    gApi.changes()
        .id(changeId)
        .edit()
        .modifyFile(copyFileName, RawInputUtil.create(newFileContent));
    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, renamedFileName);
    gApi.changes().id(changeId).edit().publish();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(initialPatchSetId);
    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, copyFileName, renamedFileName);

    DiffInfo renamedFileDiffInfo =
        getDiffRequest(changeId, CURRENT, renamedFileName).withBase(initialPatchSetId).get();
    assertThat(renamedFileDiffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(renamedFileDiffInfo).content().element(1).linesOfA().containsExactly("Line 5");
    assertThat(renamedFileDiffInfo).content().element(1).linesOfB().containsExactly("Line five");
    assertThat(renamedFileDiffInfo).content().element(1).isDueToRebase();
    assertThat(renamedFileDiffInfo).content().element(2).commonLines().isNotEmpty();

    DiffInfo copiedFileDiffInfo =
        getDiffRequest(changeId, CURRENT, copyFileName).withBase(initialPatchSetId).get();
    assertThat(copiedFileDiffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(copiedFileDiffInfo).content().element(1).linesOfA().containsExactly("Line 5");
    assertThat(copiedFileDiffInfo).content().element(1).linesOfB().containsExactly("Line five");
    assertThat(copiedFileDiffInfo).content().element(1).isDueToRebase();
    assertThat(copiedFileDiffInfo).content().element(2).commonLines().isNotEmpty();
  }

  /*
   *                change PS B
   *                   |
   * change PS A    commit4
   *    |              |
   * commit2        commit3
   *    |             /
   * commit1 --------
   */
  @Test
  public void rebaseHunksWhenRebasingOnAnotherChangeOrPatchSetAreIdentified() throws Exception {
    ObjectId commit2 =
        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 5\n", "Line five\n"));
    rebaseChangeOn(changeId, commit2);
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;

    String commit3FileContent = FILE_CONTENT.replace("Line 35\n", "Line thirty five\n");
    ObjectId commit3 = addCommit(commit1, FILE_NAME, commit3FileContent);
    ObjectId commit4 =
        addCommit(commit3, FILE_NAME, commit3FileContent.replace("Line 60\n", "Line sixty\n"));

    rebaseChangeOn(changeId, commit4);
    Function<String, String> contentModification =
        fileContent -> fileContent.replace("Line 20\n", "Line twenty\n");
    addModifiedPatchSet(changeId, FILE_NAME, contentModification);

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line five");
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 5");
    assertThat(diffInfo).content().element(1).isDueToRebase();
    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 20");
    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line twenty");
    assertThat(diffInfo).content().element(3).isNotDueToRebase();
    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 35");
    assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line thirty five");
    assertThat(diffInfo).content().element(5).isDueToRebase();
    assertThat(diffInfo).content().element(6).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(7).linesOfA().containsExactly("Line 60");
    assertThat(diffInfo).content().element(7).linesOfB().containsExactly("Line sixty");
    assertThat(diffInfo).content().element(7).isDueToRebase();
    assertThat(diffInfo).content().element(8).commonLines().isNotEmpty();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(previousPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
  }

  /*
   *                change PS B
   *                   |
   * change PS A    commit4
   *    |              |
   * commit2        commit3
   *    |             /
   * commit1 --------
   */
  @Test
  public void unrelatedFileWhenRebasingOnAnotherChangeOrPatchSetIsIgnored() throws Exception {
    ObjectId commit2 =
        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 5\n", "Line five\n"));
    rebaseChangeOn(changeId, commit2);
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;

    ObjectId commit3 =
        addCommit(commit1, FILE_NAME2, FILE_CONTENT2.replace("2nd line\n", "Second line\n"));
    ObjectId commit4 =
        addCommit(commit3, FILE_NAME, FILE_CONTENT.replace("Line 60\n", "Line sixty\n"));

    rebaseChangeOn(changeId, commit4);
    Function<String, String> contentModification =
        fileContent -> fileContent.replace("Line 20\n", "Line twenty\n");
    addModifiedPatchSet(changeId, FILE_NAME, contentModification);

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(previousPatchSetId);
    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
  }

  @Test
  public void rebaseHunksWhenReversingPatchSetOrderAreIdentified() throws Exception {
    ObjectId commit2 =
        addCommit(
            commit1,
            FILE_NAME,
            FILE_CONTENT.replace("Line 5\n", "Line five\n").replace("Line 35\n", ""));

    rebaseChangeOn(changeId, commit2);
    Function<String, String> contentModification =
        fileContent -> fileContent.replace("Line 20\n", "Line twenty\n");
    addModifiedPatchSet(changeId, FILE_NAME, contentModification);

    String currentPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    DiffInfo diffInfo =
        getDiffRequest(changeId, initialPatchSetId, FILE_NAME).withBase(currentPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line five");
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 5");
    assertThat(diffInfo).content().element(1).isDueToRebase();
    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line twenty");
    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line 20");
    assertThat(diffInfo).content().element(3).isNotDueToRebase();
    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(5).linesOfA().isNull();
    assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line 35");
    assertThat(diffInfo).content().element(5).isDueToRebase();
    assertThat(diffInfo).content().element(6).commonLines().isNotEmpty();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).revision(initialPatchSetId).files(currentPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
  }

  @Test
  public void intralineEditsInNonRebaseHunksAreIdentified() throws Exception {
    assume().that(intraline).isTrue();

    Function<String, String> contentModification =
        fileContent -> fileContent.replace("Line 1\n", "Line one\n");
    addModifiedPatchSet(changeId, FILE_NAME, contentModification);

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
    assertThat(diffInfo)
        .content()
        .element(0)
        .intralineEditsOfA()
        .containsExactly(ImmutableList.of(5, 1));
    assertThat(diffInfo)
        .content()
        .element(0)
        .intralineEditsOfB()
        .containsExactly(ImmutableList.of(5, 3));
    assertThat(diffInfo).content().element(0).isNotDueToRebase();
    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
  }

  @Test
  public void intralineEditsInRebaseHunksAreIdentified() throws Exception {
    assume().that(intraline).isTrue();

    String newFileContent = FILE_CONTENT.replace("Line 1\n", "Line one\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);

    rebaseChangeOn(changeId, commit2);
    Function<String, String> contentModification =
        fileContent -> fileContent.replace("Line 50\n", "Line fifty\n");
    addModifiedPatchSet(changeId, FILE_NAME, contentModification);

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
    assertThat(diffInfo)
        .content()
        .element(0)
        .intralineEditsOfA()
        .containsExactly(ImmutableList.of(5, 1));
    assertThat(diffInfo)
        .content()
        .element(0)
        .intralineEditsOfB()
        .containsExactly(ImmutableList.of(5, 3));
    assertThat(diffInfo).content().element(0).isDueToRebase();
    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 50");
    assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line fifty");
    assertThat(diffInfo).content().element(2).isNotDueToRebase();
    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
  }

  @Test
  public void closeNonRebaseHunksAreCombinedForIntralineOptimizations() throws Exception {
    assume().that(intraline).isTrue();

    String fileContent = FILE_CONTENT.replace("Line 5\n", "{\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, fileContent);
    rebaseChangeOn(changeId, commit2);
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;

    addModifiedPatchSet(
        changeId,
        FILE_NAME,
        content -> content.replace("Line 4\n", "Line four\n").replace("Line 6\n", "Line six\n"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 4", "{", "Line 6");
    assertThat(diffInfo)
        .content()
        .element(1)
        .linesOfB()
        .containsExactly("Line four", "{", "Line six");
    assertThat(diffInfo).content().element(1).isNotDueToRebase();
    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(previousPatchSetId);
    // Lines which weren't modified but are included in a hunk due to optimization don't count for
    // the number of inserted/deleted lines.
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(2);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
  }

  @Test
  public void closeRebaseHunksAreNotCombinedForIntralineOptimizations() throws Exception {
    assume().that(intraline).isTrue();

    String fileContent = FILE_CONTENT.replace("Line 5\n", "{\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, fileContent);
    rebaseChangeOn(changeId, commit2);
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;

    String newFileContent =
        fileContent.replace("Line 4\n", "Line four\n").replace("Line 6\n", "Line six\n");
    ObjectId commit3 = addCommit(commit1, FILE_NAME, newFileContent);
    rebaseChangeOn(changeId, commit3);

    addModifiedPatchSet(
        changeId, FILE_NAME, content -> content.replace("Line 20\n", "Line twenty\n"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 4");
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line four");
    assertThat(diffInfo).content().element(1).isDueToRebase();
    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 6");
    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line six");
    assertThat(diffInfo).content().element(3).isDueToRebase();
    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 20");
    assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line twenty");
    assertThat(diffInfo).content().element(5).isNotDueToRebase();
    assertThat(diffInfo).content().element(6).commonLines().isNotEmpty();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(previousPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
  }

  @Test
  public void closeRebaseAndNonRebaseHunksAreNotCombinedForIntralineOptimizations()
      throws Exception {
    assume().that(intraline).isTrue();

    String fileContent = FILE_CONTENT.replace("Line 5\n", "{\n").replace("Line 7\n", "{\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, fileContent);
    rebaseChangeOn(changeId, commit2);
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;

    String newFileContent =
        fileContent.replace("Line 4\n", "Line four\n").replace("Line 8\n", "Line eight\n");
    ObjectId commit3 = addCommit(commit1, FILE_NAME, newFileContent);
    rebaseChangeOn(changeId, commit3);

    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 6\n", "Line six\n"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 4");
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line four");
    assertThat(diffInfo).content().element(1).isDueToRebase();
    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 6");
    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line six");
    assertThat(diffInfo).content().element(3).isNotDueToRebase();
    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 8");
    assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line eight");
    assertThat(diffInfo).content().element(5).isDueToRebase();
    assertThat(diffInfo).content().element(6).commonLines().isNotEmpty();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(previousPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
  }

  @Test
  public void closeNonRebaseHunksNextToRebaseHunksAreCombinedForIntralineOptimizations()
      throws Exception {
    assume().that(intraline).isTrue();

    String fileContent = FILE_CONTENT.replace("Line 5\n", "{\n").replace("Line 7\n", "{\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, fileContent);
    rebaseChangeOn(changeId, commit2);
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;

    String newFileContent = fileContent.replace("Line 8\n", "Line eight!\n");
    ObjectId commit3 = addCommit(commit1, FILE_NAME, newFileContent);
    rebaseChangeOn(changeId, commit3);

    addModifiedPatchSet(
        changeId,
        FILE_NAME,
        content -> content.replace("Line 4\n", "Line four\n").replace("Line 6\n", "Line six\n"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 4", "{", "Line 6");
    assertThat(diffInfo)
        .content()
        .element(1)
        .linesOfB()
        .containsExactly("Line four", "{", "Line six");
    assertThat(diffInfo).content().element(1).isNotDueToRebase();
    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 8");
    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line eight!");
    assertThat(diffInfo).content().element(3).isDueToRebase();
    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(changeId).current().files(previousPatchSetId);
    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(2);
    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
  }

  @Test
  public void diffOfUnmodifiedFileReturnsAllFileContents() throws Exception {
    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    addModifiedPatchSet(
        changeId, FILE_NAME2, content -> content.replace("2nd line\n", "Second line\n"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
    // We don't list the full file contents here as that is not the focus of this test.
    assertThat(diffInfo)
        .content()
        .element(0)
        .commonLines()
        .containsAtLeast("Line 1", "Line two", "Line 3", "Line 4", "Line 5")
        .inOrder();
  }

  @Test
  // TODO(ghareeb): Don't exclude diffs which only contain rebase hunks within the diff caches. Only
  // filter such files in the GetFiles REST endpoint.
  @Ignore
  public void diffOfFileWithOnlyRebaseHunksConsideringWhitespaceReturnsFileContents()
      throws Exception {
    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    String newBaseFileContent = FILE_CONTENT.replace("Line 70\n", "Line seventy\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, newBaseFileContent);
    rebaseChangeOn(changeId, commit2);

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME)
            .withBase(previousPatchSetId)
            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
            .get();
    // We don't list the full file contents here as that is not the focus of this test.
    assertThat(diffInfo)
        .content()
        .element(0)
        .commonLines()
        .containsAtLeast("Line 1", "Line two", "Line 3", "Line 4", "Line 5")
        .inOrder();
    // It's crucial that the line changed in the rebase is reported correctly.
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 70");
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line seventy");
    assertThat(diffInfo).content().element(1).isDueToRebase();
  }

  @Test
  // TODO(ghareeb): Don't exclude diffs which only contain rebase hunks within the diff caches. Only
  // filter such files in the GetFiles REST endpoint.
  @Ignore
  public void diffOfFileWithOnlyRebaseHunksAndIgnoringWhitespaceReturnsFileContents()
      throws Exception {
    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    String newBaseFileContent = FILE_CONTENT.replace("Line 70\n", "Line seventy\n");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, newBaseFileContent);
    rebaseChangeOn(changeId, commit2);

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME)
            .withBase(previousPatchSetId)
            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_ALL)
            .get();
    // We don't list the full file contents here as that is not the focus of this test.
    assertThat(diffInfo)
        .content()
        .element(0)
        .commonLines()
        .containsAtLeast("Line 1", "Line two", "Line 3", "Line 4", "Line 5")
        .inOrder();
    // It's crucial that the line changed in the rebase is reported correctly.
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 70");
    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line seventy");
    assertThat(diffInfo).content().element(1).isDueToRebase();
  }

  @Test
  // TODO(ghareeb): Don't exclude diffs which only contain rebase hunks within the diff caches. Only
  // filter such files in the GetFiles REST endpoint.
  @Ignore
  public void diffOfFileWithMultilineRebaseHunkAddingNewlineAtEndOfFileReturnsFileContents()
      throws Exception {
    String baseFileContent = FILE_CONTENT.concat("Line 101");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, baseFileContent);
    rebaseChangeOn(changeId, commit2);
    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    String newBaseFileContent = baseFileContent.concat("\nLine 102\nLine 103\n");
    ObjectId commit3 = addCommit(commit2, FILE_NAME, newBaseFileContent);
    rebaseChangeOn(changeId, commit3);

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME)
            .withBase(previousPatchSetId)
            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
            .get();
    // We don't list the full file contents here as that is not the focus of this test.
    assertThat(diffInfo)
        .content()
        .element(0)
        .commonLines()
        .containsAtLeast("Line 1", "Line two", "Line 3", "Line 4", "Line 5")
        .inOrder();
    // It's crucial that the lines changed in the rebase are reported correctly.
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 101");
    assertThat(diffInfo)
        .content()
        .element(1)
        .linesOfB()
        .containsExactly("Line 101", "Line 102", "Line 103", "");
    assertThat(diffInfo).content().element(1).isDueToRebase();
  }

  @Test
  // TODO(ghareeb): Don't exclude diffs which only contain rebase hunks within the diff caches. Only
  // filter such files in the GetFiles REST endpoint.
  @Ignore
  public void diffOfFileWithMultilineRebaseHunkRemovingNewlineAtEndOfFileReturnsFileContents()
      throws Exception {
    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    String newBaseFileContent = FILE_CONTENT.concat("Line 101\nLine 102\nLine 103");
    ObjectId commit2 = addCommit(commit1, FILE_NAME, newBaseFileContent);
    rebaseChangeOn(changeId, commit2);

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME)
            .withBase(previousPatchSetId)
            .withWhitespace(DiffPreferencesInfo.Whitespace.IGNORE_NONE)
            .get();
    // We don't list the full file contents here as that is not the focus of this test.
    assertThat(diffInfo)
        .content()
        .element(0)
        .commonLines()
        .containsAtLeast("Line 1", "Line two", "Line 3", "Line 4", "Line 5")
        .inOrder();
    // It's crucial that the lines changed in the rebase are reported correctly.
    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 100", "");
    assertThat(diffInfo)
        .content()
        .element(1)
        .linesOfB()
        .containsExactly("Line 100", "Line 101", "Line 102", "Line 103");
    assertThat(diffInfo).content().element(1).isDueToRebase();
  }

  @Test
  public void symlinkConvertedToRegularFileIsIdentifiedAsAdded() throws Exception {
    // TODO(ghareeb): fix this test for the new diff cache implementation
    assume().that(useNewDiffCacheListFiles).isFalse();
    assume().that(useNewDiffCacheGetDiff).isFalse();

    String target = "file.txt";
    String symlink = "link.lnk";

    // Create a change adding file "FileName" and a symlink "symLink" pointing to the file
    PushOneCommit push =
        pushFactory
            .create(admin.newIdent(), testRepo, "Commit Subject", target, "content")
            .addSymlink(symlink, target);

    PushOneCommit.Result result = push.to("refs/for/master");
    String initialRev = gApi.changes().id(result.getChangeId()).get().currentRevision;

    // Delete the symlink with patchset 2
    gApi.changes().id(result.getChangeId()).edit().deleteFile(symlink);
    gApi.changes().id(result.getChangeId()).edit().publish();

    // Re-add the symlink as a regular file with patchset 3
    gApi.changes()
        .id(result.getChangeId())
        .edit()
        .modifyFile(symlink, RawInputUtil.create("Content of the new file named 'symlink'"));
    gApi.changes().id(result.getChangeId()).edit().publish();

    Map<String, FileInfo> changedFiles =
        gApi.changes().id(result.getChangeId()).current().files(initialRev);

    assertThat(changedFiles.keySet()).containsExactly("/COMMIT_MSG", symlink);
    assertThat(changedFiles.get(symlink).status).isEqualTo('W'); // Rewrite

    DiffInfo diffInfo =
        gApi.changes().id(result.getChangeId()).current().file(symlink).diff(initialRev);

    // The diff logic identifies two entries for the file:
    // 1. One entry as 'DELETED' for the symlink.
    // 2. Another entry as 'ADDED' for the new regular file.
    // Since the diff logic returns a single entry, we prioritize returning the 'ADDED' entry in
    // this case so that the client is able to see the new content that was added to the file.
    assertThat(diffInfo.changeType).isEqualTo(ChangeType.ADDED);
    assertThat(diffInfo.content).hasSize(1);
    assertThat(diffInfo)
        .content()
        .element(0)
        .linesOfB()
        .containsExactly("Content of the new file named 'symlink'");
  }

  @Test
  public void diffOfNonExistentFileIsAnEmptyDiffResult() throws Exception {
    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, "a_non-existent_file.txt")
            .withBase(initialPatchSetId)
            .get();
    assertThat(diffInfo).content().isEmpty();
  }

  @Test
  public void requestingDiffForOldFileNameOfRenamedFileYieldsReasonableResult() throws Exception {
    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
    String newFilePath = "a_new_file.txt";
    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath);
    gApi.changes().id(changeId).edit().publish();

    DiffInfo diffInfo =
        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
    // This behavior has been present in Gerrit for quite some time. It differs from the results
    // returned for other cases (e.g. requesting the diff for an unmodified file; requesting the
    // diff for a non-existent file). After a rename, the original file doesn't exist anymore.
    // Hence, the most reasonable thing would be to match the behavior of requesting the diff for a
    // non-existent file, which returns an empty diff.
    // This test at least guarantees that we don't run into an internal error.
    assertThat(diffInfo).content().element(0).commonLines().isNull();
    assertThat(diffInfo).content().element(0).numberOfSkippedLines().isGreaterThan(0);
  }

  @Test
  public void editNotAllowedAsBase() throws Exception {
    gApi.changes().id(changeId).edit().create();

    BadRequestException e =
        assertThrows(
            BadRequestException.class,
            () -> getDiffRequest(changeId, CURRENT, FILE_NAME).withBase("edit").get());
    assertThat(e).hasMessageThat().isEqualTo("edit not allowed as base");

    e =
        assertThrows(
            BadRequestException.class,
            () -> getDiffRequest(changeId, CURRENT, FILE_NAME).withBase("0").get());
    assertThat(e).hasMessageThat().isEqualTo("edit not allowed as base");
  }

  private String updatedCommitMessage() {
    return "An unchanged patchset\n\nChange-Id: " + changeId;
  }

  private void assertDiffForNewFile(
      PushOneCommit.Result pushResult, String path, String expectedContentSideB) throws Exception {
    DiffInfo diff =
        gApi.changes()
            .id(pushResult.getChangeId())
            .revision(pushResult.getCommit().name())
            .file(path)
            .diff();

    List<String> headers = new ArrayList<>();
    if (path.equals(COMMIT_MSG)) {
      RevCommit c = pushResult.getCommit();

      RevCommit parentCommit = c.getParents()[0];
      String parentCommitId =
          abbreviateName(parentCommit, 8, testRepo.getRevWalk().getObjectReader());
      headers.add("Parent:     " + parentCommitId + " (" + parentCommit.getShortMessage() + ")");

      SimpleDateFormat dtfmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US);
      PersonIdent author = c.getAuthorIdent();
      dtfmt.setTimeZone(author.getTimeZone());
      headers.add("Author:     " + author.getName() + " <" + author.getEmailAddress() + ">");
      headers.add("AuthorDate: " + dtfmt.format(author.getWhen().getTime()));

      PersonIdent committer = c.getCommitterIdent();
      dtfmt.setTimeZone(committer.getTimeZone());
      headers.add("Commit:     " + committer.getName() + " <" + committer.getEmailAddress() + ">");
      headers.add("CommitDate: " + dtfmt.format(committer.getWhen().getTime()));
      headers.add("");
    }

    if (!headers.isEmpty()) {
      String header = Joiner.on("\n").join(headers);
      expectedContentSideB = header + "\n" + expectedContentSideB;
    }

    assertDiffForNewFile(diff, pushResult.getCommit(), path, expectedContentSideB);
  }

  private void rebaseChangeOn(String changeId, ObjectId newParent) throws Exception {
    RebaseInput rebaseInput = new RebaseInput();
    rebaseInput.base = newParent.getName();
    gApi.changes().id(changeId).current().rebase(rebaseInput);
  }

  private ObjectId addCommit(ObjectId parentCommit, String filePath, String fileContent)
      throws Exception {
    ImmutableMap<String, String> files = ImmutableMap.of(filePath, fileContent);
    return addCommit(parentCommit, files);
  }

  private ObjectId addCommit(ObjectId parentCommit, ImmutableMap<String, String> files)
      throws Exception {
    testRepo.reset(parentCommit);
    PushOneCommit push =
        pushFactory.create(admin.newIdent(), testRepo, "Adjust files of repo", files);
    PushOneCommit.Result result = push.to("refs/for/master");
    return result.getCommit();
  }

  private ObjectId addCommit(ObjectId parentCommit, String filePath, byte[] fileContent)
      throws Exception {
    testRepo.reset(parentCommit);
    PushOneCommit.Result result = createEmptyChange();
    String changeId = result.getChangeId();
    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
    gApi.changes().id(changeId).edit().publish();
    String currentRevision = gApi.changes().id(changeId).get().currentRevision;
    GitUtil.fetch(testRepo, "refs/*:refs/*");
    return ObjectId.fromString(currentRevision);
  }

  private ObjectId addCommitRemovingFiles(ObjectId parentCommit, String... removedFilePaths)
      throws Exception {
    testRepo.reset(parentCommit);
    Map<String, String> files =
        Arrays.stream(removedFilePaths)
            .collect(toMap(Function.identity(), path -> "Irrelevant content"));
    PushOneCommit push =
        pushFactory.create(admin.newIdent(), testRepo, "Remove files from repo", files);
    PushOneCommit.Result result = push.rm("refs/for/master");
    return result.getCommit();
  }

  private ObjectId addCommitRenamingFile(
      ObjectId parentCommit, String oldFilePath, String newFilePath) throws Exception {
    testRepo.reset(parentCommit);
    PushOneCommit.Result result = createEmptyChange();
    String changeId = result.getChangeId();
    gApi.changes().id(changeId).edit().renameFile(oldFilePath, newFilePath);
    gApi.changes().id(changeId).edit().publish();
    String currentRevision = gApi.changes().id(changeId).get().currentRevision;
    GitUtil.fetch(testRepo, "refs/*:refs/*");
    return ObjectId.fromString(currentRevision);
  }

  private Result createEmptyChange() throws Exception {
    PushOneCommit push =
        pushFactory.create(admin.newIdent(), testRepo, "Test change", ImmutableMap.of());
    return push.to("refs/for/master");
  }

  private void addModifiedPatchSet(
      String changeId, String filePath, Function<String, String> contentModification)
      throws Exception {
    try (BinaryResult content = gApi.changes().id(changeId).current().file(filePath).content()) {
      String newContent = contentModification.apply(content.asString());
      gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(newContent));
    }
    gApi.changes().id(changeId).edit().publish();
  }

  private static byte[] createRgbImage(int red, int green, int blue) throws IOException {
    BufferedImage bufferedImage = new BufferedImage(10, 20, BufferedImage.TYPE_INT_RGB);
    for (int x = 0; x < bufferedImage.getWidth(); x++) {
      for (int y = 0; y < bufferedImage.getHeight(); y++) {
        int rgb = (red << 16) + (green << 8) + blue;
        bufferedImage.setRGB(x, y, rgb);
      }
    }

    ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
    ImageIO.write(bufferedImage, "png", byteArrayOutputStream);
    return byteArrayOutputStream.toByteArray();
  }

  private FileApi.DiffRequest getDiffRequest(String changeId, String revisionId, String fileName)
      throws Exception {
    return gApi.changes()
        .id(changeId)
        .revision(revisionId)
        .file(fileName)
        .diffRequest()
        .withIntraline(intraline);
  }

  /**
   * This method transforms the {@code orig} input String using the list of replace edits {@code
   * editsA}, {@code editsB} and the resulting {@code replace} String. This method currently assumes
   * that all input edits are replace edits, and that the edits are sorted according to their
   * indices.
   *
   * @return The transformed String after applying the list of replace edits to the original String.
   */
  private String transformStringUsingEditList(
      String orig, String replace, List<List<Integer>> editsA, List<List<Integer>> editsB) {
    assertThat(editsA).hasSize(editsB.size());
    StringBuilder process = new StringBuilder(orig);
    // The edits are processed right to left to avoid recomputation of indices when characters
    // are removed.
    for (int i = editsA.size() - 1; i >= 0; i--) {
      List<Integer> leftEdit = editsA.get(i);
      List<Integer> rightEdit = editsB.get(i);
      process.replace(
          leftEdit.get(0),
          leftEdit.get(0) + leftEdit.get(1),
          replace.substring(rightEdit.get(0), rightEdit.get(0) + rightEdit.get(1)));
    }
    return process.toString();
  }
}
