Repository: steveukx/git-js (ts)

Issue:
Status reports incorrect 'current' branch when on a detached head
Quick repro:
```ts
const git = SimpleGit();
await git.init();
await git.commit('Initial commit', undefined, {
  '--allow-empty': null,
});
await git.checkout((await git.log()).latest.hash);
const status = await git.status();
console.log(status.current);
```

Expected: `null`
Actual: `HEAD`

`git status --porcelain -b -u` at this point reports `## HEAD (no branch)`, and so I would expect simple-git to report current to be `null`.

I can create a PR to fix this, seems like all that's needed is the addition of a new regex.

(Disclaimer: the codebase I'm working on is using simple-git 1.110.0, but it looks like the logic is still incorrect on main.)

Pull request description:
fix: Include detached state in git.status
Include detached state in the `StatusResult` returned from `git.status()`.

Closes #693 

Patch:
diff --git a/src/lib/responses/StatusSummary.ts b/src/lib/responses/StatusSummary.ts
index f0bf1f5..4f06234 100644
--- a/src/lib/responses/StatusSummary.ts
+++ b/src/lib/responses/StatusSummary.ts
@@ -1,48 +1,24 @@
-import { FileStatusResult, StatusResult, StatusResultRenamed } from '../../../typings';
+import { StatusResult } from '../../../typings';
 import { append } from '../utils';
 import { FileStatusSummary } from './FileStatusSummary';
 
-/**
- * The StatusSummary is returned as a response to getting `git().status()`
- */
+type StatusLineParser = (result: StatusResult, file: string) => void;
+
 export class StatusSummary implements StatusResult {
-   public not_added: string[] = [];
-   public conflicted: string[] = [];
-   public created: string[] = [];
-   public deleted: string[] = [];
-   public modified: string[] = [];
-   public renamed: StatusResultRenamed[] = [];
-
-   /**
-    * All files represented as an array of objects containing the `path` and status in `index` and
-    * in the `working_dir`.
-    */
-   public files: FileStatusResult[] = [];
-   public staged: string[] = [];
-
-   /**
-    * Number of commits ahead of the tracked branch
-    */
+   public not_added = [];
+   public conflicted = [];
+   public created = [];
+   public deleted = [];
+   public modified = [];
+   public renamed = [];
+   public files = [];
+   public staged = [];
    public ahead = 0;
-
-   /**
-    *Number of commits behind the tracked branch
-    */
    public behind = 0;
+   public current = null;
+   public tracking = null;
+   public detached = false;
 
-   /**
-    * Name of the current branch
-    */
-   public current: string | null = null;
-
-   /**
-    * Name of the branch being tracked
-    */
-   public tracking: string | null = null;
-
-   /**
-    * Gets whether this StatusSummary represents a clean working branch.
-    */
    public isClean(): boolean {
       return !this.files.length;
    }
@@ -75,7 +51,7 @@ function renamedFile(line: string) {
    };
 }
 
-function parser(indexX: PorcelainFileStatus, indexY: PorcelainFileStatus, handler: (result: StatusSummary, file: string) => void): [string, (result: StatusSummary, file: string) => unknown] {
+function parser(indexX: PorcelainFileStatus, indexY: PorcelainFileStatus, handler: StatusLineParser): [string, StatusLineParser] {
    return [`${indexX}${indexY}`, handler];
 }
 
@@ -83,7 +59,7 @@ function conflicts(indexX: PorcelainFileStatus, ...indexY: PorcelainFileStatus[]
    return indexY.map(y => parser(indexX, y, (result, file) => append(result.conflicted, file)));
 }
 
-const parsers: Map<string, (result: StatusSummary, file: string) => unknown> = new Map([
+const parsers: Map<string, StatusLineParser> = new Map([
    parser(PorcelainFileStatus.NONE, PorcelainFileStatus.ADDED, (result, file) => append(result.created, file)),
    parser(PorcelainFileStatus.NONE, PorcelainFileStatus.DELETED, (result, file) => append(result.deleted, file)),
    parser(PorcelainFileStatus.NONE, PorcelainFileStatus.MODIFIED, (result, file) => append(result.modified, file)),
@@ -134,6 +110,8 @@ const parsers: Map<string, (result: StatusSummary, file: string) => unknown> = n
 
       regexResult = onEmptyBranchReg.exec(line);
       result.current = regexResult && regexResult[1] || result.current;
+
+      result.detached = /\(no branch\)/.test(line);
    }]
 ]);
 
diff --git a/typings/response.d.ts b/typings/response.d.ts
index 27c2efb..d115eee 100644
--- a/typings/response.d.ts
+++ b/typings/response.d.ts
@@ -294,12 +294,39 @@ export interface StatusResult {
    modified: string[];
    renamed: StatusResultRenamed[];
    staged: string[];
+
+   /**
+    * All files represented as an array of objects containing the `path` and status in `index` and
+    * in the `working_dir`.
+    */
    files: FileStatusResult[];
+
+   /**
+    * Number of commits ahead of the tracked branch
+    */
    ahead: number;
+
+   /**
+    *Number of commits behind the tracked branch
+    */
    behind: number;
+
+   /**
+    * Name of the current branch
+    */
    current: string | null;
+
+   /**
+    * Name of the branch being tracked
+    */
    tracking: string | null;
 
+   /**
+    * Detached status of the working copy, for more detail of what the working branch
+    * is detached from use `git.branch()`
+    */
+   detached: boolean;
+
    /**
     * Gets whether this represents a clean working branch.
     */


Test patch:
diff --git a/test/integration/status.spec.ts b/test/integration/status.spec.ts
index 9f49d33..4f5f24c 100644
--- a/test/integration/status.spec.ts
+++ b/test/integration/status.spec.ts
@@ -1,4 +1,11 @@
-import { createTestContext, newSimpleGit, setUpFilesAdded, setUpInit, SimpleGitTestContext } from '../__fixtures__';
+import {
+   createTestContext,
+   like,
+   newSimpleGit,
+   setUpFilesAdded,
+   setUpInit,
+   SimpleGitTestContext
+} from '../__fixtures__';
 
 describe('status', () => {
    let context: SimpleGitTestContext;
@@ -39,4 +46,20 @@ describe('status', () => {
       expect(status.not_added).toEqual(['dirty-dir/dirty']);
    });
 
+   it('detached head', async () => {
+      const git = newSimpleGit(context.root);
+      expect(await git.status()).toEqual(like({
+         detached: false,
+         current: expect.any(String),
+      }));
+
+      await git.raw('tag', 'v1');
+      await git.raw('checkout', 'v1');
+
+      expect(await git.status()).toEqual(like({
+         current: 'HEAD',
+         detached: true,
+      }))
+   })
+
 });
diff --git a/test/unit/commit.spec.ts b/test/unit/commit.spec.ts
index c4b1ac5..4d2ecd8 100644
--- a/test/unit/commit.spec.ts
+++ b/test/unit/commit.spec.ts
@@ -21,6 +21,12 @@ describe('commit', () => {
    });
 
    describe('usage', () => {
+      it('empty commit', async () => {
+         git.commit([], {'--amend': null, '--no-edit': null});
+         await closeWithSuccess();
+         assertExecutedCommands('commit', '--amend', '--no-edit');
+      });
+
       it('single message, no files, no options and callback', async () => {
          const task = git.commit('message', callback);
          await closeWithSuccess();
diff --git a/test/unit/status.spec.ts b/test/unit/status.spec.ts
index 37f7535..68bb0e2 100644
--- a/test/unit/status.spec.ts
+++ b/test/unit/status.spec.ts
@@ -191,7 +191,7 @@ describe('status', () => {
 ## No commits yet on master
          `);
 
-         expect(statusSummary).toEqual(expect.objectContaining({
+         expect(statusSummary).toEqual(like({
             current: `master`
          }))
       });
@@ -204,7 +204,7 @@ A  src/b.txt
 R  src/a.txt -> src/c.txt
 `);
 
-         expect(statusSummary).toEqual(expect.objectContaining({
+         expect(statusSummary).toEqual(like({
             created: ['src/b.txt'],
             modified: ['other.txt'],
             renamed: [{from: 'src/a.txt', to: 'src/c.txt'}]
@@ -212,13 +212,13 @@ R  src/a.txt -> src/c.txt
       });
 
       it('Handles renamed', () => {
-         expect(parseStatusSummary(' R  src/file.js -> src/another-file.js')).toEqual(expect.objectContaining({
+         expect(parseStatusSummary(' R  src/file.js -> src/another-file.js')).toEqual(like({
             renamed: [{from: 'src/file.js', to: 'src/another-file.js'}],
          }));
       });
 
       it('parses status - current, tracking and ahead', () => {
-         expect(parseStatusSummary('## master...origin/master [ahead 3]')).toEqual(expect.objectContaining({
+         expect(parseStatusSummary('## master...origin/master [ahead 3]')).toEqual(like({
             current: 'master',
             tracking: 'origin/master',
             ahead: 3,
@@ -227,7 +227,8 @@ R  src/a.txt -> src/c.txt
       });
 
       it('parses status - current, tracking and behind', () => {
-         expect(parseStatusSummary('## master...origin/master [behind 2]')).toEqual(expect.objectContaining({
+         expect(parseStatusSummary('## master...origin/master [behind 2]')).toEqual(like({
+            detached: false,
             current: 'master',
             tracking: 'origin/master',
             ahead: 0,
@@ -236,7 +237,7 @@ R  src/a.txt -> src/c.txt
       });
 
       it('parses status - current, tracking', () => {
-         expect(parseStatusSummary('## release/0.34.0...origin/release/0.34.0')).toEqual(expect.objectContaining({
+         expect(parseStatusSummary('## release/0.34.0...origin/release/0.34.0')).toEqual(like({
             current: 'release/0.34.0',
             tracking: 'origin/release/0.34.0',
             ahead: 0,
@@ -245,7 +246,8 @@ R  src/a.txt -> src/c.txt
       });
 
       it('parses status - HEAD no branch', () => {
-         expect(parseStatusSummary('## HEAD (no branch)')).toEqual(expect.objectContaining({
+         expect(parseStatusSummary('## HEAD (no branch)')).toEqual(like({
+            detached: true,
             current: 'HEAD',
             tracking: null,
             ahead: 0,
@@ -254,7 +256,7 @@ R  src/a.txt -> src/c.txt
       });
 
       it('parses status - with untracked', () => {
-         expect(parseStatusSummary('?? Not tracked File\nUU Conflicted\n D Removed')).toEqual(expect.objectContaining({
+         expect(parseStatusSummary('?? Not tracked File\nUU Conflicted\n D Removed')).toEqual(like({
             not_added: ['Not tracked File'],
             conflicted: ['Conflicted'],
             deleted: ['Removed'],
@@ -262,14 +264,14 @@ R  src/a.txt -> src/c.txt
       });
 
       it('parses status - modified, added and added-changed', () => {
-         expect(parseStatusSummary(' M Modified\n A Added\nAM Changed')).toEqual(expect.objectContaining({
+         expect(parseStatusSummary(' M Modified\n A Added\nAM Changed')).toEqual(like({
             modified: ['Modified', 'Changed'],
             created: ['Added', 'Changed'],
          }));
       });
 
       it('parses status', () => {
-         expect(parseStatusSummary(statusResponse('this_branch').stdOut)).toEqual(expect.objectContaining({
+         expect(parseStatusSummary(statusResponse('this_branch').stdOut)).toEqual(like({
             current: 'this_branch',
             tracking: null,
          }));
@@ -283,7 +285,7 @@ R  src/a.txt -> src/c.txt
          const statusSummary = parseStatusSummary('\n');
 
          expect(statusSummary.isClean()).toBe(true);
-         expect(statusSummary).toEqual(expect.objectContaining({
+         expect(statusSummary).toEqual(like({
             created: [],
             deleted: [],
             modified: [],
@@ -300,7 +302,7 @@ R  src/a.txt -> src/c.txt
             A  ccc
             ?? ddd
       `);
-         expect(statusSummary).toEqual(expect.objectContaining({
+         expect(statusSummary).toEqual(like({
             staged: ['bbb', 'ccc'],
             modified: ['aaa', 'bbb'],
          }));
@@ -313,7 +315,7 @@ R  src/a.txt -> src/c.txt
              M modified
             M  staged
       `);
-         expect(statusSummary).toEqual(expect.objectContaining({
+         expect(statusSummary).toEqual(like({
             staged: ['staged-modified', 'staged'],
             modified: ['staged-modified', 'modified', 'staged'],
          }));
@@ -344,7 +346,7 @@ R  src/a.txt -> src/c.txt
 MM src/git_ind_wd.js
 M  src/git_ind.js
 `);
-         expect(statusSummary).toEqual(expect.objectContaining({
+         expect(statusSummary).toEqual(like({
             files: [
                {path: 'src/git_wd.js', index: ' ', working_dir: 'M'},
                {path: 'src/git_ind_wd.js', index: 'M', working_dir: 'M'},
@@ -354,7 +356,7 @@ M  src/git_ind.js
       });
 
       it('Report conflict when both sides have added the same file', () => {
-         expect(parseStatusSummary(`## master\nAA filename`)).toEqual(expect.objectContaining({
+         expect(parseStatusSummary(`## master\nAA filename`)).toEqual(like({
             conflicted: ['filename'],
          }));
       });
@@ -370,7 +372,7 @@ M  src/git_ind.js
             AA test-foo.js
       `);
 
-         expect(statusSummary).toEqual(expect.objectContaining({
+         expect(statusSummary).toEqual(like({
             conflicted: ['package.json', 'src/git.js', 'src/index.js', 'src/newfile.js', 'test.js', 'test', 'test-foo.js']
          }));
       });