From 57baedd0b09e3bef90372e7cd11207e6f9d95c83 Mon Sep 17 00:00:00 2001 From: Sukharev <57486732+di-sukharev@users.noreply.github.com> Date: Fri, 17 Mar 2023 13:53:22 +0800 Subject: [PATCH] Feature: add staged files multiple selection (#6) (#27) * feat: add staged files multiple selection (#6) --- README.md | 8 +-- package-lock.json | 16 +++++ package.json | 4 +- src/commands/commit.ts | 94 ++++++++++++++++++------- src/commands/prepare-commit-msg-hook.ts | 13 +++- src/utils/git.ts | 85 ++++++++++++++-------- src/utils/trytm.ts | 12 ++++ 7 files changed, 167 insertions(+), 65 deletions(-) create mode 100644 src/utils/trytm.ts diff --git a/README.md b/README.md index 0063000..cd874ea 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ All the commits in this repo are done with OpenCommit — look into [the commits ## Setup -1. Install opencommit globally to use in any repository: +1. Install OpenCommit globally to use in any repository: ```sh npm install -g opencommit @@ -28,7 +28,7 @@ All the commits in this repo are done with OpenCommit — look into [the commits 2. Get your API key from [OpenAI](https://platform.openai.com/account/api-keys). Make sure you add payment details, so API works. -3. Set the key to opencommit config: +3. Set the key to OpenCommit config: ```sh opencommit config set OPENAI_API_KEY= @@ -38,7 +38,7 @@ All the commits in this repo are done with OpenCommit — look into [the commits ## Usage -You can call `opencommit` directly to generate a commit message for your staged changes: +You can call OpenCommit directly to generate a commit message for your staged changes: ```sh git add @@ -86,7 +86,7 @@ oc config set description=false ## Git hook -You can set opencommit as Git [`prepare-commit-msg`](https://git-scm.com/docs/githooks#_prepare_commit_msg) hook. Hook integrates with you IDE Source Control and allows you edit the message before commit. +You can set OpenCommit as Git [`prepare-commit-msg`](https://git-scm.com/docs/githooks#_prepare_commit_msg) hook. Hook integrates with you IDE Source Control and allows you edit the message before commit. To set the hook: diff --git a/package-lock.json b/package-lock.json index 4a24ce2..e6ec109 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "dotenv": "^16.0.3", "esbuild": "^0.15.18", "eslint": "^8.28.0", + "prettier": "^2.8.4", "ts-node": "^10.9.1", "typescript": "^4.9.3" } @@ -2497,6 +2498,21 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.4.tgz", + "integrity": "sha512-vIS4Rlc2FNh0BySk3Wkd6xmwxB0FpOndW5fisM5H8hsZSxU2VWVB5CWIkIjWvrHjIhxk2g3bfMKM87zNTrZddw==", + "dev": true, + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", diff --git a/package.json b/package.json index 52c3beb..7c238ac 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "dev": "ts-node ./src/cli.ts", "build": "rimraf out && esbuild ./src/cli.ts --bundle --outfile=out/cli.cjs --format=cjs --platform=node", "deploy": "npm run build && npm version patch && npm publish --tag latest", - "lint": "eslint src --ext ts && tsc --noEmit" + "lint": "eslint src --ext ts && tsc --noEmit", + "format": "prettier --write src" }, "devDependencies": { "@types/ini": "^1.3.31", @@ -53,6 +54,7 @@ "dotenv": "^16.0.3", "esbuild": "^0.15.18", "eslint": "^8.28.0", + "prettier": "^2.8.4", "ts-node": "^10.9.1", "typescript": "^4.9.3" }, diff --git a/src/commands/commit.ts b/src/commands/commit.ts index e791b32..381b959 100644 --- a/src/commands/commit.ts +++ b/src/commands/commit.ts @@ -3,9 +3,23 @@ import { GenerateCommitMessageErrorEnum, generateCommitMessageWithChatCompletion } from '../generateCommitMessageFromGitDiff'; -import { assertGitRepo, getStagedGitDiff } from '../utils/git'; -import { spinner, confirm, outro, isCancel, intro } from '@clack/prompts'; +import { + assertGitRepo, + getChangedFiles, + getDiff, + getStagedFiles, + gitAdd +} from '../utils/git'; +import { + spinner, + confirm, + outro, + isCancel, + intro, + multiselect +} from '@clack/prompts'; import chalk from 'chalk'; +import { trytm } from '../utils/trytm'; const generateCommitMessageFromGitDiff = async ( diff: string @@ -46,8 +60,11 @@ ${chalk.grey('——————————————————')}` if (isCommitConfirmedByUser && !isCancel(isCommitConfirmedByUser)) { const { stdout } = await execa('git', ['commit', '-m', commitMessage]); + outro(`${chalk.green('✔')} successfully committed`); + outro(stdout); + const isPushConfirmedByUser = await confirm({ message: 'Do you want to run `git push`?' }); @@ -65,35 +82,33 @@ ${chalk.grey('——————————————————')}` }; export async function commit(isStageAllFlag = false) { - intro('open-commit'); + if (isStageAllFlag) { + const changedFiles = await getChangedFiles(); + if (changedFiles) await gitAdd({ files: changedFiles }); + else { + outro('No changes detected, write some code and run `oc` again'); + process.exit(1); + } + } - const stagedFilesSpinner = spinner(); - stagedFilesSpinner.start('Counting staged files'); - const staged = await getStagedGitDiff(isStageAllFlag); - - if (!staged && isStageAllFlag) { - outro( - `${chalk.red( - 'No changes detected' - )} — write some code, stage the files ${chalk - .hex('0000FF') - .bold('`git add .`')} and rerun ${chalk - .hex('0000FF') - .bold('`oc`')} command.` - ); + const [stagedFiles, errorStagedFiles] = await trytm(getStagedFiles()); + const [changedFiles, errorChangedFiles] = await trytm(getChangedFiles()); + if (!changedFiles?.length && !stagedFiles?.length) { + outro(chalk.red('No changes detected')); process.exit(1); } - if (!staged) { - outro( - `${chalk.red('Nothing to commit')} — stage the files ${chalk - .hex('0000FF') - .bold('`git add .`')} and rerun ${chalk - .hex('0000FF') - .bold('`oc`')} command.` - ); + intro('open-commit'); + if (errorChangedFiles ?? errorStagedFiles) { + outro(`${chalk.red('✖')} ${errorChangedFiles ?? errorStagedFiles}`); + process.exit(1); + } + const stagedFilesSpinner = spinner(); + stagedFilesSpinner.start('Counting staged files'); + + if (!stagedFiles.length) { stagedFilesSpinner.stop('No files are staged'); const isStageAllAndCommitConfirmedByUser = await confirm({ message: 'Do you want to stage all files and generate commit message?' @@ -104,16 +119,41 @@ export async function commit(isStageAllFlag = false) { !isCancel(isStageAllAndCommitConfirmedByUser) ) { await commit(true); + process.exit(1); } + if (stagedFiles.length === 0 && changedFiles.length > 0) { + const files = (await multiselect({ + message: chalk.cyan('Select the files you want to add to the commit:'), + options: changedFiles.map((file) => ({ + value: file, + label: file + })) + })) as string[]; + + if (isCancel(files)) process.exit(1); + + await gitAdd({ files }); + } + + await commit(false); process.exit(1); } stagedFilesSpinner.stop( - `${staged.files.length} staged files:\n${staged.files + `${stagedFiles.length} staged files:\n${stagedFiles .map((file) => ` ${file}`) .join('\n')}` ); - await generateCommitMessageFromGitDiff(staged.diff); + const [, generateCommitError] = await trytm( + generateCommitMessageFromGitDiff(await getDiff({ files: stagedFiles })) + ); + + if (generateCommitError) { + outro(`${chalk.red('✖')} ${generateCommitError}`); + process.exit(1); + } + + process.exit(0); } diff --git a/src/commands/prepare-commit-msg-hook.ts b/src/commands/prepare-commit-msg-hook.ts index 512cd52..68547dc 100644 --- a/src/commands/prepare-commit-msg-hook.ts +++ b/src/commands/prepare-commit-msg-hook.ts @@ -1,7 +1,7 @@ import fs from 'fs/promises'; import chalk from 'chalk'; import { intro, outro } from '@clack/prompts'; -import { getStagedGitDiff } from '../utils/git'; +import { getChangedFiles, getDiff, getStagedFiles, gitAdd } from '../utils/git'; import { getConfig } from './config'; import { generateCommitMessageWithChatCompletion } from '../generateCommitMessageFromGitDiff'; @@ -17,7 +17,14 @@ export const prepareCommitMessageHook = async () => { if (commitSource) return; - const staged = await getStagedGitDiff(); + const changedFiles = await getChangedFiles(); + if (changedFiles) await gitAdd({ files: changedFiles }); + else { + outro("No changes detected, write some code and run `oc` again"); + process.exit(1); + } + + const staged = await getStagedFiles(); if (!staged) return; @@ -32,7 +39,7 @@ export const prepareCommitMessageHook = async () => { } const commitMessage = await generateCommitMessageWithChatCompletion( - staged.diff + await getDiff({ files: staged }) ); if (typeof commitMessage !== 'string') throw new Error(commitMessage.error); diff --git a/src/utils/git.ts b/src/utils/git.ts index 97af5fa..3f3dfc8 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -1,5 +1,5 @@ import { execa } from 'execa'; -import { spinner } from '@clack/prompts'; +import { outro, spinner } from '@clack/prompts'; export const assertGitRepo = async () => { try { @@ -9,41 +9,66 @@ export const assertGitRepo = async () => { } }; -const excludeBigFilesFromDiff = ['*-lock.*', '*.lock'].map( - (file) => `:(exclude)${file}` -); +// const excludeBigFilesFromDiff = ['*-lock.*', '*.lock'].map( +// (file) => `:(exclude)${file}` +// ); -export interface StagedDiff { - files: string[]; - diff: string; -} +export const getStagedFiles = async (): Promise => { + const { stdout: files } = await execa('git', [ + 'diff', + '--name-only', + '--cached' + ]); -export const getStagedGitDiff = async ( - isStageAllFlag = false -): Promise => { - if (isStageAllFlag) { - const stageAllSpinner = spinner(); - stageAllSpinner.start('Staging all changes'); - await execa('git', ['add', '.']); - stageAllSpinner.stop('Done'); + if (!files) return []; + + return files.split('\n').sort(); +}; + +export const getChangedFiles = async (): Promise => { + const { stdout: modified } = await execa('git', ['ls-files', '--modified']); + const { stdout: others } = await execa('git', [ + 'ls-files', + '--others', + '--exclude-standard' + ]); + + const files = [...modified.split('\n'), ...others.split('\n')].filter( + (file) => !!file + ); + + return files.sort(); +}; + +export const gitAdd = async ({ files }: { files: string[] }) => { + const gitAddSpinner = spinner(); + gitAddSpinner.start('Adding files to commit'); + await execa('git', ['add', ...files]); + gitAddSpinner.stop('Done'); +}; + +export const getDiff = async ({ files }: { files: string[] }) => { + const lockFiles = files.filter( + (file) => file.includes('.lock') || file.includes('-lock.') + ); + + if (lockFiles.length) { + outro( + `Some files are '.lock' files which are excluded by default from 'git diff'. No commit messages are generated for this files:\n${lockFiles.join( + '\n' + )}` + ); } - const diffStaged = ['diff', '--staged']; - const { stdout: files } = await execa('git', [ - ...diffStaged, - '--name-only', - ...excludeBigFilesFromDiff - ]); - - if (!files) return null; + const filesWithoutLocks = files.filter( + (file) => !file.includes('.lock') && !file.includes('-lock.') + ); const { stdout: diff } = await execa('git', [ - ...diffStaged, - ...excludeBigFilesFromDiff + 'diff', + '--staged', + ...filesWithoutLocks ]); - return { - files: files.split('\n').sort(), - diff - }; + return diff; }; diff --git a/src/utils/trytm.ts b/src/utils/trytm.ts new file mode 100644 index 0000000..310ede3 --- /dev/null +++ b/src/utils/trytm.ts @@ -0,0 +1,12 @@ +export const trytm = async ( + promise: Promise +): Promise<[T, null] | [null, Error]> => { + try { + const data = await promise; + return [data, null]; + } catch (throwable) { + if (throwable instanceof Error) return [null, throwable]; + + throw throwable; + } +};