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! :)
at main 262 lines 8.1 kB view raw
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});