nocommit 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # nocommit
2
+
3
+ To install dependencies:
4
+
5
+ ```bash
6
+ bun install
7
+ ```
8
+
9
+ To run:
10
+
11
+ ```bash
12
+ bun run index.ts
13
+ ```
14
+
15
+ This project was created using `bun init` in bun v1.3.0. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
package/dist/ai.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ /** Options for generating commit messages */
2
+ export interface GenerateOptions {
3
+ diffSnippets: string;
4
+ files: string[];
5
+ maxLength?: number;
6
+ count?: number;
7
+ timeout?: number;
8
+ }
9
+ export declare const generateCommitMessages: (options: GenerateOptions) => Promise<string[]>;
10
+ export declare const generateCommitMessage: (diff: string) => Promise<string>;
package/dist/ai.js ADDED
@@ -0,0 +1,97 @@
1
+ import { GoogleGenAI } from "@google/genai";
2
+ import { getApiKey, getConfig } from "./config.js";
3
+ import { KnownError } from "./error.js";
4
+ const buildPrompt = (diffSnippets, fileList, maxLength, count) => `You are an expert developer writing git commit messages.
5
+
6
+ CHANGES:
7
+ Files: ${fileList.join(", ")}
8
+
9
+ ${diffSnippets ? `CODE DIFF:\n${diffSnippets}` : ""}
10
+
11
+ TASK: Generate ${count} different commit message${count > 1 ? "s" : ""} for these changes.
12
+
13
+ FORMAT RULES:
14
+ - Use Conventional Commits: <type>: <subject>
15
+ - NO scope (use "feat: add login" NOT "feat(auth): add login")
16
+ - Maximum ${maxLength} characters per message
17
+ - Use imperative mood ("add" not "added")
18
+ - Be specific but concise
19
+
20
+ TYPES:
21
+ - feat: NEW user-facing feature
22
+ - fix: bug fix
23
+ - docs: documentation only
24
+ - style: formatting, missing semi-colons
25
+ - refactor: code change without new feature or fix
26
+ - perf: performance improvement
27
+ - test: adding/correcting tests
28
+ - build: build system, dependencies
29
+ - ci: CI configuration
30
+ - chore: maintenance, config updates
31
+
32
+ EXAMPLES (correct):
33
+ - feat: add user authentication with JWT
34
+ - fix: resolve memory leak in image processor
35
+ - refactor: simplify error handling logic
36
+
37
+ WRONG (do not use scope):
38
+ - feat(auth): add login
39
+
40
+ Output ${count > 1 ? `exactly ${count} commit messages, one per line` : "only the commit message"}, no explanations or numbering.`;
41
+ // Generates commit messages using Gemini
42
+ export const generateCommitMessages = async (options) => {
43
+ const apiKey = getApiKey();
44
+ const model = getConfig("model");
45
+ const maxLength = options.maxLength ?? getConfig("maxLength");
46
+ const count = options.count ?? getConfig("generate");
47
+ const ai = new GoogleGenAI({ apiKey });
48
+ const prompt = buildPrompt(options.diffSnippets, options.files, maxLength, count);
49
+ try {
50
+ const response = await ai.models.generateContent({
51
+ model,
52
+ contents: prompt,
53
+ config: {
54
+ maxOutputTokens: 256,
55
+ temperature: 0.7,
56
+ },
57
+ });
58
+ const text = response.text?.trim();
59
+ if (!text) {
60
+ throw new KnownError("AI returned empty response. Please try again");
61
+ }
62
+ // Parse response: split by newlines, remove empty lines
63
+ const messages = text
64
+ .split("\n")
65
+ .map((line) => line.trim())
66
+ .filter((line) => line.length > 0 && !line.match(/^\d+\./))
67
+ .map((line) => line.replace(/^[-*]\s*/, ""));
68
+ if (messages.length === 0) {
69
+ throw new KnownError("AI returned no valid commit messages.");
70
+ }
71
+ return messages.slice(0, count);
72
+ }
73
+ catch (error) {
74
+ if (error instanceof KnownError) {
75
+ throw error;
76
+ }
77
+ if (error.message?.includes("API key")) {
78
+ throw new KnownError("Invalid Gemini API Key. Run: nc config set GEMINI_API_KEY=<key>");
79
+ }
80
+ if (error.message?.includes("Quota")) {
81
+ throw new KnownError("API quota exceeded. Please check your Gemini API usage limits.");
82
+ }
83
+ if (error.message?.includes("network") || error.code === "ENOTFOUND") {
84
+ throw new KnownError("Network error. Please check your internet connection.");
85
+ }
86
+ throw new KnownError(`AI error: ${error.message}`);
87
+ }
88
+ };
89
+ // generate a single commit message
90
+ export const generateCommitMessage = async (diff) => {
91
+ const messages = await generateCommitMessages({
92
+ diffSnippets: diff,
93
+ files: [],
94
+ count: 1,
95
+ });
96
+ return messages[0] || "";
97
+ };
@@ -0,0 +1,11 @@
1
+ export interface ConfigSchema {
2
+ GEMINI_API_KEY: string;
3
+ model: string;
4
+ maxLength: number;
5
+ timeout: number;
6
+ generate: number;
7
+ }
8
+ export declare const getConfig: <K extends keyof ConfigSchema>(key: K) => ConfigSchema[K];
9
+ export declare const setConfig: <K extends keyof ConfigSchema>(key: K, value: ConfigSchema[K]) => void;
10
+ export declare const getAllConfig: () => ConfigSchema;
11
+ export declare const getApiKey: () => string;
package/dist/config.js ADDED
@@ -0,0 +1,33 @@
1
+ import Conf from "conf";
2
+ import { KnownError } from "./error.js";
3
+ const config = new Conf({
4
+ projectName: "nocommit",
5
+ defaults: {
6
+ GEMINI_API_KEY: "",
7
+ model: "gemini-2.5-flash",
8
+ maxLength: 72,
9
+ timeout: 30000,
10
+ generate: 3,
11
+ },
12
+ });
13
+ // export const getConfig = (key: string) => {
14
+ // return config.get(key as any);
15
+ // };
16
+ // get Config
17
+ export const getConfig = (key) => {
18
+ return config.get(key);
19
+ };
20
+ // set Config
21
+ export const setConfig = (key, value) => {
22
+ config.set(key, value);
23
+ };
24
+ export const getAllConfig = () => {
25
+ return config.store;
26
+ };
27
+ export const getApiKey = () => {
28
+ const key = config.get("GEMINI_API_KEY");
29
+ if (!key) {
30
+ throw new KnownError("Missing API Key. Run: nc config set GEMINI_API_KEY=<Key Your API Key>");
31
+ }
32
+ return key;
33
+ };
@@ -0,0 +1,5 @@
1
+ export declare class KnownError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ export declare const isKnownError: (error: unknown) => error is KnownError;
5
+ export declare const handleCliError: (error: unknown) => void;
package/dist/error.js ADDED
@@ -0,0 +1,18 @@
1
+ export class KnownError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = "KnownError";
5
+ }
6
+ }
7
+ export const isKnownError = (error) => {
8
+ return error instanceof KnownError;
9
+ };
10
+ export const handleCliError = (error) => {
11
+ if (isKnownError(error)) {
12
+ process.exit(1);
13
+ }
14
+ if (error instanceof Error) {
15
+ console.error("\nUnexpected error", error.stack);
16
+ }
17
+ process.exit(1);
18
+ };
package/dist/git.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ export declare const isGitRepo: () => Promise<boolean>;
2
+ export declare const assertGitRepo: () => Promise<void>;
3
+ export declare const stageAllChanges: () => Promise<void>;
4
+ export declare const getStagedDiff: () => Promise<string>;
5
+ export declare const hasStagedChanges: () => Promise<boolean>;
6
+ export declare const getStagedFiles: () => Promise<string[]>;
7
+ export declare const commitChanges: (message: string) => Promise<void>;
8
+ /**
9
+ * Extracts only changed lines from diff for token efficiency.
10
+ * Limits: 5 files max, 30 lines per file, 4000 chars total.
11
+ */
12
+ export declare const buildDiffSnippets: (files: string[], perFileMaxLines?: number, totalMaxChars?: number) => Promise<string>;
13
+ export declare const getStagedSummary: () => Promise<string>;
package/dist/git.js ADDED
@@ -0,0 +1,136 @@
1
+ import { execa } from "execa";
2
+ import { KnownError } from "./error.js";
3
+ // Exclude from diff to save API Token
4
+ const EXCLUDE = [
5
+ "package-lock.json",
6
+ "pnpm-lock.yaml",
7
+ "yarn.lock",
8
+ "bun.lock",
9
+ "node_modules/**",
10
+ "dist/**",
11
+ "build/**",
12
+ ".next/**",
13
+ "*.min.js",
14
+ "*.map",
15
+ "*.log",
16
+ ].flatMap((file) => [":(exclude)" + file]);
17
+ // Check git init or not
18
+ export const isGitRepo = async () => {
19
+ try {
20
+ await execa("git", ["rev-parse", "--is-inside-work-tree"]);
21
+ return true;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ };
27
+ export const assertGitRepo = async () => {
28
+ if (!(await isGitRepo())) {
29
+ throw new KnownError("Not a git repository. Run this command inside 'git init'");
30
+ }
31
+ };
32
+ // Check Stages everything (git add .)
33
+ export const stageAllChanges = async () => {
34
+ await execa("git", ["add", "--update"]);
35
+ };
36
+ // gets the actual code changes
37
+ export const getStagedDiff = async () => {
38
+ try {
39
+ const { stdout } = await execa("git", ["diff", "--staged", ...EXCLUDE]);
40
+ return stdout;
41
+ }
42
+ catch {
43
+ return "";
44
+ }
45
+ };
46
+ export const hasStagedChanges = async () => {
47
+ const { stdout } = await execa("git", [
48
+ "diff",
49
+ "--staged",
50
+ "--name-only",
51
+ ...EXCLUDE,
52
+ ]);
53
+ return stdout.trim().length > 0;
54
+ };
55
+ // gets the list of filenames
56
+ export const getStagedFiles = async () => {
57
+ const { stdout } = await execa("git", [
58
+ "diff",
59
+ "--staged",
60
+ "--name-only",
61
+ ...EXCLUDE,
62
+ ]);
63
+ return stdout.split("\n").filter(Boolean);
64
+ };
65
+ // git commit -m "your message here"
66
+ export const commitChanges = async (message) => {
67
+ await execa("git", ["commit", "-m", message], {
68
+ stdio: "inherit",
69
+ });
70
+ };
71
+ /**
72
+ * Extracts only changed lines from diff for token efficiency.
73
+ * Limits: 5 files max, 30 lines per file, 4000 chars total.
74
+ */
75
+ export const buildDiffSnippets = async (files, perFileMaxLines = 30, totalMaxChars = 4000) => {
76
+ try {
77
+ const targetFiles = files.slice(0, 5);
78
+ const parts = [];
79
+ let remaining = totalMaxChars;
80
+ for (const file of targetFiles) {
81
+ const { stdout } = await execa("git", [
82
+ "diff",
83
+ "--cached",
84
+ "--unified=0",
85
+ "--",
86
+ file,
87
+ ]);
88
+ if (!stdout)
89
+ continue;
90
+ const lines = stdout.split("\n").filter(Boolean);
91
+ const picked = [];
92
+ let count = 0;
93
+ for (const line of lines) {
94
+ const isHunk = line.startsWith("@@");
95
+ const isChange = (line.startsWith("+") || line.startsWith("-")) &&
96
+ !line.startsWith("+++") &&
97
+ !line.startsWith("---");
98
+ if (isHunk || isChange) {
99
+ picked.push(line);
100
+ count++;
101
+ if (count >= perFileMaxLines)
102
+ break;
103
+ }
104
+ }
105
+ if (picked.length > 0) {
106
+ const block = [`# ${file}`, ...picked].join("\n");
107
+ if (block.length <= remaining) {
108
+ parts.push(block);
109
+ remaining -= block.length;
110
+ }
111
+ else {
112
+ parts.push(block.slice(0, Math.max(0, remaining)));
113
+ remaining = 0;
114
+ }
115
+ }
116
+ if (remaining <= 0)
117
+ break;
118
+ }
119
+ if (parts.length === 0)
120
+ return "";
121
+ return parts.join("\n\n");
122
+ }
123
+ catch {
124
+ return "";
125
+ }
126
+ };
127
+ // gets a high-level statistical summary
128
+ export const getStagedSummary = async () => {
129
+ const { stdout } = await execa("git", [
130
+ "diff",
131
+ "--staged",
132
+ "--stat",
133
+ ...EXCLUDE,
134
+ ]);
135
+ return stdout;
136
+ };
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { intro, outro, spinner, select, text, isCancel } from "@clack/prompts";
4
+ import pc from "picocolors";
5
+ import { setConfig, getConfig } from "./config.js";
6
+ import { assertGitRepo, hasStagedChanges, stageAllChanges, commitChanges, getStagedFiles, buildDiffSnippets, } from "./git.js";
7
+ import { generateCommitMessages } from "./ai.js";
8
+ import { KnownError, handleCliError } from "./error.js";
9
+ const program = new Command();
10
+ program
11
+ .name("nocommit")
12
+ .version("0.0.0")
13
+ .description("AI-powered git commit message generator")
14
+ .option("-a, --all", "Stage all tracked changes before committing")
15
+ .option("-y, --yes", "Skip confirmation and commit with first suggestion");
16
+ // Main command execution logic for generating and committing messages
17
+ program.action(async (options) => {
18
+ intro(pc.bgCyan(pc.black(" nocommit ")));
19
+ try {
20
+ await assertGitRepo();
21
+ if (options.all) {
22
+ await stageAllChanges();
23
+ }
24
+ if (!(await hasStagedChanges())) {
25
+ throw new KnownError('No staged changes found. Stage files with "git add ." or use --all flag.');
26
+ }
27
+ const s = spinner();
28
+ s.start("Detecting staged files...");
29
+ const files = await getStagedFiles();
30
+ s.stop(`Found ${files.length} staged file(s):`);
31
+ console.log(pc.dim(files.map((f) => ` ${f}`).join("\n")));
32
+ s.start("Analyzing changes with AI...");
33
+ const diffSnippets = await buildDiffSnippets(files);
34
+ const messages = await generateCommitMessages({
35
+ diffSnippets,
36
+ files,
37
+ });
38
+ s.stop("Generated commit message");
39
+ if (options.yes) {
40
+ const message = messages[0];
41
+ console.log(pc.dim(`Using: ${message}`));
42
+ await commitChanges(message);
43
+ outro(pc.green("✔ Committed!"));
44
+ return;
45
+ }
46
+ let selectedMessage = messages[0];
47
+ while (true) {
48
+ const action = await select({
49
+ message: `${pc.green(selectedMessage)}`,
50
+ options: [
51
+ { value: "commit", label: "✔ Commit" },
52
+ { value: "edit", label: "✎ Edit" },
53
+ { value: "retry", label: "↻ Regenerate" },
54
+ { value: "cancel", label: "✖ Cancel" },
55
+ ],
56
+ });
57
+ if (isCancel(action) || action === "cancel") {
58
+ outro("Cancelled");
59
+ process.exit(0);
60
+ }
61
+ if (action === "commit") {
62
+ await commitChanges(selectedMessage);
63
+ outro(pc.green("✔ Committed!"));
64
+ process.exit(0);
65
+ }
66
+ if (action === "edit") {
67
+ const newMessage = await text({
68
+ message: "Edit message:",
69
+ initialValue: selectedMessage,
70
+ validate: (value) => {
71
+ if (!value || value.trim().length === 0) {
72
+ return "Message cannot be empty";
73
+ }
74
+ return undefined;
75
+ },
76
+ });
77
+ if (!isCancel(newMessage)) {
78
+ selectedMessage = newMessage.trim();
79
+ }
80
+ }
81
+ if (action === "retry") {
82
+ s.start("Regenerating...");
83
+ const newMessages = await generateCommitMessages({
84
+ diffSnippets,
85
+ files,
86
+ });
87
+ s.stop("New message generated");
88
+ selectedMessage = newMessages[0];
89
+ }
90
+ }
91
+ }
92
+ catch (error) {
93
+ outro(pc.red(error.message));
94
+ handleCliError(error);
95
+ }
96
+ });
97
+ // Command to manage application configuration, including API key and model settings
98
+ const configCmd = program.command("config").description("Manage configuration");
99
+ const VALID_CONFIG_KEYS = [
100
+ "GEMINI_API_KEY",
101
+ "model",
102
+ "maxLength",
103
+ "timeout",
104
+ "generate",
105
+ ];
106
+ configCmd
107
+ .command("set <key=value>")
108
+ .description("Set a config value")
109
+ .action((str) => {
110
+ const eqIndex = str.indexOf("=");
111
+ if (eqIndex === -1) {
112
+ console.log(pc.red("Usage: config set KEY=VALUE"));
113
+ process.exit(1);
114
+ }
115
+ const key = str.slice(0, eqIndex);
116
+ const val = str.slice(eqIndex + 1);
117
+ if (!key || !val) {
118
+ console.log(pc.red("Usage: config set KEY=VALUE"));
119
+ process.exit(1);
120
+ }
121
+ if (!VALID_CONFIG_KEYS.includes(key)) {
122
+ console.log(pc.red(`Invalid key: ${key}`));
123
+ console.log(pc.yellow(`Valid keys: ${VALID_CONFIG_KEYS.join(", ")}`));
124
+ process.exit(1);
125
+ }
126
+ if (key === "maxLength") {
127
+ const num = parseInt(val, 10);
128
+ if (isNaN(num) || num < 20 || num > 500) {
129
+ console.log(pc.red("maxLength must be a number between 20 and 500"));
130
+ process.exit(1);
131
+ }
132
+ setConfig("maxLength", num);
133
+ }
134
+ else if (key === "timeout") {
135
+ const num = parseInt(val, 10);
136
+ if (isNaN(num) || num < 5000 || num > 120000) {
137
+ console.log(pc.red("timeout must be a number between 5000 and 120000 (ms)"));
138
+ process.exit(1);
139
+ }
140
+ setConfig("timeout", num);
141
+ }
142
+ else if (key === "generate") {
143
+ const num = parseInt(val, 10);
144
+ if (isNaN(num) || num < 1 || num > 5) {
145
+ console.log(pc.red("generate must be a number between 1 and 5"));
146
+ process.exit(1);
147
+ }
148
+ setConfig("generate", num);
149
+ }
150
+ else {
151
+ setConfig(key, val);
152
+ }
153
+ console.log(pc.green(`✔ Set ${key}`));
154
+ });
155
+ configCmd
156
+ .command("get [key]")
157
+ .description("Get config value(s)")
158
+ .action((key) => {
159
+ if (key) {
160
+ if (!VALID_CONFIG_KEYS.includes(key)) {
161
+ console.log(pc.red(`Invalid key: ${key}`));
162
+ console.log(pc.yellow(`Valid keys: ${VALID_CONFIG_KEYS.join(", ")}`));
163
+ process.exit(1);
164
+ }
165
+ const val = getConfig(key);
166
+ // Mask API key for security
167
+ if (key === "GEMINI_API_KEY" &&
168
+ typeof val === "string" &&
169
+ val.length > 8) {
170
+ console.log(`${key}: ${val.slice(0, 4)}...${val.slice(-4)}`);
171
+ }
172
+ else {
173
+ console.log(`${key}: ${val}`);
174
+ }
175
+ }
176
+ else {
177
+ console.log(pc.bold("Current configuration:"));
178
+ for (const k of VALID_CONFIG_KEYS) {
179
+ const val = getConfig(k);
180
+ if (k === "GEMINI_API_KEY" &&
181
+ typeof val === "string" &&
182
+ val.length > 8) {
183
+ console.log(` ${k}: ${val.slice(0, 4)}...${val.slice(-4)}`);
184
+ }
185
+ else {
186
+ console.log(` ${k}: ${val}`);
187
+ }
188
+ }
189
+ }
190
+ });
191
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "nocommit",
3
+ "version": "0.0.1",
4
+ "description": "Writes your git commit messages for you with AI using Gemini",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "nocommit": "dist/index.js",
9
+ "nc": "dist/index.js"
10
+ },
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "dev": "tsc --watch",
14
+ "start": "node dist/index.js",
15
+ "prepublishOnly": "npm run build"
16
+ },
17
+ "keywords": [
18
+ "git",
19
+ "commit",
20
+ "ai",
21
+ "gemini",
22
+ "cli"
23
+ ],
24
+ "author": "Asim Sk",
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "engines": {
29
+ "node": ">=18.0.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^25.0.10"
33
+ },
34
+ "peerDependencies": {
35
+ "typescript": "^5.9.3"
36
+ },
37
+ "dependencies": {
38
+ "@clack/prompts": "^0.11.0",
39
+ "@google/genai": "^1.38.0",
40
+ "commander": "^14.0.2",
41
+ "conf": "^15.0.2",
42
+ "execa": "^9.6.1",
43
+ "picocolors": "^1.1.1"
44
+ }
45
+ }