From 095c53659be5f2cd40e3ffab2507082e194d6326 Mon Sep 17 00:00:00 2001
From: Peter Evans <peter-evans@users.noreply.github.com>
Date: Sat, 29 Aug 2020 17:27:00 +0900
Subject: [PATCH] feat: support checkout on a commit in addition to a ref

---
 __test__/create-or-update-branch.int.test.ts | 150 ++++++++++++++++++-
 dist/index.js                                |  59 +++++---
 src/create-or-update-branch.ts               |  41 ++++-
 src/create-pull-request.ts                   |  28 ++--
 4 files changed, 240 insertions(+), 38 deletions(-)

diff --git a/__test__/create-or-update-branch.int.test.ts b/__test__/create-or-update-branch.int.test.ts
index c3594f7..397cfbc 100644
--- a/__test__/create-or-update-branch.int.test.ts
+++ b/__test__/create-or-update-branch.int.test.ts
@@ -1,4 +1,8 @@
-import {createOrUpdateBranch, tryFetch} from '../lib/create-or-update-branch'
+import {
+  createOrUpdateBranch,
+  tryFetch,
+  getWorkingBaseAndType
+} from '../lib/create-or-update-branch'
 import * as fs from 'fs'
 import {GitCommandManager} from '../lib/git-command-manager'
 import * as path from 'path'
@@ -193,6 +197,21 @@ describe('create-or-update-branch tests', () => {
     expect(await tryFetch(git, REMOTE_NAME, NOT_EXIST_BRANCH)).toBeFalsy()
   })
 
+  it('tests getWorkingBaseAndType on a checked out ref', async () => {
+    const [workingBase, workingBaseType] = await getWorkingBaseAndType(git)
+    expect(workingBase).toEqual(BASE)
+    expect(workingBaseType).toEqual('branch')
+  })
+
+  it('tests getWorkingBaseAndType on a checked out commit', async () => {
+    // Checkout the HEAD commit SHA
+    const headSha = await git.revParse('HEAD')
+    await git.exec(['checkout', headSha])
+    const [workingBase, workingBaseType] = await getWorkingBaseAndType(git)
+    expect(workingBase).toEqual(headSha)
+    expect(workingBaseType).toEqual('commit')
+  })
+
   it('tests no changes resulting in no new branch being created', async () => {
     const commitMessage = uuidv4()
     const result = await createOrUpdateBranch(
@@ -1450,4 +1469,133 @@ describe('create-or-update-branch tests', () => {
       await gitLogMatches([_commitMessage, INIT_COMMIT_MESSAGE])
     ).toBeTruthy()
   })
+
+  // Working Base is Not a Ref (WBNR)
+  // A commit is checked out leaving the repository in a "detached HEAD" state
+
+  it('tests create and update in detached HEAD state (WBNR)', async () => {
+    // Checkout the HEAD commit SHA
+    const headSha = await git.revParse('HEAD')
+    await git.checkout(headSha)
+
+    // Create tracked and untracked file changes
+    const changes = await createChanges()
+    const commitMessage = uuidv4()
+    const result = await createOrUpdateBranch(
+      git,
+      commitMessage,
+      BASE,
+      BRANCH,
+      REMOTE_NAME,
+      false
+    )
+    expect(result.action).toEqual('created')
+    expect(await getFileContent(TRACKED_FILE)).toEqual(changes.tracked)
+    expect(await getFileContent(UNTRACKED_FILE)).toEqual(changes.untracked)
+    expect(
+      await gitLogMatches([commitMessage, INIT_COMMIT_MESSAGE])
+    ).toBeTruthy()
+
+    // Push pull request branch to remote
+    await git.push([
+      '--force-with-lease',
+      REMOTE_NAME,
+      `HEAD:refs/heads/${BRANCH}`
+    ])
+
+    await afterTest(false)
+    await beforeTest()
+
+    // Checkout the HEAD commit SHA
+    const _headSha = await git.revParse('HEAD')
+    await git.checkout(_headSha)
+
+    // Create tracked and untracked file changes
+    const _changes = await createChanges()
+    const _commitMessage = uuidv4()
+    const _result = await createOrUpdateBranch(
+      git,
+      _commitMessage,
+      BASE,
+      BRANCH,
+      REMOTE_NAME,
+      false
+    )
+    expect(_result.action).toEqual('updated')
+    expect(_result.hasDiffWithBase).toBeTruthy()
+    expect(await getFileContent(TRACKED_FILE)).toEqual(_changes.tracked)
+    expect(await getFileContent(UNTRACKED_FILE)).toEqual(_changes.untracked)
+    expect(
+      await gitLogMatches([_commitMessage, INIT_COMMIT_MESSAGE])
+    ).toBeTruthy()
+  })
+
+  it('tests create and update with commits on the base inbetween, in detached HEAD state (WBNR)', async () => {
+    // Checkout the HEAD commit SHA
+    const headSha = await git.revParse('HEAD')
+    await git.checkout(headSha)
+
+    // Create tracked and untracked file changes
+    const changes = await createChanges()
+    const commitMessage = uuidv4()
+    const result = await createOrUpdateBranch(
+      git,
+      commitMessage,
+      BASE,
+      BRANCH,
+      REMOTE_NAME,
+      false
+    )
+    expect(result.action).toEqual('created')
+    expect(await getFileContent(TRACKED_FILE)).toEqual(changes.tracked)
+    expect(await getFileContent(UNTRACKED_FILE)).toEqual(changes.untracked)
+    expect(
+      await gitLogMatches([commitMessage, INIT_COMMIT_MESSAGE])
+    ).toBeTruthy()
+
+    // Push pull request branch to remote
+    await git.push([
+      '--force-with-lease',
+      REMOTE_NAME,
+      `HEAD:refs/heads/${BRANCH}`
+    ])
+
+    await afterTest(false)
+    await beforeTest()
+
+    // Create commits on the base
+    const commitsOnBase = await createCommits(git)
+    await git.push([
+      '--force',
+      REMOTE_NAME,
+      `HEAD:refs/heads/${DEFAULT_BRANCH}`
+    ])
+
+    // Checkout the HEAD commit SHA
+    const _headSha = await git.revParse('HEAD')
+    await git.checkout(_headSha)
+
+    // Create tracked and untracked file changes
+    const _changes = await createChanges()
+    const _commitMessage = uuidv4()
+    const _result = await createOrUpdateBranch(
+      git,
+      _commitMessage,
+      BASE,
+      BRANCH,
+      REMOTE_NAME,
+      false
+    )
+    expect(_result.action).toEqual('updated')
+    expect(_result.hasDiffWithBase).toBeTruthy()
+    expect(await getFileContent(TRACKED_FILE)).toEqual(_changes.tracked)
+    expect(await getFileContent(UNTRACKED_FILE)).toEqual(_changes.untracked)
+    expect(
+      await gitLogMatches([
+        _commitMessage,
+        ...commitsOnBase.commitMsgs,
+        INIT_COMMIT_MESSAGE
+      ])
+    ).toBeTruthy()
+  })
 })
diff --git a/dist/index.js b/dist/index.js
index 7482497..1b848f2 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -2932,10 +2932,30 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
     });
 };
 Object.defineProperty(exports, "__esModule", { value: true });
-exports.createOrUpdateBranch = exports.tryFetch = void 0;
+exports.createOrUpdateBranch = exports.tryFetch = exports.getWorkingBaseAndType = exports.WorkingBaseType = void 0;
 const core = __importStar(__webpack_require__(186));
 const uuid_1 = __webpack_require__(840);
 const CHERRYPICK_EMPTY = 'The previous cherry-pick is now empty, possibly due to conflict resolution.';
+var WorkingBaseType;
+(function (WorkingBaseType) {
+    WorkingBaseType["Branch"] = "branch";
+    WorkingBaseType["Commit"] = "commit";
+})(WorkingBaseType = exports.WorkingBaseType || (exports.WorkingBaseType = {}));
+function getWorkingBaseAndType(git) {
+    return __awaiter(this, void 0, void 0, function* () {
+        const symbolicRefResult = yield git.exec(['symbolic-ref', 'HEAD', '--short'], true);
+        if (symbolicRefResult.exitCode == 0) {
+            // A ref is checked out
+            return [symbolicRefResult.stdout.trim(), WorkingBaseType.Branch];
+        }
+        else {
+            // A commit is checked out (detached HEAD)
+            const headSha = yield git.revParse('HEAD');
+            return [headSha, WorkingBaseType.Commit];
+        }
+    });
+}
+exports.getWorkingBaseAndType = getWorkingBaseAndType;
 function tryFetch(git, remote, branch) {
     return __awaiter(this, void 0, void 0, function* () {
         try {
@@ -2983,8 +3003,14 @@ function splitLines(multilineString) {
 }
 function createOrUpdateBranch(git, commitMessage, base, branch, branchRemoteName, signoff) {
     return __awaiter(this, void 0, void 0, function* () {
-        // Get the working base. This may or may not be the actual base.
-        const workingBase = yield git.symbolicRef('HEAD', ['--short']);
+        // Get the working base.
+        // When a ref, it may or may not be the actual base.
+        // When a commit, we must rebase onto the actual base.
+        const [workingBase, workingBaseType] = yield getWorkingBaseAndType(git);
+        core.info(`Working base is ${workingBaseType} '${workingBase}'`);
+        if (workingBaseType == WorkingBaseType.Commit && !base) {
+            throw new Error(`When in 'detached HEAD' state, 'base' must be supplied.`);
+        }
         // If the base is not specified it is assumed to be the working base.
         base = base ? base : workingBase;
         const baseRemote = 'origin';
@@ -3009,10 +3035,14 @@ function createOrUpdateBranch(git, commitMessage, base, branch, branchRemoteName
         }
         // Perform fetch and reset the working base
         // Commits made during the workflow will be removed
-        yield git.fetch([`${workingBase}:${workingBase}`], baseRemote, ['--force']);
+        if (workingBaseType == WorkingBaseType.Branch) {
+            core.info(`Resetting working base branch '${workingBase}' to its remote`);
+            yield git.fetch([`${workingBase}:${workingBase}`], baseRemote, ['--force']);
+        }
         // If the working base is not the base, rebase the temp branch commits
+        // This will also be true if the working base type is a commit
         if (workingBase != base) {
-            core.info(`Rebasing commits made to branch '${workingBase}' on to base branch '${base}'`);
+            core.info(`Rebasing commits made to ${workingBaseType} '${workingBase}' on to base branch '${base}'`);
             // Checkout the actual base
             yield git.fetch([`${base}:${base}`], baseRemote, ['--force']);
             yield git.checkout(base);
@@ -6927,19 +6957,14 @@ function createPullRequest(inputs) {
                 yield gitAuthHelper.configureToken(inputs.token);
                 core.endGroup();
             }
-            // Determine if the checked out ref is a valid base for a pull request
-            // The action needs the checked out HEAD ref to be a branch
-            // This check will fail in the following cases:
-            // - HEAD is detached
-            // - HEAD is a merge commit (pull_request events)
-            // - HEAD is a tag
-            core.startGroup('Checking the checked out ref');
-            const symbolicRefResult = yield git.exec(['symbolic-ref', 'HEAD', '--short'], true);
-            if (symbolicRefResult.exitCode != 0) {
-                core.debug(`${symbolicRefResult.stderr}`);
-                throw new Error('The checked out ref is not a valid base for a pull request. Unable to continue.');
+            core.startGroup('Checking the base repository state');
+            const [workingBase, workingBaseType] = yield create_or_update_branch_1.getWorkingBaseAndType(git);
+            core.info(`Working base is ${workingBaseType} '${workingBase}'`);
+            // When in detached HEAD state (checked out on a commit), we need to
+            // know the 'base' branch in order to rebase changes.
+            if (workingBaseType == create_or_update_branch_1.WorkingBaseType.Commit && !inputs.base) {
+                throw new Error(`When the repository is checked out on a commit instead of a branch, the 'base' input must be supplied.`);
             }
-            const workingBase = symbolicRefResult.stdout.trim();
             // If the base is not specified it is assumed to be the working base.
             const base = inputs.base ? inputs.base : workingBase;
             // Throw an error if the base and branch are not different branches
diff --git a/src/create-or-update-branch.ts b/src/create-or-update-branch.ts
index d5d11f9..bd67a6a 100644
--- a/src/create-or-update-branch.ts
+++ b/src/create-or-update-branch.ts
@@ -5,6 +5,28 @@ import {v4 as uuidv4} from 'uuid'
 const CHERRYPICK_EMPTY =
   'The previous cherry-pick is now empty, possibly due to conflict resolution.'
 
+export enum WorkingBaseType {
+  Branch = 'branch',
+  Commit = 'commit'
+}
+
+export async function getWorkingBaseAndType(
+  git: GitCommandManager
+): Promise<[string, WorkingBaseType]> {
+  const symbolicRefResult = await git.exec(
+    ['symbolic-ref', 'HEAD', '--short'],
+    true
+  )
+  if (symbolicRefResult.exitCode == 0) {
+    // A ref is checked out
+    return [symbolicRefResult.stdout.trim(), WorkingBaseType.Branch]
+  } else {
+    // A commit is checked out (detached HEAD)
+    const headSha = await git.revParse('HEAD')
+    return [headSha, WorkingBaseType.Commit]
+  }
+}
+
 export async function tryFetch(
   git: GitCommandManager,
   remote: string,
@@ -80,8 +102,15 @@ export async function createOrUpdateBranch(
   branchRemoteName: string,
   signoff: boolean
 ): Promise<CreateOrUpdateBranchResult> {
-  // Get the working base. This may or may not be the actual base.
-  const workingBase = await git.symbolicRef('HEAD', ['--short'])
+  // Get the working base.
+  // When a ref, it may or may not be the actual base.
+  // When a commit, we must rebase onto the actual base.
+  const [workingBase, workingBaseType] = await getWorkingBaseAndType(git)
+  core.info(`Working base is ${workingBaseType} '${workingBase}'`)
+  if (workingBaseType == WorkingBaseType.Commit && !base) {
+    throw new Error(`When in 'detached HEAD' state, 'base' must be supplied.`)
+  }
+
   // If the base is not specified it is assumed to be the working base.
   base = base ? base : workingBase
   const baseRemote = 'origin'
@@ -109,12 +138,16 @@ export async function createOrUpdateBranch(
 
   // Perform fetch and reset the working base
   // Commits made during the workflow will be removed
-  await git.fetch([`${workingBase}:${workingBase}`], baseRemote, ['--force'])
+  if (workingBaseType == WorkingBaseType.Branch) {
+    core.info(`Resetting working base branch '${workingBase}' to its remote`)
+    await git.fetch([`${workingBase}:${workingBase}`], baseRemote, ['--force'])
+  }
 
   // If the working base is not the base, rebase the temp branch commits
+  // This will also be true if the working base type is a commit
   if (workingBase != base) {
     core.info(
-      `Rebasing commits made to branch '${workingBase}' on to base branch '${base}'`
+      `Rebasing commits made to ${workingBaseType} '${workingBase}' on to base branch '${base}'`
     )
     // Checkout the actual base
     await git.fetch([`${base}:${base}`], baseRemote, ['--force'])
diff --git a/src/create-pull-request.ts b/src/create-pull-request.ts
index d16ff6b..ce46a36 100644
--- a/src/create-pull-request.ts
+++ b/src/create-pull-request.ts
@@ -1,5 +1,9 @@
 import * as core from '@actions/core'
-import {createOrUpdateBranch} from './create-or-update-branch'
+import {
+  createOrUpdateBranch,
+  getWorkingBaseAndType,
+  WorkingBaseType
+} from './create-or-update-branch'
 import {GitHubHelper} from './github-helper'
 import {GitCommandManager} from './git-command-manager'
 import {GitAuthHelper} from './git-auth-helper'
@@ -81,24 +85,16 @@ export async function createPullRequest(inputs: Inputs): Promise<void> {
       core.endGroup()
     }
 
-    // Determine if the checked out ref is a valid base for a pull request
-    // The action needs the checked out HEAD ref to be a branch
-    // This check will fail in the following cases:
-    // - HEAD is detached
-    // - HEAD is a merge commit (pull_request events)
-    // - HEAD is a tag
-    core.startGroup('Checking the checked out ref')
-    const symbolicRefResult = await git.exec(
-      ['symbolic-ref', 'HEAD', '--short'],
-      true
-    )
-    if (symbolicRefResult.exitCode != 0) {
-      core.debug(`${symbolicRefResult.stderr}`)
+    core.startGroup('Checking the base repository state')
+    const [workingBase, workingBaseType] = await getWorkingBaseAndType(git)
+    core.info(`Working base is ${workingBaseType} '${workingBase}'`)
+    // When in detached HEAD state (checked out on a commit), we need to
+    // know the 'base' branch in order to rebase changes.
+    if (workingBaseType == WorkingBaseType.Commit && !inputs.base) {
       throw new Error(
-        'The checked out ref is not a valid base for a pull request. Unable to continue.'
+        `When the repository is checked out on a commit instead of a branch, the 'base' input must be supplied.`
       )
     }
-    const workingBase = symbolicRefResult.stdout.trim()
     // If the base is not specified it is assumed to be the working base.
     const base = inputs.base ? inputs.base : workingBase
     // Throw an error if the base and branch are not different branches