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

|
|
6
6
|
|
|
@@ -19,6 +19,8 @@ co m # Show merged branches (also: -sm, --show-merged)
|
|
|
19
19
|
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
|
+
co -p # Fetch and safely sync all local branches (like hub sync)
|
|
23
|
+
co -s # List and manage stashes (also: --stash)
|
|
22
24
|
co -h # Show help
|
|
23
25
|
```
|
|
24
26
|
|
|
@@ -41,6 +43,42 @@ with **Yes** as the default (`[Y/n]`). This uses safe deletion (`git branch -d`)
|
|
|
41
43
|
not forced deletion. Remote-only branches are not offered for deletion.
|
|
42
44
|
A failed merge never triggers the deletion question.
|
|
43
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
|
+
|
|
62
|
+
### Synchronize branches
|
|
63
|
+
|
|
64
|
+
`co -p` works like `hub sync`, without requiring `hub`:
|
|
65
|
+
|
|
66
|
+
- Fetches all remotes with `--prune`.
|
|
67
|
+
- Fast-forwards local branches to their configured upstreams, including branches
|
|
68
|
+
that are not currently checked out. It does not create merge commits or rebase.
|
|
69
|
+
- Without an upstream, uses the same-named branch on `origin`, or a unique match
|
|
70
|
+
on another remote. Ambiguous matches are skipped; tracking configuration is not changed.
|
|
71
|
+
- Warns about unpushed commits or diverged history and leaves those branches unchanged.
|
|
72
|
+
- Safely deletes branches whose remote upstream disappeared only when they are
|
|
73
|
+
merged into the current `HEAD`. Unmerged and checked-out branches are kept.
|
|
74
|
+
- Skips updating the current branch if the working tree is dirty, and never updates
|
|
75
|
+
branches checked out in other worktrees.
|
|
76
|
+
|
|
77
|
+
This command synchronizes **all** local branches without a selection prompt and
|
|
78
|
+
accepts no search filter. It never pushes, force-resets, or stashes changes.
|
|
79
|
+
Network and Git failures are reported with exit code 1; skipped branches are
|
|
80
|
+
reported without treating them as failures.
|
|
81
|
+
|
|
44
82
|
### Cancel and errors
|
|
45
83
|
|
|
46
84
|
**Ctrl+C** exits with code 130 and restores the previous terminal screen,
|
|
@@ -4,7 +4,9 @@ export declare enum Commands {
|
|
|
4
4
|
DeleteBranch = 2,
|
|
5
5
|
MergeBranch = 4,
|
|
6
6
|
ShowMerged = 8,
|
|
7
|
-
ShowUnMerged = 16
|
|
7
|
+
ShowUnMerged = 16,
|
|
8
|
+
Sync = 32,
|
|
9
|
+
Stash = 64
|
|
8
10
|
}
|
|
9
11
|
export declare const getWishOfUser: (firstArg?: string) => Commands;
|
|
10
12
|
export declare function parseArgs(args?: string[]): {
|
|
@@ -9,6 +9,8 @@ var Commands;
|
|
|
9
9
|
Commands[Commands["MergeBranch"] = 4] = "MergeBranch";
|
|
10
10
|
Commands[Commands["ShowMerged"] = 8] = "ShowMerged";
|
|
11
11
|
Commands[Commands["ShowUnMerged"] = 16] = "ShowUnMerged";
|
|
12
|
+
Commands[Commands["Sync"] = 32] = "Sync";
|
|
13
|
+
Commands[Commands["Stash"] = 64] = "Stash";
|
|
12
14
|
})(Commands = exports.Commands || (exports.Commands = {}));
|
|
13
15
|
const getWishOfUser = (firstArg = process.argv[2]) => {
|
|
14
16
|
if (['help', '-h', '--h', '--help'].includes(firstArg))
|
|
@@ -17,6 +19,10 @@ const getWishOfUser = (firstArg = process.argv[2]) => {
|
|
|
17
19
|
return Commands.MergeBranch;
|
|
18
20
|
if (['-D', 'rm'].includes(firstArg))
|
|
19
21
|
return Commands.DeleteBranch;
|
|
22
|
+
if (firstArg === '-p')
|
|
23
|
+
return Commands.Sync;
|
|
24
|
+
if (['-s', '--stash'].includes(firstArg))
|
|
25
|
+
return Commands.Stash;
|
|
20
26
|
if (['m', '-sm', '--show-merged'].includes(firstArg))
|
|
21
27
|
return Commands.ShowMerged;
|
|
22
28
|
if (['un', '-un', '--no-merged'].includes(firstArg))
|
|
@@ -31,6 +37,9 @@ function parseArgs(args = process.argv.slice(2)) {
|
|
|
31
37
|
if (command === Commands.Unknown && args[0] !== '-') {
|
|
32
38
|
throw new Error('Unknown option: ' + args[0] + '. Use co --help.');
|
|
33
39
|
}
|
|
40
|
+
if (command === Commands.Sync && args.length > 1) {
|
|
41
|
+
throw new Error('co -p synchronizes all branches and does not accept a filter.');
|
|
42
|
+
}
|
|
34
43
|
const rest = args.slice(1);
|
|
35
44
|
if (rest[0] === '-')
|
|
36
45
|
rest.shift();
|
|
@@ -39,13 +48,15 @@ function parseArgs(args = process.argv.slice(2)) {
|
|
|
39
48
|
exports.parseArgs = parseArgs;
|
|
40
49
|
const getHelp = () => `
|
|
41
50
|
DESCRIPTION:
|
|
42
|
-
Quickly switch, merge and
|
|
51
|
+
Quickly switch, merge, delete and synchronize Git branches, and manage stashes.
|
|
43
52
|
|
|
44
53
|
USAGE:
|
|
45
54
|
co Select a branch to check out
|
|
46
55
|
co - 1234 Start with an editable search filter
|
|
47
56
|
co -M [- filter] Merge, then offer to delete the source branch [Y/n]
|
|
48
57
|
co -D [- filter] Delete multiple local branches (Space to select)
|
|
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]
|
|
49
60
|
co m, -sm, --show-merged Show merged branches
|
|
50
61
|
co un, -un, --no-merged Show unmerged branches
|
|
51
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;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.syncBranches = void 0;
|
|
4
|
+
const exec_1 = require("./exec");
|
|
5
|
+
const get_current_branch_name_1 = require("./get-current-branch-name");
|
|
6
|
+
const terminal_1 = require("../terminal");
|
|
7
|
+
/** Synchronize local branches without switching branches or rewriting commits. */
|
|
8
|
+
async function syncBranches() {
|
|
9
|
+
const remotes = (await (0, exec_1.exec)(['remote'])).split('\n').filter(Boolean);
|
|
10
|
+
if (!remotes.length)
|
|
11
|
+
throw new Error('No remotes configured. Add a Git remote before running co -p.');
|
|
12
|
+
(0, terminal_1.print)('Fetching all remotes...');
|
|
13
|
+
(0, terminal_1.print)(await (0, exec_1.exec)(['fetch', '--all', '--prune']));
|
|
14
|
+
const refs = new Map((await (0, exec_1.exec)(['for-each-ref', '--format=%(refname)%09%(objectname)']))
|
|
15
|
+
.split('\n').filter(Boolean).map(line => {
|
|
16
|
+
const [ref, oid] = line.split('\t');
|
|
17
|
+
return [ref, oid];
|
|
18
|
+
}));
|
|
19
|
+
const branches = (await (0, exec_1.exec)([
|
|
20
|
+
'for-each-ref', '--format=%(refname)%09%(objectname)%09%(upstream)', 'refs/heads/',
|
|
21
|
+
])).split('\n').filter(Boolean).map(line => {
|
|
22
|
+
const [ref, oid, upstream = ''] = line.split('\t');
|
|
23
|
+
return { ref, oid, upstream, name: ref.slice('refs/heads/'.length) };
|
|
24
|
+
});
|
|
25
|
+
const current = 'refs/heads/' + await (0, get_current_branch_name_1.getCurrentBranchName)();
|
|
26
|
+
const checkedOut = new Set((await (0, exec_1.exec)(['worktree', 'list', '--porcelain', '-z']))
|
|
27
|
+
.split('\0').filter(field => field.startsWith('branch ')).map(field => field.slice(7)));
|
|
28
|
+
const dirty = Boolean(await (0, exec_1.exec)(['status', '--porcelain']));
|
|
29
|
+
const stale = [];
|
|
30
|
+
const errors = [];
|
|
31
|
+
for (const branch of branches) {
|
|
32
|
+
try {
|
|
33
|
+
let upstream = branch.upstream;
|
|
34
|
+
if (!upstream) {
|
|
35
|
+
const candidates = remotes.map(remote => 'refs/remotes/' + remote + '/' + branch.name)
|
|
36
|
+
.filter(ref => refs.has(ref));
|
|
37
|
+
const origin = 'refs/remotes/origin/' + branch.name;
|
|
38
|
+
if (candidates.includes(origin))
|
|
39
|
+
upstream = origin;
|
|
40
|
+
else if (candidates.length === 1)
|
|
41
|
+
upstream = candidates[0];
|
|
42
|
+
else {
|
|
43
|
+
(0, terminal_1.print)(branch.name + ': skipped (' + (candidates.length ? 'ambiguous upstream' : 'no upstream') + ').');
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const target = refs.get(upstream);
|
|
48
|
+
if (!target) {
|
|
49
|
+
if (upstream.startsWith('refs/remotes/'))
|
|
50
|
+
stale.push(branch);
|
|
51
|
+
else
|
|
52
|
+
(0, terminal_1.print)(branch.name + ': skipped (upstream is missing).');
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const [ahead, behind] = (await (0, exec_1.exec)(['rev-list', '--left-right', '--count', branch.oid + '...' + target]))
|
|
56
|
+
.split(/\s+/).map(Number);
|
|
57
|
+
if (ahead) {
|
|
58
|
+
(0, terminal_1.print)(branch.name + ': warning: ' + ahead + ' unpushed commit(s)' +
|
|
59
|
+
(behind ? ', diverged from upstream by ' + behind + ' commit(s)' : '') + '; kept unchanged.');
|
|
60
|
+
}
|
|
61
|
+
else if (!behind) {
|
|
62
|
+
(0, terminal_1.print)(branch.name + ': up to date.');
|
|
63
|
+
}
|
|
64
|
+
else if (checkedOut.has(branch.ref) && branch.ref !== current) {
|
|
65
|
+
(0, terminal_1.print)(branch.name + ': skipped (checked out in another worktree).');
|
|
66
|
+
}
|
|
67
|
+
else if (branch.ref === current && dirty) {
|
|
68
|
+
(0, terminal_1.print)(branch.name + ': skipped (working tree has uncommitted changes).');
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
if (branch.ref === current) {
|
|
72
|
+
(0, terminal_1.print)(await (0, exec_1.exec)(['merge', '--ff-only', '--no-edit', '--', target]));
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
// Compare-and-swap protects against concurrent changes to the local ref.
|
|
76
|
+
await (0, exec_1.exec)(['update-ref', '-m', 'co sync: fast-forward', branch.ref, target, branch.oid]);
|
|
77
|
+
}
|
|
78
|
+
(0, terminal_1.print)(branch.name + ': fast-forwarded.');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
errors.push(branch.name + ': ' + (error instanceof Error ? error.message : String(error)));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Check merged status after fast-forwards so newly integrated branches can
|
|
86
|
+
// be removed too. Never force-delete or delete a checked-out branch.
|
|
87
|
+
for (const branch of stale) {
|
|
88
|
+
try {
|
|
89
|
+
if (checkedOut.has(branch.ref)) {
|
|
90
|
+
(0, terminal_1.print)(branch.name + ': kept (upstream deleted, branch is checked out).');
|
|
91
|
+
}
|
|
92
|
+
else if (await (0, exec_1.exec)(['rev-list', '--count', branch.oid, '--not', 'HEAD']) !== '0') {
|
|
93
|
+
(0, terminal_1.print)(branch.name + ': warning: upstream deleted, unmerged commits kept.');
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
(0, terminal_1.print)(await (0, exec_1.exec)(['branch', '-d', '--', branch.name]));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
errors.push(branch.name + ': ' + (error instanceof Error ? error.message : String(error)));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (errors.length)
|
|
104
|
+
throw new Error(errors.join('\n'));
|
|
105
|
+
}
|
|
106
|
+
exports.syncBranches = syncBranches;
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,8 @@ const cli_commands_1 = require("./cli-commands");
|
|
|
6
6
|
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
|
+
const sync_branches_1 = require("./commands/sync-branches");
|
|
10
|
+
const stash_1 = require("./commands/stash");
|
|
9
11
|
const terminal_1 = require("./terminal");
|
|
10
12
|
const run = async () => {
|
|
11
13
|
const { command, filter } = (0, cli_commands_1.parseArgs)();
|
|
@@ -13,6 +15,16 @@ const run = async () => {
|
|
|
13
15
|
console.log((0, cli_commands_1.getHelp)());
|
|
14
16
|
return;
|
|
15
17
|
}
|
|
18
|
+
if (command === cli_commands_1.Commands.Stash) {
|
|
19
|
+
(0, terminal_1.startTerminal)();
|
|
20
|
+
await (0, stash_1.manageStashes)(filter);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (command === cli_commands_1.Commands.Sync) {
|
|
24
|
+
(0, terminal_1.startTerminal)();
|
|
25
|
+
await (0, sync_branches_1.syncBranches)();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
16
28
|
const branchType = command === cli_commands_1.Commands.ShowMerged ? commands_1.BranchesType.Merged
|
|
17
29
|
: command === cli_commands_1.Commands.ShowUnMerged ? commands_1.BranchesType.NotMerged : commands_1.BranchesType.All;
|
|
18
30
|
const deleting = command === cli_commands_1.Commands.DeleteBranch;
|
package/package.json
CHANGED
|
@@ -19,6 +19,16 @@ 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
|
+
});
|
|
26
|
+
it('accepts sync without a filter', () => {
|
|
27
|
+
expect(parseArgs(['-p'])).toEqual({ command: Commands.Sync, filter: '' });
|
|
28
|
+
});
|
|
29
|
+
it('rejects a filter for sync instead of silently ignoring it', () => {
|
|
30
|
+
expect(() => parseArgs(['-p', '-', '1234'])).toThrow('does not accept a filter');
|
|
31
|
+
});
|
|
22
32
|
it('reports unknown options', () => {
|
|
23
33
|
expect(() => parseArgs(['--typo'])).toThrow('Unknown option');
|
|
24
34
|
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
2
|
+
import { execFileSync } from 'child_process';
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'fs';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { syncBranches } from '../commands/sync-branches';
|
|
7
|
+
|
|
8
|
+
const context = vi.hoisted(() => ({ cwd: '' }));
|
|
9
|
+
vi.mock('child_process', async importOriginal => {
|
|
10
|
+
const actual = await importOriginal<typeof import('child_process')>();
|
|
11
|
+
return {
|
|
12
|
+
...actual,
|
|
13
|
+
execFile: (file, args, callback) => actual.execFile(file, args, { cwd: context.cwd }, callback),
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
let root: string;
|
|
18
|
+
let remote: string;
|
|
19
|
+
let peer: string;
|
|
20
|
+
function gitAt(cwd: string, ...args: string[]) {
|
|
21
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
22
|
+
}
|
|
23
|
+
function git(...args: string[]) { return gitAt(context.cwd, ...args); }
|
|
24
|
+
function other(...args: string[]) { return gitAt(peer, ...args); }
|
|
25
|
+
function commit(cwd: string, text: string) {
|
|
26
|
+
writeFileSync(join(cwd, 'file.txt'), text);
|
|
27
|
+
gitAt(cwd, 'add', 'file.txt');
|
|
28
|
+
gitAt(cwd, 'commit', '-qm', text);
|
|
29
|
+
}
|
|
30
|
+
function publishFeature() {
|
|
31
|
+
other('checkout', '-qb', 'feature');
|
|
32
|
+
commit(peer, 'feature initial');
|
|
33
|
+
other('push', '-qu', 'origin', 'feature');
|
|
34
|
+
git('fetch', '-q');
|
|
35
|
+
git('branch', '--track', 'feature', 'origin/feature');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
root = mkdtempSync(join(tmpdir(), 'co-sync-'));
|
|
40
|
+
context.cwd = join(root, 'work');
|
|
41
|
+
remote = join(root, 'remote.git');
|
|
42
|
+
peer = join(root, 'peer');
|
|
43
|
+
gitAt(root, 'init', '--bare', '-q', '-b', 'main', remote);
|
|
44
|
+
gitAt(root, 'init', '-q', '-b', 'main', context.cwd);
|
|
45
|
+
git('config', 'user.name', 'Test');
|
|
46
|
+
git('config', 'user.email', 'test@example.test');
|
|
47
|
+
commit(context.cwd, 'initial');
|
|
48
|
+
git('remote', 'add', 'origin', remote);
|
|
49
|
+
git('push', '-qu', 'origin', 'main');
|
|
50
|
+
gitAt(root, 'clone', '-q', remote, peer);
|
|
51
|
+
other('config', 'user.name', 'Test');
|
|
52
|
+
other('config', 'user.email', 'test@example.test');
|
|
53
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
vi.restoreAllMocks();
|
|
58
|
+
rmSync(root, { recursive: true, force: true });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('fetches and fast-forwards both current and inactive branches without switching', async () => {
|
|
62
|
+
publishFeature();
|
|
63
|
+
commit(peer, 'feature update');
|
|
64
|
+
other('push', '-q');
|
|
65
|
+
other('checkout', '-q', 'main');
|
|
66
|
+
commit(peer, 'main update');
|
|
67
|
+
other('push', '-q');
|
|
68
|
+
|
|
69
|
+
await syncBranches();
|
|
70
|
+
|
|
71
|
+
expect(git('rev-parse', 'main')).toBe(other('rev-parse', 'main'));
|
|
72
|
+
expect(git('rev-parse', 'feature')).toBe(other('rev-parse', 'feature'));
|
|
73
|
+
expect(git('branch', '--show-current')).toBe('main');
|
|
74
|
+
expect(readFileSync(join(context.cwd, 'file.txt'), 'utf8')).toBe('main update');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('keeps unpushed commits', async () => {
|
|
78
|
+
commit(context.cwd, 'local work');
|
|
79
|
+
const before = git('rev-parse', 'HEAD');
|
|
80
|
+
await syncBranches();
|
|
81
|
+
expect(git('rev-parse', 'HEAD')).toBe(before);
|
|
82
|
+
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('unpushed'));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('does not merge or rebase diverged branches', async () => {
|
|
86
|
+
commit(context.cwd, 'local work');
|
|
87
|
+
commit(peer, 'remote work');
|
|
88
|
+
other('push', '-q');
|
|
89
|
+
const before = git('rev-parse', 'HEAD');
|
|
90
|
+
await syncBranches();
|
|
91
|
+
expect(git('rev-parse', 'HEAD')).toBe(before);
|
|
92
|
+
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('diverged'));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('preserves a dirty current working tree', async () => {
|
|
96
|
+
commit(peer, 'remote work');
|
|
97
|
+
other('push', '-q');
|
|
98
|
+
writeFileSync(join(context.cwd, 'file.txt'), 'uncommitted');
|
|
99
|
+
const before = git('rev-parse', 'HEAD');
|
|
100
|
+
await syncBranches();
|
|
101
|
+
expect(git('rev-parse', 'HEAD')).toBe(before);
|
|
102
|
+
expect(readFileSync(join(context.cwd, 'file.txt'), 'utf8')).toBe('uncommitted');
|
|
103
|
+
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('uncommitted changes'));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('uses a same-named remote branch without changing tracking configuration', async () => {
|
|
107
|
+
git('branch', '--unset-upstream', 'main');
|
|
108
|
+
commit(peer, 'remote update');
|
|
109
|
+
other('push', '-q');
|
|
110
|
+
await syncBranches();
|
|
111
|
+
expect(git('rev-parse', 'HEAD')).toBe(other('rev-parse', 'HEAD'));
|
|
112
|
+
expect(git('for-each-ref', '--format=%(upstream)', 'refs/heads/main')).toBe('');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('deletes merged branches after fetching a deleted upstream', async () => {
|
|
116
|
+
git('branch', 'obsolete');
|
|
117
|
+
git('push', '-qu', 'origin', 'obsolete');
|
|
118
|
+
other('push', '-q', 'origin', '--delete', 'obsolete');
|
|
119
|
+
await syncBranches();
|
|
120
|
+
expect(git('branch', '--list', 'obsolete')).toBe('');
|
|
121
|
+
expect(git('branch', '--list', 'main')).toContain('main');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('checks stale branches after fast-forwarding the current branch', async () => {
|
|
125
|
+
publishFeature();
|
|
126
|
+
other('checkout', '-q', 'main');
|
|
127
|
+
other('merge', '--ff-only', 'feature');
|
|
128
|
+
other('push', '-q');
|
|
129
|
+
other('push', '-q', 'origin', '--delete', 'feature');
|
|
130
|
+
await syncBranches();
|
|
131
|
+
expect(git('branch', '--list', 'feature')).toBe('');
|
|
132
|
+
expect(git('rev-parse', 'main')).toBe(other('rev-parse', 'main'));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('keeps unmerged branches when their upstream is deleted', async () => {
|
|
136
|
+
publishFeature();
|
|
137
|
+
other('push', '-q', 'origin', '--delete', 'feature');
|
|
138
|
+
const before = git('rev-parse', 'feature');
|
|
139
|
+
await syncBranches();
|
|
140
|
+
expect(git('rev-parse', 'feature')).toBe(before);
|
|
141
|
+
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('unmerged commits kept'));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('never deletes the checked-out branch even if its upstream disappeared', async () => {
|
|
145
|
+
gitAt(remote, 'config', 'receive.denyDeleteCurrent', 'ignore');
|
|
146
|
+
other('push', '-q', 'origin', '--delete', 'main');
|
|
147
|
+
await syncBranches();
|
|
148
|
+
expect(git('branch', '--show-current')).toBe('main');
|
|
149
|
+
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('branch is checked out'));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('does not update branches checked out in another worktree', async () => {
|
|
153
|
+
publishFeature();
|
|
154
|
+
const worktree = join(root, 'linked');
|
|
155
|
+
git('worktree', 'add', '-q', worktree, 'feature');
|
|
156
|
+
const before = git('rev-parse', 'feature');
|
|
157
|
+
commit(peer, 'remote feature update');
|
|
158
|
+
other('push', '-q');
|
|
159
|
+
await syncBranches();
|
|
160
|
+
expect(git('rev-parse', 'feature')).toBe(before);
|
|
161
|
+
expect(readFileSync(join(worktree, 'file.txt'), 'utf8')).toBe('feature initial');
|
|
162
|
+
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('another worktree'));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('skips local-only branches', async () => {
|
|
166
|
+
git('branch', '--no-track', 'local-only');
|
|
167
|
+
await syncBranches();
|
|
168
|
+
expect(git('branch', '--list', 'local-only')).toContain('local-only');
|
|
169
|
+
expect(console.log).toHaveBeenCalledWith('local-only: skipped (no upstream).');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('uses a unique same-named branch on a non-origin remote', async () => {
|
|
173
|
+
git('remote', 'rename', 'origin', 'upstream');
|
|
174
|
+
git('branch', '--unset-upstream', 'main');
|
|
175
|
+
commit(peer, 'remote update');
|
|
176
|
+
other('push', '-q');
|
|
177
|
+
await syncBranches();
|
|
178
|
+
expect(git('rev-parse', 'HEAD')).toBe(other('rev-parse', 'HEAD'));
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('does not guess between multiple matching remotes without origin', async () => {
|
|
182
|
+
git('remote', 'rename', 'origin', 'one');
|
|
183
|
+
git('remote', 'add', 'two', remote);
|
|
184
|
+
git('branch', '--unset-upstream', 'main');
|
|
185
|
+
commit(peer, 'remote update');
|
|
186
|
+
other('push', '-q');
|
|
187
|
+
const before = git('rev-parse', 'HEAD');
|
|
188
|
+
await syncBranches();
|
|
189
|
+
expect(git('rev-parse', 'HEAD')).toBe(before);
|
|
190
|
+
expect(console.log).toHaveBeenCalledWith('main: skipped (ambiguous upstream).');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('can update local branches while HEAD is detached without touching the worktree', async () => {
|
|
194
|
+
git('checkout', '--detach', '-q');
|
|
195
|
+
const before = git('rev-parse', 'HEAD');
|
|
196
|
+
commit(peer, 'remote update');
|
|
197
|
+
other('push', '-q');
|
|
198
|
+
await syncBranches();
|
|
199
|
+
expect(git('rev-parse', 'main')).toBe(other('rev-parse', 'main'));
|
|
200
|
+
expect(git('rev-parse', 'HEAD')).toBe(before);
|
|
201
|
+
expect(readFileSync(join(context.cwd, 'file.txt'), 'utf8')).toBe('initial');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('keeps merged stale branches checked out in other worktrees', async () => {
|
|
205
|
+
git('branch', 'obsolete');
|
|
206
|
+
git('push', '-qu', 'origin', 'obsolete');
|
|
207
|
+
git('worktree', 'add', '-q', join(root, 'linked'), 'obsolete');
|
|
208
|
+
other('push', '-q', 'origin', '--delete', 'obsolete');
|
|
209
|
+
await syncBranches();
|
|
210
|
+
expect(git('branch', '--list', 'obsolete')).toContain('obsolete');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('fails clearly when there are no remotes', async () => {
|
|
214
|
+
git('remote', 'remove', 'origin');
|
|
215
|
+
await expect(syncBranches()).rejects.toThrow('No remotes configured');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('does not change branches if fetching fails', async () => {
|
|
219
|
+
git('remote', 'set-url', 'origin', join(root, 'missing.git'));
|
|
220
|
+
const before = git('show-ref');
|
|
221
|
+
await expect(syncBranches()).rejects.toThrow();
|
|
222
|
+
expect(git('show-ref')).toBe(before);
|
|
223
|
+
});
|
|
@@ -5,12 +5,16 @@ export enum Commands {
|
|
|
5
5
|
MergeBranch = 4,
|
|
6
6
|
ShowMerged = 8,
|
|
7
7
|
ShowUnMerged = 16,
|
|
8
|
+
Sync = 32,
|
|
9
|
+
Stash = 64,
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
export const getWishOfUser = (firstArg = process.argv[2]): Commands => {
|
|
11
13
|
if (['help', '-h', '--h', '--help'].includes(firstArg)) return Commands.Help;
|
|
12
14
|
if (['-M', 'merge'].includes(firstArg)) return Commands.MergeBranch;
|
|
13
15
|
if (['-D', 'rm'].includes(firstArg)) return Commands.DeleteBranch;
|
|
16
|
+
if (firstArg === '-p') return Commands.Sync;
|
|
17
|
+
if (['-s', '--stash'].includes(firstArg)) return Commands.Stash;
|
|
14
18
|
if (['m', '-sm', '--show-merged'].includes(firstArg)) return Commands.ShowMerged;
|
|
15
19
|
if (['un', '-un', '--no-merged'].includes(firstArg)) return Commands.ShowUnMerged;
|
|
16
20
|
return Commands.Unknown;
|
|
@@ -22,6 +26,9 @@ export function parseArgs(args = process.argv.slice(2)) {
|
|
|
22
26
|
if (command === Commands.Unknown && args[0] !== '-') {
|
|
23
27
|
throw new Error('Unknown option: ' + args[0] + '. Use co --help.');
|
|
24
28
|
}
|
|
29
|
+
if (command === Commands.Sync && args.length > 1) {
|
|
30
|
+
throw new Error('co -p synchronizes all branches and does not accept a filter.');
|
|
31
|
+
}
|
|
25
32
|
const rest = args.slice(1);
|
|
26
33
|
if (rest[0] === '-') rest.shift();
|
|
27
34
|
return { command, filter: rest.join(' ') };
|
|
@@ -29,13 +36,15 @@ export function parseArgs(args = process.argv.slice(2)) {
|
|
|
29
36
|
|
|
30
37
|
export const getHelp = (): string => `
|
|
31
38
|
DESCRIPTION:
|
|
32
|
-
Quickly switch, merge and
|
|
39
|
+
Quickly switch, merge, delete and synchronize Git branches, and manage stashes.
|
|
33
40
|
|
|
34
41
|
USAGE:
|
|
35
42
|
co Select a branch to check out
|
|
36
43
|
co - 1234 Start with an editable search filter
|
|
37
44
|
co -M [- filter] Merge, then offer to delete the source branch [Y/n]
|
|
38
45
|
co -D [- filter] Delete multiple local branches (Space to select)
|
|
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]
|
|
39
48
|
co m, -sm, --show-merged Show merged branches
|
|
40
49
|
co un, -un, --no-merged Show unmerged branches
|
|
41
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
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { exec } from './exec';
|
|
2
|
+
import { getCurrentBranchName } from './get-current-branch-name';
|
|
3
|
+
import { print } from '../terminal';
|
|
4
|
+
|
|
5
|
+
/** Synchronize local branches without switching branches or rewriting commits. */
|
|
6
|
+
export async function syncBranches() {
|
|
7
|
+
const remotes = (await exec(['remote'])).split('\n').filter(Boolean);
|
|
8
|
+
if (!remotes.length) throw new Error('No remotes configured. Add a Git remote before running co -p.');
|
|
9
|
+
|
|
10
|
+
print('Fetching all remotes...');
|
|
11
|
+
print(await exec(['fetch', '--all', '--prune']));
|
|
12
|
+
|
|
13
|
+
const refs = new Map((await exec(['for-each-ref', '--format=%(refname)%09%(objectname)']))
|
|
14
|
+
.split('\n').filter(Boolean).map(line => {
|
|
15
|
+
const [ref, oid] = line.split('\t');
|
|
16
|
+
return [ref, oid] as [string, string];
|
|
17
|
+
}));
|
|
18
|
+
const branches = (await exec([
|
|
19
|
+
'for-each-ref', '--format=%(refname)%09%(objectname)%09%(upstream)', 'refs/heads/',
|
|
20
|
+
])).split('\n').filter(Boolean).map(line => {
|
|
21
|
+
const [ref, oid, upstream = ''] = line.split('\t');
|
|
22
|
+
return { ref, oid, upstream, name: ref.slice('refs/heads/'.length) };
|
|
23
|
+
});
|
|
24
|
+
const current = 'refs/heads/' + await getCurrentBranchName();
|
|
25
|
+
const checkedOut = new Set((await exec(['worktree', 'list', '--porcelain', '-z']))
|
|
26
|
+
.split('\0').filter(field => field.startsWith('branch ')).map(field => field.slice(7)));
|
|
27
|
+
const dirty = Boolean(await exec(['status', '--porcelain']));
|
|
28
|
+
const stale: typeof branches = [];
|
|
29
|
+
const errors: string[] = [];
|
|
30
|
+
|
|
31
|
+
for (const branch of branches) {
|
|
32
|
+
try {
|
|
33
|
+
let upstream = branch.upstream;
|
|
34
|
+
if (!upstream) {
|
|
35
|
+
const candidates = remotes.map(remote => 'refs/remotes/' + remote + '/' + branch.name)
|
|
36
|
+
.filter(ref => refs.has(ref));
|
|
37
|
+
const origin = 'refs/remotes/origin/' + branch.name;
|
|
38
|
+
if (candidates.includes(origin)) upstream = origin;
|
|
39
|
+
else if (candidates.length === 1) upstream = candidates[0];
|
|
40
|
+
else {
|
|
41
|
+
print(branch.name + ': skipped (' + (candidates.length ? 'ambiguous upstream' : 'no upstream') + ').');
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const target = refs.get(upstream);
|
|
47
|
+
if (!target) {
|
|
48
|
+
if (upstream.startsWith('refs/remotes/')) stale.push(branch);
|
|
49
|
+
else print(branch.name + ': skipped (upstream is missing).');
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const [ahead, behind] = (await exec(['rev-list', '--left-right', '--count', branch.oid + '...' + target]))
|
|
54
|
+
.split(/\s+/).map(Number);
|
|
55
|
+
if (ahead) {
|
|
56
|
+
print(branch.name + ': warning: ' + ahead + ' unpushed commit(s)' +
|
|
57
|
+
(behind ? ', diverged from upstream by ' + behind + ' commit(s)' : '') + '; kept unchanged.');
|
|
58
|
+
} else if (!behind) {
|
|
59
|
+
print(branch.name + ': up to date.');
|
|
60
|
+
} else if (checkedOut.has(branch.ref) && branch.ref !== current) {
|
|
61
|
+
print(branch.name + ': skipped (checked out in another worktree).');
|
|
62
|
+
} else if (branch.ref === current && dirty) {
|
|
63
|
+
print(branch.name + ': skipped (working tree has uncommitted changes).');
|
|
64
|
+
} else {
|
|
65
|
+
if (branch.ref === current) {
|
|
66
|
+
print(await exec(['merge', '--ff-only', '--no-edit', '--', target]));
|
|
67
|
+
} else {
|
|
68
|
+
// Compare-and-swap protects against concurrent changes to the local ref.
|
|
69
|
+
await exec(['update-ref', '-m', 'co sync: fast-forward', branch.ref, target, branch.oid]);
|
|
70
|
+
}
|
|
71
|
+
print(branch.name + ': fast-forwarded.');
|
|
72
|
+
}
|
|
73
|
+
} catch (error) {
|
|
74
|
+
errors.push(branch.name + ': ' + (error instanceof Error ? error.message : String(error)));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Check merged status after fast-forwards so newly integrated branches can
|
|
79
|
+
// be removed too. Never force-delete or delete a checked-out branch.
|
|
80
|
+
for (const branch of stale) {
|
|
81
|
+
try {
|
|
82
|
+
if (checkedOut.has(branch.ref)) {
|
|
83
|
+
print(branch.name + ': kept (upstream deleted, branch is checked out).');
|
|
84
|
+
} else if (await exec(['rev-list', '--count', branch.oid, '--not', 'HEAD']) !== '0') {
|
|
85
|
+
print(branch.name + ': warning: upstream deleted, unmerged commits kept.');
|
|
86
|
+
} else {
|
|
87
|
+
print(await exec(['branch', '-d', '--', branch.name]));
|
|
88
|
+
}
|
|
89
|
+
} catch (error) {
|
|
90
|
+
errors.push(branch.name + ': ' + (error instanceof Error ? error.message : String(error)));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (errors.length) throw new Error(errors.join('\n'));
|
|
94
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { Commands, getHelp, parseArgs } from './cli-commands';
|
|
|
3
3
|
import { BranchesType, checkoutBranch, getBranches, getCurrentBranchName } from './commands';
|
|
4
4
|
import { deleteBranches } from './commands/delete-barnch';
|
|
5
5
|
import { mergeBranch } from './commands/merge-branch';
|
|
6
|
+
import { syncBranches } from './commands/sync-branches';
|
|
7
|
+
import { manageStashes } from './commands/stash';
|
|
6
8
|
import { finishTerminal, installInterruptHandler, print, startTerminal } from './terminal';
|
|
7
9
|
|
|
8
10
|
export const run = async () => {
|
|
@@ -12,6 +14,18 @@ export const run = async () => {
|
|
|
12
14
|
return;
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
if (command === Commands.Stash) {
|
|
18
|
+
startTerminal();
|
|
19
|
+
await manageStashes(filter);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (command === Commands.Sync) {
|
|
24
|
+
startTerminal();
|
|
25
|
+
await syncBranches();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
15
29
|
const branchType = command === Commands.ShowMerged ? BranchesType.Merged
|
|
16
30
|
: command === Commands.ShowUnMerged ? BranchesType.NotMerged : BranchesType.All;
|
|
17
31
|
const deleting = command === Commands.DeleteBranch;
|