avil13-co 2.4.0 → 2.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # avil13/co
2
2
 
3
- Interactive Git branch checkout, merge, deletion and synchronization.
3
+ Interactive Git branch checkout, merge, deletion, synchronization and stash management.
4
4
 
5
5
  ![demo](assets/cli-demo.gif)
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`:
@@ -5,7 +5,8 @@ export declare enum Commands {
5
5
  MergeBranch = 4,
6
6
  ShowMerged = 8,
7
7
  ShowUnMerged = 16,
8
- Sync = 32
8
+ Sync = 32,
9
+ Stash = 64
9
10
  }
10
11
  export declare const getWishOfUser: (firstArg?: string) => Commands;
11
12
  export declare function parseArgs(args?: string[]): {
@@ -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
@@ -3,5 +3,5 @@ export declare enum BranchesType {
3
3
  Merged = 1,
4
4
  NotMerged = 2
5
5
  }
6
- /** Remote origin refs are also offered for checkout/merge; deletion is local only. */
6
+ /** Remote refs retain their remote prefix; deletion is local only. */
7
7
  export declare function getBranches(type: BranchesType, localOnly?: boolean): Promise<string[]>;
@@ -8,7 +8,7 @@ var BranchesType;
8
8
  BranchesType[BranchesType["Merged"] = 1] = "Merged";
9
9
  BranchesType[BranchesType["NotMerged"] = 2] = "NotMerged";
10
10
  })(BranchesType = exports.BranchesType || (exports.BranchesType = {}));
11
- /** Remote origin refs are also offered for checkout/merge; deletion is local only. */
11
+ /** Remote refs retain their remote prefix; deletion is local only. */
12
12
  async function getBranches(type, localOnly = false) {
13
13
  const args = ['branch', '--format=%(refname)'];
14
14
  if (!localOnly)
@@ -22,7 +22,7 @@ async function getBranches(type, localOnly = false) {
22
22
  .map(name => name.trim())
23
23
  .filter(name => name && !/^refs\/remotes\/.+\/HEAD$/.test(name))
24
24
  .map(name => name.replace(/^refs\/heads\//, '')
25
- .replace(/^refs\/remotes\/(?:origin\/)?/, '')))];
25
+ .replace(/^refs\/remotes\//, '')))];
26
26
  if (!branches.length)
27
27
  throw new Error('No branches found.');
28
28
  return branches;
@@ -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/dist/prompt.js CHANGED
@@ -7,7 +7,16 @@ exports.prompt = void 0;
7
7
  const inquirer_1 = __importDefault(require("inquirer"));
8
8
  const terminal_1 = require("./terminal");
9
9
  function prompt(questions) {
10
- const result = inquirer_1.default.prompt(questions);
10
+ // Bright yellow uses the terminal palette and keeps Inquirer's bold styling.
11
+ const styleQuestion = (question) => {
12
+ if (!process.stdout.isTTY || typeof question.message !== 'string')
13
+ return question;
14
+ return { ...question, message: `\x1b[93m${question.message}\x1b[39m` };
15
+ };
16
+ const styledQuestions = Array.isArray(questions)
17
+ ? questions.map(styleQuestion)
18
+ : styleQuestion(questions);
19
+ const result = inquirer_1.default.prompt(styledQuestions);
11
20
  // Inquirer 8 re-sends SIGINT asynchronously and may exit before Node handles
12
21
  // it. Handle readline's raw-mode Ctrl+C synchronously instead.
13
22
  const ui = result.ui;
package/dist/terminal.js CHANGED
@@ -5,7 +5,9 @@ exports.installInterruptHandler = exports.finishTerminal = exports.print = expor
5
5
  let active = false;
6
6
  let messages = [];
7
7
  function startTerminal() {
8
- active = Boolean(process.stdout.isTTY && process.stdin.isTTY);
8
+ // Temporarily disable the alternate screen; keep ordinary terminal output.
9
+ // active = Boolean(process.stdout.isTTY && process.stdin.isTTY);
10
+ active = false;
9
11
  messages = [];
10
12
  if (active)
11
13
  process.stdout.write('\x1b[?1049h');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "avil13-co",
3
- "version": "2.4.0",
3
+ "version": "2.5.1",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "public": true,
@@ -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
  });
@@ -49,11 +49,14 @@ describe('Git commands', () => {
49
49
  expect(git('branch', '--show-current')).toBe(branch);
50
50
  });
51
51
 
52
- it('preserves local origin-prefixed branches and deduplicates origin remote refs', async () => {
52
+ it('preserves remote prefixes and distinguishes local and remote branches', async () => {
53
53
  git('branch', 'origin/local');
54
54
  git('update-ref', 'refs/remotes/origin/main', 'HEAD');
55
+ git('update-ref', 'refs/remotes/upstream/main', 'HEAD');
55
56
  git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main');
56
- expect(await getBranches(BranchesType.All)).toEqual(['main', 'origin/local']);
57
+ expect(await getBranches(BranchesType.All)).toEqual([
58
+ 'main', 'origin/local', 'origin/main', 'upstream/main',
59
+ ]);
57
60
  });
58
61
 
59
62
  it('excludes remote refs from deletion candidates', async () => {
@@ -0,0 +1,27 @@
1
+ import { afterEach, expect, it, vi } from 'vitest';
2
+ import inquirer from 'inquirer';
3
+ import { prompt } from '../prompt';
4
+
5
+ const descriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY');
6
+
7
+ afterEach(() => {
8
+ vi.restoreAllMocks();
9
+ if (descriptor) Object.defineProperty(process.stdout, 'isTTY', descriptor);
10
+ else delete (process.stdout as any).isTTY;
11
+ });
12
+
13
+ it.each([true, false])('styles prompt headings only in a terminal (TTY=%s)', async isTTY => {
14
+ Object.defineProperty(process.stdout, 'isTTY', { value: isTTY, configurable: true });
15
+ const spy = vi.spyOn(inquirer, 'prompt').mockResolvedValue({ branch: 'main' });
16
+ const question = { name: 'branch', message: 'Select branch' };
17
+ const expected = {
18
+ ...question,
19
+ message: isTTY ? '\x1b[93mSelect branch\x1b[39m' : 'Select branch',
20
+ };
21
+
22
+ await prompt(question);
23
+ expect(spy).toHaveBeenLastCalledWith(expected);
24
+ await prompt([question]);
25
+ expect(spy).toHaveBeenLastCalledWith([expected]);
26
+ expect(question.message).toBe('Select branch');
27
+ });
@@ -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
@@ -6,7 +6,7 @@ export enum BranchesType {
6
6
  NotMerged,
7
7
  }
8
8
 
9
- /** Remote origin refs are also offered for checkout/merge; deletion is local only. */
9
+ /** Remote refs retain their remote prefix; deletion is local only. */
10
10
  export async function getBranches(type: BranchesType, localOnly = false): Promise<string[]> {
11
11
  const args = ['branch', '--format=%(refname)'];
12
12
  if (!localOnly) args.push('--all');
@@ -18,7 +18,7 @@ export async function getBranches(type: BranchesType, localOnly = false): Promis
18
18
  .map(name => name.trim())
19
19
  .filter(name => name && !/^refs\/remotes\/.+\/HEAD$/.test(name))
20
20
  .map(name => name.replace(/^refs\/heads\//, '')
21
- .replace(/^refs\/remotes\/(?:origin\/)?/, '')))];
21
+ .replace(/^refs\/remotes\//, '')))];
22
22
  if (!branches.length) throw new Error('No branches found.');
23
23
  return branches;
24
24
  }
@@ -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();
package/src/prompt.ts CHANGED
@@ -2,7 +2,15 @@ import inquirer, { Answers, QuestionCollection } from 'inquirer';
2
2
  import { finishTerminal } from './terminal';
3
3
 
4
4
  export function prompt<T extends Answers = Answers>(questions: QuestionCollection<T>): Promise<T> {
5
- const result = inquirer.prompt<T>(questions);
5
+ // Bright yellow uses the terminal palette and keeps Inquirer's bold styling.
6
+ const styleQuestion = (question: any) => {
7
+ if (!process.stdout.isTTY || typeof question.message !== 'string') return question;
8
+ return { ...question, message: `\x1b[93m${question.message}\x1b[39m` };
9
+ };
10
+ const styledQuestions = Array.isArray(questions)
11
+ ? questions.map(styleQuestion)
12
+ : styleQuestion(questions);
13
+ const result = inquirer.prompt<T>(styledQuestions);
6
14
  // Inquirer 8 re-sends SIGINT asynchronously and may exit before Node handles
7
15
  // it. Handle readline's raw-mode Ctrl+C synchronously instead.
8
16
  const ui = result.ui as any;
package/src/terminal.ts CHANGED
@@ -3,7 +3,9 @@ let active = false;
3
3
  let messages: string[] = [];
4
4
 
5
5
  export function startTerminal() {
6
- active = Boolean(process.stdout.isTTY && process.stdin.isTTY);
6
+ // Temporarily disable the alternate screen; keep ordinary terminal output.
7
+ // active = Boolean(process.stdout.isTTY && process.stdin.isTTY);
8
+ active = false;
7
9
  messages = [];
8
10
  if (active) process.stdout.write('\x1b[?1049h');
9
11
  }