Add since/until limits to the contributors query

Allow specifying since/until time-stamps for selecting a subset
of commits grouped by contributor.

Parameters are similar to Gerrit's time-stamp based query parameters.
See: https://gerrit-review.googlesource.com/Documentation/user-search.html

Change-Id: I47923130deca53c3a7ff86145597d43b704cbd5e
diff --git a/README.md b/README.md
index 1097fd7..ab9945c 100644
--- a/README.md
+++ b/README.md
@@ -52,11 +52,18 @@
 
 *REST*
 
-/projects/{project-name}/analytics~contributors
+/projects/{project-name}/analytics~contributors[?since=2006-01-02[15:04:05[.890][-0700]]][&until=2018-01-02[18:01:03[.333][-0700]]]
 
 *SSH*
 
-analytics contributors {project-name}
+analytics contributors {project-name} [--since 2006-01-02[15:04:05[.890][-0700]]] [--until 2018-01-02[18:01:03[.333][-0700]]]
+
+### Parameters
+
+- --since -b Starting timestamp to consider
+- --until -e Ending timestamp (excluded) to consider
+
+NOTE: Timestamp format is consistent with Gerrit's query syntax, see /Documentation/user-search.html for details.
 
 REST Example:
 
@@ -70,7 +77,7 @@
 SSH Example:
 
 ```
-   $ ssh -p 29418 admin@gerrit.mycompany.com analytics contributors myproject
+   $ ssh -p 29418 admin@gerrit.mycompany.com analytics contributors myproject --since 2017-08-01 --until 2017-12-31
 
    {"name":"John Doe","email":"john.doe@mycompany.com","num_commits":1,"commits":[{"sha1":"6a1f73738071e299f600017d99f7252d41b96b4b","date":"Apr 28, 2011 5:13:14 AM","merge":false}]}
    {"name":"Matt Smith","email":"matt.smith@mycompany.com","num_commits":1,"commits":[{"sha1":"54527e7e3086758a23e3b069f183db6415aca304","date":"Sep 8, 2015 3:11:23 AM","merge":true}]}
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/Contributors.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/Contributors.scala
index df3bbe9..184f550 100644
--- a/src/main/scala/com/googlesource/gerrit/plugins/analytics/Contributors.scala
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/Contributors.scala
@@ -14,14 +14,17 @@
 
 package com.googlesource.gerrit.plugins.analytics
 
-import com.google.gerrit.extensions.restapi.{Response, RestReadView}
+import com.google.gerrit.extensions.restapi.{BadRequestException, Response, RestReadView}
 import com.google.gerrit.server.git.GitRepositoryManager
 import com.google.gerrit.server.project.{ProjectResource, ProjectsCollection}
 import com.google.gerrit.sshd.{CommandMetaData, SshCommand}
 import com.google.inject.Inject
+import com.googlesource.gerrit.plugins.analytics.common.DateConversions._
 import com.googlesource.gerrit.plugins.analytics.common._
 import org.eclipse.jgit.lib.ObjectId
-import org.gitective.core.stat.{AuthorHistogramFilter, UserCommitActivity}
+import org.gitective.core.stat.UserCommitActivity
+import org.kohsuke.args4j.{Option => ArgOption}
+import org.slf4j.LoggerFactory
 
 
 @CommandMetaData(name = "contributors", description = "Extracts the list of contributors to a project")
@@ -30,30 +33,78 @@
                                     val gsonFmt: GsonFormatter)
   extends SshCommand with ProjectResourceParser {
 
-  override protected def run = gsonFmt.format(executor.get(projectRes), stdout)
+  private var beginDate: Option[Long] = None
+
+  @ArgOption(name = "--since", aliases = Array("--after", "-b"), usage = "(included) begin timestamp. Must be in the format 2006-01-02[ 15:04:05[.890][ -0700]]")
+  def setBeginDate(date: String) {
+    try {
+      beginDate = Some(date)
+    } catch {
+      case e: Exception => throw die(s"Invalid begin date ${e.getMessage}")
+    }
+  }
+
+  private var endDate: Option[Long] = None
+
+  @ArgOption(name = "--until", aliases = Array("--before", "-e"), usage = "(excluded) end timestamp. Must be in the format 2006-01-02[ 15:04:05[.890][ -0700]]")
+  def setEndDate(date: String) {
+    try {
+      endDate = Some(date)
+    } catch {
+      case e: Exception => throw die(s"Invalid end date ${e.getMessage}")
+    }
+  }
+
+  override protected def run =
+    gsonFmt.format(executor.get(projectRes, beginDate, endDate), stdout)
+
 }
 
 class ContributorsResource @Inject()(val executor: ContributorsService,
                                      val gson: GsonFormatter)
   extends RestReadView[ProjectResource] {
 
-  override def apply(projectRes: ProjectResource) = Response.ok(
-    new GsonStreamedResult[UserActivitySummary](gson, executor.get(projectRes)))
+  private var beginDate: Option[Long] = None
+
+  @ArgOption(name = "--since", aliases = Array("--after", "-b"), metaVar = "QUERY", usage = "(included) begin timestamp. Must be in the format 2006-01-02[ 15:04:05[.890][ -0700]]")
+  def setBeginDate(date: String) {
+    try {
+      beginDate = Some(date)
+    } catch {
+      case e: Exception => throw new BadRequestException(s"Invalid begin date ${e.getMessage}")
+    }
+  }
+
+  private var endDate: Option[Long] = None
+
+  @ArgOption(name = "--until", aliases = Array("--before", "-e"), metaVar = "QUERY", usage = "(excluded) end timestamp. Must be in the format 2006-01-02[ 15:04:05[.890][ -0700]]")
+  def setEndDate(date: String) {
+    try {
+      endDate = Some(date)
+    } catch {
+      case e: Exception => throw new BadRequestException(s"Invalid end date ${e.getMessage}")
+    }
+  }
+
+  override def apply(projectRes: ProjectResource) =
+    Response.ok(
+      new GsonStreamedResult[UserActivitySummary](gson, executor.get(projectRes, beginDate, endDate)))
 }
 
 class ContributorsService @Inject()(repoManager: GitRepositoryManager,
                                     histogram: UserActivityHistogram,
                                     gsonFmt: GsonFormatter) {
 
-  def get(projectRes: ProjectResource): TraversableOnce[UserActivitySummary] =
+  def get(projectRes: ProjectResource, startDate: Option[Long], stopDate: Option[Long]): TraversableOnce[UserActivitySummary] = {
     ManagedResource.use(repoManager.openRepository(projectRes.getNameKey)) {
-      histogram.get(_, new AuthorHistogramFilter)
+      histogram.get(_, new AuthorHistogramFilterByDates(startDate, stopDate))
         .par
         .map(UserActivitySummary.apply).toStream
     }
+  }
 }
 
-case class CommitInfo(val sha1: String, val date: Long, val merge: Boolean)
+case class CommitInfo(sha1: String, date: Long, merge: Boolean)
 
 case class UserActivitySummary(name: String, email: String, numCommits: Int,
                                commits: Array[CommitInfo], lastCommitDate: Long)
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/AuthorHistogramFilterByDates.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/AuthorHistogramFilterByDates.scala
new file mode 100644
index 0000000..b9cf786
--- /dev/null
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/AuthorHistogramFilterByDates.scala
@@ -0,0 +1,41 @@
+// Copyright (C) 2016 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.googlesource.gerrit.plugins.analytics.common
+
+import java.util.Date
+
+import org.eclipse.jgit.revwalk.{RevCommit, RevWalk}
+import org.gitective.core.stat.CommitHistogramFilter
+
+/**
+  * Commit filter that includes commits only on the specified interval
+  * starting from and to excluded
+  */
+class AuthorHistogramFilterByDates(val from: Option[Long] = None, val to: Option[Long] = None)
+  extends CommitHistogramFilter {
+
+  override def include(walker: RevWalk, commit: RevCommit) = {
+    val commitDate = commit.getCommitterIdent.getWhen.getTime
+    val author = commit.getAuthorIdent
+    if (from.fold(true)(commitDate >=) && to.fold(true)(commitDate <)) {
+      histogram.include(commit, author)
+      true
+    } else {
+      false
+    }
+  }
+
+  override def clone = new AuthorHistogramFilterByDates(from, to)
+}
\ No newline at end of file
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/DateConversions.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/DateConversions.scala
new file mode 100644
index 0000000..4aa31a7
--- /dev/null
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/DateConversions.scala
@@ -0,0 +1,22 @@
+// Copyright (C) 2016 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.googlesource.gerrit.plugins.analytics.common
+
+import com.google.gwtjsonrpc.common.JavaSqlTimestampHelper
+
+object DateConversions {
+
+  implicit def isoStringToLongDate(s: String): Long = JavaSqlTimestampHelper.parseTimestamp(s).getTime
+}
diff --git a/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/AuthorHistogramFilterByDatesSpec.scala b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/AuthorHistogramFilterByDatesSpec.scala
new file mode 100644
index 0000000..6f4a8bc
--- /dev/null
+++ b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/AuthorHistogramFilterByDatesSpec.scala
@@ -0,0 +1,106 @@
+// 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.googlesource.gerrit.plugins.analytics.test
+
+import java.util.Date
+
+import com.googlesource.gerrit.plugins.analytics.common.AuthorHistogramFilterByDates
+import org.eclipse.jgit.lib.PersonIdent
+import org.gitective.core.CommitFinder
+import org.scalatest.{BeforeAndAfterEach, FlatSpec, Matchers}
+
+class AuthorHistogramFilterByDatesSpec extends FlatSpec with GitTestCase with BeforeAndAfterEach with Matchers {
+
+  "Author history filter" should
+    "select one commit without intervals restriction" in {
+
+    add("file.txt", "some content")
+    val filter = new AuthorHistogramFilterByDates
+    new CommitFinder(testRepo).setFilter(filter).find
+
+    val userActivity = filter.getHistogram.getUserActivity
+    filter.getHistogram.getUserActivity should have size (1)
+    val activity = userActivity.head
+    activity.getCount should be(1)
+    activity.getName should be(author.getName)
+    activity.getEmail should be(author.getEmailAddress)
+  }
+
+  it should "select only the second of two commits based on the start timestamp" in {
+    val firstCommitTs = add("file.txt", "some content")
+      .getCommitterIdent.getWhen.getTime
+    val person = newPersonIdent("Second person", "second@company.com", new Date(firstCommitTs + 1000L))
+    val secondCommitTs = add("file.txt", "other content", author = person, committer = person)
+      .getCommitterIdent.getWhen.getTime
+
+    secondCommitTs should be > firstCommitTs
+
+    val filter = new AuthorHistogramFilterByDates(from = Some(secondCommitTs))
+    new CommitFinder(testRepo).setFilter(filter).find
+
+    val userActivity = filter.getHistogram.getUserActivity
+    userActivity should have size (1)
+    val activity = userActivity.head
+
+    activity.getTimes should have size (1)
+    activity.getName should be(person.getName)
+    activity.getEmail should be(person.getEmailAddress)
+  }
+
+  it should "select only the first of two commits based on the end timestamp" in {
+    val person = newPersonIdent("First person", "first@company.com")
+    val firstCommitTs = add("file.txt", "some content", author = person, committer = person)
+      .getCommitterIdent.getWhen.getTime
+    val secondCommitTs = add("file.txt", "other content", committer = new PersonIdent(committer, new Date(firstCommitTs + 1000L)))
+      .getCommitterIdent.getWhen.getTime
+
+    secondCommitTs should be > firstCommitTs
+
+    val filter = new AuthorHistogramFilterByDates(to = Some(secondCommitTs))
+    new CommitFinder(testRepo).setFilter(filter).find
+
+    val userActivity = filter.getHistogram.getUserActivity
+    userActivity should have size (1)
+    val activity = userActivity.head
+
+    activity.getTimes should have size (1)
+    activity.getName should be(person.getName)
+    activity.getEmail should be(person.getEmailAddress)
+  }
+
+  it should "select only one middle commit out of three based on interval from/to timestamp" in {
+    val firstCommitTs = add("file.txt", "some content")
+      .getCommitterIdent.getWhen.getTime
+    val person = newPersonIdent("Middle person", "middle@company.com", new Date(firstCommitTs + 1000L))
+    val middleCommitTs = add("file.txt", "other content", author = person, committer = person)
+      .getCommitterIdent.getWhen.getTime
+    val lastCommitTs = add("file.text", "yet other content", committer = new PersonIdent(committer, new Date(middleCommitTs + 1000L)))
+      .getCommitterIdent.getWhen.getTime
+
+    middleCommitTs should be > firstCommitTs
+    lastCommitTs should be > middleCommitTs
+
+    val filter = new AuthorHistogramFilterByDates(from = Some(middleCommitTs), to = Some(lastCommitTs))
+    new CommitFinder(testRepo).setFilter(filter).find
+
+    val userActivity = filter.getHistogram.getUserActivity
+    userActivity should have size (1)
+    val activity = userActivity.head
+
+    activity.getTimes should have size (1)
+    activity.getName should be(person.getName)
+    activity.getEmail should be(person.getEmailAddress)
+  }
+}
diff --git a/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/GitTestCase.scala b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/GitTestCase.scala
new file mode 100644
index 0000000..6375b7f
--- /dev/null
+++ b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/GitTestCase.scala
@@ -0,0 +1,373 @@
+//
+// Copyright (c) 2011 Kevin Sawicki <kevinsawicki@gmail.com>
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+//
+// 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.googlesource.gerrit.plugins.analytics.test
+
+import java.io.File
+import java.io.PrintWriter
+import java.text.MessageFormat
+import java.util.Date
+import java.util
+
+import org.eclipse.jgit.api.Git
+import org.eclipse.jgit.api.MergeResult
+import org.eclipse.jgit.api.errors.GitAPIException
+import org.eclipse.jgit.lib.Constants
+import org.eclipse.jgit.lib.PersonIdent
+import org.eclipse.jgit.lib.Ref
+import org.eclipse.jgit.merge.MergeStrategy
+import org.eclipse.jgit.notes.Note
+import org.eclipse.jgit.revwalk.RevCommit
+import org.gitective.core.CommitUtils
+import org.scalatest.{BeforeAndAfterEach, Suite}
+
+
+/**
+  * Base test case with utilities for common Git operations performed during
+  * testing
+  */
+trait GitTestCase extends BeforeAndAfterEach {
+  self: Suite =>
+  /**
+    * Test repository .git directory
+    */
+  protected var testRepo: File = null
+
+  /**
+    * Author used for commits
+    */
+  protected val author = new PersonIdent("Test Author", "author@test.com")
+
+  def newPersonIdent(name: String = "Test Person", email: String = "person@test.com", ts: Date = new Date()) =
+    new PersonIdent(new PersonIdent(name, email), ts)
+
+  /**
+    * Committer used for commits
+    */
+  protected val committer = new PersonIdent("Test Committer", "committer@test.com")
+
+  /**
+    * Set up method that initializes git repository
+    *
+    *
+    */
+
+  override def beforeEach = {
+    testRepo = initRepo
+  }
+
+  /**
+    * Initialize a new repo in a new directory
+    *
+    * @return created .git folder
+    * @throws GitAPIException
+    */
+  protected def initRepo: File = {
+    val tmpDir = System.getProperty("java.io.tmpdir")
+    assert(tmpDir != null, "java.io.tmpdir was null")
+    val dir = new File(tmpDir, "git-test-case-" + System.nanoTime)
+    assert(dir.mkdir)
+    Git.init.setDirectory(dir).setBare(false).call
+    val repo = new File(dir, Constants.DOT_GIT)
+    assert(repo.exists)
+    repo.deleteOnExit()
+    repo
+  }
+
+  /**
+    * Create branch with name and checkout
+    *
+    * @param name
+    * @return branch ref
+    *
+    */
+  protected def branch(name: String): Ref = branch(testRepo, name)
+
+  /**
+    * Create branch with name and checkout
+    *
+    * @param repo
+    * @param name
+    * @return branch ref
+    *
+    */
+  protected def branch(repo: File, name: String): Ref = {
+    val git = Git.open(repo)
+    git.branchCreate.setName(name).call
+    checkout(repo, name)
+  }
+
+  /**
+    * Checkout branch
+    *
+    * @param name
+    * @return branch ref
+    *
+    */
+  protected def checkout(name: String): Ref = checkout(testRepo, name)
+
+  /**
+    * Checkout branch
+    *
+    * @param repo
+    * @param name
+    * @return branch ref
+    *
+    */
+  @throws[Exception]
+  protected def checkout(repo: File, name: String): Ref = {
+    val git = Git.open(repo)
+    val ref = git.checkout.setName(name).call
+    assert(ref != null)
+    ref
+  }
+
+  /**
+    * Create tag with name
+    *
+    * @param name
+    * @return tag ref
+    *
+    */
+  protected def tag(name: String): Ref = tag(testRepo, name)
+
+  /**
+    * Create tag with name
+    *
+    * @param repo
+    * @param name
+    * @return tag ref
+    *
+    */
+  protected def tag(repo: File, name: String): Ref = {
+    val git = Git.open(repo)
+    git.tag.setName(name).setMessage(name).call
+    val tagRef = git.getRepository.getTags.get(name)
+    assert(tagRef != null)
+    tagRef
+  }
+
+  /**
+    * Add file to test repository
+    *
+    * @param path
+    * @param content
+    * @return commit
+    *
+    */
+  protected def add(path: String, content: String, author: PersonIdent = author, committer: PersonIdent = committer): RevCommit = add(testRepo, path, content, author, committer)
+
+  /**
+    * Add file to test repository
+    *
+    * @param repo
+    * @param path
+    * @param content
+    * @return commit
+    *
+    */
+  protected def add(repo: File, path: String, content: String, author: PersonIdent, committer: PersonIdent): RevCommit = {
+    val message = MessageFormat.format("Committing {0} at {1}", path, new Date)
+    add(repo, path, content, message, author, committer)
+  }
+
+
+  /**
+    * Add file to test repository
+    *
+    * @param repo
+    * @param path
+    * @param content
+    * @param message
+    * @return commit
+    *
+    */
+  protected def add(repo: File, path: String, content: String, message: String, author: PersonIdent, committer: PersonIdent): RevCommit = {
+    val file = new File(repo.getParentFile, path)
+    if (!file.getParentFile.exists) assert(file.getParentFile.mkdirs)
+    if (!file.exists) assert(file.createNewFile)
+    val writer = new PrintWriter(file)
+    try
+      writer.print(Option(content).fold("")(identity))
+    finally writer.close()
+    val git = Git.open(repo)
+    git.add.addFilepattern(path).call
+    val commit = git.commit.setOnly(path).setMessage(message).setAuthor(author).setCommitter(committer).call
+    assert(null != commit)
+    commit
+  }
+
+  /**
+    * Move file in test repository
+    *
+    * @param from
+    * @param to
+    * @return commit
+    *
+    */
+  protected def mv(from: String, to: String): RevCommit = mv(testRepo, from, to, MessageFormat.format("Moving {0} to {1} at {2}", from, to, new Date))
+
+  /**
+    * Move file in test repository
+    *
+    * @param from
+    * @param to
+    * @param message
+    * @return commit
+    *
+    */
+  protected def mv(from: String, to: String, message: String): RevCommit = mv(testRepo, from, to, message)
+
+  /**
+    * Move file in test repository
+    *
+    * @param repo
+    * @param from
+    * @param to
+    * @param message
+    * @return commit
+    *
+    */
+  protected def mv(repo: File, from: String, to: String, message: String): RevCommit = {
+    val file = new File(repo.getParentFile, from)
+    file.renameTo(new File(repo.getParentFile, to))
+    val git = Git.open(repo)
+    git.rm.addFilepattern(from)
+    git.add.addFilepattern(to).call
+    val commit = git.commit.setAll(true).setMessage(message).setAuthor(author).setCommitter(committer).call
+    assert(null != commit)
+    commit
+  }
+
+  /**
+    * Add files to test repository
+    *
+    * @param paths
+    * @param contents
+    * @return commit
+    *
+    */
+  protected def add(paths: util.List[String], contents: util.List[String]): RevCommit = add(testRepo, paths, contents, "Committing multiple files")
+
+  /**
+    * Add files to test repository
+    *
+    * @param repo
+    * @param paths
+    * @param contents
+    * @param message
+    * @return commit
+    *
+    */
+  protected def add(repo: File, paths: util.List[String], contents: util.List[String], message: String): RevCommit = {
+    val git = Git.open(repo)
+    var i = 0
+    while ( {
+      i < paths.size
+    }) {
+      val path = paths.get(i)
+      var content = contents.get(i)
+      val file = new File(repo.getParentFile, path)
+      if (!file.getParentFile.exists) assert(file.getParentFile.mkdirs)
+      if (!file.exists) assert(file.createNewFile)
+      val writer = new PrintWriter(file)
+      if (content == null) content = ""
+      try
+        writer.print(content)
+      finally writer.close()
+      git.add.addFilepattern(path).call
+
+      {
+        i += 1;
+        i - 1
+      }
+    }
+    val commit = git.commit.setMessage(message).setAuthor(author).setCommitter(committer).call
+    assert(null != commit)
+    commit
+  }
+
+  /**
+    * Merge ref into current branch
+    *
+    * @param ref
+    * @return result
+    *
+    */
+  protected def merge(ref: String): MergeResult = {
+    val git = Git.open(testRepo)
+    git.merge.setStrategy(MergeStrategy.RESOLVE).include(CommitUtils.getCommit(git.getRepository, ref)).call
+  }
+
+  /**
+    * Add note to latest commit with given content
+    *
+    * @param content
+    * @return note
+    *
+    */
+  protected def note(content: String): Note = note(content, "commits")
+
+  /**
+    * Add note to latest commit with given content
+    *
+    * @param content
+    * @param ref
+    * @return note
+    *
+    */
+  protected def note(content: String, ref: String): Note = {
+    val git = Git.open(testRepo)
+    val note = git.notesAdd.setMessage(content).setNotesRef(Constants.R_NOTES + ref).setObjectId(CommitUtils.getHead(git.getRepository)).call
+    assert(null != note)
+    note
+  }
+
+  /**
+    * Delete and commit file at path
+    *
+    * @param path
+    * @return commit
+    *
+    */
+  protected def delete(path: String): RevCommit = {
+    val message = MessageFormat.format("Committing {0} at {1}", path, new Date)
+    val git = Git.open(testRepo)
+    git.rm.addFilepattern(path).call
+    val commit = git.commit.setOnly(path).setMessage(message).setAuthor(author).setCommitter(committer).call
+    assert(null != commit)
+    commit
+  }
+}
\ No newline at end of file