avil13-co 2.2.5 → 2.4.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 +62 -21
- package/dist/ask.d.ts +4 -3
- package/dist/ask.js +54 -32
- package/dist/cli-commands/index.d.ts +7 -2
- package/dist/cli-commands/index.js +44 -37
- package/dist/commands/checkout-branch.js +5 -9
- package/dist/commands/delete-barnch.d.ts +1 -0
- package/dist/commands/delete-barnch.js +34 -23
- package/dist/commands/exec.d.ts +1 -1
- package/dist/commands/exec.js +11 -2
- package/dist/commands/get-branches.d.ts +2 -4
- package/dist/commands/get-branches.js +18 -35
- package/dist/commands/get-current-branch-name.d.ts +1 -1
- package/dist/commands/get-current-branch-name.js +1 -3
- package/dist/commands/merge-branch.js +27 -31
- package/dist/commands/sync-branches.d.ts +2 -0
- package/dist/commands/sync-branches.js +106 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +40 -18
- package/dist/prompt.d.ts +2 -0
- package/dist/prompt.js +24 -0
- package/dist/terminal.d.ts +5 -0
- package/dist/terminal.js +40 -0
- package/package.json +1 -1
- package/src/__tests__/ask.spec.ts +17 -2
- package/src/__tests__/cli-commands.spec.ts +31 -0
- package/src/__tests__/commands.spec.ts +110 -0
- package/src/__tests__/sync-branches.spec.ts +223 -0
- package/src/__tests__/terminal.spec.ts +56 -0
- package/src/ask.ts +51 -47
- package/src/cli-commands/index.ts +36 -46
- package/src/commands/checkout-branch.ts +4 -12
- package/src/commands/delete-barnch.ts +30 -23
- package/src/commands/exec.ts +11 -7
- package/src/commands/get-branches.ts +15 -41
- package/src/commands/get-current-branch-name.ts +2 -6
- package/src/commands/merge-branch.ts +25 -34
- package/src/commands/sync-branches.ts +94 -0
- package/src/index.ts +40 -28
- package/src/prompt.ts +18 -0
- package/src/terminal.ts +32 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
2
|
+
import { finishTerminal, print, startTerminal } from '../terminal';
|
|
3
|
+
|
|
4
|
+
const descriptors = [process.stdin, process.stdout].map(stream =>
|
|
5
|
+
Object.getOwnPropertyDescriptor(stream, 'isTTY'));
|
|
6
|
+
|
|
7
|
+
function setTTY(value: boolean) {
|
|
8
|
+
for (const stream of [process.stdin, process.stdout]) {
|
|
9
|
+
Object.defineProperty(stream, 'isTTY', { value, configurable: true });
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.spyOn(process.stdout, 'write').mockReturnValue(true);
|
|
15
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
finishTerminal(true);
|
|
20
|
+
[process.stdin, process.stdout].forEach((stream, index) => {
|
|
21
|
+
const descriptor = descriptors[index];
|
|
22
|
+
if (descriptor) Object.defineProperty(stream, 'isTTY', descriptor);
|
|
23
|
+
else delete (stream as any).isTTY;
|
|
24
|
+
});
|
|
25
|
+
vi.restoreAllMocks();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('does not emit screen controls outside a TTY', () => {
|
|
29
|
+
setTTY(false);
|
|
30
|
+
startTerminal();
|
|
31
|
+
print('result');
|
|
32
|
+
finishTerminal();
|
|
33
|
+
expect(process.stdout.write).not.toHaveBeenCalled();
|
|
34
|
+
expect(console.log).toHaveBeenCalledExactlyOnceWith('result');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('restores the screen and replays results on success only once', () => {
|
|
38
|
+
setTTY(true);
|
|
39
|
+
startTerminal();
|
|
40
|
+
print('result');
|
|
41
|
+
finishTerminal();
|
|
42
|
+
finishTerminal();
|
|
43
|
+
expect(process.stdout.write).toHaveBeenNthCalledWith(1, '\x1b[?1049h');
|
|
44
|
+
expect(process.stdout.write).toHaveBeenNthCalledWith(2, '\x1b[?1049l\x1b[?25h');
|
|
45
|
+
expect(process.stdout.write).toHaveBeenCalledTimes(2);
|
|
46
|
+
expect(console.log).toHaveBeenCalledTimes(2);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('restores the screen without replaying output on cancellation', () => {
|
|
50
|
+
setTTY(true);
|
|
51
|
+
startTerminal();
|
|
52
|
+
print('discard this');
|
|
53
|
+
finishTerminal(true);
|
|
54
|
+
expect(process.stdout.write).toHaveBeenLastCalledWith('\x1b[?1049l\x1b[?25h');
|
|
55
|
+
expect(console.log).toHaveBeenCalledTimes(1);
|
|
56
|
+
});
|
package/src/ask.ts
CHANGED
|
@@ -1,62 +1,66 @@
|
|
|
1
1
|
import inquirer from 'inquirer';
|
|
2
|
+
import { prompt } from './prompt';
|
|
2
3
|
|
|
3
|
-
inquirer
|
|
4
|
-
'autocomplete',
|
|
5
|
-
require('inquirer-autocomplete-prompt')
|
|
6
|
-
);
|
|
4
|
+
const AutocompletePrompt = require('inquirer-autocomplete-prompt');
|
|
7
5
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
6
|
+
class FilteredAutocompletePrompt extends AutocompletePrompt {
|
|
7
|
+
_run(callback) {
|
|
8
|
+
// `default` in the plugin selects a choice, it does not fill the input.
|
|
9
|
+
const filter = this.opt.initialFilter || '';
|
|
10
|
+
this.rl.line = filter;
|
|
11
|
+
this.rl.cursor = filter.length;
|
|
12
|
+
return super._run(callback);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
inquirer.registerPrompt('autocomplete', FilteredAutocompletePrompt as any);
|
|
17
|
+
|
|
18
|
+
export const ask = async (message: string, choices: string[], initialFilter = '') => {
|
|
19
|
+
return prompt<{ branch: string }>({
|
|
17
20
|
type: 'autocomplete',
|
|
18
21
|
name: 'branch',
|
|
19
22
|
message,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return Promise.resolve(list);
|
|
28
|
-
}
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
return prompt;
|
|
23
|
+
initialFilter,
|
|
24
|
+
source(_answers, input = initialFilter) {
|
|
25
|
+
return Promise.resolve(choices
|
|
26
|
+
.filter(choice => checkItem(choice, input))
|
|
27
|
+
.sort((a, b) => sortOrder(input, a, b)));
|
|
28
|
+
},
|
|
29
|
+
} as any);
|
|
32
30
|
};
|
|
33
31
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
export async function askBranches(message: string, choices: string[], filter = ''): Promise<string[]> {
|
|
33
|
+
const filtered = choices.filter(choice => checkItem(choice, filter));
|
|
34
|
+
if (!filtered.length) throw new Error('No branches match the filter.');
|
|
35
|
+
const { branches } = await prompt<{ branches: string[] }>({
|
|
36
|
+
type: 'checkbox',
|
|
37
|
+
name: 'branches',
|
|
38
|
+
message,
|
|
39
|
+
choices: filtered,
|
|
40
|
+
validate: (selected: string[]) => selected.length > 0 || 'Select at least one branch.',
|
|
41
|
+
});
|
|
42
|
+
return branches;
|
|
39
43
|
}
|
|
40
44
|
|
|
41
|
-
export function
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return 1;
|
|
45
|
+
export function checkItem(item: string, filterString: string): boolean {
|
|
46
|
+
// Literal, case-insensitive subsequence matching; no user-supplied regex.
|
|
47
|
+
const needle = filterString.replace(/\s/g, '').toLowerCase();
|
|
48
|
+
const haystack = item.toLowerCase();
|
|
49
|
+
let position = 0;
|
|
50
|
+
for (const character of needle) {
|
|
51
|
+
position = haystack.indexOf(character, position);
|
|
52
|
+
if (position === -1) return false;
|
|
53
|
+
position++;
|
|
51
54
|
}
|
|
52
|
-
|
|
53
|
-
return -1;
|
|
55
|
+
return true;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
);
|
|
58
|
+
export function sortOrder(searchString: string, name1: string, name2: string): number {
|
|
59
|
+
const search = searchString.toLowerCase();
|
|
60
|
+
const first = name1.toLowerCase().indexOf(search);
|
|
61
|
+
const second = name2.toLowerCase().indexOf(search);
|
|
62
|
+
if (first === second) return 0;
|
|
63
|
+
if (first === -1) return 1;
|
|
64
|
+
if (second === -1) return -1;
|
|
65
|
+
return first - second;
|
|
62
66
|
}
|
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
import { clc } from '../color';
|
|
2
|
-
|
|
3
|
-
const args = process.argv.slice(2);
|
|
4
|
-
|
|
5
1
|
export enum Commands {
|
|
6
2
|
Unknown = 0,
|
|
7
3
|
Help = 1,
|
|
@@ -9,52 +5,46 @@ export enum Commands {
|
|
|
9
5
|
MergeBranch = 4,
|
|
10
6
|
ShowMerged = 8,
|
|
11
7
|
ShowUnMerged = 16,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
export const getWishOfUser = (firstArg?: string): Commands => {
|
|
15
|
-
const arg: string = firstArg || args[0];
|
|
16
|
-
|
|
17
|
-
switch (true) {
|
|
18
|
-
case ['help', '-h', '--h', '--help'].includes(arg):
|
|
19
|
-
return Commands.Help;
|
|
20
|
-
|
|
21
|
-
case ['-M', 'merge'].includes(arg):
|
|
22
|
-
return Commands.MergeBranch;
|
|
23
|
-
|
|
24
|
-
case ['-D', 'rm'].includes(arg):
|
|
25
|
-
return Commands.DeleteBranch;
|
|
26
|
-
|
|
27
|
-
case ['-sm', '--show-merged'].includes(arg):
|
|
28
|
-
return Commands.ShowMerged;
|
|
8
|
+
Sync = 32,
|
|
9
|
+
}
|
|
29
10
|
|
|
30
|
-
|
|
31
|
-
|
|
11
|
+
export const getWishOfUser = (firstArg = process.argv[2]): Commands => {
|
|
12
|
+
if (['help', '-h', '--h', '--help'].includes(firstArg)) return Commands.Help;
|
|
13
|
+
if (['-M', 'merge'].includes(firstArg)) return Commands.MergeBranch;
|
|
14
|
+
if (['-D', 'rm'].includes(firstArg)) return Commands.DeleteBranch;
|
|
15
|
+
if (firstArg === '-p') return Commands.Sync;
|
|
16
|
+
if (['m', '-sm', '--show-merged'].includes(firstArg)) return Commands.ShowMerged;
|
|
17
|
+
if (['un', '-un', '--no-merged'].includes(firstArg)) return Commands.ShowUnMerged;
|
|
18
|
+
return Commands.Unknown;
|
|
19
|
+
};
|
|
32
20
|
|
|
33
|
-
|
|
34
|
-
|
|
21
|
+
export function parseArgs(args = process.argv.slice(2)) {
|
|
22
|
+
const command = getWishOfUser(args[0] || '');
|
|
23
|
+
if (!args.length) return { command, filter: '' };
|
|
24
|
+
if (command === Commands.Unknown && args[0] !== '-') {
|
|
25
|
+
throw new Error('Unknown option: ' + args[0] + '. Use co --help.');
|
|
26
|
+
}
|
|
27
|
+
if (command === Commands.Sync && args.length > 1) {
|
|
28
|
+
throw new Error('co -p synchronizes all branches and does not accept a filter.');
|
|
35
29
|
}
|
|
30
|
+
const rest = args.slice(1);
|
|
31
|
+
if (rest[0] === '-') rest.shift();
|
|
32
|
+
return { command, filter: rest.join(' ') };
|
|
36
33
|
}
|
|
37
34
|
|
|
38
|
-
|
|
39
|
-
export const getHelp = (): string => {
|
|
40
|
-
return `
|
|
35
|
+
export const getHelp = (): string => `
|
|
41
36
|
DESCRIPTION:
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
Showing not merged branches
|
|
56
|
-
|
|
57
|
-
-D, rm
|
|
58
|
-
Delete the selected branch
|
|
37
|
+
Quickly switch, merge, delete and synchronize Git branches.
|
|
38
|
+
|
|
39
|
+
USAGE:
|
|
40
|
+
co Select a branch to check out
|
|
41
|
+
co - 1234 Start with an editable search filter
|
|
42
|
+
co -M [- filter] Merge, then offer to delete the source branch [Y/n]
|
|
43
|
+
co -D [- filter] Delete multiple local branches (Space to select)
|
|
44
|
+
co -p Fetch and safely sync all local branches (like hub sync)
|
|
45
|
+
co m, -sm, --show-merged Show merged branches
|
|
46
|
+
co un, -un, --no-merged Show unmerged branches
|
|
47
|
+
co help, -h, --h, --help Show this message
|
|
48
|
+
|
|
49
|
+
Ctrl+C cancels and restores the previous terminal screen.
|
|
59
50
|
`;
|
|
60
|
-
}
|
|
@@ -1,16 +1,8 @@
|
|
|
1
1
|
import { exec } from './exec';
|
|
2
|
-
import {
|
|
2
|
+
import { print } from '../terminal';
|
|
3
3
|
|
|
4
|
-
//
|
|
5
4
|
export async function checkoutBranch(branchName: string) {
|
|
6
|
-
if (!branchName)
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const checkoutCommand = `git checkout ${branchName}`;
|
|
11
|
-
|
|
12
|
-
console.log(' ' + clc.green('git checkout', branchName) + '\n');
|
|
13
|
-
|
|
14
|
-
const checkoutCommandResult = await exec(checkoutCommand);
|
|
15
|
-
console.log(checkoutCommandResult);
|
|
5
|
+
if (!branchName) throw new Error('No branch selected.');
|
|
6
|
+
print('git checkout ' + branchName);
|
|
7
|
+
print(await exec(['checkout', branchName, '--']));
|
|
16
8
|
}
|
|
@@ -1,31 +1,38 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { prompt } from '../prompt';
|
|
2
2
|
import { exec } from './exec';
|
|
3
|
-
import {
|
|
3
|
+
import { print } from '../terminal';
|
|
4
4
|
|
|
5
|
-
export async function
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
export async function deleteBranches(branchNames: string[], confirm = true, force = true) {
|
|
6
|
+
const branches = [...new Set(branchNames)];
|
|
7
|
+
if (!branches.length || branches.some(branch => !branch)) {
|
|
8
|
+
throw new Error('No branches selected.');
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const checkoutCommandResult = await exec(command);
|
|
23
|
-
|
|
24
|
-
console.log(checkoutCommandResult);
|
|
25
|
-
console.log(clc.yellow('Branch'), clc.red(branchName), clc.yellow('has been deleted'));
|
|
11
|
+
if (confirm) {
|
|
12
|
+
const { answer } = await prompt({
|
|
13
|
+
type: 'confirm',
|
|
14
|
+
name: 'answer',
|
|
15
|
+
message: 'Delete these branches: ' + branches.join(', ') + '?',
|
|
16
|
+
default: false,
|
|
17
|
+
});
|
|
18
|
+
if (!answer) {
|
|
19
|
+
print('Branches were not deleted.');
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
26
23
|
|
|
27
|
-
|
|
24
|
+
// Attempt every selected branch and report partial failures as well.
|
|
25
|
+
const errors: string[] = [];
|
|
26
|
+
for (const branch of branches) {
|
|
27
|
+
try {
|
|
28
|
+
print(await exec(['branch', force ? '-D' : '-d', '--', branch]));
|
|
29
|
+
} catch (error) {
|
|
30
|
+
errors.push(branch + ': ' + (error instanceof Error ? error.message : String(error)));
|
|
31
|
+
}
|
|
28
32
|
}
|
|
33
|
+
if (errors.length) throw new Error(errors.join('\n'));
|
|
34
|
+
}
|
|
29
35
|
|
|
30
|
-
|
|
36
|
+
export async function deleteBranch(branchName: string) {
|
|
37
|
+
return deleteBranches([branchName]);
|
|
31
38
|
}
|
package/src/commands/exec.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
2
|
|
|
3
|
-
export function exec(
|
|
4
|
-
return new Promise((resolve, reject) =>
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
export function exec(args: string[]): Promise<string> {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
execFile('git', args, (error, stdout, stderr) => {
|
|
6
|
+
if (error) {
|
|
7
|
+
reject(new Error([stderr.trim(), stdout.trim()].filter(Boolean).join('\n') || error.message));
|
|
8
|
+
} else {
|
|
9
|
+
resolve([stdout.trimEnd(), stderr.trimEnd()].filter(Boolean).join('\n'));
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
});
|
|
9
13
|
}
|
|
@@ -1,50 +1,24 @@
|
|
|
1
1
|
import { exec } from './exec';
|
|
2
2
|
|
|
3
|
-
|
|
4
3
|
export enum BranchesType {
|
|
5
4
|
All,
|
|
6
5
|
Merged,
|
|
7
6
|
NotMerged,
|
|
8
7
|
}
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
[
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (branches.length) {
|
|
26
|
-
return branches;
|
|
27
|
-
}
|
|
28
|
-
throw new Error('No branches found...');
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function parseBranches(stdout) {
|
|
33
|
-
return stdout
|
|
34
|
-
.split('\n')
|
|
35
|
-
.filter(s => !/\s->\s/.test(s))
|
|
36
|
-
.map(cleanBranchName)
|
|
37
|
-
.filter(Boolean)
|
|
38
|
-
.filter(onlyUnique);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function cleanBranchName(name) {
|
|
42
|
-
return name
|
|
43
|
-
.replace(/^\*/, '')
|
|
44
|
-
.replace(/remotes\/origin\//, '')
|
|
45
|
-
.trim();
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function onlyUnique(value, index, self) {
|
|
49
|
-
return self.indexOf(value) === index;
|
|
9
|
+
/** Remote origin refs are also offered for checkout/merge; deletion is local only. */
|
|
10
|
+
export async function getBranches(type: BranchesType, localOnly = false): Promise<string[]> {
|
|
11
|
+
const args = ['branch', '--format=%(refname)'];
|
|
12
|
+
if (!localOnly) args.push('--all');
|
|
13
|
+
if (type === BranchesType.Merged) args.push('--merged');
|
|
14
|
+
if (type === BranchesType.NotMerged) args.push('--no-merged');
|
|
15
|
+
|
|
16
|
+
const stdout = await exec(args);
|
|
17
|
+
const branches = [...new Set(stdout.split('\n')
|
|
18
|
+
.map(name => name.trim())
|
|
19
|
+
.filter(name => name && !/^refs\/remotes\/.+\/HEAD$/.test(name))
|
|
20
|
+
.map(name => name.replace(/^refs\/heads\//, '')
|
|
21
|
+
.replace(/^refs\/remotes\/(?:origin\/)?/, '')))];
|
|
22
|
+
if (!branches.length) throw new Error('No branches found.');
|
|
23
|
+
return branches;
|
|
50
24
|
}
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import { exec } from './exec';
|
|
2
2
|
|
|
3
|
-
export async function getCurrentBranchName(){
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
const stdout = await exec(currentBranchName);
|
|
7
|
-
console.log(stdout);
|
|
3
|
+
export async function getCurrentBranchName(): Promise<string> {
|
|
4
|
+
return (await exec(['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
|
|
8
5
|
}
|
|
9
|
-
|
|
@@ -1,46 +1,37 @@
|
|
|
1
1
|
import { exec } from './exec';
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
2
|
+
import { getCurrentBranchName } from './get-current-branch-name';
|
|
3
|
+
import { deleteBranches } from './delete-barnch';
|
|
4
|
+
import { print } from '../terminal';
|
|
5
|
+
import { prompt } from '../prompt';
|
|
4
6
|
|
|
5
|
-
//
|
|
6
7
|
export async function mergeBranch(branchName: string) {
|
|
7
|
-
if (!branchName)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const currentBranch = await exec('git rev-parse --abbrev-ref HEAD');
|
|
12
|
-
|
|
13
|
-
const message = [
|
|
14
|
-
clc._blue,
|
|
15
|
-
'Do you want to merge branch ',
|
|
16
|
-
clc._green,
|
|
17
|
-
branchName, // branch
|
|
18
|
-
clc._blue,
|
|
19
|
-
' into ',
|
|
20
|
-
clc._green,
|
|
21
|
-
currentBranch, // branch
|
|
22
|
-
clc._blue,
|
|
23
|
-
'?',
|
|
24
|
-
clc._off,
|
|
25
|
-
].join('');
|
|
8
|
+
if (!branchName) throw new Error('No branch selected.');
|
|
9
|
+
const currentBranch = await getCurrentBranchName();
|
|
10
|
+
if (branchName === currentBranch) throw new Error('Cannot merge the current branch into itself.');
|
|
26
11
|
|
|
27
|
-
const
|
|
12
|
+
const { answer } = await prompt({
|
|
28
13
|
type: 'confirm',
|
|
29
14
|
name: 'answer',
|
|
30
|
-
message,
|
|
15
|
+
message: 'Merge branch ' + branchName + ' into ' + currentBranch + '?',
|
|
31
16
|
default: false,
|
|
32
17
|
});
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const checkoutCommand = `git merge ${branchName}`;
|
|
36
|
-
|
|
37
|
-
console.log(' ' + clc.green('git merge', branchName) + '\n');
|
|
38
|
-
|
|
39
|
-
const checkoutCommandResult = await exec(checkoutCommand);
|
|
40
|
-
console.log(checkoutCommandResult);
|
|
41
|
-
|
|
18
|
+
if (!answer) {
|
|
19
|
+
print('Branch was not merged.');
|
|
42
20
|
return;
|
|
43
21
|
}
|
|
44
22
|
|
|
45
|
-
|
|
23
|
+
print('git merge ' + branchName);
|
|
24
|
+
print(await exec(['merge', '--', branchName]));
|
|
25
|
+
|
|
26
|
+
// Only local branches can be deleted; never remove remote refs.
|
|
27
|
+
const locals = (await exec(['for-each-ref', '--format=%(refname)', 'refs/heads/'])).split('\n');
|
|
28
|
+
if (!locals.includes('refs/heads/' + branchName)) return;
|
|
29
|
+
|
|
30
|
+
const { remove } = await prompt({
|
|
31
|
+
type: 'confirm',
|
|
32
|
+
name: 'remove',
|
|
33
|
+
message: 'Delete merged branch ' + branchName + '?',
|
|
34
|
+
default: true,
|
|
35
|
+
});
|
|
36
|
+
if (remove) await deleteBranches([branchName], false, false);
|
|
46
37
|
}
|
|
@@ -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
|
+
}
|