avil13-co 2.4.0 → 2.5.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/README.md +18 -1
- package/dist/cli-commands/index.d.ts +2 -1
- package/dist/cli-commands/index.js +5 -1
- package/dist/commands/stash.d.ts +12 -0
- package/dist/commands/stash.js +104 -0
- package/dist/index.js +6 -0
- package/package.json +1 -1
- package/src/__tests__/cli-commands.spec.ts +4 -0
- package/src/__tests__/stash.spec.ts +171 -0
- package/src/cli-commands/index.ts +4 -1
- package/src/commands/stash.ts +105 -0
- package/src/index.ts +7 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# avil13/co
|
|
2
2
|
|
|
3
|
-
Interactive Git branch checkout, merge, deletion and
|
|
3
|
+
Interactive Git branch checkout, merge, deletion, synchronization and stash management.
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
@@ -20,6 +20,7 @@ co un # Show unmerged branches (also: -un, --no-merged)
|
|
|
20
20
|
co -M # Merge a selected branch (also: merge)
|
|
21
21
|
co -D # Delete multiple local branches (also: rm)
|
|
22
22
|
co -p # Fetch and safely sync all local branches (like hub sync)
|
|
23
|
+
co -s # List and manage stashes (also: --stash)
|
|
23
24
|
co -h # Show help
|
|
24
25
|
```
|
|
25
26
|
|
|
@@ -42,6 +43,22 @@ with **Yes** as the default (`[Y/n]`). This uses safe deletion (`git branch -d`)
|
|
|
42
43
|
not forced deletion. Remote-only branches are not offered for deletion.
|
|
43
44
|
A failed merge never triggers the deletion question.
|
|
44
45
|
|
|
46
|
+
### Stash
|
|
47
|
+
|
|
48
|
+
`co -s` or `co --stash` lists stash entries, newest first, with their dates and
|
|
49
|
+
messages. Select an entry to see its creation date, author, commit hash and
|
|
50
|
+
change statistics (including untracked files saved in the stash).
|
|
51
|
+
|
|
52
|
+
Choose **Apply** to restore changes and keep the stash, **Pop** to restore and
|
|
53
|
+
remove it on success, or **Delete** to remove it without applying. Deletion asks
|
|
54
|
+
for confirmation `[n/Y]`: Enter confirms, `n` cancels. A failed pop keeps the
|
|
55
|
+
stash, including when Git reports conflicts. Git errors end the command with
|
|
56
|
+
exit code 1; resolve any conflicts before trying again.
|
|
57
|
+
|
|
58
|
+
Use **Back to stash list** to select another entry, or **Exit** to leave without
|
|
59
|
+
changes. The list refreshes after deletion. Optional filtering is supported:
|
|
60
|
+
`co -s - search text` matches the reference, date or message.
|
|
61
|
+
|
|
45
62
|
### Synchronize branches
|
|
46
63
|
|
|
47
64
|
`co -p` works like `hub sync`, without requiring `hub`:
|
|
@@ -10,6 +10,7 @@ var Commands;
|
|
|
10
10
|
Commands[Commands["ShowMerged"] = 8] = "ShowMerged";
|
|
11
11
|
Commands[Commands["ShowUnMerged"] = 16] = "ShowUnMerged";
|
|
12
12
|
Commands[Commands["Sync"] = 32] = "Sync";
|
|
13
|
+
Commands[Commands["Stash"] = 64] = "Stash";
|
|
13
14
|
})(Commands = exports.Commands || (exports.Commands = {}));
|
|
14
15
|
const getWishOfUser = (firstArg = process.argv[2]) => {
|
|
15
16
|
if (['help', '-h', '--h', '--help'].includes(firstArg))
|
|
@@ -20,6 +21,8 @@ const getWishOfUser = (firstArg = process.argv[2]) => {
|
|
|
20
21
|
return Commands.DeleteBranch;
|
|
21
22
|
if (firstArg === '-p')
|
|
22
23
|
return Commands.Sync;
|
|
24
|
+
if (['-s', '--stash'].includes(firstArg))
|
|
25
|
+
return Commands.Stash;
|
|
23
26
|
if (['m', '-sm', '--show-merged'].includes(firstArg))
|
|
24
27
|
return Commands.ShowMerged;
|
|
25
28
|
if (['un', '-un', '--no-merged'].includes(firstArg))
|
|
@@ -45,7 +48,7 @@ function parseArgs(args = process.argv.slice(2)) {
|
|
|
45
48
|
exports.parseArgs = parseArgs;
|
|
46
49
|
const getHelp = () => `
|
|
47
50
|
DESCRIPTION:
|
|
48
|
-
Quickly switch, merge, delete and synchronize Git branches.
|
|
51
|
+
Quickly switch, merge, delete and synchronize Git branches, and manage stashes.
|
|
49
52
|
|
|
50
53
|
USAGE:
|
|
51
54
|
co Select a branch to check out
|
|
@@ -53,6 +56,7 @@ USAGE:
|
|
|
53
56
|
co -M [- filter] Merge, then offer to delete the source branch [Y/n]
|
|
54
57
|
co -D [- filter] Delete multiple local branches (Space to select)
|
|
55
58
|
co -p Fetch and safely sync all local branches (like hub sync)
|
|
59
|
+
co -s, --stash [- filter] List stashes, view details, apply, pop or delete [n/Y]
|
|
56
60
|
co m, -sm, --show-merged Show merged branches
|
|
57
61
|
co un, -un, --no-merged Show unmerged branches
|
|
58
62
|
co help, -h, --h, --help Show this message
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface StashEntry {
|
|
2
|
+
ref: string;
|
|
3
|
+
hash: string;
|
|
4
|
+
date: string;
|
|
5
|
+
author: string;
|
|
6
|
+
message: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function getStashes(): Promise<StashEntry[]>;
|
|
9
|
+
export declare function showStash(entry: StashEntry): Promise<void>;
|
|
10
|
+
export declare function applyStash(entry: StashEntry, pop?: boolean): Promise<void>;
|
|
11
|
+
export declare function dropStash(entry: StashEntry): Promise<boolean>;
|
|
12
|
+
export declare function manageStashes(filter?: string): Promise<void>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.manageStashes = exports.dropStash = exports.applyStash = exports.showStash = exports.getStashes = void 0;
|
|
4
|
+
const ask_1 = require("../ask");
|
|
5
|
+
const color_1 = require("../color");
|
|
6
|
+
const prompt_1 = require("../prompt");
|
|
7
|
+
const terminal_1 = require("../terminal");
|
|
8
|
+
const exec_1 = require("./exec");
|
|
9
|
+
async function getStashes() {
|
|
10
|
+
const output = await (0, exec_1.exec)(['stash', 'list', '--format=%gd%x00%H%x00%cI%x00%an%x00%gs']);
|
|
11
|
+
if (!output)
|
|
12
|
+
return [];
|
|
13
|
+
return output.split('\n').map(line => {
|
|
14
|
+
const [ref, hash, date, author, message] = line.split('\0');
|
|
15
|
+
return { ref, hash, date, author, message };
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
exports.getStashes = getStashes;
|
|
19
|
+
async function assertStash(entry) {
|
|
20
|
+
// Reflog indices can shift if another process changes the stash list.
|
|
21
|
+
const hash = await (0, exec_1.exec)(['rev-parse', '--verify', entry.ref]);
|
|
22
|
+
if (hash !== entry.hash)
|
|
23
|
+
throw new Error('Stash list changed. Run co --stash again.');
|
|
24
|
+
}
|
|
25
|
+
async function showStash(entry) {
|
|
26
|
+
await assertStash(entry);
|
|
27
|
+
const details = `${entry.ref}: ${entry.message}\nDate: ${entry.date}\nAuthor: ${entry.author}\nCommit: ${entry.hash}`;
|
|
28
|
+
const stats = await (0, exec_1.exec)(['stash', 'show', '--stat', '--include-untracked', '--no-color', entry.hash]);
|
|
29
|
+
const output = [details, stats].filter(Boolean).join('\n');
|
|
30
|
+
(0, terminal_1.print)(process.stdout.isTTY ? color_1.clc.gray(output) : output);
|
|
31
|
+
}
|
|
32
|
+
exports.showStash = showStash;
|
|
33
|
+
async function applyStash(entry, pop = false) {
|
|
34
|
+
await assertStash(entry);
|
|
35
|
+
// Let Git perform pop: on conflicts it preserves the stash automatically.
|
|
36
|
+
(0, terminal_1.print)(await (0, exec_1.exec)(['stash', pop ? 'pop' : 'apply', entry.ref]));
|
|
37
|
+
(0, terminal_1.print)(`${entry.ref}: ${pop ? 'popped' : 'applied (stash kept)'}.`);
|
|
38
|
+
}
|
|
39
|
+
exports.applyStash = applyStash;
|
|
40
|
+
async function dropStash(entry) {
|
|
41
|
+
const { answer } = await (0, prompt_1.prompt)({
|
|
42
|
+
type: 'input',
|
|
43
|
+
name: 'answer',
|
|
44
|
+
message: `Delete ${entry.ref}: ${entry.message}? [n/Y]`,
|
|
45
|
+
default: 'Y',
|
|
46
|
+
filter: (value) => value.trim().toLowerCase(),
|
|
47
|
+
validate: (value) => ['n', 'y'].includes(value) || 'Enter n or Y.',
|
|
48
|
+
});
|
|
49
|
+
if (answer.toLowerCase() !== 'y') {
|
|
50
|
+
(0, terminal_1.print)('Stash was not deleted.');
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
await assertStash(entry);
|
|
54
|
+
(0, terminal_1.print)(await (0, exec_1.exec)(['stash', 'drop', entry.ref]));
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
exports.dropStash = dropStash;
|
|
58
|
+
async function manageStashes(filter = '') {
|
|
59
|
+
while (true) {
|
|
60
|
+
const entries = await getStashes();
|
|
61
|
+
if (!entries.length) {
|
|
62
|
+
(0, terminal_1.print)('No stash entries.');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const filtered = entries.filter(entry => (0, ask_1.checkItem)(`${entry.ref} ${entry.date} ${entry.message}`, filter));
|
|
66
|
+
if (!filtered.length) {
|
|
67
|
+
(0, terminal_1.print)('No stash entries match the filter.');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const { entry } = await (0, prompt_1.prompt)({
|
|
71
|
+
type: 'list',
|
|
72
|
+
name: 'entry',
|
|
73
|
+
message: 'Select stash',
|
|
74
|
+
choices: [
|
|
75
|
+
...filtered.map(value => ({ name: `📦 ${value.ref} | ${value.date} | ${value.message}`, value })),
|
|
76
|
+
{ name: '🚪 Exit', value: null },
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
if (!entry)
|
|
80
|
+
return;
|
|
81
|
+
await showStash(entry);
|
|
82
|
+
const { action } = await (0, prompt_1.prompt)({
|
|
83
|
+
type: 'list',
|
|
84
|
+
name: 'action',
|
|
85
|
+
message: `Action for ${entry.ref}`,
|
|
86
|
+
choices: [
|
|
87
|
+
{ name: '⬅️ Back to stash list', value: 'back' },
|
|
88
|
+
{ name: '📥 Apply (keep stash)', value: 'apply' },
|
|
89
|
+
{ name: '📤 Pop (apply and remove on success)', value: 'pop' },
|
|
90
|
+
{ name: '🗑️ Delete stash', value: 'drop' },
|
|
91
|
+
{ name: '🚪 Exit', value: 'exit' },
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
if (action === 'exit')
|
|
95
|
+
return;
|
|
96
|
+
if (action === 'apply' || action === 'pop') {
|
|
97
|
+
await applyStash(entry, action === 'pop');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (action === 'drop')
|
|
101
|
+
await dropStash(entry);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
exports.manageStashes = manageStashes;
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ const commands_1 = require("./commands");
|
|
|
7
7
|
const delete_barnch_1 = require("./commands/delete-barnch");
|
|
8
8
|
const merge_branch_1 = require("./commands/merge-branch");
|
|
9
9
|
const sync_branches_1 = require("./commands/sync-branches");
|
|
10
|
+
const stash_1 = require("./commands/stash");
|
|
10
11
|
const terminal_1 = require("./terminal");
|
|
11
12
|
const run = async () => {
|
|
12
13
|
const { command, filter } = (0, cli_commands_1.parseArgs)();
|
|
@@ -14,6 +15,11 @@ const run = async () => {
|
|
|
14
15
|
console.log((0, cli_commands_1.getHelp)());
|
|
15
16
|
return;
|
|
16
17
|
}
|
|
18
|
+
if (command === cli_commands_1.Commands.Stash) {
|
|
19
|
+
(0, terminal_1.startTerminal)();
|
|
20
|
+
await (0, stash_1.manageStashes)(filter);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
17
23
|
if (command === cli_commands_1.Commands.Sync) {
|
|
18
24
|
(0, terminal_1.startTerminal)();
|
|
19
25
|
await (0, sync_branches_1.syncBranches)();
|
package/package.json
CHANGED
|
@@ -19,6 +19,10 @@ describe('arguments', () => {
|
|
|
19
19
|
it.each(['un', '-un', '--no-merged'])('accepts unmerged alias %s', alias => {
|
|
20
20
|
expect(parseArgs([alias]).command).toBe(Commands.ShowUnMerged);
|
|
21
21
|
});
|
|
22
|
+
it.each(['-s', '--stash'])('accepts stash alias %s and optional filter', alias => {
|
|
23
|
+
expect(parseArgs([alias])).toEqual({ command: Commands.Stash, filter: '' });
|
|
24
|
+
expect(parseArgs([alias, '-', 'work'])).toEqual({ command: Commands.Stash, filter: 'work' });
|
|
25
|
+
});
|
|
22
26
|
it('accepts sync without a filter', () => {
|
|
23
27
|
expect(parseArgs(['-p'])).toEqual({ command: Commands.Sync, filter: '' });
|
|
24
28
|
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
2
|
+
import { execFileSync } from 'child_process';
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import inquirer from 'inquirer';
|
|
7
|
+
import { applyStash, dropStash, getStashes, manageStashes, showStash } from '../commands/stash';
|
|
8
|
+
|
|
9
|
+
const context = vi.hoisted(() => ({ cwd: '' }));
|
|
10
|
+
vi.mock('child_process', async importOriginal => {
|
|
11
|
+
const actual = await importOriginal<typeof import('child_process')>();
|
|
12
|
+
return {
|
|
13
|
+
...actual,
|
|
14
|
+
execFile: (file, args, callback) => actual.execFile(file, args, { cwd: context.cwd }, callback),
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function git(...args: string[]) {
|
|
19
|
+
return execFileSync('git', args, { cwd: context.cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
20
|
+
}
|
|
21
|
+
function stash(message = 'saved work') {
|
|
22
|
+
writeFileSync(join(context.cwd, 'file.txt'), message);
|
|
23
|
+
git('stash', 'push', '-qm', message);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
context.cwd = mkdtempSync(join(tmpdir(), 'co-stash-'));
|
|
28
|
+
git('init', '-q', '-b', 'main');
|
|
29
|
+
git('config', 'user.name', 'Test');
|
|
30
|
+
git('config', 'user.email', 'test@example.test');
|
|
31
|
+
writeFileSync(join(context.cwd, 'file.txt'), 'initial');
|
|
32
|
+
git('add', '.');
|
|
33
|
+
git('commit', '-qm', 'initial');
|
|
34
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
35
|
+
});
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
vi.restoreAllMocks();
|
|
38
|
+
rmSync(context.cwd, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('handles an empty stash list without prompting', async () => {
|
|
42
|
+
const prompt = vi.spyOn(inquirer, 'prompt');
|
|
43
|
+
expect(await getStashes()).toEqual([]);
|
|
44
|
+
await manageStashes();
|
|
45
|
+
expect(prompt).not.toHaveBeenCalled();
|
|
46
|
+
expect(console.log).toHaveBeenCalledWith('No stash entries.');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('lists newest first with references, dates, author and literal messages', async () => {
|
|
50
|
+
stash('older');
|
|
51
|
+
stash('new; $(echo nope) | message');
|
|
52
|
+
const entries = await getStashes();
|
|
53
|
+
expect(entries).toHaveLength(2);
|
|
54
|
+
expect(entries[0]).toMatchObject({ ref: 'stash@{0}', author: 'Test', message: 'On main: new; $(echo nope) | message' });
|
|
55
|
+
expect(entries[0].hash).toBe(git('rev-parse', 'stash@{0}'));
|
|
56
|
+
expect(Number.isNaN(Date.parse(entries[0].date))).toBe(false);
|
|
57
|
+
expect(entries[1].ref).toBe('stash@{1}');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('shows metadata and tracked and untracked file statistics', async () => {
|
|
61
|
+
writeFileSync(join(context.cwd, 'file.txt'), 'changed');
|
|
62
|
+
writeFileSync(join(context.cwd, 'extra.txt'), 'untracked');
|
|
63
|
+
git('stash', 'push', '-qu', '-m', 'details');
|
|
64
|
+
const [entry] = await getStashes();
|
|
65
|
+
await showStash(entry);
|
|
66
|
+
expect(console.log).toHaveBeenCalledWith(expect.stringContaining(`Commit: ${entry.hash}`));
|
|
67
|
+
expect(console.log).toHaveBeenCalledWith(expect.stringMatching(/extra.txt[\s\S]*file.txt/));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('renders stash details and statistics in gray in a terminal', async () => {
|
|
71
|
+
stash();
|
|
72
|
+
const descriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY');
|
|
73
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
|
|
74
|
+
try {
|
|
75
|
+
await showStash((await getStashes())[0]);
|
|
76
|
+
expect(console.log).toHaveBeenCalledWith(expect.stringMatching(/^\x1b\[0;90m[\s\S]*file.txt[\s\S]*\x1b\[0m$/));
|
|
77
|
+
} finally {
|
|
78
|
+
if (descriptor) Object.defineProperty(process.stdout, 'isTTY', descriptor);
|
|
79
|
+
else delete (process.stdout as any).isTTY;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it.each([false, true])('applies changes with pop=%s and keeps/removes the stash', async pop => {
|
|
84
|
+
stash();
|
|
85
|
+
await applyStash((await getStashes())[0], pop);
|
|
86
|
+
expect(readFileSync(join(context.cwd, 'file.txt'), 'utf8')).toBe('saved work');
|
|
87
|
+
expect(await getStashes()).toHaveLength(pop ? 0 : 1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('keeps the stash when pop has conflicts', async () => {
|
|
91
|
+
stash();
|
|
92
|
+
writeFileSync(join(context.cwd, 'file.txt'), 'conflicting commit');
|
|
93
|
+
git('add', '.');
|
|
94
|
+
git('commit', '-qm', 'conflict');
|
|
95
|
+
await expect(applyStash((await getStashes())[0], true)).rejects.toThrow();
|
|
96
|
+
expect(await getStashes()).toHaveLength(1);
|
|
97
|
+
expect(git('status', '--porcelain')).toContain('UU file.txt');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('does not drop a stash if confirmation is declined', async () => {
|
|
101
|
+
stash();
|
|
102
|
+
const prompt = vi.spyOn(inquirer, 'prompt').mockResolvedValue({ answer: 'n' });
|
|
103
|
+
expect(await dropStash((await getStashes())[0])).toBe(false);
|
|
104
|
+
expect(await getStashes()).toHaveLength(1);
|
|
105
|
+
expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ default: 'Y', message: expect.stringContaining('[n/Y]') }));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('drops only the selected stash after confirmation', async () => {
|
|
109
|
+
stash('older');
|
|
110
|
+
stash('newer');
|
|
111
|
+
vi.spyOn(inquirer, 'prompt').mockResolvedValue({ answer: 'Y' });
|
|
112
|
+
expect(await dropStash((await getStashes())[1])).toBe(true);
|
|
113
|
+
expect((await getStashes()).map(entry => entry.message)).toEqual(['On main: newer']);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('refuses to act on an entry whose index has changed', async () => {
|
|
117
|
+
stash('older');
|
|
118
|
+
const [entry] = await getStashes();
|
|
119
|
+
stash('newer');
|
|
120
|
+
vi.spyOn(inquirer, 'prompt').mockResolvedValue({ answer: 'y' });
|
|
121
|
+
await expect(dropStash(entry)).rejects.toThrow('Stash list changed');
|
|
122
|
+
await expect(applyStash(entry)).rejects.toThrow('Stash list changed');
|
|
123
|
+
expect(await getStashes()).toHaveLength(2);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('refreshes the list after deletion and permits exiting', async () => {
|
|
127
|
+
stash('older');
|
|
128
|
+
stash('newer');
|
|
129
|
+
const [entry] = await getStashes();
|
|
130
|
+
const prompt = vi.spyOn(inquirer, 'prompt')
|
|
131
|
+
.mockResolvedValueOnce({ entry })
|
|
132
|
+
.mockResolvedValueOnce({ action: 'drop' })
|
|
133
|
+
.mockResolvedValueOnce({ answer: 'y' })
|
|
134
|
+
.mockResolvedValueOnce({ entry: null });
|
|
135
|
+
await manageStashes();
|
|
136
|
+
const choices = (prompt.mock.calls[3][0] as any).choices;
|
|
137
|
+
expect(choices).toHaveLength(2);
|
|
138
|
+
expect(choices[0].value).toMatchObject({ ref: 'stash@{0}', message: 'On main: older' });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('filters the selection and allows backing out without changes', async () => {
|
|
142
|
+
stash('older');
|
|
143
|
+
stash('newer');
|
|
144
|
+
const entry = (await getStashes())[1];
|
|
145
|
+
const prompt = vi.spyOn(inquirer, 'prompt')
|
|
146
|
+
.mockResolvedValueOnce({ entry })
|
|
147
|
+
.mockResolvedValueOnce({ action: 'back' })
|
|
148
|
+
.mockResolvedValueOnce({ entry: null });
|
|
149
|
+
await manageStashes('older');
|
|
150
|
+
expect((prompt.mock.calls[0][0] as any).choices).toHaveLength(2);
|
|
151
|
+
expect(await getStashes()).toHaveLength(2);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('reports a filter with no matches without prompting', async () => {
|
|
155
|
+
stash();
|
|
156
|
+
const prompt = vi.spyOn(inquirer, 'prompt');
|
|
157
|
+
await manageStashes('missing');
|
|
158
|
+
expect(prompt).not.toHaveBeenCalled();
|
|
159
|
+
expect(console.log).toHaveBeenCalledWith('No stash entries match the filter.');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it.each(['apply', 'pop'])('runs the selected %s action', async action => {
|
|
163
|
+
stash();
|
|
164
|
+
const [entry] = await getStashes();
|
|
165
|
+
vi.spyOn(inquirer, 'prompt')
|
|
166
|
+
.mockResolvedValueOnce({ entry })
|
|
167
|
+
.mockResolvedValueOnce({ action });
|
|
168
|
+
await manageStashes();
|
|
169
|
+
expect(readFileSync(join(context.cwd, 'file.txt'), 'utf8')).toBe('saved work');
|
|
170
|
+
expect(await getStashes()).toHaveLength(action === 'pop' ? 0 : 1);
|
|
171
|
+
});
|
|
@@ -6,6 +6,7 @@ export enum Commands {
|
|
|
6
6
|
ShowMerged = 8,
|
|
7
7
|
ShowUnMerged = 16,
|
|
8
8
|
Sync = 32,
|
|
9
|
+
Stash = 64,
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
export const getWishOfUser = (firstArg = process.argv[2]): Commands => {
|
|
@@ -13,6 +14,7 @@ export const getWishOfUser = (firstArg = process.argv[2]): Commands => {
|
|
|
13
14
|
if (['-M', 'merge'].includes(firstArg)) return Commands.MergeBranch;
|
|
14
15
|
if (['-D', 'rm'].includes(firstArg)) return Commands.DeleteBranch;
|
|
15
16
|
if (firstArg === '-p') return Commands.Sync;
|
|
17
|
+
if (['-s', '--stash'].includes(firstArg)) return Commands.Stash;
|
|
16
18
|
if (['m', '-sm', '--show-merged'].includes(firstArg)) return Commands.ShowMerged;
|
|
17
19
|
if (['un', '-un', '--no-merged'].includes(firstArg)) return Commands.ShowUnMerged;
|
|
18
20
|
return Commands.Unknown;
|
|
@@ -34,7 +36,7 @@ export function parseArgs(args = process.argv.slice(2)) {
|
|
|
34
36
|
|
|
35
37
|
export const getHelp = (): string => `
|
|
36
38
|
DESCRIPTION:
|
|
37
|
-
Quickly switch, merge, delete and synchronize Git branches.
|
|
39
|
+
Quickly switch, merge, delete and synchronize Git branches, and manage stashes.
|
|
38
40
|
|
|
39
41
|
USAGE:
|
|
40
42
|
co Select a branch to check out
|
|
@@ -42,6 +44,7 @@ USAGE:
|
|
|
42
44
|
co -M [- filter] Merge, then offer to delete the source branch [Y/n]
|
|
43
45
|
co -D [- filter] Delete multiple local branches (Space to select)
|
|
44
46
|
co -p Fetch and safely sync all local branches (like hub sync)
|
|
47
|
+
co -s, --stash [- filter] List stashes, view details, apply, pop or delete [n/Y]
|
|
45
48
|
co m, -sm, --show-merged Show merged branches
|
|
46
49
|
co un, -un, --no-merged Show unmerged branches
|
|
47
50
|
co help, -h, --h, --help Show this message
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { checkItem } from '../ask';
|
|
2
|
+
import { clc } from '../color';
|
|
3
|
+
import { prompt } from '../prompt';
|
|
4
|
+
import { print } from '../terminal';
|
|
5
|
+
import { exec } from './exec';
|
|
6
|
+
|
|
7
|
+
export interface StashEntry {
|
|
8
|
+
ref: string;
|
|
9
|
+
hash: string;
|
|
10
|
+
date: string;
|
|
11
|
+
author: string;
|
|
12
|
+
message: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function getStashes(): Promise<StashEntry[]> {
|
|
16
|
+
const output = await exec(['stash', 'list', '--format=%gd%x00%H%x00%cI%x00%an%x00%gs']);
|
|
17
|
+
if (!output) return [];
|
|
18
|
+
return output.split('\n').map(line => {
|
|
19
|
+
const [ref, hash, date, author, message] = line.split('\0');
|
|
20
|
+
return { ref, hash, date, author, message };
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function assertStash(entry: StashEntry) {
|
|
25
|
+
// Reflog indices can shift if another process changes the stash list.
|
|
26
|
+
const hash = await exec(['rev-parse', '--verify', entry.ref]);
|
|
27
|
+
if (hash !== entry.hash) throw new Error('Stash list changed. Run co --stash again.');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function showStash(entry: StashEntry) {
|
|
31
|
+
await assertStash(entry);
|
|
32
|
+
const details = `${entry.ref}: ${entry.message}\nDate: ${entry.date}\nAuthor: ${entry.author}\nCommit: ${entry.hash}`;
|
|
33
|
+
const stats = await exec(['stash', 'show', '--stat', '--include-untracked', '--no-color', entry.hash]);
|
|
34
|
+
const output = [details, stats].filter(Boolean).join('\n');
|
|
35
|
+
print(process.stdout.isTTY ? clc.gray(output) : output);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function applyStash(entry: StashEntry, pop = false) {
|
|
39
|
+
await assertStash(entry);
|
|
40
|
+
// Let Git perform pop: on conflicts it preserves the stash automatically.
|
|
41
|
+
print(await exec(['stash', pop ? 'pop' : 'apply', entry.ref]));
|
|
42
|
+
print(`${entry.ref}: ${pop ? 'popped' : 'applied (stash kept)'}.`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function dropStash(entry: StashEntry): Promise<boolean> {
|
|
46
|
+
const { answer } = await prompt<{ answer: string }>({
|
|
47
|
+
type: 'input',
|
|
48
|
+
name: 'answer',
|
|
49
|
+
message: `Delete ${entry.ref}: ${entry.message}? [n/Y]`,
|
|
50
|
+
default: 'Y',
|
|
51
|
+
filter: (value: string) => value.trim().toLowerCase(),
|
|
52
|
+
validate: (value: string) => ['n', 'y'].includes(value) || 'Enter n or Y.',
|
|
53
|
+
});
|
|
54
|
+
if (answer.toLowerCase() !== 'y') {
|
|
55
|
+
print('Stash was not deleted.');
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
await assertStash(entry);
|
|
59
|
+
print(await exec(['stash', 'drop', entry.ref]));
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function manageStashes(filter = '') {
|
|
64
|
+
while (true) {
|
|
65
|
+
const entries = await getStashes();
|
|
66
|
+
if (!entries.length) {
|
|
67
|
+
print('No stash entries.');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const filtered = entries.filter(entry => checkItem(`${entry.ref} ${entry.date} ${entry.message}`, filter));
|
|
71
|
+
if (!filtered.length) {
|
|
72
|
+
print('No stash entries match the filter.');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const { entry } = await prompt<{ entry: StashEntry | null }>({
|
|
76
|
+
type: 'list',
|
|
77
|
+
name: 'entry',
|
|
78
|
+
message: 'Select stash',
|
|
79
|
+
choices: [
|
|
80
|
+
...filtered.map(value => ({ name: `📦 ${value.ref} | ${value.date} | ${value.message}`, value })),
|
|
81
|
+
{ name: '🚪 Exit', value: null },
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
if (!entry) return;
|
|
85
|
+
await showStash(entry);
|
|
86
|
+
const { action } = await prompt<{ action: string }>({
|
|
87
|
+
type: 'list',
|
|
88
|
+
name: 'action',
|
|
89
|
+
message: `Action for ${entry.ref}`,
|
|
90
|
+
choices: [
|
|
91
|
+
{ name: '⬅️ Back to stash list', value: 'back' },
|
|
92
|
+
{ name: '📥 Apply (keep stash)', value: 'apply' },
|
|
93
|
+
{ name: '📤 Pop (apply and remove on success)', value: 'pop' },
|
|
94
|
+
{ name: '🗑️ Delete stash', value: 'drop' },
|
|
95
|
+
{ name: '🚪 Exit', value: 'exit' },
|
|
96
|
+
],
|
|
97
|
+
});
|
|
98
|
+
if (action === 'exit') return;
|
|
99
|
+
if (action === 'apply' || action === 'pop') {
|
|
100
|
+
await applyStash(entry, action === 'pop');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (action === 'drop') await dropStash(entry);
|
|
104
|
+
}
|
|
105
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { BranchesType, checkoutBranch, getBranches, getCurrentBranchName } from
|
|
|
4
4
|
import { deleteBranches } from './commands/delete-barnch';
|
|
5
5
|
import { mergeBranch } from './commands/merge-branch';
|
|
6
6
|
import { syncBranches } from './commands/sync-branches';
|
|
7
|
+
import { manageStashes } from './commands/stash';
|
|
7
8
|
import { finishTerminal, installInterruptHandler, print, startTerminal } from './terminal';
|
|
8
9
|
|
|
9
10
|
export const run = async () => {
|
|
@@ -13,6 +14,12 @@ export const run = async () => {
|
|
|
13
14
|
return;
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
if (command === Commands.Stash) {
|
|
18
|
+
startTerminal();
|
|
19
|
+
await manageStashes(filter);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
16
23
|
if (command === Commands.Sync) {
|
|
17
24
|
startTerminal();
|
|
18
25
|
await syncBranches();
|