From b696abb3c2741508fbb4029f39153140a3722e1e Mon Sep 17 00:00:00 2001
From: Yun Lai <ylai@squareup.com>
Date: Sat, 30 Jul 2022 18:41:45 +1000
Subject: [PATCH] feat: add Hermit package manager (#16258)

* feat: add Hermit package manager

* fix: pass bin directory into getRepoStatus as string rather than an array

* fix: fix up hermit manager implementations

* add docker support in exec
* move fs related operations back into  util/fs
* remove ENVVar passed on by process.env
* set concurrency in pMap
* use for instead of pMap for concurrency = 1
* use regex to pick up package reference parts

* fix: fix manager updateArtifacts test after change

* Update lib/modules/manager/hermit/extract.ts

Co-authored-by: Philip <42116482+PhilipAbed@users.noreply.github.com>

* fix: fix up test and docker reference for hermit manager

* test refer to internal fs
* docker image change to sidecar
* only symlink are read for the changed file content after hermit
  install
* no more global mock in artifacts test

* fix: use warn instead of error so error better flows up in hermit manager

* fix: partial for test type, use throw instead of reject

* fix: update snapshot

* fix: combine install packages, also make extractPackageFile async

* fix: remove weird generated readLocalSynmlink in test

* fix: removes old test

* fix: use ensureLocalPath and fix test coverage

* fix: more test coverage

* fix: use ensureLocalPath in readLocalSymlink

* Apply suggestions from code review

Co-authored-by: Michael Kriese <michael.kriese@visualon.de>

* fix: remove unused functions and types

* Apply suggestions from code review

Co-authored-by: Michael Kriese <michael.kriese@visualon.de>

* Apply suggestions from code review

Co-authored-by: Sergei Zharinov <zharinov@users.noreply.github.com>

* fix: use execSnapshots and for of loop when returning the result

* Update lib/modules/manager/hermit/artifacts.spec.ts

Co-authored-by: Michael Kriese <michael.kriese@visualon.de>

* fix: move exports below imports

Co-authored-by: Philip <42116482+PhilipAbed@users.noreply.github.com>
Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
Co-authored-by: Sergei Zharinov <zharinov@users.noreply.github.com>
---
 lib/modules/manager/api.ts                    |   2 +
 lib/modules/manager/hermit/artifacts.spec.ts  | 293 ++++++++++++++++++
 lib/modules/manager/hermit/artifacts.ts       | 258 +++++++++++++++
 .../manager/hermit/default-config.spec.ts     |  50 +++
 lib/modules/manager/hermit/default-config.ts  |   6 +
 lib/modules/manager/hermit/extract.spec.ts    |  83 +++++
 lib/modules/manager/hermit/extract.ts         | 106 +++++++
 lib/modules/manager/hermit/index.ts           |  14 +
 lib/modules/manager/hermit/readme.md          |  25 ++
 lib/modules/manager/hermit/types.ts           |  17 +
 lib/modules/manager/hermit/update.spec.ts     |  21 ++
 lib/modules/manager/hermit/update.ts          |  22 ++
 12 files changed, 897 insertions(+)
 create mode 100644 lib/modules/manager/hermit/artifacts.spec.ts
 create mode 100644 lib/modules/manager/hermit/artifacts.ts
 create mode 100644 lib/modules/manager/hermit/default-config.spec.ts
 create mode 100644 lib/modules/manager/hermit/default-config.ts
 create mode 100644 lib/modules/manager/hermit/extract.spec.ts
 create mode 100644 lib/modules/manager/hermit/extract.ts
 create mode 100644 lib/modules/manager/hermit/index.ts
 create mode 100644 lib/modules/manager/hermit/readme.md
 create mode 100644 lib/modules/manager/hermit/types.ts
 create mode 100644 lib/modules/manager/hermit/update.spec.ts
 create mode 100644 lib/modules/manager/hermit/update.ts

diff --git a/lib/modules/manager/api.ts b/lib/modules/manager/api.ts
index e01c0fecf3..7ef615a815 100644
--- a/lib/modules/manager/api.ts
+++ b/lib/modules/manager/api.ts
@@ -36,6 +36,7 @@ import * as helmValues from './helm-values';
 import * as helmfile from './helmfile';
 import * as helmsman from './helmsman';
 import * as helmv3 from './helmv3';
+import * as hermit from './hermit';
 import * as homebrew from './homebrew';
 import * as html from './html';
 import * as jenkins from './jenkins';
@@ -114,6 +115,7 @@ api.set('helm-values', helmValues);
 api.set('helmfile', helmfile);
 api.set('helmsman', helmsman);
 api.set('helmv3', helmv3);
+api.set('hermit', hermit);
 api.set('homebrew', homebrew);
 api.set('html', html);
 api.set('jenkins', jenkins);
diff --git a/lib/modules/manager/hermit/artifacts.spec.ts b/lib/modules/manager/hermit/artifacts.spec.ts
new file mode 100644
index 0000000000..bd84fb03c8
--- /dev/null
+++ b/lib/modules/manager/hermit/artifacts.spec.ts
@@ -0,0 +1,293 @@
+import { mockExecAll } from '../../../../test/exec-util';
+import { mockedFunction, partial } from '../../../../test/util';
+import { GlobalConfig } from '../../../config/global';
+import { ExecError } from '../../../util/exec/exec-error';
+import { localPathIsSymbolicLink, readLocalSymlink } from '../../../util/fs';
+import { getRepoStatus } from '../../../util/git';
+import type { StatusResult } from '../../../util/git/types';
+import type { UpdateArtifact } from '../types';
+import { updateArtifacts } from '.';
+
+jest.mock('../../../util/git');
+jest.mock('../../../util/fs');
+
+const getRepoStatusMock = mockedFunction(getRepoStatus);
+
+const lstatsMock = mockedFunction(localPathIsSymbolicLink);
+const readlinkMock = mockedFunction(readLocalSymlink);
+
+describe('modules/manager/hermit/artifacts', () => {
+  describe('updateArtifacts', () => {
+    it('should run hermit install for packages and return updated files', async () => {
+      lstatsMock.mockResolvedValue(true);
+
+      readlinkMock.mockResolvedValue('hermit');
+      GlobalConfig.set({ localDir: '' });
+
+      const execSnapshots = mockExecAll();
+      getRepoStatusMock.mockResolvedValue(
+        partial<StatusResult>({
+          not_added: ['bin/go-1.17.1'],
+          deleted: ['bin/go-1.17'],
+          modified: ['bin/go', 'bin/jq'],
+          created: ['bin/jq-extra'],
+          renamed: [
+            {
+              from: 'bin/jq-1.5',
+              to: 'bin/jq-1.6',
+            },
+          ],
+        })
+      );
+
+      const res = await updateArtifacts(
+        partial<UpdateArtifact>({
+          updatedDeps: [
+            {
+              depName: 'go',
+              currentVersion: '1.17',
+              newValue: '1.17.1',
+            },
+            {
+              depName: 'jq',
+              currentVersion: '1.5',
+              newValue: '1.6',
+            },
+          ],
+          packageFileName: 'go/bin/hermit',
+        })
+      );
+
+      expect(execSnapshots).toMatchObject([
+        { cmd: './hermit install go-1.17.1 jq-1.6' },
+      ]);
+
+      expect(res).toStrictEqual([
+        {
+          file: {
+            path: 'bin/jq-1.5',
+            type: 'deletion',
+          },
+        },
+        {
+          file: {
+            contents: 'hermit',
+            isSymlink: true,
+            isExecutable: undefined,
+            path: 'bin/jq-1.6',
+            type: 'addition',
+          },
+        },
+        {
+          file: {
+            path: 'bin/go',
+            type: 'deletion',
+          },
+        },
+        {
+          file: {
+            contents: 'hermit',
+            isSymlink: true,
+            isExecutable: undefined,
+            path: 'bin/go',
+            type: 'addition',
+          },
+        },
+        {
+          file: {
+            path: 'bin/jq',
+            type: 'deletion',
+          },
+        },
+        {
+          file: {
+            contents: 'hermit',
+            isSymlink: true,
+            isExecutable: undefined,
+            path: 'bin/jq',
+            type: 'addition',
+          },
+        },
+        {
+          file: {
+            contents: 'hermit',
+            isSymlink: true,
+            isExecutable: undefined,
+            path: 'bin/jq-extra',
+            type: 'addition',
+          },
+        },
+        {
+          file: {
+            contents: 'hermit',
+            isSymlink: true,
+            isExecutable: undefined,
+            path: 'bin/go-1.17.1',
+            type: 'addition',
+          },
+        },
+        {
+          file: {
+            path: 'bin/go-1.17',
+            type: 'deletion',
+          },
+        },
+      ]);
+    });
+
+    it('should fail on error getting link content', async () => {
+      lstatsMock.mockResolvedValue(true);
+
+      readlinkMock.mockResolvedValue(null);
+      GlobalConfig.set({ localDir: '' });
+
+      mockExecAll();
+
+      getRepoStatusMock.mockResolvedValue(
+        partial<StatusResult>({
+          not_added: [],
+          deleted: [],
+          modified: [],
+          created: [],
+          renamed: [
+            {
+              from: 'bin/jq-1.5',
+              to: 'bin/jq-1.6',
+            },
+          ],
+        })
+      );
+
+      const res = await updateArtifacts(
+        partial<UpdateArtifact>({
+          updatedDeps: [
+            {
+              depName: 'go',
+              currentVersion: '1.17',
+              newValue: '1.17.1',
+            },
+            {
+              depName: 'jq',
+              currentVersion: '1.5',
+              newValue: '1.6',
+            },
+          ],
+          packageFileName: 'go/bin/hermit',
+        })
+      );
+
+      expect(res).toEqual([
+        {
+          artifactError: {
+            stderr: 'error getting content for bin/jq-1.6',
+          },
+        },
+      ]);
+    });
+
+    it('should return error on installation error', async () => {
+      mockExecAll(
+        new ExecError('', {
+          stdout: '',
+          stderr: 'error executing hermit install',
+          cmd: '',
+          options: {
+            encoding: 'utf-8',
+          },
+        })
+      );
+
+      const res = await updateArtifacts(
+        partial<UpdateArtifact>({
+          updatedDeps: [
+            {
+              depName: 'go',
+              currentVersion: '1.17',
+              newValue: '1.17.1',
+            },
+            {
+              depName: 'jq',
+              currentVersion: '1.5',
+              newValue: '1.6',
+            },
+          ],
+          packageFileName: 'go/bin/hermit',
+        })
+      );
+
+      expect(res).toStrictEqual([
+        {
+          artifactError: {
+            lockFile: 'from: go-1.17 jq-1.5, to: go-1.17.1 jq-1.6',
+            stderr: 'error executing hermit install',
+          },
+        },
+      ]);
+    });
+
+    it('should return error on invalid update information', async () => {
+      let res = await updateArtifacts(
+        partial<UpdateArtifact>({
+          updatedDeps: [
+            {
+              currentVersion: '1.17',
+              newValue: '1.17.1',
+            },
+          ],
+          packageFileName: 'go/bin/hermit',
+        })
+      );
+
+      expect(res).toStrictEqual([
+        {
+          artifactError: {
+            lockFile: 'from: -1.17, to: -1.17.1',
+            stderr: `invalid package to update`,
+          },
+        },
+      ]);
+
+      res = await updateArtifacts(
+        partial<UpdateArtifact>({
+          updatedDeps: [
+            {
+              depName: 'go',
+              newValue: '1.17.1',
+            },
+          ],
+          packageFileName: 'go/bin/hermit',
+        })
+      );
+
+      expect(res).toStrictEqual([
+        {
+          artifactError: {
+            lockFile: 'from: go-, to: go-1.17.1',
+            stderr: `invalid package to update`,
+          },
+        },
+      ]);
+
+      res = await updateArtifacts(
+        partial<UpdateArtifact>({
+          updatedDeps: [
+            {
+              depName: 'go',
+              currentVersion: '1.17',
+            },
+          ],
+          packageFileName: 'go/bin/hermit',
+        })
+      );
+
+      expect(res).toStrictEqual([
+        {
+          artifactError: {
+            lockFile: 'from: go-1.17, to: go-',
+            stderr: `invalid package to update`,
+          },
+        },
+      ]);
+    });
+  });
+});
diff --git a/lib/modules/manager/hermit/artifacts.ts b/lib/modules/manager/hermit/artifacts.ts
new file mode 100644
index 0000000000..40a337a937
--- /dev/null
+++ b/lib/modules/manager/hermit/artifacts.ts
@@ -0,0 +1,258 @@
+import pMap from 'p-map';
+import upath from 'upath';
+import { logger } from '../../../logger';
+import { exec } from '../../../util/exec';
+import type { ExecOptions } from '../../../util/exec/types';
+import { localPathIsSymbolicLink, readLocalSymlink } from '../../../util/fs';
+import { getRepoStatus } from '../../../util/git';
+import type { UpdateArtifact, UpdateArtifactsResult } from '../types';
+import type { ReadContentResult } from './types';
+
+/**
+ * updateArtifacts runs hermit install for each updated dependencies
+ */
+export async function updateArtifacts(
+  update: UpdateArtifact
+): Promise<UpdateArtifactsResult[] | null> {
+  const { packageFileName } = update;
+  logger.debug({ packageFileName }, `hermit.updateArtifacts()`);
+
+  try {
+    await updateHermitPackage(update);
+  } catch (err) {
+    const execErr: UpdateHermitError = err;
+    logger.debug({ err }, `error updating hermit packages.`);
+    return [
+      {
+        artifactError: {
+          lockFile: `from: ${execErr.from}, to: ${execErr.to}`,
+          stderr: execErr.stderr,
+        },
+      },
+    ];
+  }
+
+  logger.debug(`scanning the changes after update`);
+
+  let updateResult: UpdateArtifactsResult[] | null = null;
+
+  try {
+    updateResult = await getUpdateResult(packageFileName);
+    logger.debug({ updateResult }, `update result for hermit`);
+  } catch (err) {
+    logger.debug({ err }, 'Error getting hermet update results');
+    return [
+      {
+        artifactError: {
+          stderr: err.message,
+        },
+      },
+    ];
+  }
+
+  return updateResult;
+}
+
+/**
+ * getContent returns the content of either link or a normal file
+ */
+async function getContent(file: string): Promise<ReadContentResult> {
+  let contents: string | null = '';
+  const isSymlink = await localPathIsSymbolicLink(file);
+
+  if (isSymlink) {
+    contents = await readLocalSymlink(file);
+  }
+
+  if (contents === null) {
+    throw new Error(`error getting content for ${file}`);
+  }
+
+  return {
+    isSymlink,
+    contents,
+  };
+}
+
+/**
+ * getAddResult returns the UpdateArtifactsResult for the added files
+ */
+function getAddResult(
+  path: string,
+  contentRes: ReadContentResult
+): UpdateArtifactsResult {
+  return {
+    file: {
+      type: 'addition',
+      path,
+      contents: contentRes.contents,
+      isSymlink: contentRes.isSymlink,
+      isExecutable: contentRes.isExecutable,
+    },
+  };
+}
+
+/**
+ * getDeleteResult returns the UpdateArtifactsResult for deleted files
+ */
+function getDeleteResult(path: string): UpdateArtifactsResult {
+  return {
+    file: {
+      type: 'deletion',
+      path,
+    },
+  };
+}
+
+/**
+ * getUpdateResult will return the update result after `hermit install`
+ * has been performed for all packages
+ */
+async function getUpdateResult(
+  packageFileName: string
+): Promise<UpdateArtifactsResult[]> {
+  const hermitFolder = `${upath.dirname(packageFileName)}/`;
+  const hermitChanges = await getRepoStatus(hermitFolder);
+  logger.debug(
+    { hermitChanges, hermitFolder },
+    `hermit changes after package update`
+  );
+
+  // handle added files
+  const added = await pMap(
+    [...hermitChanges.created, ...hermitChanges.not_added],
+    async (path: string): Promise<UpdateArtifactsResult> => {
+      const contents = await getContent(path);
+
+      return getAddResult(path, contents);
+    },
+    { concurrency: 5 }
+  );
+
+  const deleted = hermitChanges.deleted.map(getDeleteResult);
+
+  const modified = await pMap(
+    hermitChanges.modified,
+    async (path: string): Promise<UpdateArtifactsResult[]> => {
+      const contents = await getContent(path);
+      return [
+        getDeleteResult(path), // delete existing link
+        getAddResult(path, contents), // add a new link
+      ];
+    },
+    { concurrency: 5 }
+  );
+
+  const renamed = await pMap(
+    hermitChanges.renamed,
+    async (renamed): Promise<UpdateArtifactsResult[]> => {
+      const from = renamed.from;
+      const to = renamed.to;
+      const toContents = await getContent(to);
+
+      return [getDeleteResult(from), getAddResult(to, toContents)];
+    },
+    { concurrency: 5 }
+  );
+
+  return [
+    // rename will need to go first, because
+    // it needs to create the new link for the new version
+    // for the modified links to use
+    ...renamed.flat(),
+    ...modified.flat(),
+    ...added,
+    ...deleted,
+  ];
+}
+
+/**
+ * getHermitPackage returns the hermit package for running the hermit install
+ */
+function getHermitPackage(name: string, version: string): string {
+  return `${name}-${version}`;
+}
+
+/**
+ * updateHermitPackage runs hermit install for the given package
+ */
+async function updateHermitPackage(update: UpdateArtifact): Promise<void> {
+  logger.trace({ update }, `hermit.updateHermitPackage()`);
+
+  const toInstall = [];
+  const from = [];
+
+  for (const pkg of update.updatedDeps) {
+    if (!pkg.depName || !pkg.currentVersion || !pkg.newValue) {
+      logger.debug(
+        {
+          depName: pkg.depName,
+          currentVersion: pkg.currentVersion,
+          newValue: pkg.newValue,
+        },
+        'missing package update information'
+      );
+
+      throw new UpdateHermitError(
+        getHermitPackage(pkg.depName ?? '', pkg.currentVersion ?? ''),
+        getHermitPackage(pkg.depName ?? '', pkg.newValue ?? ''),
+        'invalid package to update'
+      );
+    }
+
+    const depName = pkg.depName;
+    const currentVersion = pkg.currentVersion;
+    const newValue = pkg.newValue;
+    const fromPackage = getHermitPackage(depName, currentVersion);
+    const toPackage = getHermitPackage(depName, newValue);
+    toInstall.push(toPackage);
+    from.push(fromPackage);
+  }
+
+  const execOptions: ExecOptions = {
+    docker: {
+      image: 'sidecar',
+    },
+    cwdFile: update.packageFileName,
+  };
+
+  const packagesToInstall = toInstall.join(' ');
+  const fromPackages = from.join(' ');
+
+  const execCommands = `./hermit install ${packagesToInstall}`;
+  logger.debug(
+    {
+      packageFile: update.packageFileName,
+      packagesToInstall: packagesToInstall,
+    },
+    `performing updates`
+  );
+
+  try {
+    const result = await exec(execCommands, execOptions);
+    logger.trace({ stdout: result.stdout }, `hermit command stdout`);
+  } catch (e) {
+    logger.warn({ err: e }, `error updating hermit package`);
+    throw new UpdateHermitError(
+      fromPackages,
+      packagesToInstall,
+      e.stderr,
+      e.stdout
+    );
+  }
+}
+
+export class UpdateHermitError extends Error {
+  stdout: string;
+  stderr: string;
+  from: string;
+  to: string;
+
+  constructor(from: string, to: string, stderr: string, stdout = '') {
+    super();
+    this.stdout = stdout;
+    this.stderr = stderr;
+    this.from = from;
+    this.to = to;
+  }
+}
diff --git a/lib/modules/manager/hermit/default-config.spec.ts b/lib/modules/manager/hermit/default-config.spec.ts
new file mode 100644
index 0000000000..22546f69c6
--- /dev/null
+++ b/lib/modules/manager/hermit/default-config.spec.ts
@@ -0,0 +1,50 @@
+import minimatch from 'minimatch';
+import { regEx } from '../../../util/regex';
+import { defaultConfig } from './default-config';
+
+describe('modules/manager/hermit/default-config', () => {
+  describe('excludeCommitPaths', () => {
+    function miniMatches(target: string, patterns: string[]): boolean {
+      return patterns.some((patt: string) => {
+        return minimatch(target, patt, { dot: true });
+      });
+    }
+
+    test.each`
+      path                          | expected
+      ${'bin/hermit'}               | ${true}
+      ${'gradle/bin/hermit'}        | ${true}
+      ${'nested/module/bin/hermit'} | ${true}
+      ${'nested/testbin/hermit'}    | ${false}
+      ${'other'}                    | ${false}
+      ${'nested/other'}             | ${false}
+      ${'nested/module/other'}      | ${false}
+    `('minimatches("$path") === $expected', ({ path, expected }) => {
+      expect(miniMatches(path, defaultConfig.excludeCommitPaths)).toBe(
+        expected
+      );
+    });
+  });
+
+  describe('fileMatch', () => {
+    function regexMatches(target: string, patterns: string[]): boolean {
+      return patterns.some((patt: string) => {
+        const re = regEx(patt);
+        return re.test(target);
+      });
+    }
+
+    test.each`
+      path                          | expected
+      ${'bin/hermit'}               | ${true}
+      ${'gradle/bin/hermit'}        | ${true}
+      ${'nested/module/bin/hermit'} | ${true}
+      ${'nested/testbin/hermit'}    | ${false}
+      ${'other'}                    | ${false}
+      ${'nested/other'}             | ${false}
+      ${'nested/module/other'}      | ${false}
+    `('regexMatches("$path") === $expected', ({ path, expected }) => {
+      expect(regexMatches(path, defaultConfig.fileMatch)).toBe(expected);
+    });
+  });
+});
diff --git a/lib/modules/manager/hermit/default-config.ts b/lib/modules/manager/hermit/default-config.ts
new file mode 100644
index 0000000000..2d2f5c9a02
--- /dev/null
+++ b/lib/modules/manager/hermit/default-config.ts
@@ -0,0 +1,6 @@
+export const defaultConfig = {
+  fileMatch: ['(^|/)bin/hermit$'],
+  // bin/hermit will be changed to trigger artifact update
+  // but it doesn't need to be committed
+  excludeCommitPaths: ['**/bin/hermit'],
+};
diff --git a/lib/modules/manager/hermit/extract.spec.ts b/lib/modules/manager/hermit/extract.spec.ts
new file mode 100644
index 0000000000..94a04acd40
--- /dev/null
+++ b/lib/modules/manager/hermit/extract.spec.ts
@@ -0,0 +1,83 @@
+import { mockedFunction } from '../../../../test/util';
+import { readLocalDirectory } from '../../../util/fs';
+import { HermitDatasource } from '../../datasource/hermit';
+import { extractPackageFile } from './extract';
+
+jest.mock('../../../util/fs');
+
+const readdirMock = mockedFunction(readLocalDirectory);
+
+describe('modules/manager/hermit/extract', () => {
+  describe('extractPackageFile', () => {
+    it('should list packages on command success', async () => {
+      const ret = [
+        '.go-1.17.9.pkg',
+        'go',
+        '.golangci-lint-1.40.0.pkg',
+        'golangci-lint',
+        '.jq@stable.pkg',
+        'jq',
+        '.somepackage-invalid-version.pkg',
+      ];
+      readdirMock.mockResolvedValue(ret);
+
+      const rootPackages = await extractPackageFile('', 'bin/hermit');
+      expect(rootPackages).toStrictEqual({
+        deps: [
+          {
+            datasource: HermitDatasource.id,
+            depName: 'go',
+            currentValue: `1.17.9`,
+          },
+          {
+            datasource: HermitDatasource.id,
+            depName: 'golangci-lint',
+            currentValue: `1.40.0`,
+          },
+          {
+            datasource: HermitDatasource.id,
+            depName: 'jq',
+            currentValue: `@stable`,
+          },
+        ],
+      });
+
+      const nestedRet = [
+        '.gradle-7.4.2.pkg',
+        'go',
+        '.openjdk-11.0.11_9-zulu11.48.21.pkg',
+        'java',
+        '.maven@3.8.pkg',
+        'maven',
+      ];
+      readdirMock.mockResolvedValue(nestedRet);
+      const nestedPackages = await extractPackageFile('', 'nested/bin/hermit');
+      expect(nestedPackages).toStrictEqual({
+        deps: [
+          {
+            datasource: HermitDatasource.id,
+            depName: 'gradle',
+            currentValue: '7.4.2',
+          },
+          {
+            datasource: HermitDatasource.id,
+            depName: 'openjdk',
+            currentValue: `11.0.11_9-zulu11.48.21`,
+          },
+          {
+            datasource: HermitDatasource.id,
+            depName: 'maven',
+            currentValue: '@3.8',
+          },
+        ],
+      });
+    });
+
+    it('should throw error on execution failure', async () => {
+      const msg = 'error reading directory';
+      readdirMock.mockRejectedValue(new Error(msg));
+
+      expect(await extractPackageFile('', 'bin/hermit')).toBeNull();
+    });
+  });
+});
diff --git a/lib/modules/manager/hermit/extract.ts b/lib/modules/manager/hermit/extract.ts
new file mode 100644
index 0000000000..d53806ea97
--- /dev/null
+++ b/lib/modules/manager/hermit/extract.ts
@@ -0,0 +1,106 @@
+import minimatch from 'minimatch';
+import upath from 'upath';
+import { logger } from '../../../logger';
+import { readLocalDirectory } from '../../../util/fs';
+import { regEx } from '../../../util/regex';
+import { HermitDatasource } from '../../datasource/hermit';
+import type { PackageDependency, PackageFile } from '../types';
+import type { HermitListItem } from './types';
+
+const pkgReferenceRegex = regEx(`(?<packageName>.*?)-(?<version>[0-9]{1}.*)`);
+
+/**
+ * extractPackageFile scans the folder of the package files
+ * and looking for .{packageName}-{version}.pkg
+ */
+export async function extractPackageFile(
+  content: string,
+  filename: string
+): Promise<PackageFile | null> {
+  logger.trace('hermit.extractPackageFile()');
+  const dependencies = [] as PackageDependency[];
+  const packages = await listHermitPackages(filename);
+
+  if (!packages?.length) {
+    return null;
+  }
+
+  for (const p of packages) {
+    // version of a hermit package is either a Version or a Channel
+    // Channel will prepend with @ to distinguish from normal version
+    const version = p.Version === '' ? `@${p.Channel}` : p.Version;
+
+    const dep: PackageDependency = {
+      datasource: HermitDatasource.id,
+      depName: p.Name,
+      currentValue: version,
+    };
+
+    dependencies.push(dep);
+  }
+
+  return { deps: dependencies };
+}
+
+/**
+ * listHermitPackages will fetch all installed packages from the bin folder
+ */
+async function listHermitPackages(
+  hermitFile: string
+): Promise<HermitListItem[] | null> {
+  logger.trace('hermit.listHermitPackages()');
+  const hermitFolder = upath.dirname(hermitFile);
+
+  let files: string[] = [];
+
+  try {
+    files = await readLocalDirectory(hermitFolder);
+  } catch (e) {
+    logger.debug(
+      { hermitFolder, err: e },
+      'error listing hermit package references'
+    );
+    return null;
+  }
+
+  logger.trace({ files, hermitFolder }, 'files for hermit package list');
+
+  const out = [] as HermitListItem[];
+
+  for (const f of files) {
+    if (!minimatch(f, '.*.pkg')) {
+      continue;
+    }
+
+    const fileName = f
+      .replace(`${hermitFolder}/`, '')
+      .substring(1)
+      .replace(/\.pkg$/, '');
+    const channelParts = fileName.split('@');
+
+    if (channelParts.length > 1) {
+      out.push({
+        Name: channelParts[0],
+        Channel: channelParts[1],
+        Version: '',
+      });
+    }
+
+    const groups = pkgReferenceRegex.exec(fileName)?.groups;
+    if (!groups) {
+      logger.debug(
+        { fileName },
+        'invalid hermit package reference file name found'
+      );
+      continue;
+    }
+
+    out.push({
+      Name: groups.packageName,
+      Version: groups.version,
+      Channel: '',
+    });
+  }
+
+  return out;
+}
diff --git a/lib/modules/manager/hermit/index.ts b/lib/modules/manager/hermit/index.ts
new file mode 100644
index 0000000000..c44f7cd54a
--- /dev/null
+++ b/lib/modules/manager/hermit/index.ts
@@ -0,0 +1,14 @@
+import { HermitDatasource } from '../../datasource/hermit';
+import { id as versionId } from '../../versioning/hermit';
+import { defaultConfig as partialDefaultConfig } from './default-config';
+export { updateArtifacts } from './artifacts';
+export { extractPackageFile } from './extract';
+export { updateDependency } from './update';
+
+export const defaultConfig = {
+  fileMatch: partialDefaultConfig.fileMatch,
+  excludeCommitPaths: partialDefaultConfig.excludeCommitPaths,
+  versioning: versionId,
+};
+
+export const supportedDatasources = [HermitDatasource.id];
diff --git a/lib/modules/manager/hermit/readme.md b/lib/modules/manager/hermit/readme.md
new file mode 100644
index 0000000000..596ce5a618
--- /dev/null
+++ b/lib/modules/manager/hermit/readme.md
@@ -0,0 +1,25 @@
+**_Hermit package installation token_**
+
+When upgrading private packages through, Hermit manager will uses one of the following two tokens to download private packages.
+
+```
+HERMIT_GITHUB_TOKEN
+GITHUB_TOKEN
+```
+
+These environment variable could be passed on via setting it in `customEnvironmentVariables`.
+
+**_Nested Hermit setup_**
+
+Nested Hermit setup in a single repository is also supported. e.g.
+
+```
+├bin
+├─hermit
+├─(other files)
+├
+├nested
+├─bin
+├──hermit
+├──(other files)
+```
diff --git a/lib/modules/manager/hermit/types.ts b/lib/modules/manager/hermit/types.ts
new file mode 100644
index 0000000000..60c5642f3b
--- /dev/null
+++ b/lib/modules/manager/hermit/types.ts
@@ -0,0 +1,17 @@
+export interface HermitListItem {
+  Name: string;
+  Version: string;
+  Channel: string;
+}
+
+export interface UpdateHermitResult {
+  from: string;
+  to: string;
+  newContent: string;
+}
+
+export interface ReadContentResult {
+  isSymlink?: boolean;
+  contents: string;
+  isExecutable?: boolean;
+}
diff --git a/lib/modules/manager/hermit/update.spec.ts b/lib/modules/manager/hermit/update.spec.ts
new file mode 100644
index 0000000000..e38494535e
--- /dev/null
+++ b/lib/modules/manager/hermit/update.spec.ts
@@ -0,0 +1,21 @@
+import { updateDependency } from '.';
+
+describe('modules/manager/hermit/update', () => {
+  describe('updateDependency', () => {
+    it('should append a new marking line at the end to trigger the artifact update', () => {
+      const fileContent = `#!/bin/bash
+#some hermit content
+`;
+      const ret = updateDependency({ fileContent, upgrade: {} });
+      expect(ret).toBe(`${fileContent}\n#hermit updated`);
+    });
+
+    it('should not update again if the new line has been appended', () => {
+      const fileContent = `#!/bin/bash
+#some hermit content
+#hermit updated`;
+      const ret = updateDependency({ fileContent, upgrade: {} });
+      expect(ret).toBe(`${fileContent}`);
+    });
+  });
+});
diff --git a/lib/modules/manager/hermit/update.ts b/lib/modules/manager/hermit/update.ts
new file mode 100644
index 0000000000..603f17aa83
--- /dev/null
+++ b/lib/modules/manager/hermit/update.ts
@@ -0,0 +1,22 @@
+import { logger } from '../../../logger';
+import type { UpdateDependencyConfig } from '../types';
+
+const updateLine = '#hermit updated';
+
+/**
+ * updateDependency appends a comment line once.
+ * This is only for the purpose of triggering the artifact update
+ * Hermit doesn't have a package file to update like other package managers.
+ */
+export function updateDependency({
+  fileContent,
+  upgrade,
+}: UpdateDependencyConfig): string | null {
+  logger.trace({ upgrade }, `hermit.updateDependency()`);
+  if (!fileContent.endsWith(updateLine)) {
+    logger.debug(`append update line to the fileContent if it hasn't been`);
+    return `${fileContent}\n${updateLine}`;
+  }
+
+  return fileContent;
+}
-- 
GitLab