WIP: A simple cli for daily tangled use cases and AI integration. This is for my personal use right now, but happy if others get mileage from it! :)
1import { homedir } from 'node:os';
2import { join } from 'node:path';
3import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4import {
5 clearLocalRemote,
6 clearUserRemote,
7 getConfiguredRemote,
8 loadConfig,
9 setLocalRemote,
10 setUserRemote,
11} from '../../src/lib/config.js';
12
13// Mock modules
14vi.mock('node:fs/promises');
15vi.mock('simple-git');
16vi.mock('cosmiconfig');
17
18// Import mocked modules
19import * as fs from 'node:fs/promises';
20import { cosmiconfig } from 'cosmiconfig';
21import { simpleGit } from 'simple-git';
22
23describe('Config Management', () => {
24 let originalEnv: string | undefined;
25
26 beforeEach(() => {
27 vi.clearAllMocks();
28 originalEnv = process.env.TANGLED_REMOTE;
29 delete process.env.TANGLED_REMOTE;
30 });
31
32 afterEach(() => {
33 if (originalEnv !== undefined) {
34 process.env.TANGLED_REMOTE = originalEnv;
35 } else {
36 delete process.env.TANGLED_REMOTE;
37 }
38 });
39
40 describe('loadConfig', () => {
41 it('should return config from TANGLED_REMOTE environment variable', async () => {
42 process.env.TANGLED_REMOTE = 'upstream';
43
44 const config = await loadConfig();
45
46 expect(config).toEqual({ remote: 'upstream' });
47 });
48
49 it('should load config from file when env var not set', async () => {
50 const mockExplorer = {
51 search: vi.fn().mockResolvedValue({
52 config: { remote: 'origin' },
53 filepath: '/test/.tangledrc',
54 isEmpty: false,
55 }),
56 };
57
58 vi.mocked(cosmiconfig).mockReturnValue(mockExplorer as never);
59
60 // Mock Git root
61 const mockGit = {
62 checkIsRepo: vi.fn().mockResolvedValue(true),
63 revparse: vi.fn().mockResolvedValue('/test/repo\n'),
64 };
65 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
66
67 const config = await loadConfig('/test/repo');
68
69 expect(config).toEqual({ remote: 'origin' });
70 expect(mockExplorer.search).toHaveBeenCalledWith('/test/repo');
71 });
72
73 it('should return empty config when no config file found', async () => {
74 const mockExplorer = {
75 search: vi.fn().mockResolvedValue(null),
76 };
77
78 vi.mocked(cosmiconfig).mockReturnValue(mockExplorer as never);
79
80 // Mock Git root
81 const mockGit = {
82 checkIsRepo: vi.fn().mockResolvedValue(false),
83 };
84 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
85
86 const config = await loadConfig();
87
88 expect(config).toEqual({});
89 });
90
91 it('should handle cosmiconfig errors gracefully', async () => {
92 const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
93
94 const mockExplorer = {
95 search: vi.fn().mockRejectedValue(new Error('Config read error')),
96 };
97
98 vi.mocked(cosmiconfig).mockReturnValue(mockExplorer as never);
99
100 // Mock Git root
101 const mockGit = {
102 checkIsRepo: vi.fn().mockResolvedValue(false),
103 };
104 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
105
106 const config = await loadConfig();
107
108 expect(config).toEqual({});
109 expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to load config'));
110
111 consoleWarnSpy.mockRestore();
112 });
113 });
114
115 describe('getConfiguredRemote', () => {
116 it('should return remote name from config', async () => {
117 process.env.TANGLED_REMOTE = 'upstream';
118
119 const remote = await getConfiguredRemote();
120
121 expect(remote).toBe('upstream');
122 });
123
124 it('should return null when no config found', async () => {
125 const mockExplorer = {
126 search: vi.fn().mockResolvedValue(null),
127 };
128
129 vi.mocked(cosmiconfig).mockReturnValue(mockExplorer as never);
130
131 // Mock not in Git repo
132 const mockGit = {
133 checkIsRepo: vi.fn().mockResolvedValue(false),
134 };
135 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
136
137 const remote = await getConfiguredRemote();
138
139 expect(remote).toBeNull();
140 });
141 });
142
143 describe('setLocalRemote', () => {
144 it('should write config to Git root directory', async () => {
145 const mockGit = {
146 checkIsRepo: vi.fn().mockResolvedValue(true),
147 revparse: vi.fn().mockResolvedValue('/test/repo\n'),
148 };
149 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
150 vi.mocked(fs.writeFile).mockResolvedValue(undefined);
151
152 await setLocalRemote('origin', '/test/repo');
153
154 expect(fs.writeFile).toHaveBeenCalledWith(
155 '/test/repo/.tangledrc',
156 `${JSON.stringify({ remote: 'origin' }, null, 2)}\n`,
157 'utf-8'
158 );
159 });
160
161 it('should throw error when not in Git repository', async () => {
162 const mockGit = {
163 checkIsRepo: vi.fn().mockResolvedValue(false),
164 };
165 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
166
167 await expect(setLocalRemote('origin')).rejects.toThrow('Not in a Git repository');
168 });
169
170 it('should throw error on write failure', async () => {
171 const mockGit = {
172 checkIsRepo: vi.fn().mockResolvedValue(true),
173 revparse: vi.fn().mockResolvedValue('/test/repo\n'),
174 };
175 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
176 vi.mocked(fs.writeFile).mockRejectedValue(new Error('Write failed'));
177
178 await expect(setLocalRemote('origin')).rejects.toThrow('Failed to write local config');
179 });
180 });
181
182 describe('setUserRemote', () => {
183 it('should write config to user home directory', async () => {
184 vi.mocked(fs.mkdir).mockResolvedValue(undefined);
185 vi.mocked(fs.writeFile).mockResolvedValue(undefined);
186
187 await setUserRemote('origin');
188
189 const expectedPath = join(homedir(), '.tangledrc');
190 expect(fs.mkdir).toHaveBeenCalledWith(expect.any(String), { recursive: true });
191 expect(fs.writeFile).toHaveBeenCalledWith(
192 expectedPath,
193 `${JSON.stringify({ remote: 'origin' }, null, 2)}\n`,
194 'utf-8'
195 );
196 });
197
198 it('should throw error on write failure', async () => {
199 vi.mocked(fs.mkdir).mockResolvedValue(undefined);
200 vi.mocked(fs.writeFile).mockRejectedValue(new Error('Write failed'));
201
202 await expect(setUserRemote('origin')).rejects.toThrow('Failed to write user config');
203 });
204 });
205
206 describe('clearLocalRemote', () => {
207 it('should delete local config file', async () => {
208 const mockGit = {
209 checkIsRepo: vi.fn().mockResolvedValue(true),
210 revparse: vi.fn().mockResolvedValue('/test/repo\n'),
211 };
212 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
213 vi.mocked(fs.unlink).mockResolvedValue(undefined);
214
215 await clearLocalRemote('/test/repo');
216
217 expect(fs.unlink).toHaveBeenCalledWith('/test/repo/.tangledrc');
218 });
219
220 it('should not throw error if file does not exist', async () => {
221 const mockGit = {
222 checkIsRepo: vi.fn().mockResolvedValue(true),
223 revparse: vi.fn().mockResolvedValue('/test/repo\n'),
224 };
225 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
226
227 const error = new Error('File not found') as NodeJS.ErrnoException;
228 error.code = 'ENOENT';
229 vi.mocked(fs.unlink).mockRejectedValue(error);
230
231 await expect(clearLocalRemote()).resolves.not.toThrow();
232 });
233
234 it('should throw error when not in Git repository', async () => {
235 const mockGit = {
236 checkIsRepo: vi.fn().mockResolvedValue(false),
237 };
238 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
239
240 await expect(clearLocalRemote()).rejects.toThrow('Not in a Git repository');
241 });
242 });
243
244 describe('clearUserRemote', () => {
245 it('should delete user config file', async () => {
246 vi.mocked(fs.unlink).mockResolvedValue(undefined);
247
248 await clearUserRemote();
249
250 const expectedPath = join(homedir(), '.tangledrc');
251 expect(fs.unlink).toHaveBeenCalledWith(expectedPath);
252 });
253
254 it('should not throw error if file does not exist', async () => {
255 const error = new Error('File not found') as NodeJS.ErrnoException;
256 error.code = 'ENOENT';
257 vi.mocked(fs.unlink).mockRejectedValue(error);
258
259 await expect(clearUserRemote()).resolves.not.toThrow();
260 });
261 });
262});