Feature: add staged files multiple selection (#6) (#27)

* feat: add staged files multiple selection (#6)
This commit is contained in:
Sukharev
2023-03-17 13:53:22 +08:00
committed by GitHub
parent 5d0c69e849
commit 57baedd0b0
7 changed files with 167 additions and 65 deletions

View File

@@ -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=<your_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 <files...>
@@ -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:

16
package-lock.json generated
View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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<string[]> => {
const { stdout: files } = await execa('git', [
'diff',
'--name-only',
'--cached'
]);
export const getStagedGitDiff = async (
isStageAllFlag = false
): Promise<StagedDiff | null> => {
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<string[]> => {
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;
};

12
src/utils/trytm.ts Normal file
View File

@@ -0,0 +1,12 @@
export const trytm = async <T>(
promise: Promise<T>
): Promise<[T, null] | [null, Error]> => {
try {
const data = await promise;
return [data, null];
} catch (throwable) {
if (throwable instanceof Error) return [null, throwable];
throw throwable;
}
};