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