Skip to content

Commit 57baedd

Browse files
authored
Feature: add staged files multiple selection (#6) (#27)
* feat: add staged files multiple selection (#6)
1 parent 5d0c69e commit 57baedd

File tree

7 files changed

+166
-64
lines changed

7 files changed

+166
-64
lines changed

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,15 @@ All the commits in this repo are done with OpenCommit — look into [the commits
2020

2121
## Setup
2222

23-
1. Install opencommit globally to use in any repository:
23+
1. Install OpenCommit globally to use in any repository:
2424

2525
```sh
2626
npm install -g opencommit
2727
```
2828

2929
2. Get your API key from [OpenAI](https://platform.openai.com/account/api-keys). Make sure you add payment details, so API works.
3030

31-
3. Set the key to opencommit config:
31+
3. Set the key to OpenCommit config:
3232

3333
```sh
3434
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
3838

3939
## Usage
4040

41-
You can call `opencommit` directly to generate a commit message for your staged changes:
41+
You can call OpenCommit directly to generate a commit message for your staged changes:
4242

4343
```sh
4444
git add <files...>
@@ -86,7 +86,7 @@ oc config set description=false
8686

8787
## Git hook
8888

89-
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.
89+
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.
9090

9191
To set the hook:
9292

package-lock.json

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@
4242
"dev": "ts-node ./src/cli.ts",
4343
"build": "rimraf out && esbuild ./src/cli.ts --bundle --outfile=out/cli.cjs --format=cjs --platform=node",
4444
"deploy": "npm run build && npm version patch && npm publish --tag latest",
45-
"lint": "eslint src --ext ts && tsc --noEmit"
45+
"lint": "eslint src --ext ts && tsc --noEmit",
46+
"format": "prettier --write src"
4647
},
4748
"devDependencies": {
4849
"@types/ini": "^1.3.31",
@@ -53,6 +54,7 @@
5354
"dotenv": "^16.0.3",
5455
"esbuild": "^0.15.18",
5556
"eslint": "^8.28.0",
57+
"prettier": "^2.8.4",
5658
"ts-node": "^10.9.1",
5759
"typescript": "^4.9.3"
5860
},

src/commands/commit.ts

Lines changed: 67 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,23 @@ import {
33
GenerateCommitMessageErrorEnum,
44
generateCommitMessageWithChatCompletion
55
} from '../generateCommitMessageFromGitDiff';
6-
import { assertGitRepo, getStagedGitDiff } from '../utils/git';
7-
import { spinner, confirm, outro, isCancel, intro } from '@clack/prompts';
6+
import {
7+
assertGitRepo,
8+
getChangedFiles,
9+
getDiff,
10+
getStagedFiles,
11+
gitAdd
12+
} from '../utils/git';
13+
import {
14+
spinner,
15+
confirm,
16+
outro,
17+
isCancel,
18+
intro,
19+
multiselect
20+
} from '@clack/prompts';
821
import chalk from 'chalk';
22+
import { trytm } from '../utils/trytm';
923

1024
const generateCommitMessageFromGitDiff = async (
1125
diff: string
@@ -46,8 +60,11 @@ ${chalk.grey('——————————————————')}`
4660

4761
if (isCommitConfirmedByUser && !isCancel(isCommitConfirmedByUser)) {
4862
const { stdout } = await execa('git', ['commit', '-m', commitMessage]);
63+
4964
outro(`${chalk.green('✔')} successfully committed`);
65+
5066
outro(stdout);
67+
5168
const isPushConfirmedByUser = await confirm({
5269
message: 'Do you want to run `git push`?'
5370
});
@@ -65,35 +82,33 @@ ${chalk.grey('——————————————————')}`
6582
};
6683

6784
export async function commit(isStageAllFlag = false) {
68-
intro('open-commit');
85+
if (isStageAllFlag) {
86+
const changedFiles = await getChangedFiles();
87+
if (changedFiles) await gitAdd({ files: changedFiles });
88+
else {
89+
outro('No changes detected, write some code and run `oc` again');
90+
process.exit(1);
91+
}
92+
}
6993

70-
const stagedFilesSpinner = spinner();
71-
stagedFilesSpinner.start('Counting staged files');
72-
const staged = await getStagedGitDiff(isStageAllFlag);
73-
74-
if (!staged && isStageAllFlag) {
75-
outro(
76-
`${chalk.red(
77-
'No changes detected'
78-
)} — write some code, stage the files ${chalk
79-
.hex('0000FF')
80-
.bold('`git add .`')} and rerun ${chalk
81-
.hex('0000FF')
82-
.bold('`oc`')} command.`
83-
);
94+
const [stagedFiles, errorStagedFiles] = await trytm(getStagedFiles());
95+
const [changedFiles, errorChangedFiles] = await trytm(getChangedFiles());
8496

97+
if (!changedFiles?.length && !stagedFiles?.length) {
98+
outro(chalk.red('No changes detected'));
8599
process.exit(1);
86100
}
87101

88-
if (!staged) {
89-
outro(
90-
`${chalk.red('Nothing to commit')} — stage the files ${chalk
91-
.hex('0000FF')
92-
.bold('`git add .`')} and rerun ${chalk
93-
.hex('0000FF')
94-
.bold('`oc`')} command.`
95-
);
102+
intro('open-commit');
103+
if (errorChangedFiles ?? errorStagedFiles) {
104+
outro(`${chalk.red('✖')} ${errorChangedFiles ?? errorStagedFiles}`);
105+
process.exit(1);
106+
}
96107

108+
const stagedFilesSpinner = spinner();
109+
stagedFilesSpinner.start('Counting staged files');
110+
111+
if (!stagedFiles.length) {
97112
stagedFilesSpinner.stop('No files are staged');
98113
const isStageAllAndCommitConfirmedByUser = await confirm({
99114
message: 'Do you want to stage all files and generate commit message?'
@@ -104,16 +119,41 @@ export async function commit(isStageAllFlag = false) {
104119
!isCancel(isStageAllAndCommitConfirmedByUser)
105120
) {
106121
await commit(true);
122+
process.exit(1);
123+
}
124+
125+
if (stagedFiles.length === 0 && changedFiles.length > 0) {
126+
const files = (await multiselect({
127+
message: chalk.cyan('Select the files you want to add to the commit:'),
128+
options: changedFiles.map((file) => ({
129+
value: file,
130+
label: file
131+
}))
132+
})) as string[];
133+
134+
if (isCancel(files)) process.exit(1);
135+
136+
await gitAdd({ files });
107137
}
108138

139+
await commit(false);
109140
process.exit(1);
110141
}
111142

112143
stagedFilesSpinner.stop(
113-
`${staged.files.length} staged files:\n${staged.files
144+
`${stagedFiles.length} staged files:\n${stagedFiles
114145
.map((file) => ` ${file}`)
115146
.join('\n')}`
116147
);
117148

118-
await generateCommitMessageFromGitDiff(staged.diff);
149+
const [, generateCommitError] = await trytm(
150+
generateCommitMessageFromGitDiff(await getDiff({ files: stagedFiles }))
151+
);
152+
153+
if (generateCommitError) {
154+
outro(`${chalk.red('✖')} ${generateCommitError}`);
155+
process.exit(1);
156+
}
157+
158+
process.exit(0);
119159
}

src/commands/prepare-commit-msg-hook.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fs from 'fs/promises';
22
import chalk from 'chalk';
33
import { intro, outro } from '@clack/prompts';
4-
import { getStagedGitDiff } from '../utils/git';
4+
import { getChangedFiles, getDiff, getStagedFiles, gitAdd } from '../utils/git';
55
import { getConfig } from './config';
66
import { generateCommitMessageWithChatCompletion } from '../generateCommitMessageFromGitDiff';
77

@@ -17,7 +17,14 @@ export const prepareCommitMessageHook = async () => {
1717

1818
if (commitSource) return;
1919

20-
const staged = await getStagedGitDiff();
20+
const changedFiles = await getChangedFiles();
21+
if (changedFiles) await gitAdd({ files: changedFiles });
22+
else {
23+
outro("No changes detected, write some code and run `oc` again");
24+
process.exit(1);
25+
}
26+
27+
const staged = await getStagedFiles();
2128

2229
if (!staged) return;
2330

@@ -32,7 +39,7 @@ export const prepareCommitMessageHook = async () => {
3239
}
3340

3441
const commitMessage = await generateCommitMessageWithChatCompletion(
35-
staged.diff
42+
await getDiff({ files: staged })
3643
);
3744

3845
if (typeof commitMessage !== 'string') throw new Error(commitMessage.error);

src/utils/git.ts

Lines changed: 54 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { execa } from 'execa';
2-
import { spinner } from '@clack/prompts';
2+
import { outro, spinner } from '@clack/prompts';
33

44
export const assertGitRepo = async () => {
55
try {
@@ -9,41 +9,66 @@ export const assertGitRepo = async () => {
99
}
1010
};
1111

12-
const excludeBigFilesFromDiff = ['*-lock.*', '*.lock'].map(
13-
(file) => `:(exclude)${file}`
14-
);
15-
16-
export interface StagedDiff {
17-
files: string[];
18-
diff: string;
19-
}
20-
21-
export const getStagedGitDiff = async (
22-
isStageAllFlag = false
23-
): Promise<StagedDiff | null> => {
24-
if (isStageAllFlag) {
25-
const stageAllSpinner = spinner();
26-
stageAllSpinner.start('Staging all changes');
27-
await execa('git', ['add', '.']);
28-
stageAllSpinner.stop('Done');
29-
}
12+
// const excludeBigFilesFromDiff = ['*-lock.*', '*.lock'].map(
13+
// (file) => `:(exclude)${file}`
14+
// );
3015

31-
const diffStaged = ['diff', '--staged'];
16+
export const getStagedFiles = async (): Promise<string[]> => {
3217
const { stdout: files } = await execa('git', [
33-
...diffStaged,
18+
'diff',
3419
'--name-only',
35-
...excludeBigFilesFromDiff
20+
'--cached'
3621
]);
3722

38-
if (!files) return null;
23+
if (!files) return [];
24+
25+
return files.split('\n').sort();
26+
};
27+
28+
export const getChangedFiles = async (): Promise<string[]> => {
29+
const { stdout: modified } = await execa('git', ['ls-files', '--modified']);
30+
const { stdout: others } = await execa('git', [
31+
'ls-files',
32+
'--others',
33+
'--exclude-standard'
34+
]);
35+
36+
const files = [...modified.split('\n'), ...others.split('\n')].filter(
37+
(file) => !!file
38+
);
39+
40+
return files.sort();
41+
};
42+
43+
export const gitAdd = async ({ files }: { files: string[] }) => {
44+
const gitAddSpinner = spinner();
45+
gitAddSpinner.start('Adding files to commit');
46+
await execa('git', ['add', ...files]);
47+
gitAddSpinner.stop('Done');
48+
};
49+
50+
export const getDiff = async ({ files }: { files: string[] }) => {
51+
const lockFiles = files.filter(
52+
(file) => file.includes('.lock') || file.includes('-lock.')
53+
);
54+
55+
if (lockFiles.length) {
56+
outro(
57+
`Some files are '.lock' files which are excluded by default from 'git diff'. No commit messages are generated for this files:\n${lockFiles.join(
58+
'\n'
59+
)}`
60+
);
61+
}
62+
63+
const filesWithoutLocks = files.filter(
64+
(file) => !file.includes('.lock') && !file.includes('-lock.')
65+
);
3966

4067
const { stdout: diff } = await execa('git', [
41-
...diffStaged,
42-
...excludeBigFilesFromDiff
68+
'diff',
69+
'--staged',
70+
...filesWithoutLocks
4371
]);
4472

45-
return {
46-
files: files.split('\n').sort(),
47-
diff
48-
};
73+
return diff;
4974
};

src/utils/trytm.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export const trytm = async <T>(
2+
promise: Promise<T>
3+
): Promise<[T, null] | [null, Error]> => {
4+
try {
5+
const data = await promise;
6+
return [data, null];
7+
} catch (throwable) {
8+
if (throwable instanceof Error) return [null, throwable];
9+
10+
throw throwable;
11+
}
12+
};

0 commit comments

Comments
 (0)