Files
dualfroz 4ae3a496af refactor: extract the shared foundation into an installable library
The single file extension carried 260 lines of infrastructure under its
four Nemo providers: the config file with live reload, the git query
layer, the TTL cache with .git watching, and repository detection. Any
second extension wanting repository awareness had to repeat all of it.

Move that foundation into a nemoRepoCore package with a deliberate plugin
API rather than whatever the providers happened to leave behind:

  * Config gains layered defaults, so a plugin registers its own keys and
    an empty config file is still a working one
  * RepoInfo gains a root, a named last commit tuple and a summary string
  * RepoCache gains refresh, invalidate and an invalidation callback
  * discovery separates the cheap root check used on every drawn folder
    from the upward walk used by on demand work such as menus
  * nemoFiles collapses the uri scheme and directory guards every
    provider was repeating
  * launchers learn the per emulator flag for running a command
  * version negotiation lets a plugin state the core it was built for

The config file moves to nemo-repo-core.json and is seeded from the old
nemo-repo-emblems.json when one exists, so no key changes name and
nothing needs editing by hand.
2026-08-05 20:06:41 +02:00

95 lines
2.9 KiB
Python

"""Everything that shells out to git lives here, and nowhere else."""
import os
import shutil
import subprocess
from .logs import pluginLogger
from .RepoInfo import LastCommit, RepoInfo
logger = pluginLogger("git")
gitBinary = shutil.which("git")
defaultTimeoutSeconds = 3
def runGit(arguments, cwd, timeoutSeconds=defaultTimeoutSeconds):
"""Run git and return stdout as text, or None when it could not run.
Never raises and never blocks Nemo for long: stderr is discarded, index
locks are avoided via GIT_OPTIONAL_LOCKS, and the call is capped by a
timeout. A None result always means "no answer", never "empty answer".
"""
if not gitBinary:
return None
environment = dict(os.environ)
environment["GIT_OPTIONAL_LOCKS"] = "0"
try:
completed = subprocess.run(
[gitBinary] + list(arguments),
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
env=environment,
timeout=timeoutSeconds,
)
except (subprocess.TimeoutExpired, OSError) as error:
logger.debug("git %s failed in %s: %s", " ".join(arguments), cwd, error)
return None
if completed.returncode != 0:
return None
return completed.stdout.decode("utf-8", "replace")
def _applyStatusLine(info, line):
if line.startswith("# branch.head "):
info.branch = line.split(" ", 2)[2]
elif line.startswith("# branch.upstream "):
info.upstream = line.split(" ", 2)[2]
info.hasUpstream = True
elif line.startswith("# branch.ab "):
for token in line.split(" ")[2:]:
if token.startswith("+"):
info.ahead = int(token[1:])
elif token.startswith("-"):
info.behind = int(token[1:])
elif line.startswith("1 ") or line.startswith("2 "):
indexState, worktreeState = line.split(" ", 2)[1][:2]
if indexState != ".":
info.staged += 1
if worktreeState != ".":
info.unstaged += 1
elif line.startswith("u "):
info.staged += 1
elif line.startswith("? "):
info.untracked += 1
def computeRepoInfo(repoRoot):
"""Build a fresh RepoInfo for `repoRoot`, bypassing any cache.
Plugins should normally call repoCache.get() instead; this is the
uncached primitive behind it.
"""
info = RepoInfo(repoRoot)
if not gitBinary:
info.error = "git not found in PATH"
return info
status = runGit(["status", "--porcelain=2", "--branch"], repoRoot)
if status is None:
info.error = "git status failed"
return info
for line in status.splitlines():
_applyStatusLine(info, line)
logLine = runGit(["log", "-1", "--format=%h%x09%an%x09%ar%x09%s"], repoRoot)
if logLine:
fields = logLine.strip("\n").split("\t")
if len(fields) == 4:
info.lastCommit = LastCommit(*fields)
return info