diff --git a/gcode/cli.py b/gcode/cli.py index 5e744c3..ca4b15f 100644 --- a/gcode/cli.py +++ b/gcode/cli.py @@ -252,8 +252,22 @@ def main() -> None: parser.add_argument("--session", default=DEFAULT_SESSION, help="Named session for history.") parser.add_argument("--yes", action="store_true", help="Auto-approve bash commands (unsafe).") parser.add_argument("--version", action="version", version=f"gcode {__version__}") + parser.add_argument( + "--cwd", + metavar="DIR", + help="Run as if started in DIR (like git -C), instead of the current directory.", + ) args = parser.parse_args() + # Before anything reads the working directory. .gcoderc discovery, the + # banner and every tool resolve paths against it, so changing it later + # would leave them disagreeing about which project this session is for. + if args.cwd is not None: + try: + os.chdir(args.cwd) + except OSError as exc: + parser.error(f"--cwd {args.cwd!r} is not usable: {exc.strerror}") + # Load ~/.gcode/.env first (setup module's config location) load_env() diff --git a/tests/test_cli.py b/tests/test_cli.py index d0e7f27..734ec01 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,6 @@ from unittest.mock import Mock, patch +import pytest from gcode import __version__ from gcode.cli import _cmd_diff, _cmd_status, _cmd_version, _print_help @@ -112,3 +113,110 @@ def test_report_no_models_neither_source(): assert "No model source is reachable" in ui.error.call_args.args[0] assert "Ollama" in ui.info.call_args.args[0] + + +def test_help_lists_cwd_flag(capsys): + + from gcode.cli import main + + with patch("sys.argv", ["gcode", "--help"]), pytest.raises(SystemExit) as exc: + main() + + assert exc.value.code == 0 + assert "--cwd" in capsys.readouterr().out + + +class _Sentinel(Exception): + """Raised from the first call after the chdir, to stop main() there.""" + + +def test_cwd_changes_directory_before_config_is_loaded(tmp_path, monkeypatch): + """The chdir must land before anything reads the working directory. + + .gcoderc discovery, the banner and every tool resolve against it, so a + chdir performed later would leave them describing different projects. + load_env is the first call after it, so raising there proves the ordering + without running the interactive session. + """ + import os + + from gcode.cli import main + + start = os.getcwd() + seen = {} + + def _record(): + seen["cwd"] = os.getcwd() + raise _Sentinel + + monkeypatch.setattr("gcode.cli.load_env", _record) + monkeypatch.setattr("sys.argv", ["gcode", "--cwd", str(tmp_path)]) + try: + with pytest.raises(_Sentinel): + main() + finally: + os.chdir(start) + + assert os.path.realpath(seen["cwd"]) == os.path.realpath(str(tmp_path)) + + +def test_without_cwd_the_directory_is_untouched(monkeypatch): + import os + + from gcode.cli import main + + start = os.getcwd() + seen = {} + + def _record(): + seen["cwd"] = os.getcwd() + raise _Sentinel + + monkeypatch.setattr("gcode.cli.load_env", _record) + monkeypatch.setattr("sys.argv", ["gcode"]) + try: + with pytest.raises(_Sentinel): + main() + finally: + os.chdir(start) + + assert seen["cwd"] == start + + +def test_cwd_that_does_not_exist_exits_two(tmp_path, capsys, monkeypatch): + """A bad --cwd is user input: one line on stderr, exit 2, no traceback.""" + import os + + from gcode.cli import main + + missing = tmp_path / "no-such-project" + start = os.getcwd() + monkeypatch.setattr("sys.argv", ["gcode", "--cwd", str(missing)]) + try: + with pytest.raises(SystemExit) as exc: + main() + finally: + os.chdir(start) + + assert exc.value.code == 2 + stderr = capsys.readouterr().err + assert "--cwd" in stderr + assert "Traceback" not in stderr + + +def test_cwd_pointing_at_a_file_exits_two(tmp_path, capsys, monkeypatch): + import os + + from gcode.cli import main + + target = tmp_path / "notadir.txt" + target.write_text("x\n") + start = os.getcwd() + monkeypatch.setattr("sys.argv", ["gcode", "--cwd", str(target)]) + try: + with pytest.raises(SystemExit) as exc: + main() + finally: + os.chdir(start) + + assert exc.value.code == 2