Merge "CreateBranch: Add a source_ref input parameter"
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 3711bf9..0c9d59b 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -4309,6 +4309,12 @@
 The base revision of the new branch. +
 If not set and `create_empty_commit` is `true` the branch is created with an empty initial commit. +
 If not set and `create_empty_commit` is `false` or unset `HEAD` will be used as base revision.
+|`source_ref`                |optional|
+The full name of the source ref where `revision` can be found. +
+Used when `revision` is not a ref name in order to check reachability from a
+specific ref. This ref should be visible to the caller. +
+If not set, then all visible refs under `refs/heads/` and `refs/tags/` are
+searched.
 |`create_empty_commit`|`false` if not set|
 Whether the branch should be created with an empty initial commit. +
 Cannot be used in combination with setting a `revision`. +
diff --git a/java/com/google/gerrit/extensions/api/projects/BranchInput.java b/java/com/google/gerrit/extensions/api/projects/BranchInput.java
index 63a118a..b11b588 100644
--- a/java/com/google/gerrit/extensions/api/projects/BranchInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/BranchInput.java
@@ -37,6 +37,11 @@
  *       initial content, e.g. by merging in another branch, and push the commit for review)..
  *   <li>{@code ref}: The name of the branch. The prefix refs/heads/ can be omitted. If set, must
  *       match the branch ID in the URL.
+ *   <li>{@code sourceRef}: The full name of the source ref where {@code revision} can be found.
+ *       This ref should be visible to the caller. Used when {@code revision} is not a ref name in
+ *       order to check reachability from a specific ref. If not set, then all visible refs under
+ *       refs/heads/ and refs/tags/ are searched (see {@code CreateRefControl#checkCreateCommit} for
+ *       details).
  *   <li>{@code validationOptions}: Map with key-value pairs that are forwarded as options to the
  *       ref operation validation listeners (e.g. can be used to skip certain validations). Which
  *       validation options are supported depends on the installed ref operation validation
@@ -50,5 +55,6 @@
   @DefaultInput public String revision;
   public boolean createEmptyCommit;
   public String ref;
+  public String sourceRef;
   public Map<String, String> validationOptions;
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateBranch.java b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
index 29ab59f..95bb725 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
@@ -113,13 +113,24 @@
       if (input.revision != null) {
         input.revision = input.revision.trim();
       }
+      if (input.sourceRef != null) {
+        input.sourceRef = input.sourceRef.trim();
+      }
       if (input.createEmptyCommit) {
-        if (!Strings.isNullOrEmpty(input.revision)) {
-          throw new BadRequestException("create_empty_commit and revision are mutually exclusive");
+        if (!Strings.isNullOrEmpty(input.revision) || !Strings.isNullOrEmpty(input.sourceRef)) {
+          throw new BadRequestException(
+              "create_empty_commit and revision/source_ref are mutually exclusive");
         }
       } else {
         if (Strings.isNullOrEmpty(input.revision)) {
+          if (!Strings.isNullOrEmpty(input.sourceRef)) {
+            throw new BadRequestException("must not provide source_ref if not providing revision");
+          }
           input.revision = Constants.HEAD;
+        } else if (!Strings.isNullOrEmpty(input.sourceRef)) {
+          if (input.revision.startsWith(RefNames.REFS)) {
+            throw new BadRequestException("must not provide source_ref if revision is a ref name");
+          }
         }
       }
 
@@ -255,7 +266,12 @@
     if (input.createEmptyCommit) {
       permissionBackend.user(identifiedUser.get()).ref(branchNameKey).check(RefPermission.CREATE);
     } else {
-      Ref sourceRef = repo.exactRef(input.revision);
+      Ref sourceRef;
+      if (!Strings.isNullOrEmpty(input.sourceRef)) {
+        sourceRef = repo.exactRef(input.sourceRef);
+      } else {
+        sourceRef = repo.exactRef(input.revision);
+      }
       if (sourceRef == null) {
         createRefControl.checkCreateRef(
             identifiedUser, repo, branchNameKey, revObject, /* forPush= */ false);
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index 643c1a6..d40d551 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -374,6 +374,69 @@
   }
 
   @Test
+  public void createWithRevisionAndSourceRef() throws Exception {
+    String sourceRef = "refs/sandbox/master";
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(sourceRef).group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref(sourceRef).group(REGISTERED_USERS))
+        .update();
+    // create a new branch outside refs/heads/ to use as the source
+    pushTo(sourceRef);
+    RevCommit revision = projectOperations.project(project).getHead(sourceRef);
+    // update it so that points to a different revision than the revision on which we create the new
+    // branch
+    pushTo(sourceRef);
+    assertThat(projectOperations.project(project).getHead(sourceRef)).isNotEqualTo(revision);
+
+    BranchInput input = new BranchInput();
+    input.revision = revision.name();
+    input.sourceRef = sourceRef;
+    BranchInfo created = branch(testBranch).create(input).get();
+    assertThat(created.ref).isEqualTo(testBranch.branch());
+    assertThat(created.revision).isEqualTo(revision.name());
+    assertThat(projectOperations.project(project).getHead(testBranch.branch())).isEqualTo(revision);
+  }
+
+  @Test
+  public void cannotCreateWithRevisionAndInvisibleSourceRef() throws Exception {
+    String sourceRef = "refs/sandbox/master";
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    // create a new branch outside refs/heads/ to use as the source
+    pushTo(sourceRef);
+    RevCommit secretCommit = projectOperations.project(project).getHead(sourceRef);
+    // update it so that points to a different revision than the revision on which we create the new
+    // branch
+    pushTo(sourceRef);
+    assertThat(projectOperations.project(project).getHead(sourceRef)).isNotEqualTo(secretCommit);
+
+    // hide the source branch
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref(sourceRef).group(REGISTERED_USERS))
+        .update();
+
+    TestAccount unprivileged =
+        accountCreator.create("unprivileged", "unprivileged@example.com", "unprivileged", null);
+    requestScopeOperations.setApiUser(unprivileged.id());
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name(project.get()).branch(sourceRef).get());
+
+    BranchInput input = new BranchInput();
+    input.revision = secretCommit.name();
+    input.sourceRef = sourceRef;
+    assertThrows(AuthException.class, () -> branch(testBranch).create(input));
+  }
+
+  @Test
   public void createEmptyCommitAndRevisionAreMutuallyExclusive() throws Exception {
     BranchInput input = new BranchInput();
     input.createEmptyCommit = true;
@@ -382,7 +445,7 @@
         assertThrows(BadRequestException.class, () -> branch(testBranch).create(input));
     assertThat(thrown)
         .hasMessageThat()
-        .contains("create_empty_commit and revision are mutually exclusive");
+        .contains("create_empty_commit and revision/source_ref are mutually exclusive");
   }
 
   @Test