avil13-co 2.3.0 → 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 +22 -1
- package/dist/cli-commands/index.d.ts +2 -1
- package/dist/cli-commands/index.js +8 -1
- package/dist/commands/sync-branches.d.ts +2 -0
- package/dist/commands/sync-branches.js +106 -0
- package/dist/index.js +6 -0
- package/package.json +1 -1
- package/src/__tests__/cli-commands.spec.ts +6 -0
- package/src/__tests__/sync-branches.spec.ts +223 -0
- package/src/cli-commands/index.ts +7 -1
- package/src/commands/sync-branches.ts +94 -0
- package/src/index.ts +7 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# avil13/co
|
|
2
2
|
|
|
3
|
-
Interactive Git branch checkout, merge and
|
|
3
|
+
Interactive Git branch checkout, merge, deletion and synchronization.
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
@@ -19,6 +19,7 @@ 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)
|
|
22
23
|
co -h # Show help
|
|
23
24
|
```
|
|
24
25
|
|
|
@@ -41,6 +42,26 @@ with **Yes** as the default (`[Y/n]`). This uses safe deletion (`git branch -d`)
|
|
|
41
42
|
not forced deletion. Remote-only branches are not offered for deletion.
|
|
42
43
|
A failed merge never triggers the deletion question.
|
|
43
44
|
|
|
45
|
+
### Synchronize branches
|
|
46
|
+
|
|
47
|
+
`co -p` works like `hub sync`, without requiring `hub`:
|
|
48
|
+
|
|
49
|
+
- Fetches all remotes with `--prune`.
|
|
50
|
+
- Fast-forwards local branches to their configured upstreams, including branches
|
|
51
|
+
that are not currently checked out. It does not create merge commits or rebase.
|
|
52
|
+
- Without an upstream, uses the same-named branch on `origin`, or a unique match
|
|
53
|
+
on another remote. Ambiguous matches are skipped; tracking configuration is not changed.
|
|
54
|
+
- Warns about unpushed commits or diverged history and leaves those branches unchanged.
|
|
55
|
+
- Safely deletes branches whose remote upstream disappeared only when they are
|
|
56
|
+
merged into the current `HEAD`. Unmerged and checked-out branches are kept.
|
|
57
|
+
- Skips updating the current branch if the working tree is dirty, and never updates
|
|
58
|
+
branches checked out in other worktrees.
|
|
59
|
+
|
|
60
|
+
This command synchronizes **all** local branches without a selection prompt and
|
|
61
|
+
accepts no search filter. It never pushes, force-resets, or stashes changes.
|
|
62
|
+
Network and Git failures are reported with exit code 1; skipped branches are
|
|
63
|
+
reported without treating them as failures.
|
|
64
|
+
|
|
44
65
|
### Cancel and errors
|
|
45
66
|
|
|
46
67
|
**Ctrl+C** exits with code 130 and restores the previous terminal screen,
|
|
@@ -4,7 +4,8 @@ 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
|
|
8
9
|
}
|
|
9
10
|
export declare const getWishOfUser: (firstArg?: string) => Commands;
|
|
10
11
|
export declare function parseArgs(args?: string[]): {
|
|
@@ -9,6 +9,7 @@ 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";
|
|
12
13
|
})(Commands = exports.Commands || (exports.Commands = {}));
|
|
13
14
|
const getWishOfUser = (firstArg = process.argv[2]) => {
|
|
14
15
|
if (['help', '-h', '--h', '--help'].includes(firstArg))
|
|
@@ -17,6 +18,8 @@ const getWishOfUser = (firstArg = process.argv[2]) => {
|
|
|
17
18
|
return Commands.MergeBranch;
|
|
18
19
|
if (['-D', 'rm'].includes(firstArg))
|
|
19
20
|
return Commands.DeleteBranch;
|
|
21
|
+
if (firstArg === '-p')
|
|
22
|
+
return Commands.Sync;
|
|
20
23
|
if (['m', '-sm', '--show-merged'].includes(firstArg))
|
|
21
24
|
return Commands.ShowMerged;
|
|
22
25
|
if (['un', '-un', '--no-merged'].includes(firstArg))
|
|
@@ -31,6 +34,9 @@ function parseArgs(args = process.argv.slice(2)) {
|
|
|
31
34
|
if (command === Commands.Unknown && args[0] !== '-') {
|
|
32
35
|
throw new Error('Unknown option: ' + args[0] + '. Use co --help.');
|
|
33
36
|
}
|
|
37
|
+
if (command === Commands.Sync && args.length > 1) {
|
|
38
|
+
throw new Error('co -p synchronizes all branches and does not accept a filter.');
|
|
39
|
+
}
|
|
34
40
|
const rest = args.slice(1);
|
|
35
41
|
if (rest[0] === '-')
|
|
36
42
|
rest.shift();
|
|
@@ -39,13 +45,14 @@ function parseArgs(args = process.argv.slice(2)) {
|
|
|
39
45
|
exports.parseArgs = parseArgs;
|
|
40
46
|
const getHelp = () => `
|
|
41
47
|
DESCRIPTION:
|
|
42
|
-
Quickly switch, merge and
|
|
48
|
+
Quickly switch, merge, delete and synchronize Git branches.
|
|
43
49
|
|
|
44
50
|
USAGE:
|
|
45
51
|
co Select a branch to check out
|
|
46
52
|
co - 1234 Start with an editable search filter
|
|
47
53
|
co -M [- filter] Merge, then offer to delete the source branch [Y/n]
|
|
48
54
|
co -D [- filter] Delete multiple local branches (Space to select)
|
|
55
|
+
co -p Fetch and safely sync all local branches (like hub sync)
|
|
49
56
|
co m, -sm, --show-merged Show merged branches
|
|
50
57
|
co un, -un, --no-merged Show unmerged branches
|
|
51
58
|
co help, -h, --h, --help Show this message
|
|
@@ -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,7 @@ 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");
|
|
9
10
|
const terminal_1 = require("./terminal");
|
|
10
11
|
const run = async () => {
|
|
11
12
|
const { command, filter } = (0, cli_commands_1.parseArgs)();
|
|
@@ -13,6 +14,11 @@ const run = async () => {
|
|
|
13
14
|
console.log((0, cli_commands_1.getHelp)());
|
|
14
15
|
return;
|
|
15
16
|
}
|
|
17
|
+
if (command === cli_commands_1.Commands.Sync) {
|
|
18
|
+
(0, terminal_1.startTerminal)();
|
|
19
|
+
await (0, sync_branches_1.syncBranches)();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
16
22
|
const branchType = command === cli_commands_1.Commands.ShowMerged ? commands_1.BranchesType.Merged
|
|
17
23
|
: command === cli_commands_1.Commands.ShowUnMerged ? commands_1.BranchesType.NotMerged : commands_1.BranchesType.All;
|
|
18
24
|
const deleting = command === cli_commands_1.Commands.DeleteBranch;
|
package/package.json
CHANGED
|
@@ -19,6 +19,12 @@ 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('accepts sync without a filter', () => {
|
|
23
|
+
expect(parseArgs(['-p'])).toEqual({ command: Commands.Sync, filter: '' });
|
|
24
|
+
});
|
|
25
|
+
it('rejects a filter for sync instead of silently ignoring it', () => {
|
|
26
|
+
expect(() => parseArgs(['-p', '-', '1234'])).toThrow('does not accept a filter');
|
|
27
|
+
});
|
|
22
28
|
it('reports unknown options', () => {
|
|
23
29
|
expect(() => parseArgs(['--typo'])).toThrow('Unknown option');
|
|
24
30
|
});
|
|
@@ -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,14 @@ export enum Commands {
|
|
|
5
5
|
MergeBranch = 4,
|
|
6
6
|
ShowMerged = 8,
|
|
7
7
|
ShowUnMerged = 16,
|
|
8
|
+
Sync = 32,
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export const getWishOfUser = (firstArg = process.argv[2]): Commands => {
|
|
11
12
|
if (['help', '-h', '--h', '--help'].includes(firstArg)) return Commands.Help;
|
|
12
13
|
if (['-M', 'merge'].includes(firstArg)) return Commands.MergeBranch;
|
|
13
14
|
if (['-D', 'rm'].includes(firstArg)) return Commands.DeleteBranch;
|
|
15
|
+
if (firstArg === '-p') return Commands.Sync;
|
|
14
16
|
if (['m', '-sm', '--show-merged'].includes(firstArg)) return Commands.ShowMerged;
|
|
15
17
|
if (['un', '-un', '--no-merged'].includes(firstArg)) return Commands.ShowUnMerged;
|
|
16
18
|
return Commands.Unknown;
|
|
@@ -22,6 +24,9 @@ export function parseArgs(args = process.argv.slice(2)) {
|
|
|
22
24
|
if (command === Commands.Unknown && args[0] !== '-') {
|
|
23
25
|
throw new Error('Unknown option: ' + args[0] + '. Use co --help.');
|
|
24
26
|
}
|
|
27
|
+
if (command === Commands.Sync && args.length > 1) {
|
|
28
|
+
throw new Error('co -p synchronizes all branches and does not accept a filter.');
|
|
29
|
+
}
|
|
25
30
|
const rest = args.slice(1);
|
|
26
31
|
if (rest[0] === '-') rest.shift();
|
|
27
32
|
return { command, filter: rest.join(' ') };
|
|
@@ -29,13 +34,14 @@ export function parseArgs(args = process.argv.slice(2)) {
|
|
|
29
34
|
|
|
30
35
|
export const getHelp = (): string => `
|
|
31
36
|
DESCRIPTION:
|
|
32
|
-
Quickly switch, merge and
|
|
37
|
+
Quickly switch, merge, delete and synchronize Git branches.
|
|
33
38
|
|
|
34
39
|
USAGE:
|
|
35
40
|
co Select a branch to check out
|
|
36
41
|
co - 1234 Start with an editable search filter
|
|
37
42
|
co -M [- filter] Merge, then offer to delete the source branch [Y/n]
|
|
38
43
|
co -D [- filter] Delete multiple local branches (Space to select)
|
|
44
|
+
co -p Fetch and safely sync all local branches (like hub sync)
|
|
39
45
|
co m, -sm, --show-merged Show merged branches
|
|
40
46
|
co un, -un, --no-merged Show unmerged branches
|
|
41
47
|
co help, -h, --h, --help Show this message
|
|
@@ -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,7 @@ 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';
|
|
6
7
|
import { finishTerminal, installInterruptHandler, print, startTerminal } from './terminal';
|
|
7
8
|
|
|
8
9
|
export const run = async () => {
|
|
@@ -12,6 +13,12 @@ export const run = async () => {
|
|
|
12
13
|
return;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
if (command === Commands.Sync) {
|
|
17
|
+
startTerminal();
|
|
18
|
+
await syncBranches();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
15
22
|
const branchType = command === Commands.ShowMerged ? BranchesType.Merged
|
|
16
23
|
: command === Commands.ShowUnMerged ? BranchesType.NotMerged : BranchesType.All;
|
|
17
24
|
const deleting = command === Commands.DeleteBranch;
|