From ea3a68ca63ec27b35ac6b68b36c25a0d15afe9c5 Mon Sep 17 00:00:00 2001 From: Raywonder Date: Tue, 24 Feb 2026 15:35:12 -0500 Subject: [PATCH 1/5] Add per-repo auto pull/push sync with .GITHUB bootstrap integration --- GUI/main.py | 44 ++++++++ GUI/options.py | 75 +++++++++++++- GUI/view.py | 127 ++++++++++++++++++++++- application.py | 14 +++ repo_sync.py | 218 ++++++++++++++++++++++++++++++++++++++++ tests/test_repo_sync.py | 43 ++++++++ 6 files changed, 519 insertions(+), 2 deletions(-) create mode 100644 repo_sync.py create mode 100644 tests/test_repo_sync.py diff --git a/GUI/main.py b/GUI/main.py index 8ffb46c..afa2c82 100644 --- a/GUI/main.py +++ b/GUI/main.py @@ -132,6 +132,9 @@ def __init__(self, title): # Auto-refresh timer self.auto_refresh_timer = wx.Timer(self) self.Bind(wx.EVT_TIMER, self.on_auto_refresh, self.auto_refresh_timer) + self.repo_sync_timer = wx.Timer(self) + self.Bind(wx.EVT_TIMER, self.on_repo_sync_timer, self.repo_sync_timer) + self._repo_sync_running = False # Global hotkey handler self.keyboard_handler = None @@ -149,6 +152,7 @@ def __init__(self, title): # Start auto-refresh timer if enabled self.update_auto_refresh_timer() + self.update_repo_sync_timer() # Check for updates on startup if enabled if self.app.prefs.check_for_updates: @@ -722,6 +726,46 @@ def on_auto_refresh(self, event): """Handle auto-refresh timer event.""" self.refresh_all() + def update_repo_sync_timer(self): + """Start or stop repository auto-sync timer based on settings.""" + enabled = self.app.prefs.repo_sync_enabled + interval = self.app.prefs.repo_sync_interval_minutes + if enabled and interval > 0: + self.repo_sync_timer.Start(interval * 60 * 1000) + else: + self.repo_sync_timer.Stop() + + def on_repo_sync_timer(self, event): + """Handle repository auto-sync timer event.""" + self.run_repo_sync_background() + + def run_repo_sync_background(self): + """Run repository sync in background and publish short status text.""" + if self._repo_sync_running: + return + self._repo_sync_running = True + + def do_sync(): + try: + results = self.app.repo_sync.sync_all_enabled() if self.app.repo_sync else [] + if not results: + wx.CallAfter(self.status_bar.SetStatusText, "Repo sync: no enabled repositories") + return + failed = [r for r in results if not r.ok] + if failed: + wx.CallAfter( + self.status_bar.SetStatusText, + f"Repo sync: {len(results) - len(failed)}/{len(results)} succeeded", + ) + else: + wx.CallAfter(self.status_bar.SetStatusText, f"Repo sync: {len(results)} repositories synced") + except Exception as exc: + wx.CallAfter(self.status_bar.SetStatusText, f"Repo sync error: {exc}") + finally: + self._repo_sync_running = False + + threading.Thread(target=do_sync, daemon=True).start() + def show_notification(self, title: str, message: str): """Show an OS desktop notification.""" try: diff --git a/GUI/options.py b/GUI/options.py index 23a56a6..54e162d 100644 --- a/GUI/options.py +++ b/GUI/options.py @@ -24,7 +24,7 @@ def __init__(self, parent): self.app = get_app() self.parent_window = parent - wx.Dialog.__init__(self, parent, title="Options", size=(500, 740)) + wx.Dialog.__init__(self, parent, title="Options", size=(560, 860)) self.init_ui() self.bind_events() @@ -125,6 +125,49 @@ def init_ui(self): main_sizer.Add(git_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) + # Repository sync section + sync_box = wx.StaticBox(self.panel, label="Repository Sync") + sync_sizer = wx.StaticBoxSizer(sync_box, wx.VERTICAL) + + self.repo_sync_enabled_cb = wx.CheckBox( + self.panel, + label="Enable scheduled auto sync for configured repositories" + ) + sync_sizer.Add(self.repo_sync_enabled_cb, 0, wx.LEFT | wx.TOP, 10) + + interval_row = wx.BoxSizer(wx.HORIZONTAL) + interval_label = wx.StaticText(self.panel, label="Sync interval:") + interval_row.Add(interval_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 10) + self.repo_sync_interval_spin = wx.SpinCtrl( + self.panel, + min=0, + max=240, + initial=0, + style=wx.SP_ARROW_KEYS + ) + self.repo_sync_interval_spin.SetToolTip("Minutes between sync runs (0 = disabled)") + interval_row.Add(self.repo_sync_interval_spin, 0, wx.RIGHT, 5) + interval_hint = wx.StaticText(self.panel, label="minutes (0 = disabled)") + interval_row.Add(interval_hint, 0, wx.ALIGN_CENTER_VERTICAL) + sync_sizer.Add(interval_row, 0, wx.ALL, 10) + + self.repo_sync_use_tools_cb = wx.CheckBox( + self.panel, + label="Use .GITHUB repo bootstrap updater before git sync" + ) + sync_sizer.Add(self.repo_sync_use_tools_cb, 0, wx.LEFT | wx.BOTTOM, 10) + + tools_row = wx.BoxSizer(wx.HORIZONTAL) + tools_label = wx.StaticText(self.panel, label=".GITHUB tools path:") + tools_row.Add(tools_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 10) + self.repo_sync_tools_path = wx.TextCtrl(self.panel, size=(300, -1)) + tools_row.Add(self.repo_sync_tools_path, 1, wx.RIGHT, 5) + self.repo_sync_tools_browse_btn = wx.Button(self.panel, label="Brow&se...") + tools_row.Add(self.repo_sync_tools_browse_btn, 0) + sync_sizer.Add(tools_row, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.EXPAND, 10) + + main_sizer.Add(sync_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) + # Notifications section notif_box = wx.StaticBox(self.panel, label="Desktop Notifications") notif_sizer = wx.StaticBoxSizer(notif_box, wx.VERTICAL) @@ -261,6 +304,7 @@ def bind_events(self): self.apply_btn.Bind(wx.EVT_BUTTON, self.on_apply) self.browse_btn.Bind(wx.EVT_BUTTON, self.on_browse) self.git_browse_btn.Bind(wx.EVT_BUTTON, self.on_git_browse) + self.repo_sync_tools_browse_btn.Bind(wx.EVT_BUTTON, self.on_repo_sync_tools_browse) if HOTKEY_SUPPORTED: self.clear_hotkey_btn.Bind(wx.EVT_BUTTON, self.on_clear_hotkey) @@ -279,6 +323,10 @@ def load_settings(self): self.git_path.SetValue(self.app.prefs.git_path) self.git_org_structure_cb.SetValue(self.app.prefs.git_use_org_structure) self.git_recursive_cb.SetValue(self.app.prefs.git_clone_recursive) + self.repo_sync_enabled_cb.SetValue(self.app.prefs.repo_sync_enabled) + self.repo_sync_interval_spin.SetValue(self.app.prefs.repo_sync_interval_minutes) + self.repo_sync_use_tools_cb.SetValue(self.app.prefs.repo_sync_use_github_tools) + self.repo_sync_tools_path.SetValue(self.app.prefs.repo_sync_github_tools_path) # Notification settings self.notify_activity_cb.SetValue(self.app.prefs.notify_activity) @@ -311,6 +359,10 @@ def save_settings(self): self.app.prefs.git_path = self.git_path.GetValue() self.app.prefs.git_use_org_structure = self.git_org_structure_cb.GetValue() self.app.prefs.git_clone_recursive = self.git_recursive_cb.GetValue() + self.app.prefs.repo_sync_enabled = self.repo_sync_enabled_cb.GetValue() + self.app.prefs.repo_sync_interval_minutes = self.repo_sync_interval_spin.GetValue() + self.app.prefs.repo_sync_use_github_tools = self.repo_sync_use_tools_cb.GetValue() + self.app.prefs.repo_sync_github_tools_path = self.repo_sync_tools_path.GetValue().strip() # Save notification settings self.app.prefs.notify_activity = self.notify_activity_cb.GetValue() @@ -327,6 +379,9 @@ def save_settings(self): from GUI import main if main.window and hasattr(main.window, 'update_auto_refresh_timer'): main.window.update_auto_refresh_timer() + from GUI import main + if main.window and hasattr(main.window, 'update_repo_sync_timer'): + main.window.update_repo_sync_timer() # Save hotkey if supported if HOTKEY_SUPPORTED: @@ -417,6 +472,24 @@ def on_git_browse(self, event): dlg.Destroy() + def on_repo_sync_tools_browse(self, event): + """Browse for .GITHUB tools path.""" + current = self.repo_sync_tools_path.GetValue() + if not current or not os.path.isdir(current): + current = os.path.expanduser("~") + + dlg = wx.DirDialog( + self, + "Select .GITHUB Tools Path", + defaultPath=current, + style=wx.DD_DEFAULT_STYLE + ) + + if dlg.ShowModal() == wx.ID_OK: + self.repo_sync_tools_path.SetValue(dlg.GetPath()) + + dlg.Destroy() + def on_clear_hotkey(self, event): """Clear the hotkey field.""" self.hotkey_text.SetValue("") diff --git a/GUI/view.py b/GUI/view.py index 6e2aef3..91028a7 100644 --- a/GUI/view.py +++ b/GUI/view.py @@ -94,6 +94,121 @@ def finish(self, success, message=""): self.EndModal(wx.ID_OK if success else wx.ID_CANCEL) +class RepoSyncConfigDialog(wx.Dialog): + """Configure auto pull/push for a repository.""" + + def __init__(self, parent, repo: Repository, default_path: str): + self.app = get_app() + self.repo = repo + self.repo_key = repo.full_name + self.default_path = default_path + self.sync_mgr = self.app.repo_sync + + wx.Dialog.__init__(self, parent, title=f"Auto Sync: {repo.full_name}", size=(620, 320)) + self.init_ui() + self.load_config() + theme.apply_theme(self) + + def init_ui(self): + panel = wx.Panel(self) + main = wx.BoxSizer(wx.VERTICAL) + + self.enable_cb = wx.CheckBox(panel, label="Enable auto sync for this repository") + main.Add(self.enable_cb, 0, wx.ALL, 10) + + self.pull_cb = wx.CheckBox(panel, label="Auto pull (git pull --ff-only)") + main.Add(self.pull_cb, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) + + self.push_cb = wx.CheckBox(panel, label="Auto push (push only when branch is ahead and clean)") + main.Add(self.push_cb, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) + + path_row = wx.BoxSizer(wx.HORIZONTAL) + path_row.Add(wx.StaticText(panel, label="Local path:"), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 8) + self.path_txt = wx.TextCtrl(panel) + path_row.Add(self.path_txt, 1, wx.RIGHT, 6) + self.path_browse_btn = wx.Button(panel, label="Browse...") + path_row.Add(self.path_browse_btn, 0) + main.Add(path_row, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.EXPAND, 10) + + run_row = wx.BoxSizer(wx.HORIZONTAL) + self.run_sync_btn = wx.Button(panel, label="Run Sync Now") + run_row.Add(self.run_sync_btn, 0, wx.RIGHT, 6) + self.run_update_btn = wx.Button(panel, label="Run .GITHUB Repo Update") + run_row.Add(self.run_update_btn, 0) + main.Add(run_row, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) + + main.AddStretchSpacer() + + btn_row = wx.BoxSizer(wx.HORIZONTAL) + self.save_btn = wx.Button(panel, wx.ID_OK, "&Save") + self.cancel_btn = wx.Button(panel, wx.ID_CANCEL, "&Cancel") + btn_row.Add(self.save_btn, 0, wx.RIGHT, 8) + btn_row.Add(self.cancel_btn, 0) + main.Add(btn_row, 0, wx.ALL | wx.ALIGN_CENTER, 10) + + panel.SetSizer(main) + + self.path_browse_btn.Bind(wx.EVT_BUTTON, self.on_browse_path) + self.run_sync_btn.Bind(wx.EVT_BUTTON, self.on_run_sync_now) + self.run_update_btn.Bind(wx.EVT_BUTTON, self.on_run_repo_update) + self.save_btn.Bind(wx.EVT_BUTTON, self.on_save) + + def load_config(self): + cfg = self.sync_mgr.get_repo_config(self.repo_key, default_path=self.default_path) + self.enable_cb.SetValue(cfg.get("enabled", False)) + self.pull_cb.SetValue(cfg.get("auto_pull", True)) + self.push_cb.SetValue(cfg.get("auto_push", False)) + self.path_txt.SetValue(cfg.get("path", self.default_path)) + + def on_browse_path(self, event): + current = self.path_txt.GetValue().strip() + if not current or not os.path.isdir(current): + current = os.path.expanduser("~") + dlg = wx.DirDialog(self, "Select Local Repository Path", defaultPath=current, style=wx.DD_DEFAULT_STYLE) + if dlg.ShowModal() == wx.ID_OK: + self.path_txt.SetValue(dlg.GetPath()) + dlg.Destroy() + + def _current_cfg(self) -> dict: + return { + "enabled": self.enable_cb.GetValue(), + "auto_pull": self.pull_cb.GetValue(), + "auto_push": self.push_cb.GetValue(), + "path": self.path_txt.GetValue().strip(), + } + + def on_run_repo_update(self, event): + cfg = self._current_cfg() + path = cfg["path"] + if not path: + wx.MessageBox("Set a local path first.", "Missing Path", wx.OK | wx.ICON_WARNING) + return + try: + self.sync_mgr.run_repo_update(path) + wx.MessageBox("Repo update completed.", "Repo Update", wx.OK | wx.ICON_INFORMATION) + except Exception as exc: + wx.MessageBox(str(exc), "Repo Update Failed", wx.OK | wx.ICON_ERROR) + + def on_run_sync_now(self, event): + cfg = self._current_cfg() + result = self.sync_mgr.sync_one(self.repo_key, cfg) + if result.ok: + wx.MessageBox(result.message, "Repo Sync", wx.OK | wx.ICON_INFORMATION) + else: + wx.MessageBox(result.message, "Repo Sync Failed", wx.OK | wx.ICON_ERROR) + + def on_save(self, event): + cfg = self._current_cfg() + self.sync_mgr.set_repo_config( + self.repo_key, + enabled=cfg["enabled"], + auto_pull=cfg["auto_pull"], + auto_push=cfg["auto_push"], + path=cfg["path"], + ) + self.EndModal(wx.ID_OK) + + class ViewRepoDialog(wx.Dialog): """Dialog for viewing repository details.""" @@ -179,7 +294,10 @@ def init_ui(self): # Git button - will show Clone or Pull based on whether repo exists self.git_btn = wx.Button(self.panel, -1, "&Git...") - btn_row1.Add(self.git_btn, 0) + btn_row1.Add(self.git_btn, 0, wx.RIGHT, 5) + + self.repo_sync_btn = wx.Button(self.panel, -1, "Auto S&ync...") + btn_row1.Add(self.repo_sync_btn, 0) self.main_box.Add(btn_row1, 0, wx.ALL | wx.ALIGN_CENTER, 5) @@ -228,6 +346,7 @@ def bind_events(self): self.copy_url_btn.Bind(wx.EVT_BUTTON, self.on_copy_url) self.copy_clone_btn.Bind(wx.EVT_BUTTON, self.on_copy_clone) self.git_btn.Bind(wx.EVT_BUTTON, self.on_git) + self.repo_sync_btn.Bind(wx.EVT_BUTTON, self.on_repo_sync) self.files_btn.Bind(wx.EVT_BUTTON, self.on_view_files) self.issues_btn.Bind(wx.EVT_BUTTON, self.on_view_issues) self.prs_btn.Bind(wx.EVT_BUTTON, self.on_view_prs) @@ -372,6 +491,12 @@ def on_git(self, event): else: self.do_git_clone() + def on_repo_sync(self, event): + """Open auto-sync configuration for this repository.""" + dlg = RepoSyncConfigDialog(self, self.repo, self.get_repo_path()) + dlg.ShowModal() + dlg.Destroy() + def do_git_clone(self): """Clone the repository.""" git_path = self.app.prefs.git_path diff --git a/application.py b/application.py index e4e9599..0e12bfb 100644 --- a/application.py +++ b/application.py @@ -13,6 +13,7 @@ import wx import requests from version import APP_NAME, APP_SHORTNAME, APP_VERSION, APP_AUTHOR +from repo_sync import RepoSyncManager shortname = APP_SHORTNAME name = APP_NAME @@ -31,6 +32,7 @@ def __init__(self): self.confpath = "" self.errors = [] self.currentAccount = None + self.repo_sync = None self._initialized = False @classmethod @@ -102,6 +104,18 @@ def load(self): # Auto-refresh interval in minutes (0 = disabled) self.prefs.auto_refresh_interval = self.prefs.get("auto_refresh_interval", 0) + # Repository auto-sync settings + self.prefs.repo_sync_enabled = self.prefs.get("repo_sync_enabled", False) + self.prefs.repo_sync_interval_minutes = self.prefs.get("repo_sync_interval_minutes", 0) + self.prefs.repo_sync_configs = self.prefs.get("repo_sync_configs", {}) + self.prefs.repo_sync_use_github_tools = self.prefs.get("repo_sync_use_github_tools", True) + if platform.system() == "Windows": + default_tools_path = os.path.join(os.path.expanduser("~"), "dev", "apps", ".GITHUB") + else: + default_tools_path = os.path.expanduser("~/DEV/APPS/.GITHUB") + self.prefs.repo_sync_github_tools_path = self.prefs.get("repo_sync_github_tools_path", default_tools_path) + self.repo_sync = RepoSyncManager(self.prefs) + # Check for updates on startup self.prefs.check_for_updates = self.prefs.get("check_for_updates", True) diff --git a/repo_sync.py b/repo_sync.py new file mode 100644 index 0000000..20b77ff --- /dev/null +++ b/repo_sync.py @@ -0,0 +1,218 @@ +"""Repository auto-sync helpers (auto pull/push per configured repo).""" + +from __future__ import annotations + +import os +import platform +import shutil +import subprocess +from dataclasses import dataclass +from typing import Any + + +@dataclass +class RepoSyncResult: + """Outcome for one repository sync run.""" + + repo: str + ok: bool + message: str + + +class RepoSyncManager: + """Manage per-repository auto sync preferences and git operations.""" + + PREF_CONFIGS = "repo_sync_configs" + PREF_ENABLED = "repo_sync_enabled" + PREF_INTERVAL = "repo_sync_interval_minutes" + PREF_USE_GITHUB_TOOLS = "repo_sync_use_github_tools" + PREF_GITHUB_TOOLS_PATH = "repo_sync_github_tools_path" + + def __init__(self, prefs: Any): + self.prefs = prefs + self._ensure_defaults() + + def _ensure_defaults(self): + if self.prefs.get(self.PREF_CONFIGS) is None: + self._set_pref(self.PREF_CONFIGS, {}) + if self.prefs.get(self.PREF_ENABLED) is None: + self._set_pref(self.PREF_ENABLED, False) + if self.prefs.get(self.PREF_INTERVAL) is None: + self._set_pref(self.PREF_INTERVAL, 0) + if self.prefs.get(self.PREF_USE_GITHUB_TOOLS) is None: + self._set_pref(self.PREF_USE_GITHUB_TOOLS, True) + if self.prefs.get(self.PREF_GITHUB_TOOLS_PATH) is None: + if platform.system() == "Windows": + default_tools = os.path.join(os.path.expanduser("~"), "dev", "apps", ".GITHUB") + else: + default_tools = os.path.expanduser("~/DEV/APPS/.GITHUB") + self._set_pref(self.PREF_GITHUB_TOOLS_PATH, default_tools) + + def _set_pref(self, key: str, value: Any): + setattr(self.prefs, key, value) + + def _configs(self) -> dict: + data = self.prefs.get(self.PREF_CONFIGS, {}) + if hasattr(data, "_data"): + return dict(data._data) + return dict(data) + + def _save_configs(self, configs: dict): + self._set_pref(self.PREF_CONFIGS, configs) + + def get_repo_config(self, full_name: str, default_path: str = "") -> dict: + configs = self._configs() + config = configs.get(full_name, {}) + return { + "enabled": bool(config.get("enabled", False)), + "auto_pull": bool(config.get("auto_pull", True)), + "auto_push": bool(config.get("auto_push", False)), + "path": config.get("path", default_path) or default_path, + } + + def set_repo_config( + self, + full_name: str, + enabled: bool, + auto_pull: bool, + auto_push: bool, + path: str, + ): + configs = self._configs() + configs[full_name] = { + "enabled": bool(enabled), + "auto_pull": bool(auto_pull), + "auto_push": bool(auto_push), + "path": path, + } + self._save_configs(configs) + + def get_enabled_repos(self) -> list[tuple[str, dict]]: + configs = self._configs() + return [(repo, cfg) for repo, cfg in configs.items() if cfg.get("enabled")] + + def sync_all_enabled(self) -> list[RepoSyncResult]: + if not self.prefs.get(self.PREF_ENABLED, False): + return [] + results = [] + for repo, cfg in self.get_enabled_repos(): + results.append(self.sync_one(repo, cfg)) + return results + + def sync_one(self, full_name: str, cfg: dict | None = None) -> RepoSyncResult: + cfg = cfg or self.get_repo_config(full_name) + repo_path = cfg.get("path", "") + if not repo_path: + return RepoSyncResult(full_name, False, "No local path configured.") + if not os.path.isdir(repo_path): + return RepoSyncResult(full_name, False, f"Missing path: {repo_path}") + if not os.path.isdir(os.path.join(repo_path, ".git")): + return RepoSyncResult(full_name, False, f"Not a git repo: {repo_path}") + + try: + self._maybe_run_repo_update(repo_path) + self._run_git(repo_path, ["fetch", "--all", "--prune"]) + if cfg.get("auto_pull", True): + self._run_git(repo_path, ["pull", "--ff-only"]) + push_message = "push disabled" + if cfg.get("auto_push", False): + push_message = self._maybe_push(repo_path) + return RepoSyncResult(full_name, True, f"sync complete ({push_message})") + except RuntimeError as exc: + return RepoSyncResult(full_name, False, str(exc)) + + def run_repo_update(self, repo_path: str): + """Run external repo update helper for one repository path when configured.""" + self._maybe_run_repo_update(repo_path) + + def _maybe_run_repo_update(self, repo_path: str): + if not self.prefs.get(self.PREF_USE_GITHUB_TOOLS, True): + return + + tools_path = self.prefs.get(self.PREF_GITHUB_TOOLS_PATH, "") + if not tools_path or not os.path.isdir(tools_path): + return + + if platform.system() == "Windows": + batch = os.path.join(tools_path, "raywonder-repo-bootstrap", "run-repo-update.bat") + if os.path.isfile(batch): + self._run_external(["cmd", "/c", batch, repo_path], tools_path) + return + + script = os.path.join(tools_path, "raywonder-repo-bootstrap", "scripts", "pull_and_fix_repo.ps1") + if not os.path.isfile(script): + return + + shell = shutil.which("pwsh") or shutil.which("powershell") + if not shell: + return + + self._run_external([shell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script, "-RepoRoot", repo_path], tools_path) + + def _run_external(self, cmd: list[str], cwd: str): + creationflags = subprocess.CREATE_NO_WINDOW if platform.system() == "Windows" else 0 + result = subprocess.run( + cmd, + cwd=cwd, + capture_output=True, + text=True, + creationflags=creationflags, + ) + if result.returncode != 0: + err = (result.stderr or result.stdout or "").strip() + raise RuntimeError(f"Repo bootstrap failed: {' '.join(cmd)}\n{err}") + + def _run_git(self, repo_path: str, args: list[str]) -> str: + cmd = ["git"] + args + creationflags = subprocess.CREATE_NO_WINDOW if platform.system() == "Windows" else 0 + result = subprocess.run( + cmd, + cwd=repo_path, + capture_output=True, + text=True, + creationflags=creationflags, + ) + if result.returncode != 0: + err = (result.stderr or result.stdout or "").strip() + raise RuntimeError(f"{' '.join(cmd)} failed for {repo_path}\n{err}") + return (result.stdout or "").strip() + + def _has_uncommitted_changes(self, repo_path: str) -> bool: + status = self._run_git(repo_path, ["status", "--porcelain"]) + return bool(status.strip()) + + def _current_branch(self, repo_path: str) -> str: + return self._run_git(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"]).strip() + + def _has_upstream(self, repo_path: str) -> bool: + try: + self._run_git(repo_path, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]) + return True + except RuntimeError: + return False + + def _ahead_count(self, repo_path: str) -> int: + raw = self._run_git(repo_path, ["rev-list", "--count", "@{u}..HEAD"]).strip() + try: + return int(raw) + except ValueError: + return 0 + + def _maybe_push(self, repo_path: str) -> str: + if self._has_uncommitted_changes(repo_path): + return "skipped push (dirty working tree)" + + branch = self._current_branch(repo_path) + if not branch or branch == "HEAD": + return "skipped push (detached HEAD)" + + if not self._has_upstream(repo_path): + self._run_git(repo_path, ["push", "-u", "origin", branch]) + return "pushed (set upstream)" + + ahead = self._ahead_count(repo_path) + if ahead <= 0: + return "push not needed" + + self._run_git(repo_path, ["push"]) + return f"pushed ({ahead} commit(s) ahead)" diff --git a/tests/test_repo_sync.py b/tests/test_repo_sync.py new file mode 100644 index 0000000..3f1ed0a --- /dev/null +++ b/tests/test_repo_sync.py @@ -0,0 +1,43 @@ +from repo_sync import RepoSyncManager + + +class DummyPrefs(dict): + def __getattr__(self, name): + if name in self: + return self[name] + raise AttributeError(name) + + def __setattr__(self, name, value): + self[name] = value + + +def test_repo_sync_defaults_are_initialized(): + prefs = DummyPrefs() + mgr = RepoSyncManager(prefs) + assert mgr.prefs.repo_sync_enabled is False + assert mgr.prefs.repo_sync_interval_minutes == 0 + assert isinstance(mgr.prefs.repo_sync_configs, dict) + assert mgr.prefs.repo_sync_use_github_tools is True + + +def test_repo_sync_config_roundtrip(): + prefs = DummyPrefs() + mgr = RepoSyncManager(prefs) + + mgr.set_repo_config( + "Raywonder/FastGH", + enabled=True, + auto_pull=True, + auto_push=False, + path="/tmp/FastGH", + ) + + cfg = mgr.get_repo_config("Raywonder/FastGH") + assert cfg["enabled"] is True + assert cfg["auto_pull"] is True + assert cfg["auto_push"] is False + assert cfg["path"] == "/tmp/FastGH" + + enabled = mgr.get_enabled_repos() + assert len(enabled) == 1 + assert enabled[0][0] == "Raywonder/FastGH" From 41a8b564c7e39e1668c42251996fca1579656d28 Mon Sep 17 00:00:00 2001 From: Raywonder Date: Tue, 24 Feb 2026 15:43:19 -0500 Subject: [PATCH 2/5] Add manual repo sync action and scheduled sync desktop alerts --- GUI/main.py | 28 +++++++++++++++++++++------- GUI/options.py | 5 +++++ application.py | 1 + 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/GUI/main.py b/GUI/main.py index afa2c82..a782e6d 100644 --- a/GUI/main.py +++ b/GUI/main.py @@ -277,6 +277,8 @@ def init_menu(self): m_notifications = view_menu.Append(-1, self._menu_label("Notifications", "Ctrl+6"), "Show notifications") self.Bind(wx.EVT_MENU, lambda e: self.notebook.SetSelection(5), m_notifications) view_menu.AppendSeparator() + m_repo_sync_now = view_menu.Append(-1, self._menu_label("Run Repo Sync Now", "Ctrl+Shift+Y"), "Run configured repository sync now") + self.Bind(wx.EVT_MENU, self.on_repo_sync_now, m_repo_sync_now) m_mark_all_read = view_menu.Append(-1, self._menu_label("Mark All Notifications Read", "Ctrl+Shift+R"), "Mark all notifications as read") self.Bind(wx.EVT_MENU, self.on_mark_all_notifications_read, m_mark_all_read) menu_bar.Append(view_menu, "&View") @@ -737,9 +739,9 @@ def update_repo_sync_timer(self): def on_repo_sync_timer(self, event): """Handle repository auto-sync timer event.""" - self.run_repo_sync_background() + self.run_repo_sync_background(manual=False) - def run_repo_sync_background(self): + def run_repo_sync_background(self, manual=False): """Run repository sync in background and publish short status text.""" if self._repo_sync_running: return @@ -750,22 +752,34 @@ def do_sync(): results = self.app.repo_sync.sync_all_enabled() if self.app.repo_sync else [] if not results: wx.CallAfter(self.status_bar.SetStatusText, "Repo sync: no enabled repositories") + if manual and self.app.prefs.repo_sync_notify: + wx.CallAfter(self.show_notification, "Repo Sync", "No enabled repositories configured.") return failed = [r for r in results if not r.ok] if failed: - wx.CallAfter( - self.status_bar.SetStatusText, - f"Repo sync: {len(results) - len(failed)}/{len(results)} succeeded", - ) + summary = f"Repo sync: {len(results) - len(failed)}/{len(results)} succeeded" + wx.CallAfter(self.status_bar.SetStatusText, summary) + if self.app.prefs.repo_sync_notify: + first = failed[0] + wx.CallAfter(self.show_notification, "Repo Sync Errors", f"{summary}. First failure: {first.repo}") else: - wx.CallAfter(self.status_bar.SetStatusText, f"Repo sync: {len(results)} repositories synced") + summary = f"Repo sync: {len(results)} repositories synced" + wx.CallAfter(self.status_bar.SetStatusText, summary) + if self.app.prefs.repo_sync_notify and manual: + wx.CallAfter(self.show_notification, "Repo Sync Complete", summary) except Exception as exc: wx.CallAfter(self.status_bar.SetStatusText, f"Repo sync error: {exc}") + if self.app.prefs.repo_sync_notify: + wx.CallAfter(self.show_notification, "Repo Sync Error", str(exc)) finally: self._repo_sync_running = False threading.Thread(target=do_sync, daemon=True).start() + def on_repo_sync_now(self, event): + """Trigger repository sync immediately.""" + self.run_repo_sync_background(manual=True) + def show_notification(self, title: str, message: str): """Show an OS desktop notification.""" try: diff --git a/GUI/options.py b/GUI/options.py index 54e162d..d457d72 100644 --- a/GUI/options.py +++ b/GUI/options.py @@ -185,6 +185,9 @@ def init_ui(self): self.notify_watched_cb = wx.CheckBox(self.panel, label="Notify on &watched repository updates") notif_sizer.Add(self.notify_watched_cb, 0, wx.LEFT | wx.TOP, 5) + self.notify_repo_sync_cb = wx.CheckBox(self.panel, label="Notify on repository &sync results") + notif_sizer.Add(self.notify_repo_sync_cb, 0, wx.LEFT | wx.TOP, 5) + # Auto-refresh interval refresh_row = wx.BoxSizer(wx.HORIZONTAL) @@ -333,6 +336,7 @@ def load_settings(self): self.notify_notifications_cb.SetValue(self.app.prefs.notify_notifications) self.notify_starred_cb.SetValue(self.app.prefs.notify_starred) self.notify_watched_cb.SetValue(self.app.prefs.notify_watched) + self.notify_repo_sync_cb.SetValue(self.app.prefs.repo_sync_notify) self.refresh_spin.SetValue(self.app.prefs.auto_refresh_interval) if HOTKEY_SUPPORTED: @@ -369,6 +373,7 @@ def save_settings(self): self.app.prefs.notify_notifications = self.notify_notifications_cb.GetValue() self.app.prefs.notify_starred = self.notify_starred_cb.GetValue() self.app.prefs.notify_watched = self.notify_watched_cb.GetValue() + self.app.prefs.repo_sync_notify = self.notify_repo_sync_cb.GetValue() old_interval = self.app.prefs.auto_refresh_interval new_interval = self.refresh_spin.GetValue() diff --git a/application.py b/application.py index 0e12bfb..b0d3b7b 100644 --- a/application.py +++ b/application.py @@ -109,6 +109,7 @@ def load(self): self.prefs.repo_sync_interval_minutes = self.prefs.get("repo_sync_interval_minutes", 0) self.prefs.repo_sync_configs = self.prefs.get("repo_sync_configs", {}) self.prefs.repo_sync_use_github_tools = self.prefs.get("repo_sync_use_github_tools", True) + self.prefs.repo_sync_notify = self.prefs.get("repo_sync_notify", True) if platform.system() == "Windows": default_tools_path = os.path.join(os.path.expanduser("~"), "dev", "apps", ".GITHUB") else: From 98a5bc684ac64fe95604270cb00e1b3d9f6aeaae Mon Sep 17 00:00:00 2001 From: Raywonder Date: Tue, 24 Feb 2026 15:44:38 -0500 Subject: [PATCH 3/5] Add Git LFS support for clone, pull, and scheduled repo sync --- GUI/options.py | 12 ++++++++++++ GUI/view.py | 47 ++++++++++++++++++++++++++++++++++++++++++++++- application.py | 1 + repo_sync.py | 27 +++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 1 deletion(-) diff --git a/GUI/options.py b/GUI/options.py index d457d72..32882c0 100644 --- a/GUI/options.py +++ b/GUI/options.py @@ -123,6 +123,16 @@ def init_ui(self): ) git_sizer.Add(self.git_recursive_cb, 0, wx.LEFT | wx.BOTTOM, 10) + self.git_lfs_cb = wx.CheckBox( + self.panel, + label="Enable Git &LFS support for clone/pull/sync" + ) + self.git_lfs_cb.SetToolTip( + "Runs git lfs install/pull after clone/pull and during scheduled sync.\n" + "If git-lfs is not installed, operations continue with a warning." + ) + git_sizer.Add(self.git_lfs_cb, 0, wx.LEFT | wx.BOTTOM, 10) + main_sizer.Add(git_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) # Repository sync section @@ -326,6 +336,7 @@ def load_settings(self): self.git_path.SetValue(self.app.prefs.git_path) self.git_org_structure_cb.SetValue(self.app.prefs.git_use_org_structure) self.git_recursive_cb.SetValue(self.app.prefs.git_clone_recursive) + self.git_lfs_cb.SetValue(self.app.prefs.git_lfs_enabled) self.repo_sync_enabled_cb.SetValue(self.app.prefs.repo_sync_enabled) self.repo_sync_interval_spin.SetValue(self.app.prefs.repo_sync_interval_minutes) self.repo_sync_use_tools_cb.SetValue(self.app.prefs.repo_sync_use_github_tools) @@ -363,6 +374,7 @@ def save_settings(self): self.app.prefs.git_path = self.git_path.GetValue() self.app.prefs.git_use_org_structure = self.git_org_structure_cb.GetValue() self.app.prefs.git_clone_recursive = self.git_recursive_cb.GetValue() + self.app.prefs.git_lfs_enabled = self.git_lfs_cb.GetValue() self.app.prefs.repo_sync_enabled = self.repo_sync_enabled_cb.GetValue() self.app.prefs.repo_sync_interval_minutes = self.repo_sync_interval_spin.GetValue() self.app.prefs.repo_sync_use_github_tools = self.repo_sync_use_tools_cb.GetValue() diff --git a/GUI/view.py b/GUI/view.py index 91028a7..1e3b1af 100644 --- a/GUI/view.py +++ b/GUI/view.py @@ -590,7 +590,16 @@ def run_git(): final_output[0] = "".join(output_lines) success[0] = process.returncode == 0 and not progress_dlg.cancelled - if not success[0] and not progress_dlg.cancelled: + if success[0] and self.app.prefs.git_lfs_enabled and operation in ("clone", "pull"): + lfs_repo_path = self.get_repo_path() if operation == "clone" else cwd + lfs_success, lfs_msg = self._run_lfs_post_sync(lfs_repo_path) + if lfs_msg: + wx.CallAfter(progress_dlg.append_output, "\n" + lfs_msg + "\n") + if not lfs_success: + error_message[0] = lfs_msg or "git lfs operation failed" + success[0] = False + + if not success[0] and not progress_dlg.cancelled and not error_message[0]: error_message[0] = final_output[0] except FileNotFoundError: @@ -639,6 +648,42 @@ def run_git(): wx.OK | wx.ICON_ERROR ) + def _run_lfs_post_sync(self, repo_path): + """Run optional git lfs steps after clone/pull.""" + if not os.path.isdir(os.path.join(repo_path, ".git")): + return False, f"LFS skipped: repo path not found: {repo_path}" + + creationflags = subprocess.CREATE_NO_WINDOW if platform.system() == "Windows" else 0 + + def run_cmd(args): + result = subprocess.run( + ["git"] + args, + cwd=repo_path, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + creationflags=creationflags + ) + output = (result.stdout or result.stderr or "").strip() + return result.returncode, output + + code, _ = run_cmd(["lfs", "version"]) + if code != 0: + return True, "Git LFS not installed; continuing without LFS objects." + + code, out1 = run_cmd(["lfs", "install", "--local"]) + if code != 0: + return False, f"git lfs install failed:\n{out1}" + + code, out2 = run_cmd(["lfs", "pull"]) + if code != 0: + return False, f"git lfs pull failed:\n{out2}" + + msg = "Git LFS sync completed." + if out2: + msg += f"\n{out2}" + return True, msg + def on_view_files(self, event): """Open file browser dialog.""" from GUI.files import FileBrowserDialog diff --git a/application.py b/application.py index b0d3b7b..af0294e 100644 --- a/application.py +++ b/application.py @@ -94,6 +94,7 @@ def load(self): # Git clone options self.prefs.git_use_org_structure = self.prefs.get("git_use_org_structure", False) self.prefs.git_clone_recursive = self.prefs.get("git_clone_recursive", False) + self.prefs.git_lfs_enabled = self.prefs.get("git_lfs_enabled", True) # OS notification settings self.prefs.notify_activity = self.prefs.get("notify_activity", False) diff --git a/repo_sync.py b/repo_sync.py index 20b77ff..cc80775 100644 --- a/repo_sync.py +++ b/repo_sync.py @@ -27,6 +27,7 @@ class RepoSyncManager: PREF_INTERVAL = "repo_sync_interval_minutes" PREF_USE_GITHUB_TOOLS = "repo_sync_use_github_tools" PREF_GITHUB_TOOLS_PATH = "repo_sync_github_tools_path" + PREF_GIT_LFS_ENABLED = "git_lfs_enabled" def __init__(self, prefs: Any): self.prefs = prefs @@ -114,6 +115,7 @@ def sync_one(self, full_name: str, cfg: dict | None = None) -> RepoSyncResult: self._run_git(repo_path, ["fetch", "--all", "--prune"]) if cfg.get("auto_pull", True): self._run_git(repo_path, ["pull", "--ff-only"]) + self._run_lfs_sync(repo_path) push_message = "push disabled" if cfg.get("auto_push", False): push_message = self._maybe_push(repo_path) @@ -177,6 +179,31 @@ def _run_git(self, repo_path: str, args: list[str]) -> str: raise RuntimeError(f"{' '.join(cmd)} failed for {repo_path}\n{err}") return (result.stdout or "").strip() + def _run_git_allow_fail(self, repo_path: str, args: list[str]) -> tuple[bool, str]: + cmd = ["git"] + args + creationflags = subprocess.CREATE_NO_WINDOW if platform.system() == "Windows" else 0 + result = subprocess.run( + cmd, + cwd=repo_path, + capture_output=True, + text=True, + creationflags=creationflags, + ) + output = (result.stdout or result.stderr or "").strip() + return result.returncode == 0, output + + def _run_lfs_sync(self, repo_path: str): + if not self.prefs.get(self.PREF_GIT_LFS_ENABLED, True): + return + + ok, _ = self._run_git_allow_fail(repo_path, ["lfs", "version"]) + if not ok: + return + + self._run_git(repo_path, ["lfs", "install", "--local"]) + # Pull includes fetch + checkout of LFS objects. + self._run_git(repo_path, ["lfs", "pull"]) + def _has_uncommitted_changes(self, repo_path: str) -> bool: status = self._run_git(repo_path, ["status", "--porcelain"]) return bool(status.strip()) From cfb3d044b34a97eb6e8acc7b3923e6bdb11b3d6b Mon Sep 17 00:00:00 2001 From: Raywonder Date: Tue, 24 Feb 2026 19:33:09 -0500 Subject: [PATCH 4/5] chore: add Raywonder shared sync template scaffolding --- .raywonder-sync/.gitignore | 2 ++ .raywonder-sync/.local/.gitkeep | 0 .raywonder-sync/LAYOUT_STANDARD.md | 14 ++++++++++ .raywonder-sync/README.md | 20 ++++++++++++++ .raywonder-sync/apply-layout.bat | 18 +++++++++++++ .raywonder-sync/apply-layout.sh | 20 ++++++++++++++ .raywonder-sync/macos/sync-from-dotgithub.sh | 26 +++++++++++++++++++ .../windows/sync-from-dotgithub.bat | 14 ++++++++++ .raywonder-sync/wsl/sync-from-dotgithub.sh | 24 +++++++++++++++++ 9 files changed, 138 insertions(+) create mode 100644 .raywonder-sync/.gitignore create mode 100644 .raywonder-sync/.local/.gitkeep create mode 100644 .raywonder-sync/LAYOUT_STANDARD.md create mode 100644 .raywonder-sync/README.md create mode 100644 .raywonder-sync/apply-layout.bat create mode 100755 .raywonder-sync/apply-layout.sh create mode 100755 .raywonder-sync/macos/sync-from-dotgithub.sh create mode 100644 .raywonder-sync/windows/sync-from-dotgithub.bat create mode 100755 .raywonder-sync/wsl/sync-from-dotgithub.sh diff --git a/.raywonder-sync/.gitignore b/.raywonder-sync/.gitignore new file mode 100644 index 0000000..9fbbd62 --- /dev/null +++ b/.raywonder-sync/.gitignore @@ -0,0 +1,2 @@ +.local/* +!.local/.gitkeep diff --git a/.raywonder-sync/.local/.gitkeep b/.raywonder-sync/.local/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.raywonder-sync/LAYOUT_STANDARD.md b/.raywonder-sync/LAYOUT_STANDARD.md new file mode 100644 index 0000000..ff1247b --- /dev/null +++ b/.raywonder-sync/LAYOUT_STANDARD.md @@ -0,0 +1,14 @@ +# Raywonder Repo Layout Standard + +Use a simple top-level split: + +- `apps/` for user-facing apps grouped by OS. +- `servers/` for API, signal, relay, and other backend services. + +Required starter folders: + +- `apps/macos/mac-app/` +- `apps/windows/windows-app/` +- `servers/api/` +- `servers/signal/` +- `servers/windows/` (if Windows hosts server-side roles) diff --git a/.raywonder-sync/README.md b/.raywonder-sync/README.md new file mode 100644 index 0000000..69a8bdd --- /dev/null +++ b/.raywonder-sync/README.md @@ -0,0 +1,20 @@ +# Raywonder Project Sync + +This folder links this project to the shared private `.GITHUB` automation repo. + +## Purpose +- Keep this project aligned with shared Raywonder governance/workflow tooling. +- Provide per-OS entrypoints for humans and agents. +- Keep machine-specific values local-only in `.local/`. + +## Entrypoints +- Windows: `windows/sync-from-dotgithub.bat` +- macOS: `macos/sync-from-dotgithub.sh` +- WSL/Linux: `wsl/sync-from-dotgithub.sh` + +## Local-only files +- `.local/*` is intentionally ignored by git. + +## Layout helpers +- `LAYOUT_STANDARD.md` defines the OS/app/server folder standard. +- `apply-layout.sh` and `apply-layout.bat` create the standard starter folders in the repo root. diff --git a/.raywonder-sync/apply-layout.bat b/.raywonder-sync/apply-layout.bat new file mode 100644 index 0000000..ce50a7b --- /dev/null +++ b/.raywonder-sync/apply-layout.bat @@ -0,0 +1,18 @@ +@echo off +setlocal +set "REPO=%~dp0.." + +mkdir "%REPO%\apps\macos\mac-app" 2>nul +mkdir "%REPO%\apps\windows\windows-app" 2>nul +mkdir "%REPO%\servers\api" 2>nul +mkdir "%REPO%\servers\signal" 2>nul +mkdir "%REPO%\servers\windows" 2>nul + +type nul > "%REPO%\apps\macos\mac-app\.gitkeep" +type nul > "%REPO%\apps\windows\windows-app\.gitkeep" +type nul > "%REPO%\servers\api\.gitkeep" +type nul > "%REPO%\servers\signal\.gitkeep" +type nul > "%REPO%\servers\windows\.gitkeep" + +echo Layout folders ensured in: %REPO% +exit /b 0 diff --git a/.raywonder-sync/apply-layout.sh b/.raywonder-sync/apply-layout.sh new file mode 100755 index 0000000..f332ab4 --- /dev/null +++ b/.raywonder-sync/apply-layout.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +mkdir -p \ + "$REPO_ROOT/apps/macos/mac-app" \ + "$REPO_ROOT/apps/windows/windows-app" \ + "$REPO_ROOT/servers/api" \ + "$REPO_ROOT/servers/signal" \ + "$REPO_ROOT/servers/windows" + +touch \ + "$REPO_ROOT/apps/macos/mac-app/.gitkeep" \ + "$REPO_ROOT/apps/windows/windows-app/.gitkeep" \ + "$REPO_ROOT/servers/api/.gitkeep" \ + "$REPO_ROOT/servers/signal/.gitkeep" \ + "$REPO_ROOT/servers/windows/.gitkeep" + +echo "Layout folders ensured in: $REPO_ROOT" diff --git a/.raywonder-sync/macos/sync-from-dotgithub.sh b/.raywonder-sync/macos/sync-from-dotgithub.sh new file mode 100755 index 0000000..692563c --- /dev/null +++ b/.raywonder-sync/macos/sync-from-dotgithub.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +TOOLS1="$HOME/DEV/APPS/.GITHUB/raywonder-repo-bootstrap" +TOOLS2="$HOME/dev/apps/.GITHUB/raywonder-repo-bootstrap" +TOOLS="" + +if [[ -x "$TOOLS1/run-repo-bootstrap.bat" || -f "$TOOLS1/run-repo-bootstrap.bat" ]]; then + TOOLS="$TOOLS1" +elif [[ -x "$TOOLS2/run-repo-bootstrap.bat" || -f "$TOOLS2/run-repo-bootstrap.bat" ]]; then + TOOLS="$TOOLS2" +fi + +if [[ -z "$TOOLS" ]]; then + echo "Could not find raywonder-repo-bootstrap tooling." + exit 1 +fi + +# On macOS call PowerShell updater directly if available; otherwise do report-only. +PS_SCRIPT="$TOOLS/scripts/pull_and_fix_repo.ps1" +if command -v pwsh >/dev/null 2>&1; then + pwsh -NoProfile -ExecutionPolicy Bypass -File "$PS_SCRIPT" -RepoRoot "$REPO_ROOT" +else + echo "pwsh not found; skipping PowerShell repo update." +fi diff --git a/.raywonder-sync/windows/sync-from-dotgithub.bat b/.raywonder-sync/windows/sync-from-dotgithub.bat new file mode 100644 index 0000000..5386e60 --- /dev/null +++ b/.raywonder-sync/windows/sync-from-dotgithub.bat @@ -0,0 +1,14 @@ +@echo off +setlocal +set "REPO=%~dp0..\..\.." +set "TOOLS=%USERPROFILE%\git\raywonder\.github\raywonder-repo-bootstrap" +if not exist "%TOOLS%\run-repo-bootstrap.bat" set "TOOLS=%USERPROFILE%\dev\apps\.GITHUB\raywonder-repo-bootstrap" +if not exist "%TOOLS%\run-repo-bootstrap.bat" ( + echo Could not find raywonder-repo-bootstrap tooling. + echo Expected in one of: + echo %USERPROFILE%\git\raywonder\.github\raywonder-repo-bootstrap + echo %USERPROFILE%\dev\apps\.GITHUB\raywonder-repo-bootstrap + exit /b 1 +) +call "%TOOLS%\run-repo-bootstrap.bat" "%REPO%" +exit /b %errorlevel% diff --git a/.raywonder-sync/wsl/sync-from-dotgithub.sh b/.raywonder-sync/wsl/sync-from-dotgithub.sh new file mode 100755 index 0000000..37b0903 --- /dev/null +++ b/.raywonder-sync/wsl/sync-from-dotgithub.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +TOOLS1="/mnt/c/Users/$USER/git/raywonder/.github/raywonder-repo-bootstrap" +TOOLS2="/mnt/c/Users/$USER/dev/apps/.GITHUB/raywonder-repo-bootstrap" +TOOLS="" + +if [[ -f "$TOOLS1/run-repo-update.bat" ]]; then + TOOLS="$TOOLS1" +elif [[ -f "$TOOLS2/run-repo-update.bat" ]]; then + TOOLS="$TOOLS2" +fi + +if [[ -z "$TOOLS" ]]; then + echo "Could not find raywonder-repo-bootstrap tooling." + exit 1 +fi + +if command -v powershell.exe >/dev/null 2>&1; then + powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$(wslpath -w "$TOOLS/scripts/pull_and_fix_repo.ps1")" -RepoRoot "$(wslpath -w "$REPO_ROOT")" +else + echo "powershell.exe not found in WSL PATH; skipping update." +fi From e7dc03ec17284b5fa9838d2d1e60a3aac98a1e91 Mon Sep 17 00:00:00 2001 From: Raywonder Date: Sat, 7 Mar 2026 14:01:37 -0500 Subject: [PATCH 5/5] build: add explicit macOS target arch support for Intel/Sequoia --- build.py | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/build.py b/build.py index 783bd10..deb8501 100644 --- a/build.py +++ b/build.py @@ -6,6 +6,7 @@ import sys import shutil import tempfile +import argparse import platform as platform_mod from pathlib import Path @@ -241,7 +242,7 @@ def create_windows_zip(output_dir: Path, app_dir: Path) -> Path: return zip_path -def build_macos(script_dir: Path, output_dir: Path) -> tuple: +def build_macos(script_dir: Path, output_dir: Path, target_arch: str = "native") -> tuple: """Build for macOS using PyInstaller. Returns: @@ -279,6 +280,9 @@ def build_macos(script_dir: Path, output_dir: Path) -> tuple: f"--osx-bundle-identifier={bundle_id}", ] + if target_arch != "native": + cmd.extend(["--target-arch", target_arch]) + # Add hidden imports for imp in get_hidden_imports(): cmd.extend(["--hidden-import", imp]) @@ -293,7 +297,8 @@ def build_macos(script_dir: Path, output_dir: Path) -> tuple: # Add main script cmd.append(str(main_script)) - print(f"Building {APP_NAME} v{APP_VERSION} for macOS...") + arch_label = platform_mod.machine() if target_arch == "native" else target_arch + print(f"Building {APP_NAME} v{APP_VERSION} for macOS ({arch_label})...") print(f"Output: {output_dir}") print() @@ -337,7 +342,7 @@ def build_macos(script_dir: Path, output_dir: Path) -> tuple: sign_macos_app(app_path) # Create DMG - dmg_path = create_macos_dmg(output_dir, app_path) + dmg_path = create_macos_dmg(output_dir, app_path, target_arch=target_arch) return True, dmg_path @@ -361,9 +366,10 @@ def sign_macos_app(app_path: Path): print(f"Code signing warning: {result.stderr}") -def create_macos_dmg(output_dir: Path, app_path: Path) -> Path: +def create_macos_dmg(output_dir: Path, app_path: Path, target_arch: str = "native") -> Path: """Create a DMG disk image for macOS distribution.""" - dmg_name = f"{APP_NAME}-{APP_VERSION}.dmg" + arch_suffix = "" if target_arch == "native" else f"-{target_arch}" + dmg_name = f"{APP_NAME}-{APP_VERSION}{arch_suffix}.dmg" dmg_path = output_dir / dmg_name if dmg_path.exists(): @@ -408,12 +414,26 @@ def create_macos_dmg(output_dir: Path, app_path: Path) -> Path: def main(): """Build FastGH executable using PyInstaller.""" + parser = argparse.ArgumentParser(description="Build FastGH desktop artifacts") + parser.add_argument( + "--output-dir", + default=str(Path.home() / "app_dist" / APP_NAME), + help="Output directory for build artifacts (default: ~/app_dist/FastGH)" + ) + parser.add_argument( + "--mac-arch", + choices=["native", "x86_64", "arm64", "universal2"], + default="native", + help="macOS target architecture for PyInstaller build (default: native)" + ) + args = parser.parse_args() + script_dir = Path(__file__).parent.resolve() platform = get_platform() print(f"Detected platform: {platform}") - output_dir = Path.home() / "app_dist" / APP_NAME + output_dir = Path(args.output_dir).expanduser().resolve() print(f"Building {APP_NAME} v{APP_VERSION} with PyInstaller...") print(f"Output: {output_dir}") @@ -422,7 +442,7 @@ def main(): if platform == "windows": success, artifact_path = build_windows(script_dir, output_dir) elif platform == "macos": - success, artifact_path = build_macos(script_dir, output_dir) + success, artifact_path = build_macos(script_dir, output_dir, target_arch=args.mac_arch) else: print(f"Unsupported platform: {platform}") sys.exit(1)