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.
Files changed (41) hide show
  1. package/README.md +62 -21
  2. package/dist/ask.d.ts +4 -3
  3. package/dist/ask.js +54 -32
  4. package/dist/cli-commands/index.d.ts +7 -2
  5. package/dist/cli-commands/index.js +44 -37
  6. package/dist/commands/checkout-branch.js +5 -9
  7. package/dist/commands/delete-barnch.d.ts +1 -0
  8. package/dist/commands/delete-barnch.js +34 -23
  9. package/dist/commands/exec.d.ts +1 -1
  10. package/dist/commands/exec.js +11 -2
  11. package/dist/commands/get-branches.d.ts +2 -4
  12. package/dist/commands/get-branches.js +18 -35
  13. package/dist/commands/get-current-branch-name.d.ts +1 -1
  14. package/dist/commands/get-current-branch-name.js +1 -3
  15. package/dist/commands/merge-branch.js +27 -31
  16. package/dist/commands/sync-branches.d.ts +2 -0
  17. package/dist/commands/sync-branches.js +106 -0
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.js +40 -18
  20. package/dist/prompt.d.ts +2 -0
  21. package/dist/prompt.js +24 -0
  22. package/dist/terminal.d.ts +5 -0
  23. package/dist/terminal.js +40 -0
  24. package/package.json +1 -1
  25. package/src/__tests__/ask.spec.ts +17 -2
  26. package/src/__tests__/cli-commands.spec.ts +31 -0
  27. package/src/__tests__/commands.spec.ts +110 -0
  28. package/src/__tests__/sync-branches.spec.ts +223 -0
  29. package/src/__tests__/terminal.spec.ts +56 -0
  30. package/src/ask.ts +51 -47
  31. package/src/cli-commands/index.ts +36 -46
  32. package/src/commands/checkout-branch.ts +4 -12
  33. package/src/commands/delete-barnch.ts +30 -23
  34. package/src/commands/exec.ts +11 -7
  35. package/src/commands/get-branches.ts +15 -41
  36. package/src/commands/get-current-branch-name.ts +2 -6
  37. package/src/commands/merge-branch.ts +25 -34
  38. package/src/commands/sync-branches.ts +94 -0
  39. package/src/index.ts +40 -28
  40. package/src/prompt.ts +18 -0
  41. 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.registerPrompt(
4
- 'autocomplete',
5
- require('inquirer-autocomplete-prompt')
6
- );
4
+ const AutocompletePrompt = require('inquirer-autocomplete-prompt');
7
5
 
8
- const specSymbols = {
9
- _saveCursor: '\x1b7',
10
- _restoreCursor: '\x1b8',
11
- _clear: '\x1b[H\x1b[2J',
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
- export const ask = async (message: string, choices: string[]) => {
15
- const prompt = inquirer.prompt({
16
- //@ts-ignore
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
- source(answersSoFar, input = '') {
21
- let list = choices.filter(choiceItem => checkItem(choiceItem, input));
22
-
23
- list = list.sort((a, b) => {
24
- return sortOrder(input, a, b);
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
- export function checkItem(item: string, filterString: string): boolean {
36
- const reg = getRegExp(filterString);
37
-
38
- return reg.test(item);
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 sortOrder(searchString: string, name1: string, name2: string): -1 | 1 {
42
- if (!name1.includes(searchString)) {
43
- return 1;
44
- }
45
- if (!name2.includes(searchString)) {
46
- return -1;
47
- }
48
-
49
- if ((name2.indexOf(searchString) < name1.indexOf(searchString))) {
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
- function getRegExp(filterString: string) {
58
- return new RegExp(
59
- filterString.split('').map(s => s.trim()).filter(Boolean).join('.*').replace('.*.*', '.*'),
60
- 'gi',
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
- case ['-un', '--no-merged'].includes(arg):
31
- return Commands.ShowUnMerged;
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
- default:
34
- return Commands.Unknown;
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
- A console program for quickly switching between branches
43
-
44
- OPTIONS:
45
- help, -h, --h, --help
46
- Showing this message
47
-
48
- -M, merge
49
- Merge branch
50
-
51
- -sm, --show-merged
52
- Showing merged branches
53
-
54
- -un, --no-merged
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 { clc } from '../color';
2
+ import { print } from '../terminal';
3
3
 
4
- //
5
4
  export async function checkoutBranch(branchName: string) {
6
- if (!branchName) {
7
- throw new Error('No branch...');
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 inquirer from 'inquirer';
1
+ import { prompt } from '../prompt';
2
2
  import { exec } from './exec';
3
- import { clc } from '../color';
3
+ import { print } from '../terminal';
4
4
 
5
- export async function deleteBranch(branchName: string) {
6
- if (!branchName) {
7
- throw new Error('No branch...');
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
- const res = await inquirer.prompt({
11
- type: 'confirm',
12
- name: 'answer',
13
- message: clc.yellow('Are you sure you want to delete the branch "') + clc.red(branchName) + clc.yellow('"?'),
14
- default: false,
15
- });
16
-
17
- if (res.answer === true) {
18
- const command = `git branch -D ${branchName}`;
19
-
20
- console.log(' ' + clc.redBg(`git branch -D ${branchName}`, '') + '\n');
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
- return;
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
- console.log(clc.yellow('The branch was not deleted'));
36
+ export async function deleteBranch(branchName: string) {
37
+ return deleteBranches([branchName]);
31
38
  }
@@ -1,9 +1,13 @@
1
- import { exec as exe } from 'child_process';
1
+ import { execFile } from 'child_process';
2
2
 
3
- export function exec(command): Promise<string> {
4
- return new Promise((resolve, reject) =>
5
- exe(command, (err, stdout) =>
6
- err ? reject(err) : resolve(stdout)
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
- const commandsForGetBranches = {
11
- [BranchesType.All]: 'git branch --all',
12
- [BranchesType.Merged]: 'git branch --all --merged',
13
- [BranchesType.NotMerged]: 'git branch --all --no-merged',
14
- } as const;
15
-
16
- /**
17
- * get branch list
18
- */
19
- export function getBranches(type: BranchesType): Promise<string[]> {
20
- const command = commandsForGetBranches[type];
21
-
22
- return exec(command)
23
- .then(parseBranches)
24
- .then(branches => {
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
- const currentBranchName = `git rev-parse --abbrev-ref HEAD`;
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 { clc } from '../color';
3
- import inquirer from 'inquirer';
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
- throw new Error('No branch...');
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 res = await inquirer.prompt({
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
- if (res.answer === true) {
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
- console.log(clc.yellow('The branch was not merged'));
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
+ }