--- a/src/osforge/merge.py +++ b/src/osforge/merge.py @@ -617,6 +617,15 @@ def fetch_sources( continue ref = repo.reference if (dest / ".git").is_dir(): + set_url = runner.run( + f"merge-set-origin-{repo.name}", + ["git", "-C", str(dest), "remote", "set-url", "origin", repo.url], + timeout_seconds=timeout_seconds, + ) + commands.append(set_url) + if not set_url.ok: + _fail(repo.name, f"remote update failed for {repo.name}; inspect the log") + continue fetch_argv = ["git", "-C", str(dest), "fetch", "--depth=1", "origin"] if ref: fetch_argv.append(ref) @@ -800,6 +809,7 @@ def merge_core(sources: list[MergeRepo], workspace: Path, build_dir: Path) -> di """Build the merged tree in `build_dir` from fetched source clones, mirroring the legacy main.go merge: fresh build dir, seed full-copy, overlay - select/whole-repo, fixups whole-repo + profiles, in legacy precedence order.""" + select/whole-repo, then fixups whole-repo. Profile paths are copied only + through explicit repo copyfiles.""" if build_dir.exists(): shutil.rmtree(build_dir) build_dir.mkdir(parents=True) @@ -812,9 +822,6 @@ def merge_core(sources: list[MergeRepo], workspace: Path, build_dir: Path) -> di stats["seed"] += 1 elif repo.role == "fixups": copy_repo(clone, build_dir) - profiles = clone / "profiles" - if profiles.is_dir(): - _copy_path(profiles, build_dir / "profiles") stats["fixups"] += 1 else: # overlay (including ports) if repo.selects: @@ -824,9 +831,62 @@ def merge_core(sources: list[MergeRepo], workspace: Path, build_dir: Path) -> di else: copy_repo(clone, build_dir) stats["overlay_repos"] += 1 + reapply_working_tree_packages(sources, workspace, build_dir) return stats +def reapply_working_tree_packages( + sources: list[MergeRepo], + workspace: Path, + build_dir: Path, +) -> None: + """Give local working-tree package directories final precedence.""" + categories = portage_categories(*(workspace / repo.name for repo in sources if repo.working_tree)) + for repo in sources: + if not repo.working_tree: + continue + root = workspace / repo.name + for package in sorted(portage_packages(root, categories)): + copy_package(root, build_dir, package) + + +def portage_categories(*roots: Path) -> set[str]: + categories: set[str] = set() + for root in roots: + category_file = root / "profiles" / "categories" + if category_file.is_file(): + try: + for line in category_file.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line and not line.startswith("#"): + categories.add(line) + except OSError: + pass + for root in roots: + if not root.is_dir(): + continue + for entry in root.iterdir(): + if entry.is_dir() and any(entry.glob("*/*.ebuild")): + categories.add(entry.name) + return categories + + +def portage_packages(root: Path, categories: set[str]) -> set[str]: + packages: set[str] = set() + for category in categories: + category_dir = root / category + if not category_dir.is_dir(): + continue + for package_dir in category_dir.iterdir(): + if ( + package_dir.is_dir() + and not package_dir.is_symlink() + and any(package_dir.glob("*.ebuild")) + ): + packages.add(f"{category}/{package_dir.name}") + return packages + + def run_mass_edits( repo_dir: Path, build_env_path: Path, --- a/tests/test_merge.py +++ b/tests/test_merge.py @@ -668,8 +668,8 @@ class MergeCoreTests(unittest.TestCase): self.assertFalse((build / "games" / "other").exists()) # named eclass copied self.assertEqual((build / "eclass" / "base.eclass").read_text(), "BASE") - # fixups profiles land; seed profiles merged; overlay profiles were skipped by copy_repo - self.assertEqual((build / "profiles" / "custom").read_text(), "fixprofile") + # seed profiles merged; overlay and fixups profiles were skipped by copy_repo self.assertTrue((build / "profiles" / "p").exists()) self.assertFalse((build / "profiles" / "skip").exists()) + self.assertFalse((build / "profiles" / "custom").exists()) self.assertEqual( --- a/src/osforge/beosrepo.py +++ b/src/osforge/beosrepo.py @@ -13,6 +13,7 @@ from .merge import ( DEFAULT_REPOS_CONFIG_PATH, build_merge_plan, run_merge_apply, + sync_profile_categories, ) from .merge_equiv import compare_trees, render_equivalence_report from .plan import PlanResult @@ -207,6 +208,17 @@ def apply_command(args: Namespace) -> int: def update_command(args: Namespace) -> int: config = _load_config(args) + try: + beos_master_categories_added = sync_profile_categories(args.beosrepo_root / "master") + except OSError as exc: + raise ValueError(f"profiles/categories sync failed: {exc}") from exc + if args.format == "text" and beos_master_categories_added: + print( + "local profiles/categories: added " + + ", ".join(beos_master_categories_added) + ) + print() + pre_update_commit = _commit_dirty_output_repo(config) if args.format == "text" and pre_update_commit: print(f"pre-update checkpoint: {pre_update_commit}") @@ -231,6 +245,7 @@ def update_command(args: Namespace) -> int: "plan": _result_payload(plan_result), "apply": None, "pre_update_commit": pre_update_commit, + "beos_master_categories_added": beos_master_categories_added, }, indent=2, sort_keys=True, @@ -263,6 +278,7 @@ def update_command(args: Namespace) -> int: "apply": _result_payload(apply_result), "status_path": str(status_path), "pre_update_commit": pre_update_commit, + "beos_master_categories_added": beos_master_categories_added, } print(json.dumps(payload, indent=2, sort_keys=True)) else: --- a/src/osforge/merge.py +++ b/src/osforge/merge.py @@ -543,6 +543,28 @@ def _run_merge_stages( "detail": str(config.merge_output_repo), } ) + if not _has_error(findings): + try: + added_categories = sync_profile_categories(config.merge_output_repo) + except OSError as exc: + findings.append( + { + "severity": "error", + "message": f"profile categories sync failed: {exc}", + } + ) + added_categories = [] + stages.append( + { + "name": "profile-categories", + "status": "failed" if _has_error(findings) else "succeeded", + "detail": ( + "added " + ", ".join(added_categories) + if added_categories + else "already current" + ), + } + ) # Stage 7 — commit the output repo and assert it is clean. if not _has_error(findings): if config.merge_commit: @@ -868,8 +890,45 @@ def portage_categories(*roots: Path) -> set[str]: if entry.is_dir() and any(entry.glob("*/*.ebuild")): categories.add(entry.name) return categories +def discovered_portage_categories(root: Path) -> set[str]: + categories: set[str] = set() + if not root.is_dir(): + return categories + for entry in root.iterdir(): + if entry.is_dir() and not entry.is_symlink() and any(entry.glob("*/*.ebuild")): + categories.add(entry.name) + return categories + + +def sync_profile_categories(repo_root: Path) -> list[str]: + """Add missing top-level Portage category dirs to profiles/categories.""" + discovered = discovered_portage_categories(repo_root) + if not discovered: + return [] + + category_file = repo_root / "profiles" / "categories" + existing: set[str] = set() + if category_file.is_file(): + for line in category_file.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line and not line.startswith("#"): + existing.add(line) + + missing = sorted(discovered - existing) + if not missing: + return [] + + desired = sorted(existing | discovered) + category_file.parent.mkdir(parents=True, exist_ok=True) + tmp = category_file.with_name(".categories.tmp") + tmp.write_text("\n".join(desired) + "\n", encoding="utf-8") + tmp.chmod(0o644) + tmp.replace(category_file) + return missing + + def portage_packages(root: Path, categories: set[str]) -> set[str]: packages: set[str] = set() for category in categories: --- a/tests/test_merge.py +++ b/tests/test_merge.py @@ -266,6 +266,7 @@ class MergeApplyTests(unittest.TestCase): "metadata", "changelog", "sync", + "profile-categories", ], ) self.assertIn("ebuild_changes", result.ledger["merge"])