diff --git a/.github/workflows/pre-publish.yml b/.github/workflows/pre-publish.yml index b0c3687..b6c714d 100644 --- a/.github/workflows/pre-publish.yml +++ b/.github/workflows/pre-publish.yml @@ -51,7 +51,7 @@ jobs: - name: Upload coverage report if: success() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: coverage-report path: coverage/lcov-report diff --git a/CHANGELOG.md b/CHANGELOG.md index b6890aa..c0cc630 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,3 +103,28 @@ This file is written automatically by the [version bump script](version-bump.ts) - Enhanced the configuration management system within Evergit. - Removed the `open` package and its type definitions from dependencies. - Applied code formatting improvements for better readability. + +## [0.2.1] - 2025-06-26 + +![Increment](https://img.shields.io/badge/patch-purple) + +# Changelog + +- **Error Handling:** + + - Improved error handling in the `getCurrentBranchName` function. + - Enhanced the `getCurrentBranchName` function to support new repositories and the detached HEAD state. + - Provided error guidance for missing Git user configuration. + +- **Testing:** + + - Refactored Git utilities tests for style consistency. + - Added detailed integration tests for Git utilities. + - Added test for `getGitRootDir` utility function. + +- **Features:** + + - Added git root directory support to Git functions. + +- **Code Quality:** + - Prettified code for better readability and consistency. diff --git a/README.md b/README.md index 83c35ce..efb1009 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # EverGit -![Version](https://img.shields.io/badge/version-0.2.0-blue) +![Version](https://img.shields.io/badge/version-0.2.1-blue) ![TypeScript](https://img.shields.io/badge/typescript-007ACC?style=for-the-badge&logo=typescript&logoColor=white) ![OpenAI](https://img.shields.io/badge/OpenAI-00A79D?style=for-the-badge&logo=openai&logoColor=white) diff --git a/package-lock.json b/package-lock.json index 2358a5c..f4cf1bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "evergit", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "evergit", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { "@types/oauth": "^0.9.6", diff --git a/package.json b/package.json index e273d7f..a620502 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "evergit", - "version": "0.2.0", + "version": "0.2.1", "description": "A CLI tool for generating commit messages that adhere to the Evergreen ILS project's commit policy.", "main": "src/main.ts", "bin": { diff --git a/src/cmd/commit.ts b/src/cmd/commit.ts index e5e702b..a3bf737 100644 --- a/src/cmd/commit.ts +++ b/src/cmd/commit.ts @@ -131,6 +131,27 @@ async function getStagedFiles(): Promise { function getUserInfo(): { name: string; email: string; diff: string } { const name = getConfig('name') || getGitName(); const email = getConfig('email') || getGitEmail(); + + if (!name || !email) { + const missingFields = []; + if (!name) missingFields.push('name'); + if (!email) missingFields.push('email'); + + print('error', `Git user ${missingFields.join(' and ')} not configured.`); + print('info', 'Please configure git or evergit:'); + if (!name) { + print('info', ' git config --global user.name "Your Name"'); + print('info', ' OR'); + print('info', ' evergit config set name "Your Name"'); + } + if (!email) { + print('info', ' git config --global user.email "your.email@example.com"'); + print('info', ' OR'); + print('info', ' evergit config set email "your.email@example.com"'); + } + throw new Error('Missing git configuration'); + } + return { name, email, diff --git a/src/main.ts b/src/main.ts index e3adae5..9b799f0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,7 +21,7 @@ function getGitEditor(): Promise { } export async function main(args = process.argv): Promise { - program.name('evergit').description('Automate your Evergreen ILS git workflow').version('0.2.0'); + program.name('evergit').description('Automate your Evergreen ILS git workflow').version('0.2.1'); program .command('commit') diff --git a/src/util/git.ts b/src/util/git.ts index bf6fa22..29f9772 100644 --- a/src/util/git.ts +++ b/src/util/git.ts @@ -14,6 +14,14 @@ export enum GitFileStatus { '!' = 'Ignored', } +export function getGitRootDir(): string { + try { + return execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim(); + } catch (error) { + throw new Error('Unable to determine git repository root directory.'); + } +} + export function isInGitRepo(): boolean { try { execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' }); @@ -25,7 +33,8 @@ export function isInGitRepo(): boolean { export function pushChanges(): void { try { - execSync('git push'); + const gitRoot = getGitRootDir(); + execSync(`git -C "${gitRoot}" push`); } catch (error: any) { if (error.message.includes('fatal: The current branch')) { throw new Error('The current branch has no upstream branch.'); @@ -43,27 +52,33 @@ export function setUserEmail(email: string): void { export function setupUpstreamBranch(): void { const branchName = getCurrentBranchName(); - execSync(`git push --set-upstream origin ${branchName}`); + const gitRoot = getGitRootDir(); + execSync(`git -C "${gitRoot}" push --set-upstream origin ${branchName}`); } export function stageAllFiles(): void { - execSync('git add .'); + const gitRoot = getGitRootDir(); + execSync(`git -C "${gitRoot}" add .`); } export function stageFile(filePath: string): void { - execSync(`git add ${filePath}`); + const gitRoot = getGitRootDir(); + execSync(`git -C "${gitRoot}" add "${filePath}"`); } export function unstageFile(filePath: string): void { - execSync(`git restore --staged ${filePath}`); + const gitRoot = getGitRootDir(); + execSync(`git -C "${gitRoot}" restore --staged "${filePath}"`); } export function unstageAllFiles(): void { - execSync('git restore --staged .'); + const gitRoot = getGitRootDir(); + execSync(`git -C "${gitRoot}" restore --staged .`); } export function listChangedFiles(): string[] { - const statusOutput = execSync('git status --porcelain').toString().trim(); + const gitRoot = getGitRootDir(); + const statusOutput = execSync(`git -C "${gitRoot}" status --porcelain`).toString().trim(); return statusOutput .split('\n') .map((line) => line.trim().slice(2)) @@ -71,7 +86,8 @@ export function listChangedFiles(): string[] { } export function getStatusForFile(filePath: string): GitFileStatus { - const status = execSync(`git status --porcelain "${filePath}"`).toString().trim(); + const gitRoot = getGitRootDir(); + const status = execSync(`git -C "${gitRoot}" status --porcelain "${filePath}"`).toString().trim(); if (!status) { return GitFileStatus['!']; } @@ -81,7 +97,8 @@ export function getStatusForFile(filePath: string): GitFileStatus { export function getDiffForStagedFiles(): string { const maxBufferSize = 10 * 1024 * 1024; // 10 MB buffer try { - let diff: string = execSync('git diff --staged', { maxBuffer: maxBufferSize }).toString(); + const gitRoot = getGitRootDir(); + let diff: string = execSync(`git -C "${gitRoot}" diff --staged`, { maxBuffer: maxBufferSize }).toString(); diff = removeDiffForFile(diff, 'package-lock.json'); return diff; } catch (error: any) { @@ -102,35 +119,78 @@ function removeDiffForFile(diff: string, filePath: string): string { export function getCurrentBranchName(): string { try { - const branchName = execSync('git rev-parse --abbrev-ref HEAD', { - encoding: 'utf-8', - }).trim(); - - return branchName; + const gitRoot = getGitRootDir(); + + // First try the standard approach + try { + const branchName = execSync(`git -C "${gitRoot}" rev-parse --abbrev-ref HEAD`, { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], // Capture stderr + }).trim(); + + if (branchName === 'HEAD') { + // We're in a detached HEAD state + throw new Error('Repository is in detached HEAD state. Please checkout a branch.'); + } + + return branchName; + } catch (error: any) { + // If rev-parse fails, try symbolic-ref for new repos + if (error.message?.includes('ambiguous argument') || error.message?.includes('unknown revision')) { + try { + const branchName = execSync(`git -C "${gitRoot}" symbolic-ref --short HEAD`, { + encoding: 'utf-8', + }).trim(); + return branchName; + } catch { + // If symbolic-ref also fails, we might be in a completely new repo + // Try to get the default branch name from config + try { + const defaultBranch = execSync(`git -C "${gitRoot}" config init.defaultBranch`, { + encoding: 'utf-8', + }).trim(); + return defaultBranch || 'main'; // Default to 'main' if not configured + } catch { + return 'main'; // Final fallback + } + } + } + throw error; + } } catch (error) { throw new Error('Unable to get current branch name.'); } } export function hasGitChanges(): boolean { - const status = execSync('git status --porcelain').toString().trim(); + const gitRoot = getGitRootDir(); + const status = execSync(`git -C "${gitRoot}" status --porcelain`).toString().trim(); return status.length > 0; } export function getName(): string { - return execSync('git config user.name').toString().trim(); + try { + return execSync('git config user.name', { encoding: 'utf-8' }).trim(); + } catch { + return ''; + } } export function getEmail(): string { - return execSync('git config user.email').toString().trim(); + try { + return execSync('git config user.email', { encoding: 'utf-8' }).trim(); + } catch { + return ''; + } } export function commitWithMessage(message: string): void { + const gitRoot = getGitRootDir(); const sanitizedMessage = sanitizeCommitMessage(message); const tempFilePath = path.join(os.tmpdir(), 'commit-message.txt'); fs.writeFileSync(tempFilePath, sanitizedMessage); - execSync(`git commit -F "${tempFilePath}"`); + execSync(`git -C "${gitRoot}" commit -F "${tempFilePath}"`); fs.unlinkSync(tempFilePath); // Clean up the temporary file } @@ -139,12 +199,14 @@ function sanitizeCommitMessage(message: string): string { } export function checkForRemote(remoteUrl: string): boolean { - const remoteUrls = execSync('git remote -v').toString(); + const gitRoot = getGitRootDir(); + const remoteUrls = execSync(`git -C "${gitRoot}" remote -v`).toString(); return remoteUrls.includes(remoteUrl); } export function getRemoteName(remoteUrl: string): string { - const remoteUrls = execSync('git remote -v').toString(); + const gitRoot = getGitRootDir(); + const remoteUrls = execSync(`git -C "${gitRoot}" remote -v`).toString(); const remoteName = remoteUrls.split('\n').find((line: string) => line.includes(remoteUrl)); return remoteName ? remoteName.split('\t')[0] : ''; } diff --git a/tests/util/git.test.ts b/tests/util/git.test.ts index 093caf1..c753475 100644 --- a/tests/util/git.test.ts +++ b/tests/util/git.test.ts @@ -7,6 +7,7 @@ import { commitWithMessage, setUserEmail, setUserName, + getGitRootDir, } from '../../src/util/git'; import { execSync } from 'child_process'; import * as fs from 'fs'; @@ -16,6 +17,8 @@ import * as os from 'os'; describe('Git Utilities Integration Tests', () => { const testFilePath = 'test-file.txt'; let testRepoDir: string; + const remoteName = 'origin'; + const remoteUrl = 'https://example.com/fake.git'; beforeAll(() => { // Create a temporary directory for the test repository @@ -25,7 +28,7 @@ describe('Git Utilities Integration Tests', () => { // Ensure a Git repository is initialized and an initial commit is made try { execSync('git init'); - // Set Git user name and email + execSync(`git remote add ${remoteName} ${remoteUrl}`); setUserName('Test User'); setUserEmail('test@example.com'); fs.writeFileSync(testFilePath, 'Initial content'); @@ -48,6 +51,16 @@ describe('Git Utilities Integration Tests', () => { }); }); + describe('getGitRootDir', () => { + test('should return the root directory of the Git repository', () => { + const rootDir = getGitRootDir(); + // Normalize paths to use forward slashes for comparison + const normalizedRootDir = rootDir.replace(/\\/g, '/'); + const normalizedTestRepoDir = testRepoDir.replace(/\\/g, '/'); + expect(normalizedRootDir).toBe(normalizedTestRepoDir); + }); + }); + describe('addAllChanges and hasGitChanges', () => { test('should detect changes after adding a file', () => { fs.writeFileSync(testFilePath, 'Test content'); @@ -60,6 +73,24 @@ describe('Git Utilities Integration Tests', () => { const branchName = getCurrentBranchName(); expect(branchName === 'master' || branchName === 'main').toBe(true); }); + + test('should handle new repository with no commits', () => { + // Create a new test directory for this specific test + const newRepoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'new-repo-')); + const originalCwd = process.cwd(); + + try { + process.chdir(newRepoDir); + execSync('git init'); + + // Should still be able to get branch name even with no commits + const branchName = getCurrentBranchName(); + expect(branchName === 'master' || branchName === 'main').toBe(true); + } finally { + process.chdir(originalCwd); + fs.rmSync(newRepoDir, { recursive: true, force: true }); + } + }); }); describe('getStatusForFile', () => { @@ -69,5 +100,161 @@ describe('Git Utilities Integration Tests', () => { stageAllFiles(); expect(getStatusForFile(testFilePath)).toBe('M'); }); + test('should return ignored for a .gitignored file', () => { + const ignoredFile = 'ignored.txt'; + fs.writeFileSync('.gitignore', 'ignored.txt\n'); + fs.writeFileSync(ignoredFile, 'ignore me'); + stageAllFiles(); + commitWithMessage('Add .gitignore and ignored.txt'); + expect(getStatusForFile(ignoredFile)).toBe('Ignored'); + }); + test('should return untracked for a new file', () => { + const untrackedFile = 'untracked.txt'; + fs.writeFileSync(untrackedFile, 'new file'); + expect(getStatusForFile(untrackedFile)).toBe('?'); + }); + }); + + describe('stageFile, unstageFile, unstageAllFiles', () => { + test('should stage and unstage a file', () => { + const file = 'stage-me.txt'; + fs.writeFileSync(file, 'stage this'); + require('../../src/util/git').stageFile(file); + expect(getStatusForFile(file)).toBe('A'); + require('../../src/util/git').unstageFile(file); + // After unstaging, should be untracked + expect(getStatusForFile(file)).toBe('?'); + }); + test('should unstage all files', () => { + const file = 'unstage-all.txt'; + fs.writeFileSync(file, 'unstage all'); + require('../../src/util/git').stageFile(file); + require('../../src/util/git').unstageAllFiles(); + expect(getStatusForFile(file)).toBe('?'); + }); + }); + + describe('listChangedFiles', () => { + test('should list changed files', () => { + const file = 'changed.txt'; + fs.writeFileSync(file, 'changed'); + require('../../src/util/git').stageFile(file); + const changed = require('../../src/util/git').listChangedFiles(); + expect(changed.map((f: string) => f.trim())).toContain(file); + }); + }); + + describe('getName and getEmail', () => { + test('should get the configured user name and email', () => { + const { getName, getEmail } = require('../../src/util/git'); + expect(getName()).toBe('Test User'); + expect(getEmail()).toBe('test@example.com'); + }); + + test('should return empty string when name is not configured', () => { + const { getName } = require('../../src/util/git'); + const childProcess = require('child_process'); + const originalExecSync = childProcess.execSync; + childProcess.execSync = jest.fn((cmd: string, options?: any) => { + if (typeof cmd === 'string' && cmd === 'git config user.name') { + throw new Error('No user.name set'); + } + return originalExecSync(cmd, options); + }); + try { + expect(getName()).toBe(''); + } finally { + childProcess.execSync = originalExecSync; + } + }); + + test('should return empty string when email is not configured', () => { + const { getEmail } = require('../../src/util/git'); + const childProcess = require('child_process'); + const originalExecSync = childProcess.execSync; + childProcess.execSync = jest.fn((cmd: string, options?: any) => { + if (typeof cmd === 'string' && cmd === 'git config user.email') { + throw new Error('No user.email set'); + } + return originalExecSync(cmd, options); + }); + try { + expect(getEmail()).toBe(''); + } finally { + childProcess.execSync = originalExecSync; + } + }); + }); + + describe('commitWithMessage', () => { + test('should commit with special characters in the message', () => { + fs.writeFileSync('special.txt', 'special'); + require('../../src/util/git').stageFile('special.txt'); + expect(() => commitWithMessage('A commit with "quotes" and special chars !@#$%^&*()')).not.toThrow(); + }); + }); + + describe('checkForRemote and getRemoteName', () => { + test('should detect the remote and get its name', () => { + const { checkForRemote, getRemoteName } = require('../../src/util/git'); + expect(checkForRemote(remoteUrl)).toBe(true); + expect(getRemoteName(remoteUrl)).toBe(remoteName); + }); + }); + + describe('getDiffForStagedFiles', () => { + test('should return a diff for staged files', () => { + const file = 'diffme.txt'; + fs.writeFileSync(file, 'diff this'); + require('../../src/util/git').stageFile(file); + const diff = require('../../src/util/git').getDiffForStagedFiles(); + expect(diff).toContain('diff --git'); + }); + test('should throw on ENOBUFS error', () => { + // Mock execSync to throw ENOBUFS only for diff command + const git = require('../../src/util/git'); + const childProcess = require('child_process'); + const originalExecSync = childProcess.execSync; + childProcess.execSync = jest.fn((cmd: string, options?: any) => { + if (typeof cmd === 'string' && cmd.includes('diff --staged')) { + const err = new Error('ENOBUFS'); + (err as any).code = 'ENOBUFS'; + throw err; + } + return originalExecSync(cmd, options); + }); + try { + expect(() => git.getDiffForStagedFiles()).toThrow('Buffer overflow'); + } finally { + childProcess.execSync = originalExecSync; + } + }); + }); + + describe('pushChanges and setupUpstreamBranch', () => { + test('should throw if no upstream branch', () => { + const git = require('../../src/util/git'); + // Create a new branch with no upstream + execSync('git checkout -b test-branch'); + expect(() => git.pushChanges()).toThrow('The current branch has no upstream branch.'); + }); + test('should not throw when setting up upstream branch', () => { + const git = require('../../src/util/git'); + const childProcess = require('child_process'); + const originalExecSync = childProcess.execSync; + execSync('git checkout -b upstream-branch'); + // Mock execSync to prevent actual push only + childProcess.execSync = jest.fn((cmd: string, options?: any) => { + if (typeof cmd === 'string' && cmd.includes('push --set-upstream')) { + return ''; + } + return originalExecSync(cmd, options); + }); + try { + expect(() => git.setupUpstreamBranch()).not.toThrow(); + } finally { + childProcess.execSync = originalExecSync; + } + }); }); });