eckra 1.0.0
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/CONTRIBUTING.md +85 -0
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/package.json +47 -0
- package/screenshot.jpg +0 -0
- package/src/helpers/ai.js +240 -0
- package/src/helpers/config.js +122 -0
- package/src/helpers/git.js +655 -0
- package/src/helpers/lmstudio.js +11 -0
- package/src/helpers/patch.js +91 -0
- package/src/index.js +73 -0
- package/src/ui/app.js +177 -0
- package/src/ui/branch.js +295 -0
- package/src/ui/commit.js +250 -0
- package/src/ui/common.js +106 -0
- package/src/ui/config.js +269 -0
- package/src/ui/log.js +146 -0
- package/src/ui/menu.js +393 -0
- package/src/ui/modules/amend.js +43 -0
- package/src/ui/modules/blame.js +56 -0
- package/src/ui/modules/branch.js +223 -0
- package/src/ui/modules/commit.js +232 -0
- package/src/ui/modules/conflict.js +93 -0
- package/src/ui/modules/diff.js +68 -0
- package/src/ui/modules/log.js +52 -0
- package/src/ui/modules/more.js +94 -0
- package/src/ui/modules/rebase.js +72 -0
- package/src/ui/modules/remote.js +74 -0
- package/src/ui/modules/search.js +46 -0
- package/src/ui/modules/settings.js +123 -0
- package/src/ui/modules/stage.js +174 -0
- package/src/ui/modules/stash.js +96 -0
- package/src/ui/modules/stats.js +57 -0
- package/src/ui/modules/status.js +86 -0
- package/src/ui/modules/sync.js +73 -0
- package/src/ui/modules/tag.js +85 -0
- package/src/ui/modules/undo.js +49 -0
- package/src/ui/modules/worktree.js +131 -0
- package/src/ui/push.js +184 -0
- package/src/ui/status.js +156 -0
- package/tests/ai.test.js +112 -0
- package/tests/config.test.js +123 -0
- package/tests/patch.test.js +44 -0
package/src/ui/status.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
const chalk = require("chalk");
|
|
2
|
+
const Table = require("cli-table3");
|
|
3
|
+
const boxen = require("boxen");
|
|
4
|
+
const {
|
|
5
|
+
getGitStatus,
|
|
6
|
+
getCurrentBranch,
|
|
7
|
+
getRemotes,
|
|
8
|
+
} = require("../helpers/git");
|
|
9
|
+
|
|
10
|
+
async function showStatus() {
|
|
11
|
+
const status = await getGitStatus();
|
|
12
|
+
const remotes = await getRemotes();
|
|
13
|
+
|
|
14
|
+
console.log("\n");
|
|
15
|
+
|
|
16
|
+
// Branch info
|
|
17
|
+
console.log(
|
|
18
|
+
boxen(
|
|
19
|
+
chalk.cyan("šæ Branch: ") +
|
|
20
|
+
chalk.yellow.bold(status.current) +
|
|
21
|
+
(status.tracking ? chalk.gray(` ā ${status.tracking}`) : "") +
|
|
22
|
+
(status.ahead ? chalk.green(` ā${status.ahead}`) : "") +
|
|
23
|
+
(status.behind ? chalk.red(` ā${status.behind}`) : ""),
|
|
24
|
+
{
|
|
25
|
+
padding: { left: 2, right: 2, top: 0, bottom: 0 },
|
|
26
|
+
borderStyle: "round",
|
|
27
|
+
borderColor: "cyan",
|
|
28
|
+
},
|
|
29
|
+
),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// Staged files
|
|
33
|
+
if (status.staged.length > 0) {
|
|
34
|
+
console.log(
|
|
35
|
+
chalk.green.bold("\nā Staged Files (ready to commit):"),
|
|
36
|
+
);
|
|
37
|
+
const stagedTable = new Table({
|
|
38
|
+
head: [chalk.green("File"), chalk.green("Status")],
|
|
39
|
+
colWidths: [50, 15],
|
|
40
|
+
style: { head: [], border: ["gray"] },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
status.staged.forEach((file) => {
|
|
44
|
+
let fileStatus = "modified";
|
|
45
|
+
if (status.created.includes(file)) fileStatus = "new file";
|
|
46
|
+
if (status.deleted.includes(file)) fileStatus = "deleted";
|
|
47
|
+
if (status.renamed.includes(file)) fileStatus = "renamed";
|
|
48
|
+
|
|
49
|
+
stagedTable.push([chalk.green(file), chalk.green(fileStatus)]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
console.log(stagedTable.toString());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Modified files (not staged)
|
|
56
|
+
if (status.modified.length > 0 || status.deleted.length > 0) {
|
|
57
|
+
console.log(
|
|
58
|
+
chalk.red.bold("\nā Modified Files (not staged):"),
|
|
59
|
+
);
|
|
60
|
+
const modifiedTable = new Table({
|
|
61
|
+
head: [chalk.red("File"), chalk.red("Status")],
|
|
62
|
+
colWidths: [50, 15],
|
|
63
|
+
style: { head: [], border: ["gray"] },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
status.modified.forEach((file) => {
|
|
67
|
+
if (!status.staged.includes(file)) {
|
|
68
|
+
modifiedTable.push([chalk.red(file), chalk.red("modified")]);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
status.deleted.forEach((file) => {
|
|
73
|
+
if (!status.staged.includes(file)) {
|
|
74
|
+
modifiedTable.push([chalk.red(file), chalk.red("deleted")]);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (modifiedTable.length > 0) {
|
|
79
|
+
console.log(modifiedTable.toString());
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Untracked files
|
|
84
|
+
if (status.not_added.length > 0) {
|
|
85
|
+
console.log(chalk.blue.bold("\n? Untracked Files:"));
|
|
86
|
+
const untrackedTable = new Table({
|
|
87
|
+
head: [chalk.blue("File")],
|
|
88
|
+
colWidths: [65],
|
|
89
|
+
style: { head: [], border: ["gray"] },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
status.not_added.forEach((file) => {
|
|
93
|
+
untrackedTable.push([chalk.blue(file)]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
console.log(untrackedTable.toString());
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Conflicted files
|
|
100
|
+
if (status.conflicted.length > 0) {
|
|
101
|
+
console.log(chalk.yellow.bold("\nā ļø Conflicted Files:"));
|
|
102
|
+
const conflictTable = new Table({
|
|
103
|
+
head: [chalk.yellow("File")],
|
|
104
|
+
colWidths: [65],
|
|
105
|
+
style: { head: [], border: ["gray"] },
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
status.conflicted.forEach((file) => {
|
|
109
|
+
conflictTable.push([chalk.yellow(file)]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
console.log(conflictTable.toString());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Summary
|
|
116
|
+
const totalChanges =
|
|
117
|
+
status.staged.length + status.modified.length + status.not_added.length;
|
|
118
|
+
|
|
119
|
+
if (totalChanges === 0) {
|
|
120
|
+
console.log(
|
|
121
|
+
boxen(
|
|
122
|
+
chalk.green(
|
|
123
|
+
"⨠Working directory clean - nothing to commit.",
|
|
124
|
+
),
|
|
125
|
+
{ padding: 1, borderStyle: "round", borderColor: "green" },
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
} else {
|
|
129
|
+
console.log(chalk.gray("\nā".repeat(40)));
|
|
130
|
+
console.log(
|
|
131
|
+
chalk.white("Summary: ") +
|
|
132
|
+
chalk.green(`${status.staged.length} staged`) +
|
|
133
|
+
chalk.gray(" | ") +
|
|
134
|
+
chalk.red(`${status.modified.length} modified`) +
|
|
135
|
+
chalk.gray(" | ") +
|
|
136
|
+
chalk.blue(`${status.not_added.length} untracked`),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Remote info
|
|
141
|
+
if (remotes.length > 0) {
|
|
142
|
+
console.log(chalk.gray("\nš” Remotes:"));
|
|
143
|
+
remotes.forEach((remote) => {
|
|
144
|
+
console.log(
|
|
145
|
+
chalk.gray(` ${remote.name}: `) +
|
|
146
|
+
chalk.white(remote.refs.fetch || remote.refs.push),
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
console.log("\n");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = {
|
|
155
|
+
showStatus,
|
|
156
|
+
};
|
package/tests/ai.test.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
const axios = require('axios');
|
|
2
|
+
const { generateCommitMessage } = require('../src/helpers/ai');
|
|
3
|
+
const configHelper = require('../src/helpers/config');
|
|
4
|
+
|
|
5
|
+
jest.mock('axios');
|
|
6
|
+
jest.mock('../src/helpers/config');
|
|
7
|
+
|
|
8
|
+
describe('AI Helper', () => {
|
|
9
|
+
const mockDiff = 'diff content';
|
|
10
|
+
const mockFiles = ['file1.js'];
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
jest.clearAllMocks();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('should call OpenAI API correctly', async () => {
|
|
17
|
+
configHelper.getConfig.mockReturnValue({
|
|
18
|
+
aiProvider: 'openai',
|
|
19
|
+
openaiApiKey: 'sk-test',
|
|
20
|
+
openaiModel: 'gpt-4o'
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
axios.post.mockResolvedValue({
|
|
24
|
+
data: {
|
|
25
|
+
choices: [{ message: { content: 'feat: openai commit' } }]
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const message = await generateCommitMessage(mockDiff, mockFiles);
|
|
30
|
+
|
|
31
|
+
expect(message).toBe('feat: openai commit');
|
|
32
|
+
expect(axios.post).toHaveBeenCalledWith(
|
|
33
|
+
'https://api.openai.com/v1/chat/completions',
|
|
34
|
+
expect.objectContaining({
|
|
35
|
+
model: 'gpt-4o',
|
|
36
|
+
messages: expect.any(Array)
|
|
37
|
+
}),
|
|
38
|
+
expect.objectContaining({
|
|
39
|
+
headers: expect.objectContaining({
|
|
40
|
+
'Authorization': 'Bearer sk-test'
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('should call Anthropic API correctly', async () => {
|
|
47
|
+
configHelper.getConfig.mockReturnValue({
|
|
48
|
+
aiProvider: 'anthropic',
|
|
49
|
+
anthropicApiKey: 'sk-ant-test',
|
|
50
|
+
anthropicModel: 'claude-3'
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
axios.post.mockResolvedValue({
|
|
54
|
+
data: {
|
|
55
|
+
content: [{ text: 'feat: anthropic commit' }]
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const message = await generateCommitMessage(mockDiff, mockFiles);
|
|
60
|
+
|
|
61
|
+
expect(message).toBe('feat: anthropic commit');
|
|
62
|
+
expect(axios.post).toHaveBeenCalledWith(
|
|
63
|
+
'https://api.anthropic.com/v1/messages',
|
|
64
|
+
expect.objectContaining({
|
|
65
|
+
model: 'claude-3',
|
|
66
|
+
system: expect.any(String) // System prompt is separate in Anthropic
|
|
67
|
+
}),
|
|
68
|
+
expect.objectContaining({
|
|
69
|
+
headers: expect.objectContaining({
|
|
70
|
+
'x-api-key': 'sk-ant-test'
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('should call Ollama API correctly', async () => {
|
|
77
|
+
configHelper.getConfig.mockReturnValue({
|
|
78
|
+
aiProvider: 'ollama',
|
|
79
|
+
ollamaUrl: 'http://localhost:11434',
|
|
80
|
+
ollamaModel: 'llama3'
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
axios.post.mockResolvedValue({
|
|
84
|
+
data: {
|
|
85
|
+
message: { content: 'feat: ollama commit' }
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const message = await generateCommitMessage(mockDiff, mockFiles);
|
|
90
|
+
|
|
91
|
+
expect(message).toBe('feat: ollama commit');
|
|
92
|
+
expect(axios.post).toHaveBeenCalledWith(
|
|
93
|
+
'http://localhost:11434/api/chat',
|
|
94
|
+
expect.objectContaining({
|
|
95
|
+
model: 'llama3',
|
|
96
|
+
stream: false
|
|
97
|
+
}),
|
|
98
|
+
expect.any(Object)
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('should handle API errors gracefully', async () => {
|
|
103
|
+
configHelper.getConfig.mockReturnValue({ aiProvider: 'openai' });
|
|
104
|
+
|
|
105
|
+
axios.post.mockRejectedValue({
|
|
106
|
+
response: { status: 401, data: { error: 'Unauthorized' } }
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await expect(generateCommitMessage(mockDiff, mockFiles))
|
|
110
|
+
.rejects.toThrow('AI Provider Error (openai): 401');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const { getConfig, DEFAULT_CONFIG } = require('../src/helpers/config');
|
|
5
|
+
|
|
6
|
+
// Mock fs to avoid touching real files
|
|
7
|
+
jest.mock('fs');
|
|
8
|
+
|
|
9
|
+
describe('Config Helper', () => {
|
|
10
|
+
const MOCK_HOMEDIR = '/mock/home';
|
|
11
|
+
const MOCK_CWD = '/mock/project';
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
jest.resetModules();
|
|
15
|
+
jest.clearAllMocks();
|
|
16
|
+
|
|
17
|
+
// Mock os.homedir
|
|
18
|
+
jest.spyOn(os, 'homedir').mockReturnValue(MOCK_HOMEDIR);
|
|
19
|
+
|
|
20
|
+
// Mock process.cwd
|
|
21
|
+
jest.spyOn(process, 'cwd').mockReturnValue(MOCK_CWD);
|
|
22
|
+
|
|
23
|
+
// Default fs behavior
|
|
24
|
+
fs.existsSync.mockReturnValue(false);
|
|
25
|
+
fs.mkdirSync.mockImplementation(() => {});
|
|
26
|
+
fs.readFileSync.mockImplementation(() => '');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('should return default config when no config files exist', () => {
|
|
30
|
+
const config = getConfig();
|
|
31
|
+
expect(config).toEqual(DEFAULT_CONFIG);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('should merge global config correctly', () => {
|
|
35
|
+
const globalConfig = {
|
|
36
|
+
model: 'global-model-v1',
|
|
37
|
+
lmStudioUrl: 'http://global-url:1234'
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Setup mocks for global config presence
|
|
41
|
+
fs.existsSync.mockImplementation((filePath) => {
|
|
42
|
+
if (filePath.includes('.eckra') && filePath.includes('config.json')) return true;
|
|
43
|
+
return false;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
fs.readFileSync.mockImplementation((filePath) => {
|
|
47
|
+
if (filePath.includes('.eckra') && filePath.includes('config.json')) {
|
|
48
|
+
return JSON.stringify(globalConfig);
|
|
49
|
+
}
|
|
50
|
+
return '';
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const config = getConfig();
|
|
54
|
+
|
|
55
|
+
expect(config.model).toBe(globalConfig.model);
|
|
56
|
+
expect(config.lmStudioUrl).toBe(globalConfig.lmStudioUrl);
|
|
57
|
+
// Should verify other defaults remain
|
|
58
|
+
expect(config.language).toBe(DEFAULT_CONFIG.language);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('should prioritize local .eckrarc over global config', () => {
|
|
62
|
+
const globalConfig = {
|
|
63
|
+
model: 'global-model',
|
|
64
|
+
aiProvider: 'lmstudio'
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const localConfig = {
|
|
68
|
+
model: 'local-model',
|
|
69
|
+
aiProvider: 'openai'
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Setup mocks for both files
|
|
73
|
+
fs.existsSync.mockImplementation((filePath) => {
|
|
74
|
+
// Global config
|
|
75
|
+
if (filePath.includes('.eckra') && filePath.includes('config.json')) return true;
|
|
76
|
+
// Local config in CWD
|
|
77
|
+
if (filePath === path.join(MOCK_CWD, '.eckrarc')) return true;
|
|
78
|
+
return false;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
fs.readFileSync.mockImplementation((filePath) => {
|
|
82
|
+
if (filePath.includes('config.json')) return JSON.stringify(globalConfig);
|
|
83
|
+
if (filePath.includes('.eckrarc')) return JSON.stringify(localConfig);
|
|
84
|
+
return '';
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const config = getConfig();
|
|
88
|
+
|
|
89
|
+
expect(config.model).toBe(localConfig.model);
|
|
90
|
+
expect(config.aiProvider).toBe(localConfig.aiProvider);
|
|
91
|
+
expect(config.language).toBe(DEFAULT_CONFIG.language);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('should look for .eckrarc in parent directories', () => {
|
|
95
|
+
const parentDir = path.dirname(MOCK_CWD);
|
|
96
|
+
const localConfig = { aiInstruction: 'parent instruction' };
|
|
97
|
+
|
|
98
|
+
// Update cwd mock to be a subdir
|
|
99
|
+
jest.spyOn(process, 'cwd').mockReturnValue(MOCK_CWD);
|
|
100
|
+
|
|
101
|
+
// Setup mocks
|
|
102
|
+
fs.existsSync.mockImplementation((filePath) => {
|
|
103
|
+
// Not in CWD
|
|
104
|
+
if (filePath === path.join(MOCK_CWD, '.eckrarc')) return false;
|
|
105
|
+
// Found in Parent
|
|
106
|
+
if (filePath === path.join(parentDir, '.eckrarc')) return true;
|
|
107
|
+
return false;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
fs.readFileSync.mockImplementation((filePath) => {
|
|
111
|
+
if (filePath === path.join(parentDir, '.eckrarc')) return JSON.stringify(localConfig);
|
|
112
|
+
return '';
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// We need to mock path.dirname to actually traverse up in our mocked environment logic if needed,
|
|
116
|
+
// but getConfig uses real path module. Ideally we rely on real path behavior or mock carefully.
|
|
117
|
+
// The getConfig implementation uses a while loop with path.dirname.
|
|
118
|
+
// Since we are mocking fs, we just need to ensure the loop calls fs.existsSync with the parent path.
|
|
119
|
+
|
|
120
|
+
const config = getConfig();
|
|
121
|
+
expect(config.aiInstruction).toBe(localConfig.aiInstruction);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const { parseDiff, generatePatch } = require('../src/helpers/patch');
|
|
2
|
+
|
|
3
|
+
describe('Patch Helper', () => {
|
|
4
|
+
const mockDiff = `diff --git a/test.js b/test.js
|
|
5
|
+
index 1234567..89abcdef 100644
|
|
6
|
+
--- a/test.js
|
|
7
|
+
+++ b/test.js
|
|
8
|
+
@@ -1,3 +1,4 @@
|
|
9
|
+
line 1
|
|
10
|
+
+added line
|
|
11
|
+
line 2
|
|
12
|
+
line 3
|
|
13
|
+
@@ -10,3 +11,3 @@
|
|
14
|
+
old line
|
|
15
|
+
-removed line
|
|
16
|
+
+new line
|
|
17
|
+
other line`;
|
|
18
|
+
|
|
19
|
+
test('should parse diff into hunks correctly', () => {
|
|
20
|
+
const files = parseDiff(mockDiff);
|
|
21
|
+
|
|
22
|
+
expect(files).toHaveLength(1);
|
|
23
|
+
expect(files[0].name).toBe('test.js');
|
|
24
|
+
expect(files[0].hunks).toHaveLength(2);
|
|
25
|
+
|
|
26
|
+
expect(files[0].hunks[0].header).toContain('@@ -1,3 +1,4 @@');
|
|
27
|
+
expect(files[0].hunks[1].header).toContain('@@ -10,3 +11,3 @@');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('should generate patch for selected hunks', () => {
|
|
31
|
+
const files = parseDiff(mockDiff);
|
|
32
|
+
const patch = generatePatch(files[0], [0]); // Only first hunk
|
|
33
|
+
|
|
34
|
+
expect(patch).toContain('@@ -1,3 +1,4 @@');
|
|
35
|
+
expect(patch).not.toContain('@@ -10,3 +11,3 @@');
|
|
36
|
+
expect(patch).toContain('+added line');
|
|
37
|
+
expect(patch).not.toContain('-removed line');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('should handle empty diffs', () => {
|
|
41
|
+
const files = parseDiff('');
|
|
42
|
+
expect(files).toHaveLength(0);
|
|
43
|
+
});
|
|
44
|
+
});
|