diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ba0175..dd1eaf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,149 +1,12 @@ # Changelog -## [Unreleased] - -### Added -- **Remote Package Index & Synchronization (`ebuild/packages/index_sync.py`).** - Downloads and validates central/mirror package repository indices into a local cache - (`~/.ebuild/index/`), caching full recipe definitions. Enforces HTTPS transport, - path-traversal sanitization (`^[a-zA-Z0-9_-]+$`), 10s socket timeouts, and 10MB response - size limits. Index synchronization supports air-gapped operation via `--offline` and `EBUILD_OFFLINE=1`; package archive fetching is not yet offline-gated. -- **Package Discovery & Multi-Source Search (`ebuild search`, `ebuild/packages/repository.py`).** - Search across local project recipes, system-shipped recipes, and cached remote indices. - Supports `--all`, `--json`, `--build-system`, and `--license` filters. -- **Index Synchronization Command (`ebuild update-index`).** - CLI command to refresh local package and recipe index caches from remote repositories. -- **Source-Ranked Recipe Precedence (`ebuild/packages/registry.py`).** - Enforces strict 3-tier precedence hierarchy during package resolution: project-local recipes (`./recipes/`) > system-shipped recipes (`recipes/`) > cached remote index recipes (`~/.ebuild/index/recipes/`), guaranteeing reproducible builds (§9.2) and ensuring project-level pins override remote definitions. -- **Stale Cached Recipe Pruning (`ebuild/packages/index_sync.py`, `ebuild update-index`).** - `ebuild update-index` automatically prunes stale cached `.yaml` and `.yml` recipes from `~/.ebuild/index/recipes/` that are absent from the newly synchronized remote package index. An index that yields no usable entries is treated as a delivery fault: nothing is pruned, the cached index is left in place, and the condition is reported. Surfaced the count of pruned recipes in CLI output and returned `SyncResult`. -- **Expanded Shipped Recipes Catalog (`recipes/`).** - Added 5 verified recipes with HTTPS release pins and SHA-256 integrity digests: - `cjson` (v1.7.18), `nanopb` (v0.4.9.1), `lvgl` (v9.2.2), `tinyusb` (v0.18.0), and `unity` (v2.6.1). - -### Fixed -- **`ebuild test` now finds Windows test binaries.** The Ninja edge for a - native `type: test` target already carried the platform suffix - (`_exe_suffix()` names it `.exe` on Windows), but `ebuild test` - rebuilt the path itself without that suffix, asked ninja to build - ``, and then looked for that unsuffixed path. Ninja reported an - unknown target and the runner reported "built, but no binary". Both - steps now go through the same `executable_output_path()` the edge itself - uses (`ebuild/build/ninja_backend.py`, `ebuild/cli/commands.py`). -- **`ebuild package` now finds the Windows binary too.** It looked up - `/` directly instead of through `executable_output_path()`, - so on Windows it reported "No built artifact" after a build that had - succeeded. Now uses the same helper as `ebuild test` - (`ebuild/cli/commands.py`). -- **`ebuild build`'s flash/RAM report went silent on Windows.** - `_report_footprint` also looked up `/` directly, so on - Windows the artifact was never found and the function returned with no - diagnostic — the report just never appeared, with no indication it was - skipped rather than not applicable. Now uses `executable_output_path()` - and logs at debug level when it has nothing to measure - (`ebuild/cli/commands.py`). -- **`pytest` collection no longer aborts on Windows without gcc.** - `tests/ebuild/test_build_dir_resolution.py` evaluated - `subprocess.run(["gcc", "--version"])` in a `skipif`. When gcc is not - installed, Windows raises `FileNotFoundError` instead of a non-zero - returncode, which pytest treats as a collection ERROR and stops the - suite. The probe now uses `shutil.which` and catches `OSError`. -- **A path containing a space produced a silently wrong `build.ninja`.** Paths - were written into build statements unescaped, but Ninja ends the output list - at the first unescaped `:` and splits on unescaped spaces. A build directory - under `C:\Users\Jane Doe\` parsed into four targets instead of one, so - `ebuild build` failed with `expected build command name` or built the wrong - thing. `$`, spaces and `:` are now escaped in generated build statements - (`ebuild/build/ninja_backend.py`). -- **`NinjaBackend` could not generate anything.** `_object_path()` is called by - both `_write_ninja()` and `_write_compile_commands()`, but the method itself - was dropped in a merge, so every `generate()` raised - `AttributeError: 'NinjaBackend' object has no attribute '_object_path'`. - The method and its regression tests are restored: objects are named - `obj//.o`, so a source listed by two targets compiles once - per target instead of both targets claiming one output — which ninja rejects - with `multiple rules generate ...` (`ebuild/build/ninja_backend.py`). -- **`ebuild.build.dispatch` was unimportable.** Two branches independently added - an unhandled-backend `else` clause to `BackendDispatcher.configure()`; the - merge kept both, leaving a second `else` after the first and a `SyntaxError` - that broke every command importing the module. The duplicate is removed and - the two clauses are consolidated into one - (`ebuild/build/dispatch.py`). -- **`configure(backend="ninja")` no longer silently succeeds.** `ebuild build` - routes `backend: ninja` with no `targets` into the dispatcher, which has no - ninja configure step; the no-op let the CLI report success having built - nothing. It now raises with a message naming the missing `targets` - (`ebuild/build/dispatch.py`). -- **Unhandled-backend errors no longer contradict themselves.** The message - listed `ALL_BACKENDS` as supported, which includes `ninja` — the very backend - being rejected. Each step now reports only the backends it handles - (`ebuild/build/dispatch.py`). -- **Ninja backend: header changes now trigger a rebuild.** The generated `cc` - rule declared no depfile, so Ninja only knew about the sources listed in - `build.yaml`. Editing a header left stale object files in place and the build - reported success. The rule now compiles with `-MMD -MF $out.d` and declares - `depfile`/`deps`, so Ninja tracks the real include graph - (`ebuild/build/ninja_backend.py`). -- **Package registry: versions that are not purely numeric no longer raise.** - Version ordering parsed every dot-separated component with `int()`, so a - recipe declaring `v2.9.3` -- the upstream tag form `recipes/littlefs.yaml` - already downloads -- made `ebuild list-packages` fail with `ValueError`. - Prereleases (`3.6.0-rc1`), distribution revisions (`1.2.13-1`) and build - metadata (`1.0.0+build2`) failed the same way. It also replaced the - resolver's actionable "package not found" error, which enumerates the - registry, with a traceback. Dot-separated integers keep their numeric - ordering; anything else is ranked below every numeric version and ordered - lexicographically rather than guessed at (`ebuild/packages/registry.py`). -- **Declared targets are no longer overridden by backend auto-detection.** - Backend detection inspects only the filesystem, so a `Makefile` kept for - `make flash`, or a `CMakeLists.txt` belonging to one subcomponent, won over - a `build.yaml` that declared its own `targets:`. The external tool ran, none - of the declared targets were built, no build directory was produced, and - `ebuild build` still reported "Build completed successfully" with exit code - 0. When the backend was auto-detected and targets are declared, the ninja - backend is now used and the choice is logged. An explicit `backend:` in - `build.yaml` or `--backend` still takes precedence (`ebuild/cli/commands.py`). -- **A relative `--build-dir` is now anchored to the project.** ebuild created - and reported the build directory relative to the process working directory, - while the ninja it launched read the same relative path from - `cfg.source_dir`. The two agree only when the working directory is the - project directory, so `ebuild build --config sub/build.yaml` failed with - "ninja: error: loading '_build/build.ninja': No such file or directory" one - line after reporting that it generated that file, and `ebuild configure` - reported success having written it where a later build would not look. A - relative `--build-dir` now resolves against the directory containing - `build.yaml`, as an absolute path, so both sides agree regardless of the - working directory (`ebuild/cli/commands.py`). - -### Added -- `ebuild.build.dispatch.UnknownBackendError`, raised for a backend a dispatch - step does not handle. It derives from both `ValueError` and `RuntimeError` - because the clauses it replaces raised one each and callers depend on both — - notably the CLI's `except RuntimeError`, which turns this into a clean - `exit 1` rather than a traceback. New code should catch - `UnknownBackendError`. - -## [3.0.1] - 2026-05-16 - -### Production Release — Unified EmbeddedOS-org v3.0.1 - -This is the synchronized production release across all 18 EmbeddedOS-org repos. - -- Refreshed governance: LICENSE, NOTICE, CITATION.cff, SECURITY.md -- CI/CD pipelines hardened: release.yml, book-build.yml, video-build.yml, deploy-pages.yml -- Release artifacts produced for: Linux x64/arm64, macOS x64/arm64, Windows x64, Docker, plus per-repo embedded/mobile/extension targets -- mdBook documentation built and deployed to GitHub Pages -- Promo video rendered and attached as a release asset - -## [3.0.0] - 2026-05-13 - ### Production Release — Unified EmbeddedOS-org v3.0.0 -This is the synchronized production release across all 18 EmbeddedOS-org repos. +This is the synchronized production release across all 18 EmbeddedOS-org repositories. -- Refreshed governance: LICENSE, NOTICE, CITATION.cff, SECURITY.md -- CI/CD pipelines hardened: release.yml, book-build.yml, video-build.yml, deploy-pages.yml -- Release artifacts produced for: Linux x64/arm64, macOS x64/arm64, Windows x64, Docker, plus per-repo embedded/mobile/extension targets +- Refreshed governance: `LICENSE`, `NOTICE`, `CITATION.cff`, `SECURITY.md` +- CI/CD pipelines hardened: `release.yml`, `book-build.yml`, `video-build.yml`, `deploy-pages.yml` +- Release artifacts produced for Linux x64/arm64, macOS x64/arm64, Windows x64, Docker, plus per-repo embedded/mobile/extension targets - mdBook documentation built and deployed to GitHub Pages - Promo video rendered and attached as a release asset @@ -152,21 +15,35 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **Dependency repositories now respect their remote default branch when no branch is configured.** ebuild no longer assumes `master` for unconfigured dependency repositories. When no branch is specified, Git uses the dependency repository's own default branch, allowing custom and third-party repositories whose default branch is `main` or another branch to be cloned correctly. + +- Fixed handling of relative `--build-dir` paths. + +### Added + +- Improved support for dependency repository configuration and Git-based dependency management. + ## [0.1.0] - 2026-03-31 ### Added + - Initial release of ebuild - Unified monorepo build system for EoS ecosystem - 18 CLI commands (build, clean, flash, test, analyze, sdk, release, etc.) - Yocto-style SDK generation for 14 targets -- Deliverable packager (ZIP per target + manifest.json) +- Deliverable packager (ZIP per target + `manifest.json`) - Hardware analyzer (KiCad/YAML schematic parsing) - Gated release pipeline (all repos must pass) -- Optional layer integration (eai, eni, eipc) -- Cross-compilation for aarch64, arm, riscv64 +- Optional layer integration (`eai`, `eni`, `eipc`) +- Cross-compilation for `aarch64`, `arm`, `riscv64` - Complete CI/CD pipeline with nightly, weekly, EoSim sanity, and simulation test runs - Full cross-platform support (Linux, Windows, macOS) - ISO/IEC standards compliance documentation - MIT license +[Unreleased]: https://github.com/embeddedos-org/ebuild/compare/v0.1.0...HEAD [0.1.0]: https://github.com/embeddedos-org/ebuild/releases/tag/v0.1.0 diff --git a/ebuild/deps/manager.py b/ebuild/deps/manager.py index 0db81cc..e71c4e5 100644 --- a/ebuild/deps/manager.py +++ b/ebuild/deps/manager.py @@ -53,8 +53,10 @@ class DepsManager: 2. Environment variables ``EBUILD_EOS_PATH`` / ``EBUILD_EBOOT_PATH`` 3. ``~/.ebuild/config.yaml`` custom ``path:`` override 4. ``~/.ebuild/repos//`` (cached git clone) - 5. Sibling directory ``..//`` (workspace layout; ``eboot`` also tries ``eBoot``) - 6. Embedded ``core//`` (legacy fallback — prints deprecation warning) + 5. Sibling directory ``..//`` (workspace layout; + ``eboot`` also tries ``eBoot``) + 6. Embedded ``core//`` (legacy fallback — prints deprecation + warning) """ def __init__(self, cli_overrides: Optional[Dict[str, str]] = None) -> None: @@ -71,17 +73,19 @@ def load_config(self) -> Dict[str, Any]: if EBUILD_CONFIG_PATH.exists(): with open(EBUILD_CONFIG_PATH, "r", encoding="utf-8") as f: data = yaml.safe_load(f) or {} - # Merge missing keys from defaults. Deep-copied: setup(), - # set_url(), set_branch() and link() mutate these nested dicts + + # Merge missing keys from defaults. Deep-copied because setup(), + # set_url(), set_branch() and link() mutate nested dictionaries # in place, and an aliased default would leak one instance's # edits into DEFAULT_CONFIG for the rest of the process. for key, val in DEFAULT_CONFIG.items(): if key not in data: data[key] = copy.deepcopy(val) elif key == "repos" and isinstance(val, dict): - for rname, rval in val.items(): - if rname not in data["repos"]: - data["repos"][rname] = copy.deepcopy(rval) + for repo_name, repo_value in val.items(): + if repo_name not in data["repos"]: + data["repos"][repo_name] = copy.deepcopy(repo_value) + return data self._config = copy.deepcopy(DEFAULT_CONFIG) @@ -91,11 +95,18 @@ def load_config(self) -> Dict[str, Any]: def save_config(self) -> None: """Write the current configuration to ``~/.ebuild/config.yaml``.""" ensure_ebuild_home() + with open(EBUILD_CONFIG_PATH, "w", encoding="utf-8") as f: - yaml.dump(self._config, f, default_flow_style=False, sort_keys=False) + yaml.dump( + self._config, + f, + default_flow_style=False, + sort_keys=False, + ) @property def config(self) -> Dict[str, Any]: + """Return the current configuration.""" return self._config # ------------------------------------------------------------------ @@ -104,10 +115,11 @@ def config(self) -> Dict[str, Any]: @property def cache_dir(self) -> Path: - """Resolved cache directory (from config or env var).""" + """Resolve the cache directory from config or environment.""" env = os.environ.get("EBUILD_REPOS_DIR") if env: return Path(env) + raw = self._config.get("cache_dir", str(EBUILD_REPOS_DIR)) return Path(os.path.expanduser(raw)) @@ -123,47 +135,71 @@ def setup( path: Optional[str] = None, shallow: bool = True, ) -> Path: - """Clone or link a repo. + """Clone or link a repository. Args: repo_name: ``"eos"`` or ``"eboot"``. - url: Git URL override. Falls back to config → default. - branch: Branch/tag override. Falls back to config → ``"master"``. + url: Git URL override. Falls back to config, then default URL. + branch: Branch/tag override. Falls back to configured branch. + If no branch is configured, the remote repository's default + branch is used. path: If given, register this local path instead of cloning. - shallow: Use ``--depth 1`` for faster clones (default *True*). + shallow: Use ``--depth 1`` for faster clones. Returns: - The resolved local path of the repo. + The resolved local path of the repository. """ - repo_cfg = self._config.setdefault("repos", {}).setdefault(repo_name, {}) + repo_cfg = self._config.setdefault("repos", {}).setdefault( + repo_name, + {}, + ) if url: repo_cfg["url"] = url + if branch: repo_cfg["branch"] = branch if path: - # Link to local repo — no clone needed - p = Path(path).resolve() - if not p.is_dir(): - raise FileNotFoundError(f"Local repo path does not exist: {p}") - repo_cfg["path"] = str(p) + # Link to local repo — no clone needed. + local_path = Path(path).resolve() + + if not local_path.is_dir(): + raise FileNotFoundError( + f"Local repo path does not exist: {local_path}" + ) + + repo_cfg["path"] = str(local_path) self.save_config() - return p + return local_path - # Clone to cache effective_url = repo_cfg.get("url") or self._default_url(repo_name) - effective_branch = repo_cfg.get("branch") or "master" + + # Do not invent a branch when one was not configured. + # Passing None to _git_clone causes git to use the remote's + # default branch. + effective_branch = repo_cfg.get("branch") dest = self.cache_dir / repo_name + if dest.exists(): - # Already cloned — optionally switch branch - self._checkout_branch(dest, effective_branch) + # Already cloned — only switch branches when one was + # explicitly configured. + if effective_branch: + self._checkout_branch(dest, effective_branch) + self.save_config() return dest self.cache_dir.mkdir(parents=True, exist_ok=True) - self._git_clone(effective_url, dest, effective_branch, shallow) + + self._git_clone( + effective_url, + dest, + effective_branch, + shallow, + ) + self.save_config() return dest @@ -180,63 +216,83 @@ def get_repo_path( Args: repo_name: ``"eos"`` or ``"eboot"``. - project_dir: Current project directory (for sibling/core fallback). + project_dir: Current project directory for sibling/core fallback. Returns: - Absolute path to the repo, or *None* if not found anywhere. + Absolute path to the repo, or ``None`` if not found. """ # 1. CLI override cli_path = self._cli_overrides.get(repo_name) + if cli_path: - p = Path(cli_path).resolve() - if p.is_dir(): - return p + path = Path(cli_path).resolve() + + if path.is_dir(): + return path # 2. Environment variable env_var = ENV_PATH_VARS.get(repo_name) + if env_var: - env_val = os.environ.get(env_var) - if env_val: - p = Path(env_val).resolve() - if p.is_dir(): - return p + env_value = os.environ.get(env_var) + + if env_value: + path = Path(env_value).resolve() + + if path.is_dir(): + return path # 3. Config path override repo_cfg = self._config.get("repos", {}).get(repo_name, {}) config_path = repo_cfg.get("path") + if config_path: - p = Path(config_path).resolve() - if p.is_dir(): - return p + path = Path(config_path).resolve() + + if path.is_dir(): + return path # 4. Cached clone cached = self.cache_dir / repo_name + if cached.is_dir(): return cached - # 5. Sibling directory (try documented aliases; Linux is case-sensitive) + # 5. Sibling directory if project_dir: - names = SIBLING_DIR_NAMES.get(repo_name, (repo_name,)) + names = SIBLING_DIR_NAMES.get( + repo_name, + (repo_name,), + ) + for name in names: sibling = project_dir.parent / name + if sibling.is_dir(): return sibling - # 6. Legacy embedded core// (deprecation warning) + # 6. Legacy embedded core// fallback if project_dir: core_path = project_dir / "core" / repo_name + if core_path.is_dir(): warnings.warn( f"Using embedded core/{repo_name}/ is deprecated. " - f"Run 'ebuild setup' to clone repos to ~/.ebuild/repos/.", + "Run 'ebuild setup' to clone repos to " + "~/.ebuild/repos/.", DeprecationWarning, stacklevel=2, ) + return core_path return None - def is_available(self, repo_name: str, project_dir: Optional[Path] = None) -> bool: + def is_available( + self, + repo_name: str, + project_dir: Optional[Path] = None, + ) -> bool: """Check if *repo_name* is resolvable anywhere.""" return self.get_repo_path(repo_name, project_dir) is not None @@ -248,32 +304,42 @@ def update(self, repo_name: Optional[str] = None) -> Dict[str, str]: """Git pull latest for one or all repos. Returns: - Dict mapping repo name to result string (e.g. ``"updated"``). + Dict mapping repo name to result string. """ names = [repo_name] if repo_name else list(KNOWN_REPOS) results: Dict[str, str] = {} for name in names: repo_dir = self.cache_dir / name + if not repo_dir.is_dir(): results[name] = "not cloned" continue repo_cfg = self._config.get("repos", {}).get(name, {}) + if repo_cfg.get("path"): results[name] = "linked (skipped)" continue try: subprocess.run( - ["git", "-C", str(repo_dir), "pull", "--ff-only"], + [ + "git", + "-C", + str(repo_dir), + "pull", + "--ff-only", + ], capture_output=True, text=True, check=True, ) + results[name] = "updated" - except subprocess.CalledProcessError as e: - results[name] = f"failed: {e.stderr.strip()}" + + except subprocess.CalledProcessError as error: + results[name] = f"failed: {error.stderr.strip()}" return results @@ -282,22 +348,29 @@ def update(self, repo_name: Optional[str] = None) -> Dict[str, str]: # ------------------------------------------------------------------ def status(self) -> List[Dict[str, Any]]: - """Return status info for all known repos.""" + """Return status information for all known repositories.""" entries: List[Dict[str, Any]] = [] + for name in KNOWN_REPOS: repo_cfg = self._config.get("repos", {}).get(name, {}) cached = self.cache_dir / name info: Dict[str, Any] = { "name": name, - "url": repo_cfg.get("url", self._default_url(name)), - "branch": repo_cfg.get("branch", "master"), + "url": repo_cfg.get( + "url", + self._default_url(name), + ), + "branch": repo_cfg.get("branch"), "config_path": repo_cfg.get("path"), "cached": cached.is_dir(), - "cache_location": str(cached) if cached.is_dir() else None, + "cache_location": ( + str(cached) + if cached.is_dir() + else None + ), } - # Get current git branch/commit if cloned if cached.is_dir(): info["git_branch"] = self._git_current_branch(cached) info["git_commit"] = self._git_head_commit(cached) @@ -310,18 +383,40 @@ def status(self) -> List[Dict[str, Any]]: # Link / unlink # ------------------------------------------------------------------ - def link(self, repo_name: str, local_path: str) -> None: - """Register a local path override (no symlink — just config entry).""" - p = Path(local_path).resolve() - if not p.is_dir(): - raise FileNotFoundError(f"Path does not exist: {p}") - repo_cfg = self._config.setdefault("repos", {}).setdefault(repo_name, {}) - repo_cfg["path"] = str(p) + def link( + self, + repo_name: str, + local_path: str, + ) -> None: + """Register a local path override.""" + path = Path(local_path).resolve() + + if not path.is_dir(): + raise FileNotFoundError( + f"Path does not exist: {path}" + ) + + repo_cfg = self._config.setdefault( + "repos", + {}, + ).setdefault( + repo_name, + {}, + ) + + repo_cfg["path"] = str(path) self.save_config() def unlink(self, repo_name: str) -> None: """Remove a local path override, reverting to cache.""" - repo_cfg = self._config.get("repos", {}).get(repo_name, {}) + repo_cfg = self._config.get( + "repos", + {}, + ).get( + repo_name, + {}, + ) + repo_cfg.pop("path", None) self.save_config() @@ -329,15 +424,37 @@ def unlink(self, repo_name: str) -> None: # URL / branch setters # ------------------------------------------------------------------ - def set_url(self, repo_name: str, url: str) -> None: - """Change the git URL for a repo.""" - repo_cfg = self._config.setdefault("repos", {}).setdefault(repo_name, {}) + def set_url( + self, + repo_name: str, + url: str, + ) -> None: + """Change the git URL for a repository.""" + repo_cfg = self._config.setdefault( + "repos", + {}, + ).setdefault( + repo_name, + {}, + ) + repo_cfg["url"] = url self.save_config() - def set_branch(self, repo_name: str, branch: str) -> None: - """Change the branch/tag for a repo.""" - repo_cfg = self._config.setdefault("repos", {}).setdefault(repo_name, {}) + def set_branch( + self, + repo_name: str, + branch: str, + ) -> None: + """Change the branch/tag for a repository.""" + repo_cfg = self._config.setdefault( + "repos", + {}, + ).setdefault( + repo_name, + {}, + ) + repo_cfg["branch"] = branch self.save_config() @@ -347,60 +464,149 @@ def set_branch(self, repo_name: str, branch: str) -> None: @staticmethod def _default_url(repo_name: str) -> str: + """Return the default repository URL.""" if repo_name == "eos": return DEFAULT_EOS_REPO_URL + if repo_name == "eboot": return DEFAULT_EBOOT_REPO_URL + return "" @staticmethod - def _git_clone(url: str, dest: Path, branch: str, shallow: bool) -> None: + def _git_clone( + url: str, + dest: Path, + branch: Optional[str], + shallow: bool, + ) -> None: + """Clone a repository. + + When ``branch`` is ``None``, no ``--branch`` option is supplied. + Git then checks out the remote repository's default branch. + """ cmd = ["git", "clone"] + if shallow: cmd.extend(["--depth", "1"]) - cmd.extend(["--branch", branch, url, str(dest)]) - result = subprocess.run(cmd, capture_output=True, text=True) + if branch: + cmd.extend(["--branch", branch]) + + cmd.extend([url, str(dest)]) + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + ) + if result.returncode != 0: - raise RuntimeError(f"Failed to clone {url}: {result.stderr.strip()}") + raise RuntimeError( + f"Failed to clone {url}: {result.stderr.strip()}" + ) @staticmethod - def _checkout_branch(repo_dir: Path, branch: str) -> None: + def _checkout_branch( + repo_dir: Path, + branch: Optional[str], + ) -> None: + """Checkout a configured branch in an existing repository. + + If no branch is configured, this method intentionally does nothing. + """ + if not branch: + return + current = DepsManager._git_current_branch(repo_dir) - if current != branch: - subprocess.run( - ["git", "-C", str(repo_dir), "fetch", "--all"], - capture_output=True, - text=True, + + if current == branch: + return + + fetch = subprocess.run( + [ + "git", + "-C", + str(repo_dir), + "fetch", + "--all", + ], + capture_output=True, + text=True, + ) + + if fetch.returncode != 0: + raise RuntimeError( + f"Failed to fetch {repo_dir}: " + f"{fetch.stderr.strip()}" ) - subprocess.run( - ["git", "-C", str(repo_dir), "checkout", branch], - capture_output=True, - text=True, + + checkout = subprocess.run( + [ + "git", + "-C", + str(repo_dir), + "checkout", + branch, + ], + capture_output=True, + text=True, + ) + + if checkout.returncode != 0: + raise RuntimeError( + f"Failed to checkout {branch}: " + f"{checkout.stderr.strip()}" ) @staticmethod def _git_current_branch(repo_dir: Path) -> str: + """Return the current git branch name.""" try: result = subprocess.run( - ["git", "-C", str(repo_dir), "rev-parse", "--abbrev-ref", "HEAD"], + [ + "git", + "-C", + str(repo_dir), + "rev-parse", + "--abbrev-ref", + "HEAD", + ], capture_output=True, text=True, check=True, ) + return result.stdout.strip() - except (subprocess.CalledProcessError, FileNotFoundError): + + except ( + subprocess.CalledProcessError, + FileNotFoundError, + ): return "(unknown)" @staticmethod def _git_head_commit(repo_dir: Path) -> str: + """Return the abbreviated current git commit.""" try: result = subprocess.run( - ["git", "-C", str(repo_dir), "rev-parse", "--short", "HEAD"], + [ + "git", + "-C", + str(repo_dir), + "rev-parse", + "--short", + "HEAD", + ], capture_output=True, text=True, check=True, ) + return result.stdout.strip() - except (subprocess.CalledProcessError, FileNotFoundError): + + except ( + subprocess.CalledProcessError, + FileNotFoundError, + ): return "(unknown)" diff --git a/tests/ebuild/test_build_dir_resolution.py b/tests/ebuild/test_build_dir_resolution.py index a19be13..e08444f 100644 --- a/tests/ebuild/test_build_dir_resolution.py +++ b/tests/ebuild/test_build_dir_resolution.py @@ -28,7 +28,6 @@ import os import shutil import subprocess -import shutil import textwrap from pathlib import Path from types import SimpleNamespace diff --git a/tests/ebuild/test_deps_manager.py b/tests/ebuild/test_deps_manager.py index 58949d8..0de2492 100644 --- a/tests/ebuild/test_deps_manager.py +++ b/tests/ebuild/test_deps_manager.py @@ -1,9 +1,10 @@ # SPDX-License-Identifier: MIT # Copyright (c) 2026 EoS Project -"""Host tests for DepsManager sibling path resolution.""" +"""Tests for DepsManager.""" from pathlib import Path +from unittest.mock import Mock import pytest @@ -13,17 +14,26 @@ @pytest.fixture def isolated_deps(tmp_path, monkeypatch): - """Keep get_repo_path off the real ~/.ebuild cache and config.""" + """Keep tests away from the real ~/.ebuild directory.""" home = tmp_path / "ebuild-home" repos = home / "repos" config = home / "config.yaml" - monkeypatch.setattr("ebuild.deps.manager.ensure_ebuild_home", lambda: home) - monkeypatch.setattr("ebuild.deps.manager.EBUILD_CONFIG_PATH", config) + + monkeypatch.setattr( + "ebuild.deps.manager.ensure_ebuild_home", + lambda: home, + ) + monkeypatch.setattr( + "ebuild.deps.manager.EBUILD_CONFIG_PATH", + config, + ) monkeypatch.setenv("EBUILD_REPOS_DIR", str(repos)) monkeypatch.delenv("EBUILD_EOS_PATH", raising=False) monkeypatch.delenv("EBUILD_EBOOT_PATH", raising=False) + home.mkdir() repos.mkdir() + return tmp_path @@ -33,69 +43,144 @@ def _case_sensitive_is_dir(self: Path) -> bool: names = {child.name for child in self.parent.iterdir()} except OSError: return False + return self.name in names def test_eboot_aliases_include_github_casing(): + """eBoot should be recognized as an eboot sibling.""" assert SIBLING_DIR_NAMES["eboot"] == ("eboot", "eBoot") -def test_sibling_eboot_resolves_camel_case(isolated_deps, monkeypatch): +def test_sibling_eboot_resolves_camel_case( + isolated_deps, + monkeypatch, +): + """Resolve an eBoot sibling directory.""" workspace = isolated_deps / "ws" project = workspace / "ebuild" camel = workspace / "eBoot" + project.mkdir(parents=True) camel.mkdir() - monkeypatch.setattr(Path, "is_dir", _case_sensitive_is_dir) - resolved = DepsManager().get_repo_path("eboot", project_dir=project) + monkeypatch.setattr( + Path, + "is_dir", + _case_sensitive_is_dir, + ) + + resolved = DepsManager().get_repo_path( + "eboot", + project_dir=project, + ) assert resolved is not None assert resolved.name == "eBoot" -def test_sibling_eboot_still_resolves_lowercase(isolated_deps, monkeypatch): +def test_sibling_eboot_still_resolves_lowercase( + isolated_deps, + monkeypatch, +): + """Resolve the lowercase eboot sibling directory.""" workspace = isolated_deps / "ws" project = workspace / "ebuild" lower = workspace / "eboot" + project.mkdir(parents=True) lower.mkdir() - monkeypatch.setattr(Path, "is_dir", _case_sensitive_is_dir) - resolved = DepsManager().get_repo_path("eboot", project_dir=project) + monkeypatch.setattr( + Path, + "is_dir", + _case_sensitive_is_dir, + ) + + resolved = DepsManager().get_repo_path( + "eboot", + project_dir=project, + ) assert resolved is not None assert resolved.name == "eboot" -def test_sibling_eboot_missing_returns_none(isolated_deps, monkeypatch): +def test_sibling_eboot_missing_returns_none( + isolated_deps, + monkeypatch, +): + """Return None when no eboot sibling exists.""" workspace = isolated_deps / "ws" project = workspace / "ebuild" + project.mkdir(parents=True) - monkeypatch.setattr(Path, "is_dir", _case_sensitive_is_dir) - resolved = DepsManager().get_repo_path("eboot", project_dir=project) + monkeypatch.setattr( + Path, + "is_dir", + _case_sensitive_is_dir, + ) + + resolved = DepsManager().get_repo_path( + "eboot", + project_dir=project, + ) assert resolved is None -def test_setters_do_not_mutate_module_defaults(isolated_deps): - """In-place config edits must not leak into the shared DEFAULT_CONFIG. +def test_setters_do_not_mutate_module_defaults( + isolated_deps, +): + """Config changes must not mutate DEFAULT_CONFIG.""" + manager = DepsManager() - load_config() used to alias the nested default repo dicts instead of - copying them, so set_branch() on one DepsManager rewrote the module-level - template — and every later instance in the process inherited the edit as - its 'default'. - """ - mgr = DepsManager() - mgr.set_branch("eos", "dev") - mgr.set_url("eboot", "https://example.invalid/eboot.git") + manager.set_branch("eos", "dev") + manager.set_url( + "eboot", + "https://example.invalid/eboot.git", + ) assert DEFAULT_CONFIG["repos"]["eos"]["branch"] == "master" - assert DEFAULT_CONFIG["repos"]["eboot"]["url"].endswith("/eBoot.git") + assert DEFAULT_CONFIG["repos"]["eboot"]["url"].endswith( + "/eBoot.git" + ) + + +def test_setup_uses_remote_default_branch_when_unconfigured( + isolated_deps, + monkeypatch, +): + """Do not pass --branch when no branch is configured.""" + manager = DepsManager() + + manager._config["repos"]["eos"] = { + "url": "https://example.invalid/eos.git", + } + + clone_result = Mock() + clone_result.returncode = 0 + clone_result.stderr = "" + + mock_run = Mock(return_value=clone_result) + + monkeypatch.setattr( + "ebuild.deps.manager.subprocess.run", + mock_run, + ) + + destination = manager.setup( + "eos", + shallow=False, + ) + + assert destination == manager.cache_dir / "eos" + mock_run.assert_called_once() + + clone_command = mock_run.call_args.args[0] - # A fresh instance starting from a clean config must not see them either. - fresh = DepsManager() - fresh._config = fresh.load_config() - assert fresh.config["repos"]["eos"]["branch"] == "dev" # persisted for us - assert DEFAULT_CONFIG["repos"]["eos"]["branch"] == "master" # not for all + assert clone_command[:2] == ["git", "clone"] + assert "https://example.invalid/eos.git" in clone_command + assert "--branch" not in clone_command + assert "master" not in clone_command diff --git a/tests/ebuild/test_package_recipe.py b/tests/ebuild/test_package_recipe.py index 38c38de..927515b 100644 --- a/tests/ebuild/test_package_recipe.py +++ b/tests/ebuild/test_package_recipe.py @@ -114,4 +114,5 @@ def test_depends_alias_must_be_a_list(): """ with pytest.raises(RecipeError, match="dependencies"): - load_recipe_from_string(content) \ No newline at end of file + load_recipe_from_string(content) + diff --git a/tests/unit/test_ci_gate.py b/tests/unit/test_ci_gate.py index 964ca54..fda8563 100644 --- a/tests/unit/test_ci_gate.py +++ b/tests/unit/test_ci_gate.py @@ -11,6 +11,8 @@ required check does not cover. """ +import itertools +import re import yaml import pytest from pathlib import Path @@ -211,8 +213,6 @@ def test_gate_fails_on_any_non_success_result(jobs): # check cannot say which of the three it means, and a Windows-only failure is # indistinguishable from the other two legs without opening the run. -import itertools -import re # `include` and `exclude` shape a matrix but are not dimensions of it, so they # are not part of the cartesian product. diff --git a/tests/unit/test_deps_manager.py b/tests/unit/test_deps_manager.py new file mode 100644 index 0000000..a375ec7 --- /dev/null +++ b/tests/unit/test_deps_manager.py @@ -0,0 +1,47 @@ +import subprocess +from pathlib import Path +from unittest.mock import patch + +from ebuild.deps.manager import DepsManager + + +def test_git_clone_without_branch_does_not_pass_branch(tmp_path): + dest = tmp_path / "repo" + + with patch("ebuild.deps.manager.subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, stdout="", stderr="" + ) + + DepsManager._git_clone( + "https://github.com/example/repo.git", + dest, + None, + True, + ) + + command = mock_run.call_args[0][0] + + assert "--branch" not in command + assert "master" not in command + + +def test_git_clone_with_branch_passes_branch(tmp_path): + dest = tmp_path / "repo" + + with patch("ebuild.deps.manager.subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, stdout="", stderr="" + ) + + DepsManager._git_clone( + "https://github.com/example/repo.git", + dest, + "develop", + True, + ) + + command = mock_run.call_args[0][0] + + assert "--branch" in command + assert "develop" in command