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,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.d.ts CHANGED
@@ -1 +1 @@
1
- export {};
1
+ export declare const run: () => Promise<void>;
package/dist/index.js CHANGED
@@ -1,34 +1,56 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.run = void 0;
3
4
  const ask_1 = require("./ask");
4
5
  const cli_commands_1 = require("./cli-commands");
5
6
  const commands_1 = require("./commands");
6
7
  const delete_barnch_1 = require("./commands/delete-barnch");
7
8
  const merge_branch_1 = require("./commands/merge-branch");
9
+ const sync_branches_1 = require("./commands/sync-branches");
10
+ const terminal_1 = require("./terminal");
8
11
  const run = async () => {
9
- const userWish = (0, cli_commands_1.getWishOfUser)();
10
- if (cli_commands_1.Commands.Help === userWish) {
12
+ const { command, filter } = (0, cli_commands_1.parseArgs)();
13
+ if (command === cli_commands_1.Commands.Help) {
11
14
  console.log((0, cli_commands_1.getHelp)());
12
15
  return;
13
16
  }
14
- let branchType = commands_1.BranchesType.All;
15
- if (cli_commands_1.Commands.ShowMerged === userWish) {
16
- branchType = commands_1.BranchesType.Merged;
17
+ if (command === cli_commands_1.Commands.Sync) {
18
+ (0, terminal_1.startTerminal)();
19
+ await (0, sync_branches_1.syncBranches)();
20
+ return;
17
21
  }
18
- else if (cli_commands_1.Commands.ShowUnMerged === userWish) {
19
- branchType = commands_1.BranchesType.NotMerged;
22
+ const branchType = command === cli_commands_1.Commands.ShowMerged ? commands_1.BranchesType.Merged
23
+ : command === cli_commands_1.Commands.ShowUnMerged ? commands_1.BranchesType.NotMerged : commands_1.BranchesType.All;
24
+ const deleting = command === cli_commands_1.Commands.DeleteBranch;
25
+ let branches = await (0, commands_1.getBranches)(branchType, deleting);
26
+ if (deleting) {
27
+ const currentBranch = await (0, commands_1.getCurrentBranchName)();
28
+ branches = branches.filter(branch => branch !== currentBranch);
29
+ if (!branches.length)
30
+ throw new Error('No local branches available for deletion.');
20
31
  }
21
- const branches = await (0, commands_1.getBranches)(branchType);
22
- const { branch } = await (0, ask_1.ask)('Select branch', branches);
23
- if (cli_commands_1.Commands.DeleteBranch === userWish) {
24
- (0, delete_barnch_1.deleteBranch)(branch);
25
- return;
32
+ (0, terminal_1.startTerminal)();
33
+ if (deleting) {
34
+ const selected = await (0, ask_1.askBranches)('Select branches to delete (Space to select)', branches, filter);
35
+ await (0, delete_barnch_1.deleteBranches)(selected);
26
36
  }
27
- if (cli_commands_1.Commands.MergeBranch === userWish) {
28
- (0, merge_branch_1.mergeBranch)(branch);
29
- return;
37
+ else {
38
+ const { branch } = await (0, ask_1.ask)('Select branch', branches, filter);
39
+ if (command === cli_commands_1.Commands.MergeBranch)
40
+ await (0, merge_branch_1.mergeBranch)(branch);
41
+ else {
42
+ await (0, commands_1.checkoutBranch)(branch);
43
+ (0, terminal_1.print)(await (0, commands_1.getCurrentBranchName)());
44
+ }
30
45
  }
31
- await (0, commands_1.checkoutBranch)(branch);
32
- await (0, commands_1.getCurrentBranchName)();
33
46
  };
34
- run();
47
+ exports.run = run;
48
+ const removeInterruptHandler = (0, terminal_1.installInterruptHandler)();
49
+ (0, exports.run)().catch(error => {
50
+ (0, terminal_1.finishTerminal)();
51
+ console.error('Error: ' + (error instanceof Error ? error.message : String(error)));
52
+ process.exitCode = 1;
53
+ }).finally(() => {
54
+ (0, terminal_1.finishTerminal)();
55
+ removeInterruptHandler();
56
+ });
@@ -0,0 +1,2 @@
1
+ import { Answers, QuestionCollection } from 'inquirer';
2
+ export declare function prompt<T extends Answers = Answers>(questions: QuestionCollection<T>): Promise<T>;
package/dist/prompt.js ADDED
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.prompt = void 0;
7
+ const inquirer_1 = __importDefault(require("inquirer"));
8
+ const terminal_1 = require("./terminal");
9
+ function prompt(questions) {
10
+ const result = inquirer_1.default.prompt(questions);
11
+ // Inquirer 8 re-sends SIGINT asynchronously and may exit before Node handles
12
+ // it. Handle readline's raw-mode Ctrl+C synchronously instead.
13
+ const ui = result.ui;
14
+ if (ui) {
15
+ ui.rl.removeListener('SIGINT', ui.onForceClose);
16
+ ui.rl.once('SIGINT', () => {
17
+ ui.close();
18
+ (0, terminal_1.finishTerminal)(true);
19
+ process.exit(130);
20
+ });
21
+ }
22
+ return result;
23
+ }
24
+ exports.prompt = prompt;
@@ -0,0 +1,5 @@
1
+ /// <reference types="node" />
2
+ export declare function startTerminal(): void;
3
+ export declare function print(message: string): void;
4
+ export declare function finishTerminal(cancelled?: boolean): void;
5
+ export declare function installInterruptHandler(): () => NodeJS.Process;
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.installInterruptHandler = exports.finishTerminal = exports.print = exports.startTerminal = void 0;
4
+ // Keep interactive output isolated from the user's shell history.
5
+ let active = false;
6
+ let messages = [];
7
+ function startTerminal() {
8
+ active = Boolean(process.stdout.isTTY && process.stdin.isTTY);
9
+ messages = [];
10
+ if (active)
11
+ process.stdout.write('\x1b[?1049h');
12
+ }
13
+ exports.startTerminal = startTerminal;
14
+ function print(message) {
15
+ if (!message.trim())
16
+ return;
17
+ if (active)
18
+ messages.push(message.trimEnd());
19
+ console.log(message.trimEnd());
20
+ }
21
+ exports.print = print;
22
+ function finishTerminal(cancelled = false) {
23
+ if (!active)
24
+ return;
25
+ active = false;
26
+ process.stdout.write('\x1b[?1049l\x1b[?25h');
27
+ if (!cancelled && messages.length)
28
+ console.log(messages.join('\n'));
29
+ messages = [];
30
+ }
31
+ exports.finishTerminal = finishTerminal;
32
+ function installInterruptHandler() {
33
+ const onInterrupt = () => {
34
+ finishTerminal(true);
35
+ process.exit(130);
36
+ };
37
+ process.on('SIGINT', onInterrupt);
38
+ return () => process.removeListener('SIGINT', onInterrupt);
39
+ }
40
+ exports.installInterruptHandler = installInterruptHandler;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "avil13-co",
3
- "version": "2.2.5",
3
+ "version": "2.4.0",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "public": true,
@@ -1,7 +1,22 @@
1
1
  import { expect, it } from 'vitest';
2
- import { sortOrder } from '../ask';
2
+ import { checkItem, sortOrder } from '../ask';
3
3
 
4
- it('should ', () => {
4
+ it.each(['[', '(', '*', '+', '?', '.', '\\', '$', '^'])('treats %s literally', symbol => {
5
+ expect(checkItem('task/' + symbol + '1234', symbol)).toBe(true);
6
+ expect(checkItem('task/1234', symbol)).toBe(false);
7
+ });
8
+
9
+ it('matches case-insensitive subsequences and ignores spaces', () => {
10
+ expect(checkItem('feature/TASK-1234', 't 14')).toBe(true);
11
+ expect(checkItem('feature/TASK-1234', '4321')).toBe(false);
12
+ });
13
+
14
+ it('returns zero for equivalent ranks', () => {
15
+ expect(sortOrder('task', 'first', 'second')).toBe(0);
16
+ expect(sortOrder('', 'first', 'second')).toBe(0);
17
+ });
18
+
19
+ it('ranks contiguous matches by position and keeps other choices stable', () => {
5
20
  const list = [
6
21
  'chore/EDODEV-10449-act-master',
7
22
  'chore/release-to-master',
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Commands, parseArgs } from '../cli-commands';
3
+
4
+ describe('arguments', () => {
5
+ it('defaults to checkout', () => {
6
+ expect(parseArgs([])).toEqual({ command: Commands.Unknown, filter: '' });
7
+ });
8
+ it('preloads a checkout filter', () => {
9
+ expect(parseArgs(['-', '1234'])).toEqual({ command: Commands.Unknown, filter: '1234' });
10
+ });
11
+ it('accepts filters with commands', () => {
12
+ expect(parseArgs(['-D', '-', 'task', '1234'])).toEqual({
13
+ command: Commands.DeleteBranch, filter: 'task 1234',
14
+ });
15
+ });
16
+ it.each(['m', '-sm', '--show-merged'])('accepts merged alias %s', alias => {
17
+ expect(parseArgs([alias]).command).toBe(Commands.ShowMerged);
18
+ });
19
+ it.each(['un', '-un', '--no-merged'])('accepts unmerged alias %s', alias => {
20
+ expect(parseArgs([alias]).command).toBe(Commands.ShowUnMerged);
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
+ });
28
+ it('reports unknown options', () => {
29
+ expect(() => parseArgs(['--typo'])).toThrow('Unknown option');
30
+ });
31
+ });
@@ -0,0 +1,110 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { execFileSync } from 'child_process';
3
+ import { mkdtempSync, rmSync } from 'fs';
4
+ import { tmpdir } from 'os';
5
+ import { join } from 'path';
6
+ import inquirer from 'inquirer';
7
+ import { exec } from '../commands/exec';
8
+ import { BranchesType, getBranches } from '../commands/get-branches';
9
+ import { checkoutBranch } from '../commands/checkout-branch';
10
+ import { deleteBranches } from '../commands/delete-barnch';
11
+ import { mergeBranch } from '../commands/merge-branch';
12
+
13
+ const context = vi.hoisted(() => ({ cwd: '' }));
14
+ vi.mock('child_process', async importOriginal => {
15
+ const actual = await importOriginal<typeof import('child_process')>();
16
+ return {
17
+ ...actual,
18
+ execFile: (file, args, callback) => actual.execFile(file, args, { cwd: context.cwd }, callback),
19
+ };
20
+ });
21
+
22
+ function git(...args: string[]) {
23
+ return execFileSync('git', args, { cwd: context.cwd, encoding: 'utf8' }).trim();
24
+ }
25
+
26
+ beforeEach(() => {
27
+ context.cwd = mkdtempSync(join(tmpdir(), 'co-test-'));
28
+ git('init', '-q', '-b', 'main');
29
+ git('config', 'user.name', 'Test');
30
+ git('config', 'user.email', 'test@example.test');
31
+ git('commit', '-q', '--allow-empty', '-m', 'initial');
32
+ vi.spyOn(console, 'log').mockImplementation(() => {});
33
+ });
34
+
35
+ afterEach(() => {
36
+ vi.restoreAllMocks();
37
+ rmSync(context.cwd, { recursive: true, force: true });
38
+ });
39
+
40
+ describe('Git commands', () => {
41
+ it('reports Git diagnostics on failure', async () => {
42
+ await expect(exec(['checkout', 'missing', '--'])).rejects.toThrow('missing');
43
+ });
44
+
45
+ it('checks out branch names containing shell metacharacters literally', async () => {
46
+ const branch = 'task/1234;echo-pwned';
47
+ git('branch', branch);
48
+ await checkoutBranch(branch);
49
+ expect(git('branch', '--show-current')).toBe(branch);
50
+ });
51
+
52
+ it('preserves local origin-prefixed branches and deduplicates origin remote refs', async () => {
53
+ git('branch', 'origin/local');
54
+ git('update-ref', 'refs/remotes/origin/main', 'HEAD');
55
+ git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main');
56
+ expect(await getBranches(BranchesType.All)).toEqual(['main', 'origin/local']);
57
+ });
58
+
59
+ it('excludes remote refs from deletion candidates', async () => {
60
+ git('update-ref', 'refs/remotes/origin/remote-only', 'HEAD');
61
+ expect(await getBranches(BranchesType.All, true)).toEqual(['main']);
62
+ });
63
+
64
+ it('deletes multiple selected branches after one confirmation', async () => {
65
+ git('branch', 'one');
66
+ git('branch', 'two');
67
+ const prompt = vi.spyOn(inquirer, 'prompt').mockResolvedValue({ answer: true });
68
+ await deleteBranches(['one', 'two']);
69
+ expect(git('branch', '--format=%(refname:short)')).toBe('main');
70
+ expect(prompt).toHaveBeenCalledTimes(1);
71
+ });
72
+
73
+ it('does not delete when confirmation is declined', async () => {
74
+ git('branch', 'one');
75
+ vi.spyOn(inquirer, 'prompt').mockResolvedValue({ answer: false });
76
+ await deleteBranches(['one']);
77
+ expect(git('branch', '--list', 'one')).toContain('one');
78
+ });
79
+
80
+ it('continues deleting and reports partial failures', async () => {
81
+ git('branch', 'one');
82
+ await expect(deleteBranches(['missing', 'one'], false)).rejects.toThrow('missing');
83
+ expect(git('branch', '--list', 'one')).toBe('');
84
+ });
85
+
86
+ it('offers safe deletion after successful merge, defaulting to yes', async () => {
87
+ git('branch', 'feature');
88
+ const prompt = vi.spyOn(inquirer, 'prompt')
89
+ .mockResolvedValueOnce({ answer: true })
90
+ .mockResolvedValueOnce({ remove: true });
91
+ await mergeBranch('feature');
92
+ expect(prompt.mock.calls[1][0]).toMatchObject({ default: true });
93
+ expect(git('branch', '--list', 'feature')).toBe('');
94
+ });
95
+
96
+ it('keeps the merged branch if deletion is declined', async () => {
97
+ git('branch', 'feature');
98
+ vi.spyOn(inquirer, 'prompt')
99
+ .mockResolvedValueOnce({ answer: true })
100
+ .mockResolvedValueOnce({ remove: false });
101
+ await mergeBranch('feature');
102
+ expect(git('branch', '--list', 'feature')).toContain('feature');
103
+ });
104
+
105
+ it('never offers deletion after a failed merge', async () => {
106
+ const prompt = vi.spyOn(inquirer, 'prompt').mockResolvedValue({ answer: true });
107
+ await expect(mergeBranch('missing')).rejects.toThrow('missing');
108
+ expect(prompt).toHaveBeenCalledTimes(1);
109
+ });
110
+ });
@@ -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
+ });