1+ """Tests for vcspull add command functionality."""
2+
3+ from __future__ import annotations
4+
5+ import contextlib
6+ import logging
7+ import typing as t
8+
9+ import pytest
10+ import yaml
11+
12+ from vcspull .cli import cli
13+ from vcspull .cli .add import add_repo
14+
15+ if t .TYPE_CHECKING :
16+ import pathlib
17+
18+ from typing_extensions import TypeAlias
19+
20+ ExpectedOutput : TypeAlias = t .Optional [t .Union [str , list [str ]]]
21+
22+
23+ @pytest .fixture (autouse = True )
24+ def reset_logging () -> t .Generator [None , None , None ]:
25+ """Reset logging configuration between tests."""
26+ # Store original handlers
27+ logger = logging .getLogger ("vcspull.cli.add" )
28+ original_handlers = logger .handlers [:]
29+ original_level = logger .level
30+
31+ yield
32+
33+ # Reset after test
34+ logger .handlers = original_handlers
35+ logger .setLevel (original_level )
36+
37+
38+ class AddRepoFixture (t .NamedTuple ):
39+ """Pytest fixture for vcspull add command."""
40+
41+ # pytest internal: used for naming test
42+ test_id : str
43+
44+ # test parameters
45+ cli_args : list [str ]
46+ initial_config : dict [str , t .Any ] | None
47+ expected_config_contains : dict [str , t .Any ]
48+ expected_in_output : ExpectedOutput = None
49+ expected_not_in_output : ExpectedOutput = None
50+ expected_log_level : str = "INFO"
51+ should_create_config : bool = False
52+
53+
54+ ADD_REPO_FIXTURES : list [AddRepoFixture ] = [
55+ # Simple repo addition with default base dir
56+ AddRepoFixture (
57+ test_id = "simple-repo-default-dir" ,
58+ cli_args = ["add" , "myproject" , "git@github.com:user/myproject.git" ],
59+ initial_config = None ,
60+ should_create_config = True ,
61+ expected_config_contains = {
62+ "./" : {
63+ "myproject" : {"repo" : "git@github.com:user/myproject.git" },
64+ },
65+ },
66+ expected_in_output = "Successfully added 'myproject'" ,
67+ ),
68+ # Add with custom base directory
69+ AddRepoFixture (
70+ test_id = "custom-base-dir" ,
71+ cli_args = [
72+ "add" ,
73+ "mylib" ,
74+ "https://github.com/org/mylib" ,
75+ "--dir" ,
76+ "~/projects/libs" ,
77+ ],
78+ initial_config = None ,
79+ should_create_config = True ,
80+ expected_config_contains = {
81+ "~/projects/libs/" : {
82+ "mylib" : {"repo" : "https://github.com/org/mylib" },
83+ },
84+ },
85+ expected_in_output = "Successfully added 'mylib'" ,
86+ ),
87+ # Add to existing config
88+ AddRepoFixture (
89+ test_id = "add-to-existing" ,
90+ cli_args = ["add" , "project2" , "git@github.com:user/project2.git" , "--dir" , "~/work" ],
91+ initial_config = {
92+ "~/work/" : {
93+ "project1" : {"repo" : "git@github.com:user/project1.git" },
94+ },
95+ },
96+ expected_config_contains = {
97+ "~/work/" : {
98+ "project1" : {"repo" : "git@github.com:user/project1.git" },
99+ "project2" : {"repo" : "git@github.com:user/project2.git" },
100+ },
101+ },
102+ expected_in_output = "Successfully added 'project2'" ,
103+ ),
104+ # Duplicate repo detection
105+ AddRepoFixture (
106+ test_id = "duplicate-repo" ,
107+ cli_args = [
108+ "add" ,
109+ "existing" ,
110+ "git@github.com:other/existing.git" ,
111+ "--dir" ,
112+ "~/code" ,
113+ ],
114+ initial_config = {
115+ "~/code/" : {
116+ "existing" : {"repo" : "git@github.com:user/existing.git" },
117+ },
118+ },
119+ expected_config_contains = {
120+ "~/code/" : {
121+ "existing" : {"repo" : "git@github.com:user/existing.git" },
122+ },
123+ },
124+ expected_in_output = [
125+ "Repository 'existing' already exists" ,
126+ "Current URL: git@github.com:user/existing.git" ,
127+ ],
128+ expected_log_level = "WARNING" ,
129+ ),
130+ # Path inference
131+ AddRepoFixture (
132+ test_id = "path-inference" ,
133+ cli_args = [
134+ "add" ,
135+ "inferred" ,
136+ "git@github.com:user/inferred.git" ,
137+ "--path" ,
138+ "~/dev/projects/inferred" ,
139+ ],
140+ initial_config = None ,
141+ should_create_config = True ,
142+ expected_config_contains = {
143+ "~/dev/projects/inferred/" : {
144+ "inferred" : {"repo" : "git@github.com:user/inferred.git" },
145+ },
146+ },
147+ expected_in_output = "Successfully added 'inferred'" ,
148+ ),
149+ ]
150+
151+
152+ @pytest .mark .parametrize (
153+ list (AddRepoFixture ._fields ),
154+ ADD_REPO_FIXTURES ,
155+ ids = [test .test_id for test in ADD_REPO_FIXTURES ],
156+ )
157+ def test_add_repo_cli (
158+ tmp_path : pathlib .Path ,
159+ capsys : pytest .CaptureFixture [str ],
160+ caplog : pytest .LogCaptureFixture ,
161+ monkeypatch : pytest .MonkeyPatch ,
162+ test_id : str ,
163+ cli_args : list [str ],
164+ initial_config : dict [str , t .Any ] | None ,
165+ expected_config_contains : dict [str , t .Any ],
166+ expected_in_output : ExpectedOutput ,
167+ expected_not_in_output : ExpectedOutput ,
168+ expected_log_level : str ,
169+ should_create_config : bool ,
170+ ) -> None :
171+ """Test vcspull add command through CLI."""
172+ caplog .set_level (expected_log_level )
173+
174+ # Set up config file path
175+ config_file = tmp_path / ".vcspull.yaml"
176+
177+ # Create initial config if provided
178+ if initial_config :
179+ yaml_content = yaml .dump (initial_config , default_flow_style = False )
180+ config_file .write_text (yaml_content , encoding = "utf-8" )
181+
182+ # Add config path to CLI args if not specified
183+ if "-c" not in cli_args and "--config" not in cli_args :
184+ cli_args = cli_args [:1 ] + ["-c" , str (config_file )] + cli_args [1 :]
185+
186+ # Change to tmp directory
187+ monkeypatch .chdir (tmp_path )
188+
189+ # Run CLI command
190+ with contextlib .suppress (SystemExit ):
191+ cli (cli_args )
192+
193+ # Capture output
194+ captured = capsys .readouterr ()
195+ output = "" .join ([* caplog .messages , captured .out , captured .err ])
196+
197+ # Check expected output (strip ANSI codes for comparison)
198+ import re
199+ clean_output = re .sub (r'\x1b\[[0-9;]*m' , '' , output ) # Strip ANSI codes
200+
201+ if expected_in_output is not None :
202+ if isinstance (expected_in_output , str ):
203+ expected_in_output = [expected_in_output ]
204+ for needle in expected_in_output :
205+ assert needle in clean_output , f"Expected '{ needle } ' in output, got: { clean_output } "
206+
207+ if expected_not_in_output is not None :
208+ if isinstance (expected_not_in_output , str ):
209+ expected_not_in_output = [expected_not_in_output ]
210+ for needle in expected_not_in_output :
211+ assert needle not in clean_output , f"Unexpected '{ needle } ' in output"
212+
213+ # Verify config file
214+ if should_create_config or initial_config :
215+ assert config_file .exists (), "Config file should exist"
216+
217+ # Load and verify config
218+ with config_file .open () as f :
219+ config_data = yaml .safe_load (f )
220+
221+ # Check expected config contents
222+ for key , value in expected_config_contains .items ():
223+ assert key in config_data , f"Expected key '{ key } ' in config"
224+ if isinstance (value , dict ):
225+ for subkey , subvalue in value .items ():
226+ assert subkey in config_data [key ], f"Expected '{ subkey } ' in config['{ key } ']"
227+ assert config_data [key ][subkey ] == subvalue , (
228+ f"Config mismatch for { key } /{ subkey } : "
229+ f"expected { subvalue } , got { config_data [key ][subkey ]} "
230+ )
231+
232+
233+ class TestAddRepoUnit :
234+ """Unit tests for add_repo function."""
235+
236+ def test_add_repo_direct_call (
237+ self ,
238+ tmp_path : pathlib .Path ,
239+ caplog : pytest .LogCaptureFixture ,
240+ ) -> None :
241+ """Test direct add_repo function call."""
242+ caplog .set_level ("INFO" )
243+ config_file = tmp_path / ".vcspull.yaml"
244+
245+ # Call add_repo directly
246+ add_repo (
247+ name = "direct-test" ,
248+ url = "git@github.com:user/direct.git" ,
249+ config_file_path_str = str (config_file ),
250+ path = None ,
251+ base_dir = None ,
252+ )
253+
254+ # Verify
255+ assert config_file .exists ()
256+ with config_file .open () as f :
257+ config_data = yaml .safe_load (f )
258+
259+ assert "./" in config_data
260+ assert "direct-test" in config_data ["./" ]
261+ assert config_data ["./" ]["direct-test" ] == {
262+ "repo" : "git@github.com:user/direct.git" ,
263+ }
264+
265+ def test_add_repo_invalid_config (
266+ self ,
267+ tmp_path : pathlib .Path ,
268+ capsys : pytest .CaptureFixture [str ],
269+ monkeypatch : pytest .MonkeyPatch ,
270+ ) -> None :
271+ """Test handling of invalid config file."""
272+ config_file = tmp_path / ".vcspull.yaml"
273+
274+ # Write invalid YAML
275+ config_file .write_text ("invalid: yaml: content:" , encoding = "utf-8" )
276+
277+ # Change to tmp directory
278+ monkeypatch .chdir (tmp_path )
279+
280+ # Try to add repo
281+ add_repo (
282+ name = "test" ,
283+ url = "git@github.com:user/test.git" ,
284+ config_file_path_str = str (config_file ),
285+ path = None ,
286+ base_dir = None ,
287+ )
288+
289+ # Should log error to stderr
290+ captured = capsys .readouterr ()
291+ assert "Error loading YAML" in captured .err
292+
293+
294+ def test_add_command_help (
295+ capsys : pytest .CaptureFixture [str ],
296+ ) -> None :
297+ """Test add command help output."""
298+ with contextlib .suppress (SystemExit ):
299+ cli (["add" , "--help" ])
300+
301+ captured = capsys .readouterr ()
302+ output = captured .out + captured .err
303+
304+ # Check help content
305+ assert "Add a repository to the vcspull configuration file" in output
306+ assert "name" in output
307+ assert "url" in output
308+ assert "--path" in output
309+ assert "--dir" in output
310+ assert "--config" in output
0 commit comments