diff --git a/smart-lan-router/smart-lan-router.py b/smart-lan-router/smart-lan-router.py index d35dffe..923f6e4 100755 --- a/smart-lan-router/smart-lan-router.py +++ b/smart-lan-router/smart-lan-router.py @@ -322,6 +322,48 @@ def enforce_hostname(name: str) -> None: # Pull — propagate declared truth + this agent's own code # --------------------------------------------------------------------------- +def _heal_pull_blockers(as_owner, repo_root: str, err: str) -> bool: + """git refuses an ff merge over working files it would touch — even when + their content is byte-identical to the incoming commit (e.g. a tree that + was bridged by rsync before the same files landed upstream). Clearing only + exact matches is lossless: untracked twins are removed (the merge recreates + them verbatim), tracked edits matching the target are reset to HEAD (the + merge fast-forwards them straight back). Any file that differs stays put + and keeps blocking — real local work is never destroyed.""" + paths: list[str] = [] + collect = False + for line in err.splitlines(): + if "would be overwritten by" in line: + collect = True + elif collect and line.startswith(("\t", " ")): + paths.append(line.strip()) + else: + collect = False + if not paths: + return False + healed = 0 + for p in paths: + rc, want, _ = as_owner(["rev-parse", f"@{{u}}:{p}"], 15) + if rc != 0: + return False + rc, have, _ = as_owner(["hash-object", "--", p], 15) + if rc != 0 or have.strip() != want.strip(): + logger.warning("pull blocked by %s which differs from upstream — leaving it", p) + return False + if as_owner(["ls-files", "--error-unmatch", "--", p], 15)[0] == 0: + if as_owner(["checkout", "--", p], 15)[0] != 0: + return False + else: + try: + os.unlink(os.path.join(repo_root, p)) + except OSError as exc: + logger.warning("pull self-heal: cannot remove %s: %s", p, exc) + return False + healed += 1 + logger.info("pull self-heal: cleared %d file(s) byte-identical to upstream", healed) + return True + + def git_pull(repo_root: str, ctx: dict) -> bool: """ff-only pull as the REPO OWNER (root-owned .git objects would break the autocommit service). Returns True iff HEAD moved (caller exits to restart).""" @@ -343,7 +385,10 @@ def git_pull(repo_root: str, ctx: dict) -> bool: rc, before, _ = as_owner(["rev-parse", "HEAD"], 15) if rc != 0: return False - rc, _, err = as_owner(["pull", "--ff-only", "--quiet"]) + for _ in range(3): # untracked + tracked blockers can surface in separate aborts + rc, _, err = as_owner(["pull", "--ff-only", "--quiet"]) + if rc == 0 or not _heal_pull_blockers(as_owner, repo_root, err): + break if rc != 0: logger.warning("git pull failed (keeping current code/config): %s", err.strip()[:200]) return False