Skip to content

Commit 8a8acd3

Browse files
committed
Added save command that pushes to remote repo.
1 parent c379a89 commit 8a8acd3

File tree

3 files changed

+316
-0
lines changed

3 files changed

+316
-0
lines changed

src/__tests__/save.test.ts

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
jest.mock('fs-extra', () => ({
2+
__esModule: true,
3+
default: {
4+
pathExists: jest.fn(),
5+
},
6+
}));
7+
8+
jest.mock('execa', () => ({
9+
__esModule: true,
10+
execa: jest.fn(),
11+
}));
12+
13+
jest.mock('inquirer', () => ({
14+
__esModule: true,
15+
default: {
16+
prompt: jest.fn(),
17+
},
18+
}));
19+
20+
import fs from 'fs-extra';
21+
import { execa } from 'execa';
22+
import inquirer from 'inquirer';
23+
import { runSave } from '../save.js';
24+
25+
const mockPathExists = fs.pathExists as jest.MockedFunction<typeof fs.pathExists>;
26+
const mockExeca = execa as jest.MockedFunction<typeof execa>;
27+
const mockPrompt = inquirer.prompt as jest.MockedFunction<typeof inquirer.prompt>;
28+
29+
const cwd = '/tmp/my-repo';
30+
31+
describe('runSave', () => {
32+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
33+
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
34+
35+
beforeEach(() => {
36+
jest.clearAllMocks();
37+
});
38+
39+
afterAll(() => {
40+
consoleErrorSpy.mockRestore();
41+
consoleLogSpy.mockRestore();
42+
});
43+
44+
it('throws and logs error when .git directory does not exist', async () => {
45+
mockPathExists.mockResolvedValue(false);
46+
47+
await expect(runSave(cwd)).rejects.toThrow('Not a git repository');
48+
expect(mockPathExists).toHaveBeenCalledWith(`${cwd}/.git`);
49+
expect(consoleErrorSpy).toHaveBeenCalledWith(
50+
expect.stringContaining('This directory is not a git repository'),
51+
);
52+
expect(mockExeca).not.toHaveBeenCalled();
53+
expect(mockPrompt).not.toHaveBeenCalled();
54+
});
55+
56+
it('logs "No changes to save" and returns when working tree is clean', async () => {
57+
mockPathExists.mockResolvedValue(true);
58+
mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
59+
60+
await runSave(cwd);
61+
62+
expect(mockExeca).toHaveBeenCalledTimes(1);
63+
expect(mockExeca).toHaveBeenCalledWith('git', ['status', '--porcelain'], {
64+
cwd,
65+
encoding: 'utf8',
66+
});
67+
expect(consoleLogSpy).toHaveBeenCalledWith(
68+
expect.stringContaining('No changes to save'),
69+
);
70+
expect(mockPrompt).not.toHaveBeenCalled();
71+
});
72+
73+
it('logs "No changes to save" when status output is only whitespace', async () => {
74+
mockPathExists.mockResolvedValue(true);
75+
mockExeca.mockResolvedValue({ stdout: ' \n ', stderr: '', exitCode: 0 });
76+
77+
await runSave(cwd);
78+
79+
expect(consoleLogSpy).toHaveBeenCalledWith(
80+
expect.stringContaining('No changes to save'),
81+
);
82+
expect(mockPrompt).not.toHaveBeenCalled();
83+
});
84+
85+
it('prompts to save; when user says no, logs "Nothing has been saved"', async () => {
86+
mockPathExists.mockResolvedValue(true);
87+
mockExeca.mockResolvedValue({ stdout: ' M file.ts', stderr: '', exitCode: 0 });
88+
mockPrompt.mockResolvedValueOnce({ saveChanges: false });
89+
90+
await runSave(cwd);
91+
92+
expect(mockPrompt).toHaveBeenCalledTimes(1);
93+
expect(mockPrompt).toHaveBeenCalledWith([
94+
expect.objectContaining({
95+
type: 'confirm',
96+
name: 'saveChanges',
97+
message: expect.stringContaining('Would you like to save them?'),
98+
}),
99+
]);
100+
expect(consoleLogSpy).toHaveBeenCalledWith(
101+
expect.stringContaining('Nothing has been saved'),
102+
);
103+
expect(mockExeca).toHaveBeenCalledTimes(1); // only status, no add/commit/push
104+
});
105+
106+
it('when user says yes but message is empty, logs "No message provided"', async () => {
107+
mockPathExists.mockResolvedValue(true);
108+
mockExeca.mockResolvedValue({ stdout: ' M file.ts', stderr: '', exitCode: 0 });
109+
mockPrompt
110+
.mockResolvedValueOnce({ saveChanges: true })
111+
.mockResolvedValueOnce({ message: ' ' });
112+
113+
await runSave(cwd);
114+
115+
expect(mockPrompt).toHaveBeenCalledTimes(2);
116+
expect(consoleLogSpy).toHaveBeenCalledWith(
117+
expect.stringContaining('No message provided'),
118+
);
119+
expect(mockExeca).toHaveBeenCalledTimes(1); // only status
120+
});
121+
122+
it('when user says yes and provides message, runs add, commit, push and logs success', async () => {
123+
mockPathExists.mockResolvedValue(true);
124+
mockExeca
125+
.mockResolvedValueOnce({ stdout: ' M file.ts', stderr: '', exitCode: 0 })
126+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
127+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
128+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
129+
mockPrompt
130+
.mockResolvedValueOnce({ saveChanges: true })
131+
.mockResolvedValueOnce({ message: 'Fix bug in save command' });
132+
133+
await runSave(cwd);
134+
135+
expect(mockExeca).toHaveBeenCalledTimes(4);
136+
expect(mockExeca).toHaveBeenNthCalledWith(1, 'git', ['status', '--porcelain'], {
137+
cwd,
138+
encoding: 'utf8',
139+
});
140+
expect(mockExeca).toHaveBeenNthCalledWith(2, 'git', ['add', '.'], {
141+
cwd,
142+
stdio: 'inherit',
143+
});
144+
expect(mockExeca).toHaveBeenNthCalledWith(3, 'git', [
145+
'commit',
146+
'-m',
147+
'Fix bug in save command',
148+
], { cwd, stdio: 'inherit' });
149+
expect(mockExeca).toHaveBeenNthCalledWith(4, 'git', ['push'], {
150+
cwd,
151+
stdio: 'inherit',
152+
});
153+
expect(consoleLogSpy).toHaveBeenCalledWith(
154+
expect.stringContaining('Changes saved and pushed to GitHub successfully'),
155+
);
156+
});
157+
158+
it('throws and logs push-failure message when push fails with exitCode 128', async () => {
159+
mockPathExists.mockResolvedValue(true);
160+
mockExeca
161+
.mockResolvedValueOnce({ stdout: ' M file.ts', stderr: '', exitCode: 0 })
162+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
163+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
164+
.mockRejectedValueOnce(Object.assign(new Error('push failed'), { exitCode: 128 }));
165+
166+
mockPrompt
167+
.mockResolvedValueOnce({ saveChanges: true })
168+
.mockResolvedValueOnce({ message: 'WIP' });
169+
170+
await expect(runSave(cwd)).rejects.toMatchObject({ message: 'push failed' });
171+
172+
expect(consoleErrorSpy).toHaveBeenCalledWith(
173+
expect.stringContaining('Push failed'),
174+
);
175+
expect(consoleErrorSpy).toHaveBeenCalledWith(
176+
expect.stringContaining('remote'),
177+
);
178+
});
179+
180+
it('throws and logs generic failure when add/commit/push fails with other exitCode', async () => {
181+
mockPathExists.mockResolvedValue(true);
182+
mockExeca
183+
.mockResolvedValueOnce({ stdout: ' M file.ts', stderr: '', exitCode: 0 })
184+
.mockRejectedValueOnce(Object.assign(new Error('add failed'), { exitCode: 1 }));
185+
186+
mockPrompt
187+
.mockResolvedValueOnce({ saveChanges: true })
188+
.mockResolvedValueOnce({ message: 'WIP' });
189+
190+
await expect(runSave(cwd)).rejects.toMatchObject({ message: 'add failed' });
191+
192+
expect(consoleErrorSpy).toHaveBeenCalledWith(
193+
expect.stringContaining('Save or push failed'),
194+
);
195+
});
196+
197+
it('throws and logs error when execa throws without exitCode', async () => {
198+
mockPathExists.mockResolvedValue(true);
199+
mockExeca
200+
.mockResolvedValueOnce({ stdout: ' M file.ts', stderr: '', exitCode: 0 })
201+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
202+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
203+
.mockRejectedValueOnce(new Error('network error'));
204+
205+
mockPrompt
206+
.mockResolvedValueOnce({ saveChanges: true })
207+
.mockResolvedValueOnce({ message: 'WIP' });
208+
209+
await expect(runSave(cwd)).rejects.toThrow('network error');
210+
211+
expect(consoleErrorSpy).toHaveBeenCalledWith(
212+
expect.stringContaining('An error occurred'),
213+
);
214+
expect(consoleErrorSpy).toHaveBeenCalledWith(
215+
expect.stringContaining('network error'),
216+
);
217+
});
218+
});

src/cli.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import path from 'path';
88
import { defaultTemplates } from './templates.js';
99
import { mergeTemplates } from './template-loader.js';
1010
import { checkGhAuth, createRepo, repoExists as ghRepoExists, sanitizeRepoName } from './github.js';
11+
import { runSave } from './save.js';
1112

1213
/** Project data provided by the user */
1314
type ProjectData = {
@@ -336,4 +337,18 @@ program
336337
console.log('\n✨ All updates completed successfully! ✨');
337338
});
338339

340+
/** Command to save changes: check for changes, prompt to commit, and push */
341+
program
342+
.command('save')
343+
.description('Check for changes, optionally commit them with a message, and push to the current branch')
344+
.argument('[path]', 'Path to the repository (defaults to current directory)')
345+
.action(async (repoPath) => {
346+
const cwd = repoPath ? path.resolve(repoPath) : process.cwd();
347+
try {
348+
await runSave(cwd);
349+
} catch {
350+
process.exit(1);
351+
}
352+
});
353+
339354
program.parse(process.argv);

src/save.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import path from 'path';
2+
import fs from 'fs-extra';
3+
import { execa } from 'execa';
4+
import inquirer from 'inquirer';
5+
6+
/**
7+
* Runs the save flow: verify repo, check for changes, prompt to commit, then add/commit/push.
8+
* Throws on fatal errors (not a git repo, or git command failure). Caller should catch and process.exit(1).
9+
*/
10+
export async function runSave(cwd: string): Promise<void> {
11+
const gitDir = path.join(cwd, '.git');
12+
if (!(await fs.pathExists(gitDir))) {
13+
console.error('❌ This directory is not a git repository (.git not found).');
14+
console.error(' Initialize with "git init" or create a project with "patternfly-cli create".\n');
15+
throw new Error('Not a git repository');
16+
}
17+
18+
const { stdout: statusOut } = await execa('git', ['status', '--porcelain'], {
19+
cwd,
20+
encoding: 'utf8',
21+
});
22+
const hasChanges = statusOut.trim().length > 0;
23+
24+
if (!hasChanges) {
25+
console.log('📭 No changes to save (working tree clean).\n');
26+
return;
27+
}
28+
29+
const { saveChanges } = await inquirer.prompt([
30+
{
31+
type: 'confirm',
32+
name: 'saveChanges',
33+
message: 'You have uncommitted changes. Would you like to save them?',
34+
default: true,
35+
},
36+
]);
37+
38+
if (!saveChanges) {
39+
console.log('\n📭 Nothing has been saved.\n');
40+
return;
41+
}
42+
43+
const { message } = await inquirer.prompt([
44+
{
45+
type: 'input',
46+
name: 'message',
47+
message: 'Describe your changes (commit message):',
48+
validate: (input: string) => {
49+
if (!input?.trim()) return 'A commit message is required to save.';
50+
return true;
51+
},
52+
},
53+
]);
54+
55+
const commitMessage = (message as string).trim();
56+
if (!commitMessage) {
57+
console.log('\n📭 No message provided; nothing has been saved.\n');
58+
return;
59+
}
60+
61+
try {
62+
await execa('git', ['add', '.'], { cwd, stdio: 'inherit' });
63+
await execa('git', ['commit', '-m', commitMessage], { cwd, stdio: 'inherit' });
64+
await execa('git', ['push'], { cwd, stdio: 'inherit' });
65+
console.log('\n✅ Changes saved and pushed to GitHub successfully.\n');
66+
} catch (err) {
67+
if (err && typeof err === 'object' && 'exitCode' in err) {
68+
const code = (err as { exitCode?: number }).exitCode;
69+
if (code === 128) {
70+
console.error(
71+
'\n❌ Push failed. You may need to set a remote (e.g. "git remote add origin <url>") or run "gh auth login".\n',
72+
);
73+
} else {
74+
console.error('\n❌ Save or push failed. See the output above for details.\n');
75+
}
76+
} else {
77+
console.error('\n❌ An error occurred:');
78+
if (err instanceof Error) console.error(` ${err.message}\n`);
79+
else console.error(` ${String(err)}\n`);
80+
}
81+
throw err;
82+
}
83+
}

0 commit comments

Comments
 (0)