🔀 merge(sync): reconcile plum's master branch into main

Both hosts had drifted since 2026-04-17 (merge-base a905acc): apricot's
checkout tracked origin/main (the canonical dev line, +23 commits) while
plum's tracked origin/master (+2 commits). The Forgejo hub only reconciles
same-branch, so the two diverged silently and permanently.

This merge brings master's two unique commits onto main:
- 0d9d2e8 feat: tray-side ignore_repos support (app.py/local_agent.py/local_git.py)
  — main only had daemon/CLI-side ignore support; the tray lacked it.
- 22cd4d0 fix: git_branch default master→main — main still hardcoded "master".

After this, plum is repointed to main and master is retired so both hosts
track the same branch and the hub keeps them in sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-10 02:13:52 -07:00
commit 2280d37bbc
5 changed files with 105 additions and 6 deletions

View file

@ -12,6 +12,8 @@ Usage:
"""
import argparse
import json
import logging
import os
import sys
from pathlib import Path
@ -22,6 +24,45 @@ _tray_dir = os.path.join(_script_dir, "src", "auto_commit_service", "tray")
sys.path.insert(0, _tray_dir)
def _load_ignore_repos_from_config() -> list[str]:
"""Read ignore_repos from ~/.config/commits/startup-config.json.
Mirrors the daemon's config loader (see auto_commit_service.__main__).
Supports either a top-level `ignore_repos` array (shared) or a per-daemon
entry whose `directory` matches the current working dir. Missing or
unreadable files yield an empty list — never raises.
"""
config_path = Path.home() / ".config" / "commits" / "startup-config.json"
if not config_path.exists():
return []
try:
data = json.loads(config_path.read_text())
except (OSError, json.JSONDecodeError) as exc:
logging.warning(f"commits-tray: failed to read {config_path}: {exc}")
return []
ignore: list[str] = []
if isinstance(data.get("ignore_repos"), list):
ignore.extend(str(p) for p in data["ignore_repos"] if p)
cwd = str(Path.cwd())
for daemon in data.get("daemons", []) or []:
if isinstance(daemon, dict) and daemon.get("directory") == cwd:
entries = daemon.get("ignore_repos")
if isinstance(entries, list):
ignore.extend(str(p) for p in entries if p)
break
# De-dup while preserving order
seen: set[str] = set()
deduped: list[str] = []
for p in ignore:
if p not in seen:
seen.add(p)
deduped.append(p)
return deduped
def main():
parser = argparse.ArgumentParser(description="ACS menu bar app + local commit agent")
parser.add_argument(
@ -59,8 +100,21 @@ def main():
default=131072,
help="Per-repo diff size cap in bytes before truncation (default: 131072)",
)
parser.add_argument(
"--ignore-repo",
action="append",
default=[],
dest="ignore_repos",
help=(
"Substring matched against absolute repo paths; matching repos "
"are excluded from discovery. Repeat for multiple. Merged with "
"ignore_repos from ~/.config/commits/startup-config.json."
),
)
args = parser.parse_args()
ignore_repos = list(_load_ignore_repos_from_config()) + list(args.ignore_repos or [])
# Import after argparse so --help exits cleanly before rumps is required
from app import run_tray # noqa: E402
@ -72,6 +126,7 @@ def main():
dry_run=args.dry_run,
max_diff_bytes=args.max_diff_bytes,
cycle_seconds=args.cycle,
ignore_repos=ignore_repos,
)

View file

@ -105,7 +105,7 @@ class AutoCommitSettings(BaseServiceSettings): # type: ignore[misc]
# Git settings
git_remote: str = Field(default="origin", description="Git remote to push to")
git_branch: str = Field(default="master", description="Git branch to push to")
git_branch: str = Field(default="main", description="Git branch to push to")
co_authors: list[str] = Field(
default=["Lilith Autocommit <noreply@atlilith.com>"],
description="Co-author trailers appended to every commit message",

View file

@ -52,6 +52,7 @@ class CommitsTrayApp(rumps.App):
commit_local: bool = True,
dry_run: bool = False,
max_diff_bytes: int = 131072,
ignore_repos: list[str] | None = None,
):
super().__init__(
name="Commits",
@ -62,6 +63,7 @@ class CommitsTrayApp(rumps.App):
self._daemon_url = daemon_url
self._repos_paths = repos_paths or [Path.home() / "Code"]
self._commit_local = commit_local
self._ignore_repos = list(ignore_repos or [])
# Remote daemon client (for status + LLM health checks)
self.client = DaemonClient(base_url=daemon_url)
@ -73,6 +75,7 @@ class CommitsTrayApp(rumps.App):
cycle_seconds=cycle_seconds,
dry_run=dry_run,
max_diff_bytes=max_diff_bytes,
ignore_repos=self._ignore_repos,
)
# For local commit history display
@ -234,7 +237,11 @@ class CommitsTrayApp(rumps.App):
def _update_commits_menu(self) -> None:
"""Refresh the recent commits submenu from local git repos."""
if not self._repos_discovered:
self._local_repos = discover_repos(self._repos_paths, max_depth=4)
self._local_repos = discover_repos(
self._repos_paths,
max_depth=4,
ignore_repos=self._ignore_repos,
)
self._repos_discovered = True
if not self._local_repos:
@ -322,6 +329,7 @@ def run_tray(
commit_local: bool = True,
dry_run: bool = False,
max_diff_bytes: int = 131072,
ignore_repos: list[str] | None = None,
) -> None:
"""Launch the menu bar application with local commit agent.
@ -333,6 +341,8 @@ def run_tray(
Pass False to run tray in monitor-only mode (no local commits).
dry_run: If True, scan and generate messages but skip git commit/push.
max_diff_bytes: Per-repo diff size cap before truncation.
ignore_repos: Substrings matched against absolute repo paths; any
matching repo is excluded from discovery and never scanned.
"""
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s")
app = CommitsTrayApp(
@ -342,5 +352,6 @@ def run_tray(
commit_local=commit_local,
dry_run=dry_run,
max_diff_bytes=max_diff_bytes,
ignore_repos=ignore_repos,
)
app.run()

View file

@ -82,6 +82,7 @@ class LocalCommitAgent:
recovery_state_path: Path | None = None,
dry_run: bool = False,
max_diff_bytes: int = 131072,
ignore_repos: list[str] | None = None,
):
self.acs_url = acs_url.rstrip("/")
self.repos_paths = repos_paths
@ -89,6 +90,7 @@ class LocalCommitAgent:
self.co_author = co_author
self.dry_run = dry_run
self.max_diff_bytes = max_diff_bytes
self.ignore_repos = list(ignore_repos or [])
self._client = httpx.Client(base_url=self.acs_url, timeout=30.0)
self._running = False
@ -171,8 +173,15 @@ class LocalCommitAgent:
if self._running:
return
self._stop_event.clear()
self._repos = discover_repos(self.repos_paths, max_depth=4)
logger.info(f"Local agent starting: {len(self._repos)} repos, cycle={self.cycle_seconds}s")
self._repos = discover_repos(
self.repos_paths,
max_depth=4,
ignore_repos=self.ignore_repos,
)
logger.info(
f"Local agent starting: {len(self._repos)} repos, "
f"cycle={self.cycle_seconds}s, ignore_repos={self.ignore_repos}"
)
self._running = True
self._thread = threading.Thread(target=self._loop, daemon=True)
self._thread.start()

View file

@ -43,8 +43,21 @@ def _is_usable_repo(path: Path) -> bool:
return dot_git.is_file() # gitfile (worktree/submodule)
def discover_repos(base_paths: list[Path], max_depth: int = 4) -> list[Path]:
"""Find git repositories under the given base paths."""
def discover_repos(
base_paths: list[Path],
max_depth: int = 4,
ignore_repos: list[str] | None = None,
) -> list[Path]:
"""Find git repositories under the given base paths.
Args:
base_paths: Directories to walk.
max_depth: Maximum recursion depth.
ignore_repos: Substrings matched against the absolute repo path; any
repo whose path contains one of these strings is excluded. Mirrors
the daemon's `git/discovery.py` semantics so the same
startup-config entries work for both surfaces.
"""
repos: list[Path] = []
for base in base_paths:
@ -52,6 +65,17 @@ def discover_repos(base_paths: list[Path], max_depth: int = 4) -> list[Path]:
continue
_walk_for_repos(base, repos, depth=0, max_depth=max_depth)
if ignore_repos:
patterns = [p for p in ignore_repos if p]
if patterns:
filtered: list[Path] = []
for repo in repos:
repo_str = str(repo)
if any(pattern in repo_str for pattern in patterns):
continue
filtered.append(repo)
return filtered
return repos