Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 45 additions & 33 deletions src/archml/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ def _cmd_init(args: argparse.Namespace) -> int:

def _cmd_check(args: argparse.Namespace) -> int:
"""Handle the check subcommand."""
from archml.workspace.config import GitPathImport, LocalPathImport
from archml.workspace.config import GitPathImport, LocalPathImport, find_workspace_root

directory = Path(args.directory).resolve()

Expand All @@ -178,11 +178,16 @@ def _cmd_check(args: argparse.Namespace) -> int:
workspace_yaml = directory / ".archml-workspace.yaml"

if not workspace_yaml.exists():
print(
f"Error: no ArchML workspace found at '{directory}'. Run 'archml init' to initialize a workspace.",
file=sys.stderr,
)
return 1
root = find_workspace_root(directory)
if root is None:
print(
f"Error: no ArchML workspace found at '{directory}' or any parent directory."
"Run 'archml init' to initialize a workspace.",
file=sys.stderr,
)
return 1
directory = root
workspace_yaml = directory / ".archml-workspace.yaml"

# The empty-string key represents the workspace root for non-mnemonic imports.
source_import_map: dict[str, Path] = {"": directory}
Expand Down Expand Up @@ -244,6 +249,8 @@ def _cmd_check(args: argparse.Namespace) -> int:

def _cmd_serve(args: argparse.Namespace) -> int:
"""Handle the serve subcommand."""
from archml.workspace.config import find_workspace_root

directory = Path(args.directory).resolve()

if not directory.exists():
Expand All @@ -253,11 +260,16 @@ def _cmd_serve(args: argparse.Namespace) -> int:
workspace_yaml = directory / ".archml-workspace.yaml"

if not workspace_yaml.exists():
print(
f"Error: no ArchML workspace found at '{directory}'. Run 'archml init' to initialize a workspace.",
file=sys.stderr,
)
return 1
root = find_workspace_root(directory)
if root is None:
print(
f"Error: no ArchML workspace found at '{directory}' or any parent directory."
"Run 'archml init' to initialize a workspace.",
file=sys.stderr,
)
return 1
directory = root
workspace_yaml = directory / ".archml-workspace.yaml"

from archml.webui.app import create_app

Expand All @@ -269,7 +281,7 @@ def _cmd_serve(args: argparse.Namespace) -> int:

def _cmd_sync_remote(args: argparse.Namespace) -> int:
"""Handle the sync-remote subcommand."""
from archml.workspace.config import GitPathImport
from archml.workspace.config import GitPathImport, find_workspace_root
from archml.workspace.git_ops import GitError, clone_at_commit, get_current_commit
from archml.workspace.lockfile import LOCKFILE_NAME, LockfileError, load_lockfile

Expand All @@ -279,18 +291,18 @@ def _cmd_sync_remote(args: argparse.Namespace) -> int:
print(f"Error: directory '{directory}' does not exist.", file=sys.stderr)
return 1

workspace_file = directory / ".archml-workspace"
if not workspace_file.exists():
print(
f"Error: no ArchML workspace found at '{directory}'. Run 'archml init' to initialize a workspace.",
file=sys.stderr,
)
return 1

workspace_yaml = directory / ".archml-workspace.yaml"
if not workspace_yaml.exists():
print("No workspace configuration found. Nothing to sync.")
return 0
root = find_workspace_root(directory)
if root is None:
print(
f"Error: no ArchML workspace found at '{directory}' or any parent directory."
"Run 'archml init' to initialize a workspace.",
file=sys.stderr,
)
return 1
directory = root
workspace_yaml = directory / ".archml-workspace.yaml"

try:
config = load_workspace_config(workspace_yaml)
Expand Down Expand Up @@ -357,7 +369,7 @@ def _cmd_sync_remote(args: argparse.Namespace) -> int:

def _cmd_update_remote(args: argparse.Namespace) -> int:
"""Handle the update-remote subcommand."""
from archml.workspace.config import GitPathImport
from archml.workspace.config import GitPathImport, find_workspace_root
from archml.workspace.git_ops import GitError, is_commit_hash, resolve_commit
from archml.workspace.lockfile import (
LOCKFILE_NAME,
Expand All @@ -374,18 +386,18 @@ def _cmd_update_remote(args: argparse.Namespace) -> int:
print(f"Error: directory '{directory}' does not exist.", file=sys.stderr)
return 1

workspace_file = directory / ".archml-workspace"
if not workspace_file.exists():
print(
f"Error: no ArchML workspace found at '{directory}'. Run 'archml init' to initialize a workspace.",
file=sys.stderr,
)
return 1

workspace_yaml = directory / ".archml-workspace.yaml"
if not workspace_yaml.exists():
print("No workspace configuration found. Nothing to update.")
return 0
root = find_workspace_root(directory)
if root is None:
print(
f"Error: no ArchML workspace found at '{directory}' or any parent directory."
"Run 'archml init' to initialize a workspace.",
file=sys.stderr,
)
return 1
directory = root
workspace_yaml = directory / ".archml-workspace.yaml"

try:
config = load_workspace_config(workspace_yaml)
Expand Down
25 changes: 25 additions & 0 deletions src/archml/workspace/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,31 @@ class WorkspaceConfig(BaseModel):
source_imports: list[LocalPathImport | GitPathImport] = Field(alias="source-imports", default_factory=list)


WORKSPACE_CONFIG_FILENAME = ".archml-workspace.yaml"


def find_workspace_root(start_dir: Path) -> Path | None:
"""Walk up the directory tree to find the ArchML workspace root.

Searches for a .archml-workspace.yaml file starting from start_dir and
ascending through parent directories until the filesystem root is reached.

Args:
start_dir: Directory to start the search from.

Returns:
The directory containing .archml-workspace.yaml, or None if not found.
"""
current = start_dir.resolve()
while True:
if (current / WORKSPACE_CONFIG_FILENAME).exists():
return current
parent = current.parent
if parent == current:
return None
current = parent


def load_workspace_config(path: Path) -> WorkspaceConfig:
"""Load and validate a workspace configuration file.

Expand Down
79 changes: 54 additions & 25 deletions tests/cli/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,13 +201,29 @@ def test_check_invalid_workspace_yaml(


def test_check_fails_if_no_workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""check exits with error code 1 when no workspace file is found."""
"""check exits with error code 1 when no workspace file is found anywhere in the tree."""
monkeypatch.setattr(sys, "argv", ["archml", "check", str(tmp_path)])
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 1


def test_check_autodetects_workspace_in_parent_directory(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""check finds the workspace by walking up from a subdirectory."""
(tmp_path / ".archml-workspace.yaml").write_text("build-directory: .archml-build\n")
(tmp_path / "arch.archml").write_text("component MyComponent {}\n")
subdir = tmp_path / "src" / "components"
subdir.mkdir(parents=True)
monkeypatch.setattr(sys, "argv", ["archml", "check", str(subdir)])
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 0
captured = capsys.readouterr()
assert "No issues found." in captured.out


def test_check_fails_if_directory_does_not_exist(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""check exits with error code 1 when directory does not exist."""
missing = tmp_path / "nonexistent"
Expand Down Expand Up @@ -298,13 +314,29 @@ def test_check_excludes_build_directory_from_scan(


def test_serve_fails_if_no_workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""serve exits with error code 1 when no workspace file is found."""
"""serve exits with error code 1 when no workspace file is found anywhere in the tree."""
monkeypatch.setattr(sys, "argv", ["archml", "serve", str(tmp_path)])
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 1


def test_serve_autodetects_workspace_in_parent_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""serve finds the workspace by walking up from a subdirectory."""
(tmp_path / ".archml-workspace.yaml").write_text("build-directory: .archml-build\n")
subdir = tmp_path / "src" / "components"
subdir.mkdir(parents=True)
monkeypatch.setattr(sys, "argv", ["archml", "serve", str(subdir)])
mock_app = MagicMock()
with (
patch("archml.webui.app.create_app", return_value=mock_app),
pytest.raises(SystemExit) as exc_info,
):
main()
assert exc_info.value.code == 0
mock_app.run.assert_called_once()


def test_serve_fails_if_directory_does_not_exist(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""serve exits with error code 1 when directory does not exist."""
missing = tmp_path / "nonexistent"
Expand Down Expand Up @@ -368,18 +400,23 @@ def test_sync_remote_fails_if_directory_does_not_exist(tmp_path: Path, monkeypat
assert exc_info.value.code == 1


def test_sync_remote_no_workspace_yaml(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""sync-remote exits 0 with a message when no workspace YAML is present."""
(tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n")
monkeypatch.setattr(sys, "argv", ["archml", "sync-remote", str(tmp_path)])
def test_sync_remote_autodetects_workspace_in_parent(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""sync-remote finds the workspace by walking up from a subdirectory."""
(tmp_path / ".archml-workspace.yaml").write_text("build-directory: build\n")
subdir = tmp_path / "src" / "components"
subdir.mkdir(parents=True)
monkeypatch.setattr(sys, "argv", ["archml", "sync-remote", str(subdir)])
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 0
captured = capsys.readouterr()
assert "Nothing to sync" in captured.out


def test_sync_remote_no_git_imports(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""sync-remote exits 0 when no git imports are configured."""
(tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n")
(tmp_path / ".archml-workspace.yaml").write_text("build-directory: build\n")
monkeypatch.setattr(sys, "argv", ["archml", "sync-remote", str(tmp_path)])
with pytest.raises(SystemExit) as exc_info:
Expand All @@ -389,7 +426,6 @@ def test_sync_remote_no_git_imports(tmp_path: Path, monkeypatch: pytest.MonkeyPa

def test_sync_remote_fails_without_lockfile(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""sync-remote exits 1 when the lockfile is missing."""
(tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n")
(tmp_path / ".archml-workspace.yaml").write_text(
"build-directory: build\n"
"source-imports:\n"
Expand All @@ -407,7 +443,6 @@ def test_sync_remote_fails_if_repo_not_in_lockfile(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""sync-remote exits 1 when a configured repo is missing from the lockfile."""
(tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n")
(tmp_path / ".archml-workspace.yaml").write_text(
"build-directory: build\n"
"source-imports:\n"
Expand All @@ -428,7 +463,6 @@ def test_sync_remote_skips_repo_already_at_pinned_commit(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""sync-remote skips a repo that is already at the pinned commit."""
(tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n")
(tmp_path / ".archml-workspace.yaml").write_text(
"build-directory: build\n"
"source-imports:\n"
Expand Down Expand Up @@ -458,7 +492,6 @@ def test_sync_remote_clones_repo(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""sync-remote calls clone_at_commit when repo is not at the pinned commit."""
(tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n")
(tmp_path / ".archml-workspace.yaml").write_text(
"build-directory: build\n"
"source-imports:\n"
Expand Down Expand Up @@ -494,7 +527,6 @@ def test_sync_remote_reports_error_on_clone_failure(
"""sync-remote exits 1 and reports an error if clone_at_commit fails."""
from archml.workspace.git_ops import GitError

(tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n")
(tmp_path / ".archml-workspace.yaml").write_text(
"build-directory: build\n"
"source-imports:\n"
Expand Down Expand Up @@ -523,7 +555,6 @@ def test_sync_remote_reports_error_on_clone_failure(

def test_sync_remote_uses_custom_sync_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""sync-remote uses the remote-sync-directory from workspace config."""
(tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n")
(tmp_path / ".archml-workspace.yaml").write_text(
"build-directory: build\n"
"remote-sync-directory: custom-remotes\n"
Expand Down Expand Up @@ -574,18 +605,23 @@ def test_update_remote_fails_if_directory_does_not_exist(tmp_path: Path, monkeyp
assert exc_info.value.code == 1


def test_update_remote_no_workspace_yaml(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""update-remote exits 0 with a message when no workspace YAML is present."""
(tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n")
monkeypatch.setattr(sys, "argv", ["archml", "update-remote", str(tmp_path)])
def test_update_remote_autodetects_workspace_in_parent(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""update-remote finds the workspace by walking up from a subdirectory."""
(tmp_path / ".archml-workspace.yaml").write_text("build-directory: build\n")
subdir = tmp_path / "src" / "components"
subdir.mkdir(parents=True)
monkeypatch.setattr(sys, "argv", ["archml", "update-remote", str(subdir)])
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 0
captured = capsys.readouterr()
assert "Nothing to update" in captured.out


def test_update_remote_no_git_imports(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""update-remote exits 0 when no git imports are configured."""
(tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n")
(tmp_path / ".archml-workspace.yaml").write_text("build-directory: build\n")
monkeypatch.setattr(sys, "argv", ["archml", "update-remote", str(tmp_path)])
with pytest.raises(SystemExit) as exc_info:
Expand All @@ -596,7 +632,6 @@ def test_update_remote_no_git_imports(tmp_path: Path, monkeypatch: pytest.Monkey
def test_update_remote_creates_lockfile_from_branch(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""update-remote creates a lockfile by resolving branch revisions."""
resolved_commit = "c" * 40
(tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n")
(tmp_path / ".archml-workspace.yaml").write_text(
"build-directory: build\n"
"source-imports:\n"
Expand Down Expand Up @@ -625,7 +660,6 @@ def test_update_remote_creates_lockfile_from_branch(tmp_path: Path, monkeypatch:

def test_update_remote_pins_commit_hash_without_network(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""update-remote does not call git for revisions that are already commit hashes."""
(tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n")
(tmp_path / ".archml-workspace.yaml").write_text(
"build-directory: build\n"
"source-imports:\n"
Expand All @@ -652,7 +686,6 @@ def test_update_remote_updates_existing_lockfile(tmp_path: Path, monkeypatch: py
"""update-remote updates an existing lockfile with new commits."""
old_commit = "0" * 40
new_commit = "1" * 40
(tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n")
(tmp_path / ".archml-workspace.yaml").write_text(
"build-directory: build\n"
"source-imports:\n"
Expand Down Expand Up @@ -687,7 +720,6 @@ def test_update_remote_exits_1_on_resolution_failure(
"""update-remote exits 1 if a revision cannot be resolved."""
from archml.workspace.git_ops import GitError

(tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n")
(tmp_path / ".archml-workspace.yaml").write_text(
"build-directory: build\n"
"source-imports:\n"
Expand All @@ -714,7 +746,6 @@ def test_check_command_uses_synced_remote_repos(
remote_dir.mkdir(parents=True)
(remote_dir / "api.archml").write_text("interface PaymentAPI { field amount: Decimal }\n")

(tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n")
(tmp_path / ".archml-workspace.yaml").write_text(
"build-directory: build\n"
"source-imports:\n"
Expand Down Expand Up @@ -745,7 +776,6 @@ def test_check_command_loads_remote_repo_mnemonics(
"build-directory: build\nsource-imports:\n - name: lib\n local-path: src/lib\n"
)

(tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n")
(tmp_path / ".archml-workspace.yaml").write_text(
"build-directory: build\n"
"source-imports:\n"
Expand Down Expand Up @@ -775,7 +805,6 @@ def test_check_command_warns_on_invalid_remote_workspace_yaml(
# Malformed remote workspace config
(remote_dir / ".archml-workspace.yaml").write_text("bad yaml: [unterminated\n")

(tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n")
(tmp_path / ".archml-workspace.yaml").write_text(
"build-directory: build\n"
"source-imports:\n"
Expand Down
Loading