@camaradesuk/git-worktree-tools 1.4.1 → 1.6.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 (200) hide show
  1. package/README.md +82 -11
  2. package/dist/api/list.d.ts +10 -4
  3. package/dist/api/list.d.ts.map +1 -1
  4. package/dist/api/list.js +5 -1
  5. package/dist/api/list.js.map +1 -1
  6. package/dist/api/list.test.d.ts +5 -0
  7. package/dist/api/list.test.d.ts.map +1 -0
  8. package/dist/api/list.test.js +390 -0
  9. package/dist/api/list.test.js.map +1 -0
  10. package/dist/cli/cleanpr.js +35 -4
  11. package/dist/cli/cleanpr.js.map +1 -1
  12. package/dist/cli/cleanpr.test.js +254 -0
  13. package/dist/cli/cleanpr.test.js.map +1 -1
  14. package/dist/cli/lswt.js +54 -6
  15. package/dist/cli/lswt.js.map +1 -1
  16. package/dist/cli/lswt.test.js +207 -0
  17. package/dist/cli/lswt.test.js.map +1 -1
  18. package/dist/cli/newpr.js +94 -66
  19. package/dist/cli/newpr.js.map +1 -1
  20. package/dist/cli/newpr.test.js +10 -9
  21. package/dist/cli/newpr.test.js.map +1 -1
  22. package/dist/cli/wt/clean.d.ts +16 -0
  23. package/dist/cli/wt/clean.d.ts.map +1 -0
  24. package/dist/cli/wt/clean.js +64 -0
  25. package/dist/cli/wt/clean.js.map +1 -0
  26. package/dist/cli/wt/completion.d.ts +12 -0
  27. package/dist/cli/wt/completion.d.ts.map +1 -0
  28. package/dist/cli/wt/completion.js +246 -0
  29. package/dist/cli/wt/completion.js.map +1 -0
  30. package/dist/cli/wt/completion.test.d.ts +5 -0
  31. package/dist/cli/wt/completion.test.d.ts.map +1 -0
  32. package/dist/cli/wt/completion.test.js +173 -0
  33. package/dist/cli/wt/completion.test.js.map +1 -0
  34. package/dist/cli/wt/config.d.ts +13 -0
  35. package/dist/cli/wt/config.d.ts.map +1 -0
  36. package/dist/cli/wt/config.js +40 -0
  37. package/dist/cli/wt/config.js.map +1 -0
  38. package/dist/cli/wt/entry.test.d.ts +8 -0
  39. package/dist/cli/wt/entry.test.d.ts.map +1 -0
  40. package/dist/cli/wt/entry.test.js +198 -0
  41. package/dist/cli/wt/entry.test.js.map +1 -0
  42. package/dist/cli/wt/link.d.ts +13 -0
  43. package/dist/cli/wt/link.d.ts.map +1 -0
  44. package/dist/cli/wt/link.js +88 -0
  45. package/dist/cli/wt/link.js.map +1 -0
  46. package/dist/cli/wt/list.d.ts +16 -0
  47. package/dist/cli/wt/list.d.ts.map +1 -0
  48. package/dist/cli/wt/list.js +65 -0
  49. package/dist/cli/wt/list.js.map +1 -0
  50. package/dist/cli/wt/new.d.ts +18 -0
  51. package/dist/cli/wt/new.d.ts.map +1 -0
  52. package/dist/cli/wt/new.js +78 -0
  53. package/dist/cli/wt/new.js.map +1 -0
  54. package/dist/cli/wt/run-command.d.ts +31 -0
  55. package/dist/cli/wt/run-command.d.ts.map +1 -0
  56. package/dist/cli/wt/run-command.js +49 -0
  57. package/dist/cli/wt/run-command.js.map +1 -0
  58. package/dist/cli/wt/run-command.test.d.ts +5 -0
  59. package/dist/cli/wt/run-command.test.d.ts.map +1 -0
  60. package/dist/cli/wt/run-command.test.js +88 -0
  61. package/dist/cli/wt/run-command.test.js.map +1 -0
  62. package/dist/cli/wt/state.d.ts +13 -0
  63. package/dist/cli/wt/state.d.ts.map +1 -0
  64. package/dist/cli/wt/state.js +38 -0
  65. package/dist/cli/wt/state.js.map +1 -0
  66. package/dist/cli/wt/wt.test.d.ts +8 -0
  67. package/dist/cli/wt/wt.test.d.ts.map +1 -0
  68. package/dist/cli/wt/wt.test.js +378 -0
  69. package/dist/cli/wt/wt.test.js.map +1 -0
  70. package/dist/cli/wt.d.ts +25 -0
  71. package/dist/cli/wt.d.ts.map +1 -0
  72. package/dist/cli/wt.js +74 -0
  73. package/dist/cli/wt.js.map +1 -0
  74. package/dist/cli/wtconfig.js +4 -4
  75. package/dist/cli/wtconfig.js.map +1 -1
  76. package/dist/cli/wtlink.js +66 -9
  77. package/dist/cli/wtlink.js.map +1 -1
  78. package/dist/cli/wtlink.test.js +101 -0
  79. package/dist/cli/wtlink.test.js.map +1 -1
  80. package/dist/e2e/cli.e2e.test.js +156 -1
  81. package/dist/e2e/cli.e2e.test.js.map +1 -1
  82. package/dist/e2e/lswt/lswt.e2e.test.js +33 -0
  83. package/dist/e2e/lswt/lswt.e2e.test.js.map +1 -1
  84. package/dist/e2e/newpr-full-flow.e2e.test.d.ts +2 -0
  85. package/dist/e2e/newpr-full-flow.e2e.test.d.ts.map +1 -0
  86. package/dist/e2e/newpr-full-flow.e2e.test.js +279 -0
  87. package/dist/e2e/newpr-full-flow.e2e.test.js.map +1 -0
  88. package/dist/e2e/wtlink/wtlink.e2e.test.js +52 -0
  89. package/dist/e2e/wtlink/wtlink.e2e.test.js.map +1 -1
  90. package/dist/integration/lswt-remote-pr.integration.test.d.ts +2 -0
  91. package/dist/integration/lswt-remote-pr.integration.test.d.ts.map +1 -0
  92. package/dist/integration/lswt-remote-pr.integration.test.js +222 -0
  93. package/dist/integration/lswt-remote-pr.integration.test.js.map +1 -0
  94. package/dist/integration/newpr-branchfrom-head.integration.test.d.ts +2 -0
  95. package/dist/integration/newpr-branchfrom-head.integration.test.d.ts.map +1 -0
  96. package/dist/integration/newpr-branchfrom-head.integration.test.js +498 -0
  97. package/dist/integration/newpr-branchfrom-head.integration.test.js.map +1 -0
  98. package/dist/lib/git.d.ts +1 -0
  99. package/dist/lib/git.d.ts.map +1 -1
  100. package/dist/lib/git.js +17 -30
  101. package/dist/lib/git.js.map +1 -1
  102. package/dist/lib/git.test.js +154 -123
  103. package/dist/lib/git.test.js.map +1 -1
  104. package/dist/lib/github.d.ts +45 -0
  105. package/dist/lib/github.d.ts.map +1 -1
  106. package/dist/lib/github.js +172 -0
  107. package/dist/lib/github.js.map +1 -1
  108. package/dist/lib/github.test.js +127 -1
  109. package/dist/lib/github.test.js.map +1 -1
  110. package/dist/lib/json-output.d.ts +11 -1
  111. package/dist/lib/json-output.d.ts.map +1 -1
  112. package/dist/lib/json-output.js +42 -1
  113. package/dist/lib/json-output.js.map +1 -1
  114. package/dist/lib/json-output.test.js +2 -0
  115. package/dist/lib/json-output.test.js.map +1 -1
  116. package/dist/lib/lswt/action-executors.d.ts.map +1 -1
  117. package/dist/lib/lswt/action-executors.js +143 -35
  118. package/dist/lib/lswt/action-executors.js.map +1 -1
  119. package/dist/lib/lswt/action-executors.test.js +362 -0
  120. package/dist/lib/lswt/action-executors.test.js.map +1 -1
  121. package/dist/lib/lswt/actions.d.ts.map +1 -1
  122. package/dist/lib/lswt/actions.js +38 -0
  123. package/dist/lib/lswt/actions.js.map +1 -1
  124. package/dist/lib/lswt/actions.test.js +126 -0
  125. package/dist/lib/lswt/actions.test.js.map +1 -1
  126. package/dist/lib/lswt/environment.d.ts +4 -0
  127. package/dist/lib/lswt/environment.d.ts.map +1 -1
  128. package/dist/lib/lswt/environment.js +23 -0
  129. package/dist/lib/lswt/environment.js.map +1 -1
  130. package/dist/lib/lswt/environment.test.js +129 -2
  131. package/dist/lib/lswt/environment.test.js.map +1 -1
  132. package/dist/lib/lswt/formatters.d.ts +2 -1
  133. package/dist/lib/lswt/formatters.d.ts.map +1 -1
  134. package/dist/lib/lswt/formatters.js +27 -2
  135. package/dist/lib/lswt/formatters.js.map +1 -1
  136. package/dist/lib/lswt/formatters.test.js +66 -2
  137. package/dist/lib/lswt/formatters.test.js.map +1 -1
  138. package/dist/lib/lswt/fuzzy-search.d.ts +27 -0
  139. package/dist/lib/lswt/fuzzy-search.d.ts.map +1 -0
  140. package/dist/lib/lswt/fuzzy-search.js +130 -0
  141. package/dist/lib/lswt/fuzzy-search.js.map +1 -0
  142. package/dist/lib/lswt/fuzzy-search.test.d.ts +5 -0
  143. package/dist/lib/lswt/fuzzy-search.test.d.ts.map +1 -0
  144. package/dist/lib/lswt/fuzzy-search.test.js +207 -0
  145. package/dist/lib/lswt/fuzzy-search.test.js.map +1 -0
  146. package/dist/lib/lswt/index.d.ts +3 -1
  147. package/dist/lib/lswt/index.d.ts.map +1 -1
  148. package/dist/lib/lswt/index.js +3 -2
  149. package/dist/lib/lswt/index.js.map +1 -1
  150. package/dist/lib/lswt/interactive.d.ts +50 -4
  151. package/dist/lib/lswt/interactive.d.ts.map +1 -1
  152. package/dist/lib/lswt/interactive.js +458 -56
  153. package/dist/lib/lswt/interactive.js.map +1 -1
  154. package/dist/lib/lswt/interactive.test.js +454 -66
  155. package/dist/lib/lswt/interactive.test.js.map +1 -1
  156. package/dist/lib/lswt/types.d.ts +8 -2
  157. package/dist/lib/lswt/types.d.ts.map +1 -1
  158. package/dist/lib/lswt/worktree-info.d.ts +11 -0
  159. package/dist/lib/lswt/worktree-info.d.ts.map +1 -1
  160. package/dist/lib/lswt/worktree-info.js +48 -0
  161. package/dist/lib/lswt/worktree-info.js.map +1 -1
  162. package/dist/lib/lswt/worktree-info.test.js +169 -0
  163. package/dist/lib/lswt/worktree-info.test.js.map +1 -1
  164. package/dist/lib/newpr/action-deps.test.d.ts +5 -0
  165. package/dist/lib/newpr/action-deps.test.d.ts.map +1 -0
  166. package/dist/lib/newpr/action-deps.test.js +111 -0
  167. package/dist/lib/newpr/action-deps.test.js.map +1 -0
  168. package/dist/lib/newpr/args.d.ts.map +1 -1
  169. package/dist/lib/newpr/args.js +6 -2
  170. package/dist/lib/newpr/args.js.map +1 -1
  171. package/dist/lib/newpr/args.test.js +209 -1
  172. package/dist/lib/newpr/args.test.js.map +1 -1
  173. package/dist/lib/newpr/scenario-handler.d.ts.map +1 -1
  174. package/dist/lib/newpr/scenario-handler.js +14 -5
  175. package/dist/lib/newpr/scenario-handler.js.map +1 -1
  176. package/dist/lib/newpr/scenario-handler.test.js +6 -0
  177. package/dist/lib/newpr/scenario-handler.test.js.map +1 -1
  178. package/dist/lib/prompts.d.ts +4 -0
  179. package/dist/lib/prompts.d.ts.map +1 -1
  180. package/dist/lib/prompts.js +178 -1
  181. package/dist/lib/prompts.js.map +1 -1
  182. package/dist/lib/prompts.test.js +279 -0
  183. package/dist/lib/prompts.test.js.map +1 -1
  184. package/dist/lib/wtlink/link-configs.test.js +282 -2
  185. package/dist/lib/wtlink/link-configs.test.js.map +1 -1
  186. package/dist/lib/wtlink/main-menu.js +1 -0
  187. package/dist/lib/wtlink/main-menu.js.map +1 -1
  188. package/dist/lib/wtlink/main-menu.test.d.ts +5 -0
  189. package/dist/lib/wtlink/main-menu.test.d.ts.map +1 -0
  190. package/dist/lib/wtlink/main-menu.test.js +124 -0
  191. package/dist/lib/wtlink/main-menu.test.js.map +1 -0
  192. package/dist/lib/wtlink/manage-manifest.d.ts +5 -0
  193. package/dist/lib/wtlink/manage-manifest.d.ts.map +1 -1
  194. package/dist/lib/wtlink/manage-manifest.js +65 -2
  195. package/dist/lib/wtlink/manage-manifest.js.map +1 -1
  196. package/dist/lib/wtlink/manage-manifest.test.js +144 -2
  197. package/dist/lib/wtlink/manage-manifest.test.js.map +1 -1
  198. package/dist/mcp/server.test.js +49 -0
  199. package/dist/mcp/server.test.js.map +1 -1
  200. package/package.json +2 -1
@@ -1,179 +1,219 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { execSync } from 'child_process';
2
+ import { spawnSync, execSync } from 'child_process';
3
3
  import * as path from 'path';
4
4
  import * as git from './git.js';
5
5
  // Mock child_process
6
6
  vi.mock('child_process', () => ({
7
+ spawnSync: vi.fn(),
7
8
  execSync: vi.fn(),
8
9
  }));
10
+ const mockSpawnSync = vi.mocked(spawnSync);
9
11
  const mockExecSync = vi.mocked(execSync);
12
+ /**
13
+ * Helper to create a successful spawnSync result
14
+ */
15
+ function mockSpawnSuccess(stdout) {
16
+ return {
17
+ status: 0,
18
+ signal: null,
19
+ output: ['', stdout, ''],
20
+ pid: 123,
21
+ stdout,
22
+ stderr: '',
23
+ error: undefined,
24
+ };
25
+ }
26
+ /**
27
+ * Helper to create a failed spawnSync result
28
+ */
29
+ function mockSpawnFailure(stderr) {
30
+ return {
31
+ status: 1,
32
+ signal: null,
33
+ output: ['', '', stderr],
34
+ pid: 123,
35
+ stdout: '',
36
+ stderr,
37
+ error: undefined,
38
+ };
39
+ }
10
40
  describe('git', () => {
11
41
  beforeEach(() => {
12
42
  vi.clearAllMocks();
13
43
  });
14
44
  describe('exec', () => {
15
45
  it('executes git command and returns output with trailing whitespace trimmed', () => {
16
- mockExecSync.mockReturnValue('output with trailing whitespace \n');
46
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('output with trailing whitespace \n'));
17
47
  const result = git.exec(['status']);
18
48
  // Leading whitespace is preserved (important for git status), trailing is trimmed
19
49
  expect(result).toBe('output with trailing whitespace');
20
- expect(mockExecSync).toHaveBeenCalledWith('git status', expect.any(Object));
50
+ expect(mockSpawnSync).toHaveBeenCalledWith('git', ['status'], expect.any(Object));
21
51
  });
22
52
  it('preserves leading whitespace in output', () => {
23
53
  // Leading spaces are significant in git status --porcelain output
24
- mockExecSync.mockReturnValue(' M file.txt\n');
54
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess(' M file.txt\n'));
25
55
  const result = git.exec(['status', '--porcelain']);
26
56
  expect(result).toBe(' M file.txt');
27
57
  });
28
58
  it('passes cwd option correctly', () => {
29
- mockExecSync.mockReturnValue('output');
59
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('output'));
30
60
  git.exec(['status'], { cwd: '/some/path' });
31
- expect(mockExecSync).toHaveBeenCalledWith('git status', expect.objectContaining({ cwd: '/some/path' }));
61
+ expect(mockSpawnSync).toHaveBeenCalledWith('git', ['status'], expect.objectContaining({ cwd: '/some/path' }));
32
62
  });
33
63
  it('throws error with stderr message on failure', () => {
34
- const error = new Error('Command failed');
35
- error.stderr = Buffer.from('fatal: not a git repository');
36
- mockExecSync.mockImplementation(() => {
37
- throw error;
38
- });
64
+ mockSpawnSync.mockReturnValue(mockSpawnFailure('fatal: not a git repository'));
39
65
  expect(() => git.exec(['status'])).toThrow('Git command failed');
40
66
  });
41
67
  });
42
68
  describe('execSafe', () => {
43
69
  it('returns output on success', () => {
44
- mockExecSync.mockReturnValue('output');
70
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('output'));
45
71
  const result = git.execSafe(['status']);
46
72
  expect(result).toBe('output');
47
73
  });
48
74
  it('returns null on failure', () => {
49
- mockExecSync.mockImplementation(() => {
50
- throw new Error('Command failed');
51
- });
75
+ mockSpawnSync.mockReturnValue(mockSpawnFailure('Command failed'));
52
76
  const result = git.execSafe(['status']);
53
77
  expect(result).toBeNull();
54
78
  });
55
79
  });
56
80
  describe('getRepoRoot', () => {
57
81
  it('returns normalized path from git rev-parse', () => {
58
- mockExecSync.mockReturnValue('/home/user/repo\n');
82
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('/home/user/repo\n'));
59
83
  const result = git.getRepoRoot();
60
84
  expect(result).toContain('repo');
61
- expect(mockExecSync).toHaveBeenCalledWith('git rev-parse --show-toplevel', expect.any(Object));
85
+ expect(mockSpawnSync).toHaveBeenCalledWith('git', ['rev-parse', '--show-toplevel'], expect.any(Object));
62
86
  });
63
87
  });
64
88
  describe('getRepoName', () => {
65
89
  it('extracts name from SSH remote URL', () => {
66
- mockExecSync.mockReturnValue('git@github.com:org/my-repo.git');
90
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('git@github.com:org/my-repo.git'));
67
91
  const result = git.getRepoName('/repo');
68
92
  expect(result).toBe('my-repo');
69
93
  });
70
94
  it('extracts name from HTTPS remote URL', () => {
71
- mockExecSync.mockReturnValue('https://github.com/org/my-repo.git');
95
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('https://github.com/org/my-repo.git'));
72
96
  const result = git.getRepoName('/repo');
73
97
  expect(result).toBe('my-repo');
74
98
  });
75
99
  it('extracts name from URL without .git suffix', () => {
76
- mockExecSync.mockReturnValue('https://github.com/org/my-repo');
100
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('https://github.com/org/my-repo'));
77
101
  const result = git.getRepoName('/repo');
78
102
  expect(result).toBe('my-repo');
79
103
  });
80
104
  it('falls back to directory name when no remote', () => {
81
- mockExecSync.mockImplementation(() => {
82
- throw new Error('No remote');
83
- });
105
+ mockSpawnSync.mockReturnValue(mockSpawnFailure('No remote'));
84
106
  const result = git.getRepoName('/home/user/my-project');
85
107
  expect(result).toBe('my-project');
86
108
  });
109
+ it('extracts name from Unix local path with .git suffix', () => {
110
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('/path/to/my-repo.git'));
111
+ const result = git.getRepoName('/repo');
112
+ expect(result).toBe('my-repo');
113
+ });
114
+ it('extracts name from Windows local path with .git suffix', () => {
115
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('C:\\Users\\test\\repos\\my-repo.git'));
116
+ const result = git.getRepoName('/repo');
117
+ expect(result).toBe('my-repo');
118
+ });
119
+ it('extracts name from Windows local path without .git suffix', () => {
120
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('C:\\Users\\test\\repos\\my-repo'));
121
+ const result = git.getRepoName('/repo');
122
+ expect(result).toBe('my-repo');
123
+ });
124
+ it('extracts name from Windows short path format', () => {
125
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\main-repo.git'));
126
+ const result = git.getRepoName('/repo');
127
+ expect(result).toBe('main-repo');
128
+ });
87
129
  });
88
130
  describe('getCurrentBranch', () => {
89
131
  it('returns branch name', () => {
90
- mockExecSync.mockReturnValue('feature/my-branch');
132
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('feature/my-branch'));
91
133
  const result = git.getCurrentBranch();
92
134
  expect(result).toBe('feature/my-branch');
93
135
  });
94
136
  it('returns null for detached HEAD', () => {
95
- mockExecSync.mockReturnValue('HEAD');
137
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('HEAD'));
96
138
  const result = git.getCurrentBranch();
97
139
  expect(result).toBeNull();
98
140
  });
99
141
  });
100
142
  describe('isDetachedHead', () => {
101
143
  it('returns true when in detached HEAD state', () => {
102
- mockExecSync.mockReturnValue('HEAD');
144
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('HEAD'));
103
145
  expect(git.isDetachedHead()).toBe(true);
104
146
  });
105
147
  it('returns false when on a branch', () => {
106
- mockExecSync.mockReturnValue('main');
148
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('main'));
107
149
  expect(git.isDetachedHead()).toBe(false);
108
150
  });
109
151
  });
110
152
  describe('getWorkingTreeStatus', () => {
111
153
  it('returns clean for empty status', () => {
112
- mockExecSync.mockReturnValue('');
154
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess(''));
113
155
  expect(git.getWorkingTreeStatus()).toBe('clean');
114
156
  });
115
157
  it('returns staged_only for staged changes', () => {
116
- mockExecSync.mockReturnValue('M file.txt');
158
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('M file.txt'));
117
159
  expect(git.getWorkingTreeStatus()).toBe('staged_only');
118
160
  });
119
161
  it('returns unstaged_only for unstaged changes', () => {
120
- mockExecSync.mockReturnValue(' M file.txt');
162
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess(' M file.txt'));
121
163
  expect(git.getWorkingTreeStatus()).toBe('unstaged_only');
122
164
  });
123
165
  it('returns unstaged_only for untracked files', () => {
124
- mockExecSync.mockReturnValue('?? newfile.txt');
166
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('?? newfile.txt'));
125
167
  expect(git.getWorkingTreeStatus()).toBe('unstaged_only');
126
168
  });
127
169
  it('returns both for staged and unstaged changes', () => {
128
- mockExecSync.mockReturnValue('MM file.txt');
170
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('MM file.txt'));
129
171
  expect(git.getWorkingTreeStatus()).toBe('both');
130
172
  });
131
173
  it('returns both for staged changes and untracked files', () => {
132
- mockExecSync.mockReturnValue('M staged.txt\n?? untracked.txt');
174
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('M staged.txt\n?? untracked.txt'));
133
175
  expect(git.getWorkingTreeStatus()).toBe('both');
134
176
  });
135
177
  });
136
178
  describe('getStagedFiles', () => {
137
179
  it('returns list of staged files', () => {
138
- mockExecSync.mockReturnValue('file1.txt\nfile2.txt\n');
180
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('file1.txt\nfile2.txt\n'));
139
181
  const result = git.getStagedFiles();
140
182
  expect(result).toEqual(['file1.txt', 'file2.txt']);
141
183
  });
142
184
  it('returns empty array when nothing staged', () => {
143
- mockExecSync.mockImplementation(() => {
144
- throw new Error('No staged files');
145
- });
185
+ mockSpawnSync.mockReturnValue(mockSpawnFailure('No staged files'));
146
186
  const result = git.getStagedFiles();
147
187
  expect(result).toEqual([]);
148
188
  });
149
189
  });
150
190
  describe('getUnstagedFiles', () => {
151
191
  it('returns list of unstaged and untracked files', () => {
152
- mockExecSync.mockReturnValue(' M modified.txt\n?? untracked.txt');
192
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess(' M modified.txt\n?? untracked.txt'));
153
193
  const result = git.getUnstagedFiles();
154
194
  expect(result).toEqual(['modified.txt', 'untracked.txt']);
155
195
  });
156
196
  it('excludes staged-only files', () => {
157
- mockExecSync.mockReturnValue('M staged.txt\n M both.txt');
197
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('M staged.txt\n M both.txt'));
158
198
  const result = git.getUnstagedFiles();
159
199
  expect(result).toEqual(['both.txt']);
160
200
  });
161
201
  it('returns empty array when working tree is clean', () => {
162
- mockExecSync.mockReturnValue('');
202
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess(''));
163
203
  const result = git.getUnstagedFiles();
164
204
  expect(result).toEqual([]);
165
205
  });
166
206
  });
167
207
  describe('listWorktrees', () => {
168
208
  it('parses worktree list porcelain output', () => {
169
- mockExecSync.mockReturnValue('worktree /home/user/repo\n' +
209
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('worktree /home/user/repo\n' +
170
210
  'HEAD abc123def456\n' +
171
211
  'branch refs/heads/main\n' +
172
212
  '\n' +
173
213
  'worktree /home/user/repo.pr42\n' +
174
214
  'HEAD def456abc123\n' +
175
215
  'branch refs/heads/feature/test\n' +
176
- '\n');
216
+ '\n'));
177
217
  const result = git.listWorktrees();
178
218
  expect(result).toHaveLength(2);
179
219
  expect(result[0]).toEqual({
@@ -196,207 +236,203 @@ describe('git', () => {
196
236
  });
197
237
  });
198
238
  it('handles bare repository', () => {
199
- mockExecSync.mockReturnValue('worktree /home/user/repo.git\n' + 'bare\n' + '\n');
239
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('worktree /home/user/repo.git\n' + 'bare\n' + '\n'));
200
240
  const result = git.listWorktrees();
201
241
  expect(result[0].isBare).toBe(true);
202
242
  expect(result[0].isMain).toBe(true);
203
243
  });
204
244
  it('handles locked and prunable worktrees', () => {
205
- mockExecSync.mockReturnValue('worktree /home/user/repo.pr1\n' +
245
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('worktree /home/user/repo.pr1\n' +
206
246
  'HEAD abc123\n' +
207
247
  'branch refs/heads/test\n' +
208
248
  'locked\n' +
209
249
  'prunable\n' +
210
- '\n');
250
+ '\n'));
211
251
  const result = git.listWorktrees();
212
252
  expect(result[0].isLocked).toBe(true);
213
253
  expect(result[0].isPrunable).toBe(true);
214
254
  });
215
255
  it('handles detached HEAD in worktree', () => {
216
- mockExecSync.mockReturnValue('worktree /home/user/repo\n' + 'HEAD abc123\n' + 'detached\n' + '\n');
256
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('worktree /home/user/repo\n' + 'HEAD abc123\n' + 'detached\n' + '\n'));
217
257
  const result = git.listWorktrees();
218
258
  expect(result[0].branch).toBeNull();
219
259
  });
220
260
  });
221
261
  describe('getCommitRelationship', () => {
222
262
  it('returns same when HEAD equals base', () => {
223
- mockExecSync
224
- .mockReturnValueOnce('abc123') // getHeadCommit
225
- .mockReturnValueOnce('abc123'); // getRefCommit
263
+ mockSpawnSync
264
+ .mockReturnValueOnce(mockSpawnSuccess('abc123')) // getHeadCommit
265
+ .mockReturnValueOnce(mockSpawnSuccess('abc123')); // getRefCommit
226
266
  const result = git.getCommitRelationship('main');
227
267
  expect(result).toBe('same');
228
268
  });
229
269
  it('returns divergent when base branch does not exist', () => {
230
- mockExecSync
231
- .mockReturnValueOnce('abc123') // getHeadCommit
232
- .mockImplementationOnce(() => {
233
- throw new Error('unknown revision');
234
- }); // getRefCommit fails
270
+ mockSpawnSync
271
+ .mockReturnValueOnce(mockSpawnSuccess('abc123')) // getHeadCommit
272
+ .mockReturnValueOnce(mockSpawnFailure('unknown revision')); // getRefCommit fails
235
273
  const result = git.getCommitRelationship('main');
236
274
  expect(result).toBe('divergent');
237
275
  });
238
276
  });
239
277
  describe('getCommitsAhead', () => {
240
278
  it('returns list of commits ahead of base', () => {
241
- mockExecSync.mockReturnValue('abc123 First commit\ndef456 Second commit');
279
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('abc123 First commit\ndef456 Second commit'));
242
280
  const result = git.getCommitsAhead('main');
243
281
  expect(result).toEqual(['abc123 First commit', 'def456 Second commit']);
244
282
  });
245
283
  it('returns empty array when not ahead', () => {
246
- mockExecSync.mockImplementation(() => {
247
- throw new Error('No commits');
248
- });
284
+ mockSpawnSync.mockReturnValue(mockSpawnFailure('No commits'));
249
285
  const result = git.getCommitsAhead('main');
250
286
  expect(result).toEqual([]);
251
287
  });
252
288
  });
253
289
  describe('addWorktree', () => {
254
290
  it('creates worktree with existing branch', () => {
255
- mockExecSync.mockReturnValue('');
291
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess(''));
256
292
  git.addWorktree('/path/to/worktree', 'feature-branch');
257
- expect(mockExecSync).toHaveBeenCalledWith('git worktree add "/path/to/worktree" feature-branch', expect.any(Object));
293
+ expect(mockSpawnSync).toHaveBeenCalledWith('git', ['worktree', 'add', '/path/to/worktree', 'feature-branch'], expect.any(Object));
258
294
  });
259
295
  it('creates worktree with new branch', () => {
260
- mockExecSync.mockReturnValue('');
296
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess(''));
261
297
  git.addWorktree('/path/to/worktree', 'new-branch', { createBranch: true });
262
- expect(mockExecSync).toHaveBeenCalledWith('git worktree add -b new-branch "/path/to/worktree"', expect.any(Object));
298
+ expect(mockSpawnSync).toHaveBeenCalledWith('git', ['worktree', 'add', '-b', 'new-branch', '/path/to/worktree'], expect.any(Object));
263
299
  });
264
300
  it('creates worktree with new branch from start point', () => {
265
- mockExecSync.mockReturnValue('');
301
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess(''));
266
302
  git.addWorktree('/path/to/worktree', 'new-branch', {
267
303
  createBranch: true,
268
304
  startPoint: 'origin/main',
269
305
  });
270
- expect(mockExecSync).toHaveBeenCalledWith('git worktree add -b new-branch "/path/to/worktree" "origin/main"', expect.any(Object));
306
+ expect(mockSpawnSync).toHaveBeenCalledWith('git', ['worktree', 'add', '-b', 'new-branch', '/path/to/worktree', 'origin/main'], expect.any(Object));
271
307
  });
272
308
  });
273
309
  describe('removeWorktree', () => {
274
310
  it('removes worktree', () => {
275
- mockExecSync.mockReturnValue('');
311
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess(''));
276
312
  git.removeWorktree('/path/to/worktree');
277
- expect(mockExecSync).toHaveBeenCalledWith('git worktree remove "/path/to/worktree"', expect.any(Object));
313
+ expect(mockSpawnSync).toHaveBeenCalledWith('git', ['worktree', 'remove', '/path/to/worktree'], expect.any(Object));
278
314
  });
279
315
  it('force removes worktree', () => {
280
- mockExecSync.mockReturnValue('');
316
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess(''));
281
317
  git.removeWorktree('/path/to/worktree', { force: true });
282
- expect(mockExecSync).toHaveBeenCalledWith('git worktree remove --force "/path/to/worktree"', expect.any(Object));
318
+ expect(mockSpawnSync).toHaveBeenCalledWith('git', ['worktree', 'remove', '--force', '/path/to/worktree'], expect.any(Object));
283
319
  });
284
320
  });
285
321
  describe('createBranch', () => {
286
322
  it('creates branch from HEAD', () => {
287
- mockExecSync.mockReturnValue('');
323
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess(''));
288
324
  git.createBranch('new-branch');
289
- expect(mockExecSync).toHaveBeenCalledWith('git branch new-branch', expect.any(Object));
325
+ expect(mockSpawnSync).toHaveBeenCalledWith('git', ['branch', 'new-branch'], expect.any(Object));
290
326
  });
291
327
  it('creates branch from start point', () => {
292
- mockExecSync.mockReturnValue('');
328
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess(''));
293
329
  git.createBranch('new-branch', 'origin/main');
294
- expect(mockExecSync).toHaveBeenCalledWith('git branch new-branch "origin/main"', expect.any(Object));
330
+ expect(mockSpawnSync).toHaveBeenCalledWith('git', ['branch', 'new-branch', 'origin/main'], expect.any(Object));
295
331
  });
296
332
  });
297
333
  describe('deleteBranch', () => {
298
334
  it('deletes branch with -d flag', () => {
299
- mockExecSync.mockReturnValue('');
335
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess(''));
300
336
  git.deleteBranch('old-branch');
301
- expect(mockExecSync).toHaveBeenCalledWith('git branch -d old-branch', expect.any(Object));
337
+ expect(mockSpawnSync).toHaveBeenCalledWith('git', ['branch', '-d', 'old-branch'], expect.any(Object));
302
338
  });
303
339
  it('force deletes branch with -D flag', () => {
304
- mockExecSync.mockReturnValue('');
340
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess(''));
305
341
  git.deleteBranch('old-branch', { force: true });
306
- expect(mockExecSync).toHaveBeenCalledWith('git branch -D old-branch', expect.any(Object));
342
+ expect(mockSpawnSync).toHaveBeenCalledWith('git', ['branch', '-D', 'old-branch'], expect.any(Object));
307
343
  });
308
344
  });
309
345
  describe('commit', () => {
310
346
  it('creates commit with message', () => {
311
- mockExecSync
312
- .mockReturnValueOnce('') // commit
313
- .mockReturnValueOnce('abc123'); // getHeadCommit
347
+ mockSpawnSync
348
+ .mockReturnValueOnce(mockSpawnSuccess('')) // commit
349
+ .mockReturnValueOnce(mockSpawnSuccess('abc123')); // getHeadCommit
314
350
  const result = git.commit({ message: 'Test commit' });
315
- expect(mockExecSync).toHaveBeenCalledWith('git commit -m "Test commit"', expect.any(Object));
351
+ expect(mockSpawnSync).toHaveBeenCalledWith('git', ['commit', '-m', 'Test commit'], expect.any(Object));
316
352
  expect(result).toBe('abc123');
317
353
  });
318
354
  it('creates commit with all flag', () => {
319
- mockExecSync.mockReturnValueOnce('').mockReturnValueOnce('abc123');
355
+ mockSpawnSync
356
+ .mockReturnValueOnce(mockSpawnSuccess(''))
357
+ .mockReturnValueOnce(mockSpawnSuccess('abc123'));
320
358
  git.commit({ message: 'Test commit', all: true });
321
- expect(mockExecSync).toHaveBeenCalledWith('git commit -a -m "Test commit"', expect.any(Object));
359
+ expect(mockSpawnSync).toHaveBeenCalledWith('git', ['commit', '-a', '-m', 'Test commit'], expect.any(Object));
322
360
  });
323
361
  it('creates empty commit when allowed', () => {
324
- mockExecSync.mockReturnValueOnce('').mockReturnValueOnce('abc123');
362
+ mockSpawnSync
363
+ .mockReturnValueOnce(mockSpawnSuccess(''))
364
+ .mockReturnValueOnce(mockSpawnSuccess('abc123'));
325
365
  git.commit({ message: 'Empty commit', allowEmpty: true });
326
- expect(mockExecSync).toHaveBeenCalledWith('git commit --allow-empty -m "Empty commit"', expect.any(Object));
366
+ expect(mockSpawnSync).toHaveBeenCalledWith('git', ['commit', '--allow-empty', '-m', 'Empty commit'], expect.any(Object));
327
367
  });
328
368
  });
329
369
  describe('push', () => {
330
370
  it('pushes to remote', () => {
331
- mockExecSync.mockReturnValue('');
371
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess(''));
332
372
  git.push();
333
- expect(mockExecSync).toHaveBeenCalledWith('git push', expect.any(Object));
373
+ expect(mockSpawnSync).toHaveBeenCalledWith('git', ['push'], expect.any(Object));
334
374
  });
335
375
  it('pushes with upstream flag', () => {
336
- mockExecSync.mockReturnValue('');
376
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess(''));
337
377
  git.push({ setUpstream: true, remote: 'origin', branch: 'feature' });
338
- expect(mockExecSync).toHaveBeenCalledWith('git push -u origin feature', expect.any(Object));
378
+ expect(mockSpawnSync).toHaveBeenCalledWith('git', ['push', '-u', 'origin', 'feature'], expect.any(Object));
339
379
  });
340
380
  it('force pushes', () => {
341
- mockExecSync.mockReturnValue('');
381
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess(''));
342
382
  git.push({ force: true });
343
- expect(mockExecSync).toHaveBeenCalledWith('git push --force', expect.any(Object));
383
+ expect(mockSpawnSync).toHaveBeenCalledWith('git', ['push', '--force'], expect.any(Object));
344
384
  });
345
385
  });
346
386
  describe('stash', () => {
347
387
  it('creates stash and returns reference', () => {
348
- mockExecSync.mockReturnValue('Saved working directory');
388
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('Saved working directory'));
349
389
  const result = git.stash();
350
390
  expect(result).toBe('stash@{0}');
351
391
  });
352
392
  it('returns null when nothing to stash', () => {
353
- mockExecSync.mockReturnValue('No local changes to save');
393
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('No local changes to save'));
354
394
  const result = git.stash();
355
395
  expect(result).toBeNull();
356
396
  });
357
397
  it('stashes with keep-index flag', () => {
358
- mockExecSync.mockReturnValue('Saved');
398
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('Saved'));
359
399
  git.stash({ keepIndex: true });
360
- expect(mockExecSync).toHaveBeenCalledWith('git stash push --keep-index', expect.any(Object));
400
+ expect(mockSpawnSync).toHaveBeenCalledWith('git', ['stash', 'push', '--keep-index'], expect.any(Object));
361
401
  });
362
402
  it('stashes with message', () => {
363
- mockExecSync.mockReturnValue('Saved');
403
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('Saved'));
364
404
  git.stash({ message: 'WIP: feature' });
365
- expect(mockExecSync).toHaveBeenCalledWith('git stash push -m "WIP: feature"', expect.any(Object));
405
+ expect(mockSpawnSync).toHaveBeenCalledWith('git', ['stash', 'push', '-m', 'WIP: feature'], expect.any(Object));
366
406
  });
367
407
  it('stashes untracked files', () => {
368
- mockExecSync.mockReturnValue('Saved');
408
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('Saved'));
369
409
  git.stash({ includeUntracked: true });
370
- expect(mockExecSync).toHaveBeenCalledWith('git stash push --include-untracked', expect.any(Object));
410
+ expect(mockSpawnSync).toHaveBeenCalledWith('git', ['stash', 'push', '--include-untracked'], expect.any(Object));
371
411
  });
372
412
  });
373
413
  describe('branchExists', () => {
374
414
  it('returns true when branch exists', () => {
375
- mockExecSync.mockReturnValue('abc123');
415
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('abc123'));
376
416
  expect(git.branchExists('main')).toBe(true);
377
417
  });
378
418
  it('returns false when branch does not exist', () => {
379
- mockExecSync.mockImplementation(() => {
380
- throw new Error('unknown revision');
381
- });
419
+ mockSpawnSync.mockReturnValue(mockSpawnFailure('unknown revision'));
382
420
  expect(git.branchExists('nonexistent')).toBe(false);
383
421
  });
384
422
  });
385
423
  describe('remoteBranchExists', () => {
386
424
  it('returns true when remote branch exists', () => {
387
- mockExecSync.mockReturnValue('abc123');
425
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('abc123'));
388
426
  expect(git.remoteBranchExists('main')).toBe(true);
389
427
  });
390
428
  it('returns false when remote branch does not exist', () => {
391
- mockExecSync.mockImplementation(() => {
392
- throw new Error('unknown revision');
393
- });
429
+ mockSpawnSync.mockReturnValue(mockSpawnFailure('unknown revision'));
394
430
  expect(git.remoteBranchExists('nonexistent')).toBe(false);
395
431
  });
396
432
  it('checks specific remote', () => {
397
- mockExecSync.mockReturnValue('abc123');
433
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('abc123'));
398
434
  git.remoteBranchExists('main', 'upstream');
399
- expect(mockExecSync).toHaveBeenCalledWith('git rev-parse --verify "refs/remotes/upstream/main"', expect.any(Object));
435
+ expect(mockSpawnSync).toHaveBeenCalledWith('git', ['rev-parse', '--verify', 'refs/remotes/upstream/main'], expect.any(Object));
400
436
  });
401
437
  });
402
438
  describe('getMainWorktreeRoot', () => {
@@ -404,43 +440,38 @@ describe('git', () => {
404
440
  // Use path.join to create platform-appropriate paths
405
441
  const repoPath = path.join('/home', 'user', 'repo');
406
442
  const gitDir = path.join(repoPath, '.git');
407
- mockExecSync.mockReturnValue(gitDir);
443
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess(gitDir));
408
444
  const result = git.getMainWorktreeRoot(repoPath);
409
445
  expect(result).toBe(path.resolve(repoPath));
410
446
  });
411
447
  it('returns main worktree root when in linked worktree', () => {
412
448
  const mainRepo = path.join('/home', 'user', 'main-repo');
413
449
  const worktreeGitDir = path.join(mainRepo, '.git', 'worktrees', 'feature-branch');
414
- mockExecSync.mockReturnValue(worktreeGitDir);
450
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess(worktreeGitDir));
415
451
  const result = git.getMainWorktreeRoot(path.join('/home', 'user', 'main-repo.pr42'));
416
452
  expect(result).toBe(path.resolve(mainRepo));
417
453
  });
418
454
  it('falls back to getRepoRoot when commonDir is null', () => {
419
455
  const fallbackPath = path.join('/fallback', 'root');
420
- mockExecSync.mockImplementation((cmd) => {
421
- if (cmd.includes('--git-common-dir')) {
422
- throw new Error('failed');
423
- }
424
- // getRepoRoot calls rev-parse --show-toplevel
425
- return fallbackPath;
426
- });
456
+ // First call (git-common-dir) fails, second call (show-toplevel) succeeds
457
+ mockSpawnSync
458
+ .mockReturnValueOnce(mockSpawnFailure('failed'))
459
+ .mockReturnValueOnce(mockSpawnSuccess(fallbackPath));
427
460
  const result = git.getMainWorktreeRoot();
428
- expect(result).toBe(fallbackPath);
461
+ expect(result).toContain('fallback');
429
462
  });
430
463
  });
431
464
  describe('isGitIgnored', () => {
432
465
  it('returns true when file is ignored', () => {
433
- mockExecSync.mockReturnValue('node_modules/');
466
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess('node_modules/'));
434
467
  expect(git.isGitIgnored('node_modules/')).toBe(true);
435
468
  });
436
469
  it('returns false when file is not ignored', () => {
437
- mockExecSync.mockImplementation(() => {
438
- throw new Error('no output for unignored files');
439
- });
470
+ mockSpawnSync.mockReturnValue(mockSpawnFailure('no output for unignored files'));
440
471
  expect(git.isGitIgnored('src/index.ts')).toBe(false);
441
472
  });
442
473
  it('returns false when check-ignore returns empty', () => {
443
- mockExecSync.mockReturnValue('');
474
+ mockSpawnSync.mockReturnValue(mockSpawnSuccess(''));
444
475
  expect(git.isGitIgnored('src/index.ts')).toBe(false);
445
476
  });
446
477
  });