From c3a99ebd662bcd64dd120138bc2da8018ce3ff1f Mon Sep 17 00:00:00 2001 From: Parker Holladay Date: Thu, 26 Dec 2024 21:28:01 -0700 Subject: [PATCH] Update husky compatibility - husky has started using the `git config --local core.hooksPath` to setup a local hooks directory instead of managing hooks in the `.git/hooks` dir --- src/common/services/__specs__/git.spec.js | 180 ++++++++++----- src/common/services/__specs__/repo.spec.js | 69 +++--- src/common/services/git.js | 72 ++++-- src/common/services/repo.js | 13 +- src/common/utils/__specs__/install.spec.js | 53 ++++- src/common/utils/install.js | 247 +++++++++++---------- 6 files changed, 409 insertions(+), 225 deletions(-) diff --git a/src/common/services/__specs__/git.spec.js b/src/common/services/__specs__/git.spec.js index 67ea19c..e23010e 100644 --- a/src/common/services/__specs__/git.spec.js +++ b/src/common/services/__specs__/git.spec.js @@ -7,17 +7,15 @@ import * as exec from '../../utils/exec' import sandbox from '../../../../test/sandbox' describe('services/git', () => { - let execResult - - beforeEach(() => { - execResult = '' - sandbox.stub(exec, 'execute').callsFake(() => execResult) - }) afterEach(() => { sandbox.restore() }) describe('#setAuthor', () => { + beforeEach(() => { + sandbox.stub(exec, 'execute') + }) + it('executes a git command to set author name and email', () => { subject.setAuthor('author-name', 'author-email') @@ -27,6 +25,10 @@ describe('services/git', () => { }) describe('#setCoAuthors', () => { + beforeEach(() => { + sandbox.stub(exec, 'execute') + }) + it('executes a git command to set co-author(s)', () => { const coAuthors = [ { name: 'co-author-1', email: 'co-author1@email.com' }, @@ -53,6 +55,7 @@ describe('services/git', () => { beforeEach(() => { sandbox.stub(subject, 'setAuthor') sandbox.stub(subject, 'setCoAuthors') + sandbox.stub(exec, 'execute') users = [{ name: 'First User', @@ -128,6 +131,10 @@ describe('services/git', () => { }) describe('#setGitLogAlias', () => { + beforeEach(() => { + sandbox.stub(exec, 'execute') + }) + it('executes a git command to set the `git lg` alias', () => { subject.setGitLogAlias('path/to/git/log/script') expect(exec.execute).to.have.been.calledWith('git config --global alias.lg "!path/to/git/log/script"') @@ -141,9 +148,12 @@ describe('services/git', () => { describe('#initRepo', () => { let repoPath + let repoHooksPath + let localHooksPath let pathExists let repoExists let submoduleExists + let submoduleStatus let isSubmoduleDir let postCommitExists let postCommitPath @@ -152,21 +162,29 @@ describe('services/git', () => { beforeEach(() => { repoPath = '/repo/path' + repoHooksPath = path.join(repoPath, '.git', 'hooks') + localHooksPath = null pathExists = true repoExists = true submoduleExists = false isSubmoduleDir = true postCommitExists = false - postCommitPath = path.join(repoPath, '.git', 'hooks', 'post-commit') - postCommitGitCollabPath = path.join(repoPath, '.git', 'hooks', 'post-commit.git-collab') + postCommitPath = path.join(repoHooksPath, 'post-commit') + postCommitGitCollabPath = path.join(repoHooksPath, 'git-collab', 'post-commit') gitHookPath = path.join(subject.GIT_COLLAB_PATH, 'post-commit') sandbox.stub(fs, 'existsSync') .withArgs(repoPath).callsFake(() => pathExists) .withArgs(path.join(repoPath, '.git')).callsFake(() => repoExists) .withArgs(path.join(repoPath, '.git', 'modules')).callsFake(() => submoduleExists) - .withArgs(path.join(repoPath, '.git', 'hooks', 'post-commit')).callsFake(() => postCommitExists) + .withArgs(path.join(repoHooksPath, 'post-commit')).callsFake(() => postCommitExists) sandbox.stub(fs, 'statSync').callsFake(() => ({ isDirectory: () => isSubmoduleDir })) + sandbox.stub(exec, 'execute') + .withArgs('git submodule status', { cwd: repoPath }).callsFake(() => submoduleStatus) + .withArgs('git config --local core.hooksPath', { cwd: repoPath }).callsFake(() => { + if (!localHooksPath) throw new Error() + return `${localHooksPath}\n` + }) }) describe('when path is a git repo', () => { @@ -181,23 +199,29 @@ describe('services/git', () => { .withArgs(postCommitPath).callsFake(() => existingPostCommitScript) .withArgs(gitHookPath).callsFake(() => gitHookContents) sandbox.stub(fs, 'writeFileSync') + sandbox.stub(fs, 'mkdirSync') }) - it('copies the post-commit.git-collab file', () => { + it('creates the git-collab directory', () => { + subject.initRepo(repoPath) + expect(fs.mkdirSync).to.have.been.calledWith(path.join(repoHooksPath, 'git-collab'), { recursive: true }) + }) + + it('copies the git-collab/post-commit file', () => { subject.initRepo(repoPath) expect(fs.readFileSync).to.have.been.calledWith(gitHookPath, 'utf-8') expect(fs.writeFileSync).to.have.been.calledWith(postCommitGitCollabPath, gitHookContents, { encoding: 'utf-8', mode: 0o755 }) }) - it('writes post-commit file to call post-commit.git-collab', () => { + it('writes post-commit file to call git-collab/post-commit', () => { subject.initRepo(repoPath) expect(fs.writeFileSync).to.have.been.calledWith(postCommitPath, subject.POST_COMMIT_BASE, { encoding: 'utf-8', mode: 0o755 }) }) - it('returns true', () => { - const success = subject.initRepo(repoPath) - expect(success).to.be.true + it('returns valid', () => { + const actual = subject.initRepo(repoPath) + expect(actual).to.deep.equal({ hooksPath: repoHooksPath, isValid: true }) }) describe('when post-commit already exists', () => { @@ -209,10 +233,10 @@ describe('services/git', () => { existingPostCommitScript = '#!/bin/bash\n\necho "Committed"' const expected = `${subject.POST_COMMIT_BASE}\n\necho "Committed"` - const success = subject.initRepo(repoPath) + const actual = subject.initRepo(repoPath) expect(fs.writeFileSync).to.have.been.calledWith(postCommitPath, expected, { encoding: 'utf-8', mode: 0o755 }) - expect(success).to.be.true + expect(actual).to.deep.equal({ hooksPath: repoHooksPath, isValid: true }) }) describe('when post-commit contains call to git-switch', () => { @@ -220,18 +244,70 @@ describe('services/git', () => { existingPostCommitScript = subject.GIT_SWITCH_POST_COMMIT_BASE sandbox.stub(fs, 'unlinkSync') - const success = subject.initRepo(repoPath) + const actual = subject.initRepo(repoPath) + + expect(fs.writeFileSync).to.have.been.calledWith(postCommitPath, subject.POST_COMMIT_BASE, { encoding: 'utf-8', mode: 0o755 }) + expect(fs.unlinkSync).to.have.been.calledWith(path.join(repoHooksPath, 'post-commit.git-switch')) + expect(actual).to.deep.equal({ hooksPath: repoHooksPath, isValid: true }) + }) + }) + + describe('when the post-commit contains old post-commit.git-collab', () => { + it('replaces post-commit.git-collab hook with git-collab/post-commit', () => { + existingPostCommitScript = subject.GIT_SWITCH_POST_COMMIT_BASE + sandbox.stub(fs, 'unlinkSync') + + const actual = subject.initRepo(repoPath) expect(fs.writeFileSync).to.have.been.calledWith(postCommitPath, subject.POST_COMMIT_BASE, { encoding: 'utf-8', mode: 0o755 }) - expect(fs.unlinkSync).to.have.been.calledWith(path.join(repoPath, '.git', 'hooks', 'post-commit.git-switch')) - expect(success).to.be.true + expect(fs.unlinkSync).to.have.been.calledWith(path.join(repoHooksPath, 'post-commit.git-collab')) + expect(actual).to.deep.equal({ hooksPath: repoHooksPath, isValid: true }) + }) + }) + }) + + describe('when the repo has a local hooksPath', () => { + beforeEach(() => { + localHooksPath = '.some-tool/hooks' + repoHooksPath = path.join(repoPath, '.some-tool', 'hooks') + }) + + it('uses the local hooksPath', () => { + const actual = subject.initRepo(repoPath) + + expect(fs.readFileSync).to.have.been.calledWith(gitHookPath, 'utf-8') + expect(exec.execute).to.have.been.calledWith('git config --local core.hooksPath', { cwd: repoPath }) + expect(fs.writeFileSync).to.have.been.calledWith(path.join(repoHooksPath, 'post-commit'), subject.POST_COMMIT_BASE, { encoding: 'utf-8', mode: 0o755 }) + expect(fs.writeFileSync).to.have.been.calledWith(path.join(repoHooksPath, 'git-collab', 'post-commit'), gitHookContents, { encoding: 'utf-8', mode: 0o755 }) + expect(actual).to.deep.equal({ hooksPath: repoHooksPath, isValid: true }) + }) + + it('adds `.gitignore` to local `git-collab` directory', () => { + const actual = subject.initRepo(repoPath) + + expect(fs.writeFileSync).to.have.been.calledWith(path.join(repoHooksPath, 'git-collab', '.gitignore'), '*', { encoding: 'utf-8' }) + expect(actual).to.deep.equal({ hooksPath: repoHooksPath, isValid: true }) + }) + + describe('when the local hooksPath is .husky', () => { + beforeEach(() => { + localHooksPath = '.husky/_' + repoHooksPath = path.join(repoPath, '.husky') + }) + + it('uses the .husky path', () => { + const actual = subject.initRepo(repoPath) + + expect(fs.writeFileSync).to.have.been.calledWith(path.join(repoHooksPath, 'post-commit'), subject.POST_COMMIT_BASE, { encoding: 'utf-8', mode: 0o755 }) + expect(fs.writeFileSync).to.have.been.calledWith(path.join(repoHooksPath, 'git-collab', 'post-commit'), gitHookContents, { encoding: 'utf-8', mode: 0o755 }) + expect(fs.writeFileSync).to.have.been.calledWith(path.join(repoHooksPath, 'git-collab', '.gitignore'), '*', { encoding: 'utf-8' }) + expect(actual).to.deep.equal({ hooksPath: repoHooksPath, isValid: true }) }) }) }) describe('when sub-modules exist', () => { let submoduleDirs - let submoduleStatus let submodule1GitHooksPath let submodule2GitHooksPath let submodule3GitHooksPath @@ -245,26 +321,24 @@ describe('services/git', () => { submodule1GitHooksPath = path.join(repoPath, '.git', 'modules', 'mod1', 'hooks') submodule2GitHooksPath = path.join(repoPath, '.git', 'modules', 'subdir', 'mod2', 'hooks') submodule3GitHooksPath = path.join(repoPath, '.git', 'modules', 'subdir', 'mod2', 'hooks') - - execResult = submoduleStatus }) it('installs post-commit files in sub-modules', () => { - const success = subject.initRepo(repoPath) + const actual = subject.initRepo(repoPath) expect(fs.readFileSync).to.have.been.calledWith(gitHookPath, 'utf-8') expect(exec.execute).to.have.been.calledWith('git submodule status') - expect(fs.writeFileSync).to.have.been.calledWith(path.join(submodule1GitHooksPath, 'post-commit.git-collab'), gitHookContents, { encoding: 'utf-8', mode: 0o755 }) expect(fs.writeFileSync).to.have.been.calledWith(path.join(submodule1GitHooksPath, 'post-commit'), subject.POST_COMMIT_BASE, { encoding: 'utf-8', mode: 0o755 }) + expect(fs.writeFileSync).to.have.been.calledWith(path.join(submodule1GitHooksPath, 'git-collab', 'post-commit'), gitHookContents, { encoding: 'utf-8', mode: 0o755 }) - expect(fs.writeFileSync).to.have.been.calledWith(path.join(submodule2GitHooksPath, 'post-commit.git-collab'), gitHookContents, { encoding: 'utf-8', mode: 0o755 }) expect(fs.writeFileSync).to.have.been.calledWith(path.join(submodule2GitHooksPath, 'post-commit'), subject.POST_COMMIT_BASE, { encoding: 'utf-8', mode: 0o755 }) + expect(fs.writeFileSync).to.have.been.calledWith(path.join(submodule2GitHooksPath, 'git-collab', 'post-commit'), gitHookContents, { encoding: 'utf-8', mode: 0o755 }) - expect(fs.writeFileSync).to.have.been.calledWith(path.join(submodule3GitHooksPath, 'post-commit.git-collab'), gitHookContents, { encoding: 'utf-8', mode: 0o755 }) expect(fs.writeFileSync).to.have.been.calledWith(path.join(submodule3GitHooksPath, 'post-commit'), subject.POST_COMMIT_BASE, { encoding: 'utf-8', mode: 0o755 }) + expect(fs.writeFileSync).to.have.been.calledWith(path.join(submodule3GitHooksPath, 'git-collab', 'post-commit'), gitHookContents, { encoding: 'utf-8', mode: 0o755 }) - expect(success).to.be.true + expect(actual).to.deep.equal({ hooksPath: repoHooksPath, isValid: true }) }) }) }) @@ -272,64 +346,67 @@ describe('services/git', () => { describe('when path does not exist', () => { it('return false', () => { pathExists = false - expect(subject.initRepo(repoPath)).to.be.false + expect(subject.initRepo(repoPath)).to.deep.equal({ isValid: false }) }) }) describe('when path is not a git repo', () => { - it('return false', () => { + it('return invalid result', () => { repoExists = false - expect(subject.initRepo(repoPath)).to.be.false + expect(subject.initRepo(repoPath)).to.deep.equal({ isValid: false }) }) }) }) describe('#removeRepo', () => { - const postCommitGitCollab = '\n\n/bin/bash "$(dirname $0)"/post-commit.git-collab' + const postCommitGitCollab = '\n\n[ -f "$(dirname $0)/git-collab/post-commit" ] && . $(dirname $0)/git-collab/post-commit' let postCommitScript let repoPath + let repoHooksPath let submoduleExists let postCommitExists let postCommitGitCollabExists - let postCommitGitCollabPath beforeEach(() => { repoPath = '/repo/path' + repoHooksPath = path.join(repoPath, '.some-tool', 'hooks') submoduleExists = false postCommitExists = true postCommitGitCollabExists = true - postCommitScript = `#!/bin/bash${postCommitGitCollab}\n\necho "Committed"` - postCommitGitCollabPath = path.join(repoPath, '.git', 'hooks', 'post-commit.git-collab') + postCommitScript = `#!/usr/bin/env sh${postCommitGitCollab}\n\necho "Committed"` sandbox.stub(fs, 'existsSync') .withArgs(path.join(repoPath, '.git', 'modules')).callsFake(() => submoduleExists) - .withArgs(path.join(repoPath, '.git', 'modules', 'mod1', 'hooks', 'post-commit.git-collab')).callsFake(() => submoduleExists) .withArgs(path.join(repoPath, '.git', 'modules', 'mod1', 'hooks', 'post-commit')).callsFake(() => submoduleExists) - .withArgs(path.join(repoPath, '.git', 'modules')).callsFake(() => submoduleExists) - .withArgs(path.join(repoPath, '.git', 'hooks', 'post-commit')).callsFake(() => postCommitExists) - .withArgs(postCommitGitCollabPath).callsFake(() => postCommitGitCollabExists) - sandbox.stub(fs, 'unlinkSync') + .withArgs(path.join(repoPath, '.git', 'modules', 'mod1', 'hooks', 'git-collab')).callsFake(() => submoduleExists) + .withArgs(path.join(repoHooksPath, 'post-commit')).callsFake(() => postCommitExists) + .withArgs(path.join(repoHooksPath, 'git-collab')).callsFake(() => postCommitGitCollabExists) sandbox.stub(fs, 'readFileSync').callsFake(() => postCommitScript) + sandbox.stub(fs, 'unlinkSync') + sandbox.stub(fs, 'rmdirSync') sandbox.stub(fs, 'writeFileSync') }) - it('deletes the post-commit.git-collab file', () => { - subject.removeRepo(repoPath) - expect(fs.unlinkSync).to.have.been.calledWith(postCommitGitCollabPath) + it('deletes the git-collab dir', () => { + subject.removeRepo(repoPath, repoHooksPath) + expect(fs.rmdirSync).to.have.been.calledWith(path.join(repoHooksPath, 'git-collab')) }) it('removes the git collab call in post-commit', () => { const expected = postCommitScript.replace(postCommitGitCollab, '') - subject.removeRepo(repoPath) - expect(fs.writeFileSync).to.have.been.calledWith(path.join(repoPath, '.git', 'hooks', 'post-commit'), expected, { encoding: 'utf-8', mode: 0o755 }) + subject.removeRepo(repoPath, repoHooksPath) + expect(fs.writeFileSync).to.have.been.calledWith(path.join(repoHooksPath, 'post-commit'), expected, { encoding: 'utf-8', mode: 0o755 }) }) describe('when no other post-commit hooks exist', () => { + beforeEach(() => { + postCommitScript = `#!/usr/bin/env sh${postCommitGitCollab}` + }) + it('deletes the post-commit hook', () => { - postCommitScript = `#!/bin/bash${postCommitGitCollab}` - subject.removeRepo(repoPath) - expect(fs.unlinkSync).to.have.been.calledWith(postCommitGitCollabPath) - expect(fs.unlinkSync).to.have.been.calledWith(path.join(repoPath, '.git', 'hooks', 'post-commit')) + subject.removeRepo(repoPath, repoHooksPath) + expect(fs.unlinkSync).to.have.been.calledWith(path.join(repoHooksPath, 'post-commit')) + expect(fs.rmdirSync).to.have.been.calledWith(path.join(repoHooksPath, 'git-collab')) }) }) @@ -337,18 +414,19 @@ describe('services/git', () => { const submoduleDirs = ['mod1'] beforeEach(() => { + repoHooksPath = path.join(repoPath, '.git', 'hooks') submoduleExists = true - postCommitScript = `#!/bin/bash${postCommitGitCollab}` + postCommitScript = `#!/usr/bin/env sh${postCommitGitCollab}` sandbox.stub(fs, 'readdirSync').callsFake(() => submoduleDirs) }) it('removes post-commit files in sub-modules', () => { const submodule1GitHooksPath = path.join(repoPath, '.git', 'modules', 'mod1', 'hooks') - subject.removeRepo(repoPath) + subject.removeRepo(repoPath, repoHooksPath) - expect(fs.unlinkSync).to.have.been.calledWith(path.join(submodule1GitHooksPath, 'post-commit.git-collab')) expect(fs.unlinkSync).to.have.been.calledWith(path.join(submodule1GitHooksPath, 'post-commit')) + expect(fs.rmdirSync).to.have.been.calledWith(path.join(submodule1GitHooksPath, 'git-collab')) }) }) }) diff --git a/src/common/services/__specs__/repo.spec.js b/src/common/services/__specs__/repo.spec.js index da10d54..9128a27 100644 --- a/src/common/services/__specs__/repo.spec.js +++ b/src/common/services/__specs__/repo.spec.js @@ -10,8 +10,8 @@ describe('services/repo', () => { beforeEach(() => { repos = [ - { name: 'one', path: '/repo/one', isValid: false }, - { name: 'two', path: '/repo/two', isValid: true } + { name: 'one', path: '/repo/one', hooksPath: 'repo/one/.git/hooks', isValid: false }, + { name: 'two', path: '/repo/two', hooksPath: 'repo/two/.git/hooks', isValid: true } ] config = { repos } @@ -35,73 +35,85 @@ describe('services/repo', () => { }) describe('#add', () => { - let didRepoSucceed + let repoPath + let initRepoResult beforeEach(() => { - didRepoSucceed = true + repoPath = '/foo/bar' + initRepoResult = { hooksPath: '/foo/bar/.git/hooks', isValid: true } sandbox.stub(configUtil, 'write') - sandbox.stub(gitService, 'initRepo').callsFake(() => didRepoSucceed) + sandbox.stub(gitService, 'initRepo').callsFake(() => initRepoResult) }) it('adds repo to config sorted by name', () => { - const newRepo = '/foo/bar' const expected = [ - { name: 'bar', path: newRepo, isValid: true }, + { name: 'bar', path: repoPath, hooksPath: initRepoResult.hooksPath, isValid: true }, ...repos ] - const actual = subject.add(newRepo) + const actual = subject.add(repoPath) - expect(gitService.initRepo).to.have.been.calledWith(newRepo) + expect(gitService.initRepo).to.have.been.calledWith(repoPath) expect(configUtil.write).to.have.been.calledWith({ repos: expected }) expect(actual).to.deep.equal(expected) }) describe('when a repo with the path already exists', () => { + beforeEach(() => { + repoPath = '/repo/one' + initRepoResult = { hooksPath: '/repo/one/.git/hooks', isValid: true } + }) + it('re-initializes the repo', () => { - const existingRepo = '/repo/one' const expected = [ - { name: 'one', path: existingRepo, isValid: true }, + { name: 'one', path: repoPath, hooksPath: initRepoResult.hooksPath, isValid: true }, repos[1] ] - const actual = subject.add(existingRepo) + const actual = subject.add(repoPath) - expect(gitService.initRepo).to.have.been.calledWith(existingRepo) + expect(gitService.initRepo).to.have.been.calledWith(repoPath) expect(configUtil.write).to.have.been.calledWith({ repos: expected }) expect(actual).to.deep.equal(expected) }) }) describe('when the repo has a trailing slash', () => { + beforeEach(() => { + repoPath = '/foo/bar/' + }) + it('adds removes the trailing slash', () => { - const newRepo = '/foo/bar/' - const modifiedRepo = '/foo/bar' + const expectedPath = '/foo/bar' const expected = [ - { name: 'bar', path: modifiedRepo, isValid: true }, + { name: 'bar', path: expectedPath, hooksPath: initRepoResult.hooksPath, isValid: true }, ...repos ] - const actual = subject.add(newRepo) + const actual = subject.add(repoPath) - expect(gitService.initRepo).to.have.been.calledWith(modifiedRepo) + expect(gitService.initRepo).to.have.been.calledWith(expectedPath) expect(configUtil.write).to.have.been.calledWith({ repos: expected }) expect(actual).to.deep.equal(expected) }) }) describe('when using windows paths', () => { + beforeEach(() => { + repoPath = 'C:\\foo\\bar' + initRepoResult = { hooksPath: 'C:\\foo\\bar\\.git\\hooks', isValid: true } + }) + it('adds repo to config sorted by name', () => { - const newRepo = 'C:\\foo\\bar' const expected = [ - { name: 'bar', path: newRepo, isValid: true }, + { name: 'bar', path: repoPath, hooksPath: initRepoResult.hooksPath, isValid: true }, ...repos ] - const actual = subject.add(newRepo) + const actual = subject.add(repoPath) - expect(gitService.initRepo).to.have.been.calledWith(newRepo) + expect(gitService.initRepo).to.have.been.calledWith(repoPath) expect(configUtil.write).to.have.been.calledWith({ repos: expected }) expect(actual).to.deep.equal(expected) }) @@ -109,12 +121,12 @@ describe('services/repo', () => { describe('when git service fails to init repo hooks', () => { beforeEach(() => { - didRepoSucceed = false + initRepoResult = { isValid: false } }) it('adds the repo with isValid set to false', () => { const expected = [ - { name: 'bar-2', path: '/foo/bar-2', isValid: false }, + { name: 'bar-2', path: '/foo/bar-2', hooksPath: '', isValid: false }, ...repos ] @@ -136,13 +148,14 @@ describe('services/repo', () => { it('removes the repo from config', () => { const repoToDelete = repos[1].path + const hooksPath = repos[1].hooksPath const expected = { repos: [repos[0]] } subject.remove(repoToDelete) - expect(gitService.removeRepo).to.have.been.calledWith(repoToDelete) + expect(gitService.removeRepo).to.have.been.calledWith(repoToDelete, hooksPath) expect(configUtil.write).to.have.been.calledWith(expected) }) @@ -157,6 +170,7 @@ describe('services/repo', () => { describe('when path has trailing slash', () => { it('removes the repo from config', () => { const normalizedPath = repos[1].path + const hooksPath = repos[1].hooksPath const repoToDelete = `${repos[1].path}/` const expected = { repos: [repos[0]] @@ -164,7 +178,7 @@ describe('services/repo', () => { subject.remove(repoToDelete) - expect(gitService.removeRepo).to.have.been.calledWith(normalizedPath) + expect(gitService.removeRepo).to.have.been.calledWith(normalizedPath, hooksPath) expect(configUtil.write).to.have.been.calledWith(expected) }) }) @@ -172,13 +186,14 @@ describe('services/repo', () => { describe('when repo hooks are not configured', () => { it('does not call git service', () => { const repoToDelete = repos[0].path + const hooksPath = repos[0].hooksPath const expected = { repos: [repos[1]] } subject.remove(repoToDelete) - expect(gitService.removeRepo).to.not.have.been.calledWith(repoToDelete) + expect(gitService.removeRepo).to.not.have.been.calledWith(repoToDelete, hooksPath) expect(configUtil.write).to.have.been.calledWith(expected) }) }) diff --git a/src/common/services/git.js b/src/common/services/git.js index fa3f80b..c7e02bc 100644 --- a/src/common/services/git.js +++ b/src/common/services/git.js @@ -6,7 +6,8 @@ import { execute, logger } from '../utils' export const GIT_COLLAB_PATH = path.join(os.homedir(), '.git-collab') export const GIT_SWITCH_POST_COMMIT_BASE = '#!/bin/bash\n\n/bin/bash "$(dirname $0)"/post-commit.git-switch' -export const POST_COMMIT_BASE = '#!/bin/bash\n\n/bin/bash "$(dirname $0)"/post-commit.git-collab' +export const POST_COMMIT_BASE_OLD = '#!/bin/bash\n\n/bin/bash "$(dirname $0)"/post-commit.git-collab' +export const POST_COMMIT_BASE = '#!/usr/bin/env sh\n\n[ -f "$(dirname $0)/git-collab/post-commit" ] && . $(dirname $0)/git-collab/post-commit' export const setAuthor = (name, email) => { execute(`git config --global user.name "${name}"`) @@ -38,10 +39,17 @@ export const setGitLogAlias = (scriptPath) => { const copyGitCollabPostCommit = (gitHooksPath) => { const source = path.join(GIT_COLLAB_PATH, 'post-commit') - const destination = path.join(gitHooksPath, 'post-commit.git-collab') + const gitCollabDir = path.join(gitHooksPath, 'git-collab') + fs.mkdirSync(gitCollabDir, { recursive: true }) + + const destination = path.join(gitCollabDir, 'post-commit') const postCommitContents = fs.readFileSync(source, 'utf-8') fs.writeFileSync(destination, postCommitContents, { encoding: 'utf-8', mode: 0o755 }) + + if (!gitHooksPath.match(/\.git\/hooks$/)) { + fs.writeFileSync(path.join(gitHooksPath, 'git-collab', '.gitignore'), '*', { encoding: 'utf-8' }) + } } const mergePostCommitScripts = (postCommitFile, gitHooksPath) => { @@ -50,6 +58,13 @@ const mergePostCommitScripts = (postCommitFile, gitHooksPath) => { postCommitScript = postCommitScript.replace('git-switch', 'git-collab') fs.unlinkSync(path.join(gitHooksPath, 'post-commit.git-switch')) } + if (postCommitScript.includes('post-commit.git-collab')) { + fs.unlinkSync(path.join(gitHooksPath, 'post-commit.git-collab')) + } + + if (postCommitScript.includes(POST_COMMIT_BASE_OLD)) { + postCommitScript = postCommitScript.replace(POST_COMMIT_BASE_OLD, POST_COMMIT_BASE) + } if (!postCommitScript.includes(POST_COMMIT_BASE)) { const temp = postCommitScript.substring(postCommitScript.indexOf('\n')) @@ -75,7 +90,7 @@ const addPostCommitFiles = (destination) => { const getSubmodulesForRepo = (repoPath) => { const submodulesStatus = execute('git submodule status', { cwd: repoPath }) - const statuses = (submodulesStatus && submodulesStatus.trim().split('\n')) || [] + const statuses = submodulesStatus?.toString().trim().split('\n') ?? [] return statuses.map((s) => s.trim().split(' ')[1]) } @@ -95,26 +110,45 @@ const addPostCommitFilesToSubModules = (repoPath) => { export const initRepo = (repoPath) => { if (!fs.existsSync(repoPath)) { - logger.error('The specified path does not exist') - return false + logger.error('Path not found:', repoPath) + return { isValid: false } } if (!fs.existsSync(path.join(repoPath, '.git'))) { - logger.error('The specified path does not contain a ".git" directory') - return false + logger.error('Path not a git repository:', repoPath) + return { isValid: false } } - logger.info(`Writing post-commit hook to repo "${repoPath}"`) + logger.info('Writing post-commit hook to repository:', repoPath) + + let hooksPath = path.join(repoPath, '.git', 'hooks') + try { + const localHooksPath = execute('git config --local core.hooksPath', { cwd: repoPath }) + hooksPath = localHooksPath + ? path.join(repoPath, localHooksPath.toString().trim()) + : hooksPath - addPostCommitFiles(path.join(repoPath, '.git', 'hooks')) + // Do not write to the managed husky directory + if (hooksPath.match(/\.husky\/_$/)) { + hooksPath = hooksPath.replace(/\.husky\/_$/, '.husky') + } + } catch (e) { + // no-op + } + + addPostCommitFiles(hooksPath) addPostCommitFilesToSubModules(repoPath) - return true + return { hooksPath, isValid: true } } const removeGitCollabPostCommitScript = (gitHooksPath) => { - const postCommitGitCollabFile = path.join(gitHooksPath, 'post-commit.git-collab') - if (fs.existsSync(postCommitGitCollabFile)) { - fs.unlinkSync(postCommitGitCollabFile) + const postCommitGitCollabDir = path.join(gitHooksPath, 'git-collab') + if (fs.existsSync(postCommitGitCollabDir)) { + fs.rmdirSync(postCommitGitCollabDir, { recursive: true, force: true }) + } + const oldPostCommitGitCollabFile = path.join(gitHooksPath, 'post-commit.git-collab') + if (fs.existsSync(oldPostCommitGitCollabFile)) { + fs.unlinkSync(oldPostCommitGitCollabFile) } } @@ -122,18 +156,20 @@ const removePostCommitScript = (gitHooksPath) => { const postCommitFile = path.join(gitHooksPath, 'post-commit') if (fs.existsSync(postCommitFile)) { let postCommitScript = fs.readFileSync(postCommitFile, 'utf-8') - if (postCommitScript === POST_COMMIT_BASE) { + if (postCommitScript === POST_COMMIT_BASE || postCommitScript === POST_COMMIT_BASE_OLD) { fs.unlinkSync(postCommitFile) } else { - postCommitScript = postCommitScript.replace(POST_COMMIT_BASE, '#!/bin/bash') + postCommitScript = postCommitScript + .replace(POST_COMMIT_BASE, '#!/usr/bin/env sh') + .replace(POST_COMMIT_BASE, '#!/usr/bin/env sh') fs.writeFileSync(postCommitFile, postCommitScript, { encoding: 'utf-8', mode: 0o755 }) } } } const removePostCommitFiles = (target) => { - removeGitCollabPostCommitScript(target) removePostCommitScript(target) + removeGitCollabPostCommitScript(target) } const removePostCommitFilesFromSubModules = (target) => { @@ -144,7 +180,7 @@ const removePostCommitFilesFromSubModules = (target) => { } } -export const removeRepo = (repoPath) => { - removePostCommitFiles(path.join(repoPath, '.git', 'hooks')) +export const removeRepo = (repoPath, hooksPath) => { + removePostCommitFiles(hooksPath) removePostCommitFilesFromSubModules(path.join(repoPath, '.git', 'modules')) } diff --git a/src/common/services/repo.js b/src/common/services/repo.js index bf572a7..070ba26 100644 --- a/src/common/services/repo.js +++ b/src/common/services/repo.js @@ -30,11 +30,11 @@ export const add = (path) => { repos = repos.filter((r) => r !== existingRepo) } - const isValid = gitService.initRepo(path) + const { hooksPath, isValid } = gitService.initRepo(path) return persist([ ...repos, - { name, path, isValid } + { name, path, hooksPath: hooksPath ?? '', isValid } ]) } @@ -48,11 +48,16 @@ export const remove = (path) => { return repos } - if (repos[foundIndex].isValid) { - gitService.removeRepo(path) + const foundRepo = repos[foundIndex] + if (foundRepo.isValid) { + gitService.removeRepo(path, foundRepo.hooksPath) } repos.splice(foundIndex, 1) return persist(repos) } + +export const update = (repos) => { + return persist(repos) +} diff --git a/src/common/utils/__specs__/install.spec.js b/src/common/utils/__specs__/install.spec.js index 0373add..6316a63 100644 --- a/src/common/utils/__specs__/install.spec.js +++ b/src/common/utils/__specs__/install.spec.js @@ -60,6 +60,7 @@ describe('utils/install', () => { .withArgs(GIT_LOG_CO_AUTHOR_FILE).callsFake(() => existingGitLogCoAuthorFileContents) .withArgs(GIT_SWITCH_CONFIG_FILE).callsFake(() => existingGitSwitchConfigFileContents) sandbox.stub(repoService, 'get').callsFake(() => existingRepos) + sandbox.stub(repoService, 'update') sandbox.stub(userService, 'get').callsFake(() => users) sandbox.stub(notificationService, 'showUpdateAvailable') sandbox.stub(userService, 'shortenUserIds') @@ -98,7 +99,7 @@ describe('utils/install', () => { subject(platform, appExecutablePath, appVersion) - expect(fs.mkdirSync).to.have.been.calledWith(GIT_COLLAB_PATH, 0o755) + expect(fs.mkdirSync).to.have.been.calledWith(GIT_COLLAB_PATH, { mode: 0o755 }) }) }) @@ -175,17 +176,27 @@ describe('utils/install', () => { }) describe('when the config file exists', () => { + let repoOneInitResult + let repoTwoInitResult + beforeEach(() => { - existingRepos = [{ path: 'repo/one' }, { path: 'repo/two' }] + existingRepos = [ + { path: 'repo/one', hooksPath: 'repo/one/.git/hooks', name: 'one', isValid: true }, + { path: 'repo/two', hooksPath: 'repo/two/.git/hooks', name: 'two', isValid: true } + ] + repoOneInitResult = { hooksPath: 'repo/one/.git/hooks', isValid: true } + repoTwoInitResult = { hooksPath: 'repo/two/.git/hooks', isValid: true } sandbox.stub(gitService, 'initRepo') + .withArgs(existingRepos[0].path).callsFake(() => repoOneInitResult) + .withArgs(existingRepos[1].path).callsFake(() => repoTwoInitResult) sandbox.stub(fs, 'writeFileSync') }) it('re-initializes all the repos', () => { subject(platform, appExecutablePath, appVersion) - expect(gitService.initRepo).to.have.been.calledWith('repo/one') - expect(gitService.initRepo).to.have.been.calledWith('repo/two') + expect(gitService.initRepo).to.have.been.calledWith(existingRepos[0].path) + expect(gitService.initRepo).to.have.been.calledWith(existingRepos[1].path) expect(gitService.initRepo).to.have.been.calledTwice }) @@ -208,8 +219,8 @@ describe('utils/install', () => { it('re-initializes all the repos', () => { subject(platform, appExecutablePath, appVersion) - expect(gitService.initRepo).to.have.been.calledWith('repo/one') - expect(gitService.initRepo).to.have.been.calledWith('repo/two') + expect(gitService.initRepo).to.have.been.calledWith(existingRepos[0].path) + expect(gitService.initRepo).to.have.been.calledWith(existingRepos[1].path) expect(gitService.initRepo).to.have.been.calledTwice }) @@ -236,6 +247,18 @@ describe('utils/install', () => { }) }) + describe('when git-log-co-author exists', () => { + it('does not create .git-collab/git-log-co-authors', () => { + subject(platform, appExecutablePath, appVersion) + expect(fs.writeFileSync).to.not.have.been.calledWith(GIT_LOG_CO_AUTHOR_FILE, gitLogCoAuthorFileContents, { encoding: 'utf-8', mode: 0o755 }) + }) + + it('creates a git log alias', () => { + subject(platform, appExecutablePath, appVersion) + expect(gitService.setGitLogAlias).to.have.been.calledWith(GIT_LOG_CO_AUTHOR_FILE) + }) + }) + describe('when git-log-co-author is outdated', () => { beforeEach(() => { existingGitLogCoAuthorFileContents = 'outdated-content-here' @@ -252,10 +275,22 @@ describe('utils/install', () => { }) }) - describe('when git-log-co-author exists', () => { - it('creates a git log alias', () => { + describe('when repo init returns updated result', () => { + beforeEach(() => { + existingRepos = [ + { hooksPath: 'repo/one/.git/hooks', isValid: false, name: 'one', path: 'repo/one' }, + { hooksPath: 'repo/two/.git/hooks', isValid: true, name: 'two', path: 'repo/two' } + ] + repoOneInitResult = { hooksPath: 'repo/one/.git/hooks', isValid: true } + repoTwoInitResult = { hooksPath: 'repo/two/.some-tool/hooks', isValid: true } + }) + + it('updates the repos', () => { subject(platform, appExecutablePath, appVersion) - expect(gitService.setGitLogAlias).to.have.been.calledWith(GIT_LOG_CO_AUTHOR_FILE) + expect(repoService.update).to.have.been.calledWith([ + { hooksPath: 'repo/one/.git/hooks', isValid: true, name: 'one', path: 'repo/one' }, + { hooksPath: 'repo/two/.some-tool/hooks', isValid: true, path: 'repo/two', name: 'two' } + ]) }) }) }) diff --git a/src/common/utils/install.js b/src/common/utils/install.js index 33eb308..3414834 100644 --- a/src/common/utils/install.js +++ b/src/common/utils/install.js @@ -27,7 +27,7 @@ export function install(platform, appExecutablePath, appVersion) { function installConfigFile() { if (!fs.existsSync(GIT_COLLAB_PATH)) { - fs.mkdirSync(GIT_COLLAB_PATH, 0o755) + fs.mkdirSync(GIT_COLLAB_PATH, { mode: 0o755 }) } if (!fs.existsSync(CONFIG_FILE)) { @@ -67,34 +67,43 @@ function getAutoRotateCommand(platform, appExecutablePath) { } export function getPostCommitHookScript(autoRotate) { - return `#!/bin/sh + return `#!/usr/bin/env sh -body=$(git log -1 HEAD --format="%b") -author=$(git log -1 HEAD --format="%an <%ae>") -co_authors_string=$(git config --global git-collab.co-authors) -co_authors=$(echo $co_authors_string | tr ";" "\n") +readonly co_authors=$(git config --global git-collab.co-authors | tr ';' '\\n') +[ -z "$co_authors" ] && exit 0 -echo -e "git-collab > Author:\\n $author" +readonly subject=$(git log -1 --format="%s") +readonly body=$(git log -1 --format="%b") +readonly author=$(git log -1 --format="%an <%ae>") -if [[ "$body" != *$co_authors ]]; then - subject=$(git log -1 HEAD --format="%s") +match_co_authors() { + _co_author_lines=$(printf "%s\\n" "$2" | wc -l) + _body_end=$(printf "%s" "$1" | tail -n "$_co_author_lines") - echo -e "git-collab > Co-Author(s):\\n\${co_authors//Co-Authored-By:/ }" - echo "" + [ "$_body_end" = "$2" ] +} + +match_co_authors "$body" "$co_authors" && exit 0 + +printf "git-collab > Author:\\n %s\\n" "$author" +printf "git-collab > Co-Author(s):\\n%s\\n\\n" "$(printf "%s" "$co_authors" | sed 's/^Co-Authored-By: / /g')" + +case "$body" in + ""|"Co-Authored-By:"*) + new_body=$co_authors + ;; + *) + new_body=\${body%%Co-Authored-By*} + new_body=$(printf "%s\\n\\n%s" "$new_body" "$co_authors") + ;; +esac - if [[ "$body" == Co-Authored-By* ]]; then - body=$co_authors - else - body=\${body//Co-Authored-By*/} - body="$body\n\n$co_authors" - fi +message="$(printf "%s\\n\\n%s" "$subject" "$new_body")" - git commit --amend --no-verify --message="$subject\n\n$body" +git commit --quiet --amend --no-verify --message="$message" - echo "" - echo "git-collab > Rotating author and co-author(s)" - ${autoRotate} -fi +printf "git-collab > Rotating author and co-author(s)\\n\\n" +${autoRotate} ` } @@ -110,130 +119,136 @@ function installPostCommitHook(autoRotate) { const repos = repoService.get() for (const repo of repos) { - gitService.initRepo(repo.path) + const { hooksPath, isValid } = gitService.initRepo(repo.path) + if (isValid !== repo.isValid || hooksPath !== repo.hooksPath) { + repo.hooksPath = hooksPath + repo.isValid = isValid + } } + + repoService.update(repos) } export function getGitLogCoAuthorScript() { - return `#!/bin/bash + return `#!/usr/bin/env sh -# Pretty formatting for git logs with github's co-author support. +# Pretty formatting for git logs with github's co-author support -this_ifs=$'\\037' -begin_commit="---begin_commit---" -begin_commit_regex="^($begin_commit)(.*)" -co_author_regex="([Cc]o-[Aa]uthored-[Bb]y: )(.*)( <.*)" - -red="\\e[01;31m" -green="\\e[01;32m" -yellow="\\e[33m" -blue="\\e[01;34m" -magenta="\\e[01;35m" -cyan="\\e[01;36m" -white="\\e[37m" +readonly line_ifs=$(printf '\\037') +readonly branch_ifs="|" +readonly begin_commit="### begin_commit ###" commit_hash="" date="" -branches=() -summary="" +branches="" +subject="" author="" -co_authors=() - -function join_by { - local delim=$1 - shift - echo -n "$1" - shift - printf "%s" "\${@/#/$delim}" +co_authors="" + +init_colors() { + red=$(printf '\\033[31m') + green=$(printf '\\033[32m') + yellow=$(printf '\\033[33m') + blue=$(printf '\\033[34m') + magenta=$(printf '\\033[35m') + cyan=$(printf '\\033[36m') + white=$(printf '\\033[37m') + reset=$(printf '\\033[0m') } -function print_branches { - if [ "\${#branches[@]}" != 0 ]; then - formatted_branches=() - - for ref in "\${branches[@]}"; do - case "$ref" in - HEAD*) - formatted_branches+=("$cyan$ref$magenta") - ;; - tag*) - formatted_branches+=("$red$ref$magenta") - ;; - *) - formatted_branches+=($ref) - ;; - esac - done - - echo "$magenta($(join_by ", " \${formatted_branches[@]}))$white " - fi -} +print_branches() { + [ -z "$branches" ] && return + formatted_branches="" -function print_co_authors { - [ \${#co_authors[@]} -ne 0 ] && echo " $blue($(join_by ", " \${co_authors[@]}))$white" || echo "" -} + reset_ifs=$IFS + IFS=$branch_ifs + for ref in $branches; do + [ -n "$formatted_branches" ] && formatted_branches="$formatted_branches, " -function print_commit { - echo -e "$cyan$commit_hash $yellow($date)$white - $(print_branches)$summary $green<$author>$(print_co_authors)" -} + # Remove leading spaces + ref=\${ref#"\${ref%%[! ]*}"} -function parse_commit_hash() { - commit_hash=$(echo -e "$1" | sed -e "s/$begin_commit\\(.*\\)/\\1/") -} + case "$ref" in + HEAD*) formatted_branches="$formatted_branches$cyan$ref$magenta";; + tag*) formatted_branches="$formatted_branches$red$ref$magenta";; + *) formatted_branches="$formatted_branches$ref";; + esac + done + IFS=$reset_ifs -function parse_branches() { - trimmed=$(echo -e "$1" | sed -e "s/,[[:space:]]\\+/,/g") + printf "%s" "$magenta($formatted_branches)$reset" +} - local IFS="," - read -a branches <<< $trimmed +print_co_authors() { + [ -n "$co_authors" ] && printf "%s" "$blue($co_authors)$reset" } -function parse_co_author { - if [[ $1 =~ $co_author_regex ]]; then - author_name=\${BASH_REMATCH[2]} - [[ ! "\${co_authors[@]}" =~ "$author_name" ]] && co_authors+=($author_name) - fi +print_commit() { + printf "%s %s - %s %s %s%s\\n" \\ + "$cyan$commit_hash$reset" \\ + "$yellow($date)$reset" \\ + "$(print_branches)" \\ + "$white$subject$reset" \\ + "$green<$author>$reset" \\ + "$(print_co_authors)" } -function parse_line { - branches=() - co_authors=() - data=($@) - - parse_commit_hash \${data[0]} - date=\${data[1]} - parse_branches \${data[2]} - summary=\${data[3]} - author=\${data[4]} - parse_co_author \${data[5]} +parse_co_author() { + case "$1" in + *[Cc]o-[Aa]uthored-[Bb]y:*) + # Remove 'Co-Authored-By: ' / 'Co-authored-by: ' prefix + author_name=\${1#*[Bb]y: } + author_name=\${author_name%% <*} + [ -z "$co_authors" ] && co_authors=$author_name || co_authors="$co_authors, $author_name" + ;; + esac } -function parse_git_log { - local IFS=$this_ifs +parse_line() { + line=$1 + + reset_ifs=$IFS + IFS=$line_ifs + set -- $line + IFS=$reset_ifs - while read line; do - if [[ $line =~ $begin_commit_regex ]]; then - if [ -n "$commit_hash" ]; then - print_commit - fi + commit_hash=\${1#$begin_commit} # Remove the '### begin_commit ###' prefix + date="$2" + branches=$(printf "%s" "$3" | tr ',' "$branch_ifs") + subject="$4" + author="$5" + [ -n "$6" ] && parse_co_author "$6" +} - parse_line $line - else - parse_co_author $line - fi +parse_git_log() { + while read -r line; do + case "$line" in + "$begin_commit"*) + if [ -n "$commit_hash" ]; then + print_commit + commit_hash="" + co_authors="" + fi + + parse_line "$line" + ;; + *) + parse_co_author "$line" + ;; + esac done - print_commit + print_commit # Print the last commit } -function main { - git log --pretty=format:"$begin_commit%h$this_ifs%as, %ar$this_ifs%D$this_ifs%s$this_ifs%an$this_ifs%b%n" | - sed "/^[[:blank:]]*$/d" | - parse_git_log | - less -RFX -} +init_colors -main +git log \\ + --no-notes \\ + --no-decorate \\ + --pretty=format:"$begin_commit%h\${line_ifs}%as, %ar\${line_ifs}%D\${line_ifs}%s\${line_ifs}%an\${line_ifs}%b%n" | + parse_git_log | + less -RFX ` }