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.
This commit is contained in:
1 parent
d81b502300
commit
4ae3a496af
19 files changed
+871
-708
No files matched your search
@@ -0,0 +1,187 @@
|
||||
"""The single JSON config file shared by the core and all of its plugins.
|
||||
|
||||
Layout on disk (every key optional, plugins fill in their own defaults):
|
||||
|
||||
{
|
||||
"cache_ttl_seconds": 5,
|
||||
"editor_command": "code",
|
||||
"terminal_command": null,
|
||||
"log_level": "warning",
|
||||
"indicators": { ... }, // owned by whichever plugin registers them
|
||||
"actions": { ... }
|
||||
}
|
||||
|
||||
Keys use the JSON spelling they have always had, so a config written for the
|
||||
pre-split single-file extension keeps working unchanged.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
from .logs import applyLogLevel, pluginLogger
|
||||
|
||||
logger = pluginLogger("config")
|
||||
|
||||
configFileName = "nemo-repo-core.json"
|
||||
legacyConfigFileName = "nemo-repo-emblems.json"
|
||||
|
||||
coreDefaults = {
|
||||
"cache_ttl_seconds": 5,
|
||||
"editor_command": "code",
|
||||
"terminal_command": None,
|
||||
"log_level": "warning",
|
||||
}
|
||||
|
||||
|
||||
def configDirectory():
|
||||
return os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
|
||||
|
||||
|
||||
def defaultConfigPath():
|
||||
override = os.environ.get("NEMO_REPO_CORE_CONFIG")
|
||||
if override:
|
||||
return os.path.expanduser(override)
|
||||
return os.path.join(configDirectory(), configFileName)
|
||||
|
||||
|
||||
def legacyConfigPath():
|
||||
return os.path.join(configDirectory(), legacyConfigFileName)
|
||||
|
||||
|
||||
def mergeDictionaries(base, overlay):
|
||||
"""Recursive merge; `overlay` wins, dictionaries merge instead of replace."""
|
||||
merged = copy.deepcopy(base)
|
||||
for key, value in overlay.items():
|
||||
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
||||
merged[key] = mergeDictionaries(merged[key], value)
|
||||
else:
|
||||
merged[key] = copy.deepcopy(value)
|
||||
return merged
|
||||
|
||||
|
||||
class Config:
|
||||
"""Live view of the config file, reloaded whenever the file changes.
|
||||
|
||||
Effective values are the merge of three layers, later ones winning:
|
||||
core defaults, defaults registered by plugins, then the user's file.
|
||||
"""
|
||||
|
||||
def __init__(self, path=None):
|
||||
self.path = path or defaultConfigPath()
|
||||
self.defaults = copy.deepcopy(coreDefaults)
|
||||
self.userData = {}
|
||||
self.data = copy.deepcopy(self.defaults)
|
||||
self._changeCallbacks = []
|
||||
self._monitor = None
|
||||
self._createFileIfMissing()
|
||||
self.reload()
|
||||
self._watchFile()
|
||||
|
||||
# -- layers ------------------------------------------------------------
|
||||
|
||||
def registerDefaults(self, defaults):
|
||||
"""Add a plugin's default block, then recompute effective values.
|
||||
|
||||
Safe to call from several plugins; blocks are deep-merged, so two
|
||||
plugins can both contribute keys to the same section.
|
||||
"""
|
||||
self.defaults = mergeDictionaries(self.defaults, defaults)
|
||||
self._recompute()
|
||||
|
||||
def _recompute(self):
|
||||
self.data = mergeDictionaries(self.defaults, self.userData)
|
||||
applyLogLevel(self.data.get("log_level", "warning"))
|
||||
|
||||
# -- reading -----------------------------------------------------------
|
||||
|
||||
def section(self, name):
|
||||
"""Merged sub-object for `name`, or an empty dict when absent."""
|
||||
value = self.data.get(name)
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
def value(self, name, fallback=None):
|
||||
current = self.data.get(name)
|
||||
return fallback if current is None else current
|
||||
|
||||
def flag(self, name, fallback=False):
|
||||
return bool(self.data.get(name, fallback))
|
||||
|
||||
def sectionFlag(self, sectionName, key, fallback=False):
|
||||
entry = self.section(sectionName).get(key, fallback)
|
||||
if isinstance(entry, dict):
|
||||
return bool(entry.get("enabled", fallback))
|
||||
return bool(entry)
|
||||
|
||||
@property
|
||||
def ttlSeconds(self):
|
||||
try:
|
||||
return max(0.0, float(self.data.get("cache_ttl_seconds", 5)))
|
||||
except (TypeError, ValueError):
|
||||
return 5.0
|
||||
|
||||
@property
|
||||
def editorCommand(self):
|
||||
return self.data.get("editor_command") or "code"
|
||||
|
||||
@property
|
||||
def terminalCommand(self):
|
||||
return self.data.get("terminal_command")
|
||||
|
||||
# -- change notification ------------------------------------------------
|
||||
|
||||
def onChange(self, callback):
|
||||
"""Register `callback()` to run after every successful reload."""
|
||||
self._changeCallbacks.append(callback)
|
||||
|
||||
# -- file handling -------------------------------------------------------
|
||||
|
||||
def _createFileIfMissing(self):
|
||||
if os.path.exists(self.path):
|
||||
return
|
||||
directory = os.path.dirname(self.path)
|
||||
try:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
legacy = legacyConfigPath()
|
||||
if os.path.isfile(legacy):
|
||||
shutil.copyfile(legacy, self.path)
|
||||
logger.info("Migrated config from %s to %s", legacy, self.path)
|
||||
return
|
||||
with open(self.path, "w") as handle:
|
||||
json.dump(coreDefaults, handle, indent=2)
|
||||
handle.write("\n")
|
||||
except OSError as error:
|
||||
logger.warning("Could not create %s: %s", self.path, error)
|
||||
|
||||
def reload(self):
|
||||
try:
|
||||
with open(self.path) as handle:
|
||||
loaded = json.load(handle)
|
||||
except FileNotFoundError:
|
||||
loaded = {}
|
||||
except (OSError, ValueError) as error:
|
||||
logger.warning("Ignoring unreadable config %s: %s", self.path, error)
|
||||
return
|
||||
self.userData = loaded if isinstance(loaded, dict) else {}
|
||||
self._recompute()
|
||||
for callback in list(self._changeCallbacks):
|
||||
callback()
|
||||
|
||||
def _watchFile(self):
|
||||
try:
|
||||
gfile = Gio.File.new_for_path(self.path)
|
||||
self._monitor = gfile.monitor_file(Gio.FileMonitorFlags.NONE, None)
|
||||
self._monitor.connect("changed", self._onFileChanged)
|
||||
except GLib.Error as error:
|
||||
logger.warning("Config file will not live-reload: %s", error)
|
||||
|
||||
def _onFileChanged(self, monitor, gfile, otherFile, eventType):
|
||||
if eventType in (Gio.FileMonitorEvent.CHANGES_DONE_HINT,
|
||||
Gio.FileMonitorEvent.CREATED):
|
||||
self.reload()
|
||||
|
||||
|
||||
coreConfig = Config()
|
||||
@@ -0,0 +1,110 @@
|
||||
"""TTL cache in front of git, invalidated eagerly by watching .git.
|
||||
|
||||
Without this, Nemo would fork a git process for every folder it draws, on
|
||||
every redraw. With it, a repository is queried at most once per TTL, and the
|
||||
entry is dropped the moment .git changes so a commit or checkout shows up
|
||||
immediately instead of after the TTL.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import weakref
|
||||
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
from .Config import coreConfig
|
||||
from .gitQuery import computeRepoInfo
|
||||
from .logs import pluginLogger
|
||||
|
||||
logger = pluginLogger("cache")
|
||||
|
||||
|
||||
class RepoCache:
|
||||
"""Keyed by repository root path. Shared by every plugin in the family."""
|
||||
|
||||
def __init__(self, ttlProvider):
|
||||
self._ttlProvider = ttlProvider
|
||||
self._entries = {}
|
||||
self._monitors = {}
|
||||
self._trackedFiles = {}
|
||||
self._invalidateCallbacks = []
|
||||
|
||||
def get(self, repoRoot):
|
||||
"""Cached RepoInfo for `repoRoot`, computing it when stale or absent."""
|
||||
now = time.monotonic()
|
||||
entry = self._entries.get(repoRoot)
|
||||
if entry is not None:
|
||||
info, storedAt = entry
|
||||
if (now - storedAt) < self._ttlProvider():
|
||||
return info
|
||||
return self.refresh(repoRoot)
|
||||
|
||||
def refresh(self, repoRoot):
|
||||
"""Recompute now, ignoring the TTL, and store the result."""
|
||||
info = computeRepoInfo(repoRoot)
|
||||
self._entries[repoRoot] = (info, time.monotonic())
|
||||
self._ensureWatch(repoRoot)
|
||||
return info
|
||||
|
||||
def invalidate(self, repoRoot=None):
|
||||
"""Drop one entry, or the whole cache when called without argument."""
|
||||
if repoRoot is None:
|
||||
roots = list(self._entries)
|
||||
self._entries.clear()
|
||||
else:
|
||||
roots = [repoRoot] if self._entries.pop(repoRoot, None) else []
|
||||
for root in roots:
|
||||
self._notify(root)
|
||||
|
||||
def registerFileObject(self, repoRoot, nemoFile):
|
||||
"""Track a live Nemo.FileInfo so it can be redrawn on git changes.
|
||||
|
||||
References are weak: Nemo owns the object's lifetime, we only ask it
|
||||
to re-query us when the repository underneath it moves.
|
||||
"""
|
||||
tracked = self._trackedFiles.setdefault(repoRoot, weakref.WeakSet())
|
||||
try:
|
||||
tracked.add(nemoFile)
|
||||
except TypeError:
|
||||
logger.debug("Object for %s is not weak-referenceable", repoRoot)
|
||||
|
||||
def onInvalidate(self, callback):
|
||||
"""Register `callback(repoRoot)`, run whenever an entry is dropped."""
|
||||
self._invalidateCallbacks.append(callback)
|
||||
|
||||
# -- internals ----------------------------------------------------------
|
||||
|
||||
def _notify(self, repoRoot):
|
||||
for nemoFile in list(self._trackedFiles.get(repoRoot, ())):
|
||||
nemoFile.invalidate_extension_info()
|
||||
for callback in list(self._invalidateCallbacks):
|
||||
callback(repoRoot)
|
||||
|
||||
def _ensureWatch(self, repoRoot):
|
||||
if repoRoot in self._monitors:
|
||||
return
|
||||
gitDirectory = os.path.join(repoRoot, ".git")
|
||||
if not os.path.isdir(gitDirectory):
|
||||
return
|
||||
|
||||
monitors = []
|
||||
for path in (gitDirectory, os.path.join(gitDirectory, "refs", "heads")):
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
try:
|
||||
gfile = Gio.File.new_for_path(path)
|
||||
monitor = gfile.monitor_directory(Gio.FileMonitorFlags.NONE, None)
|
||||
except GLib.Error as error:
|
||||
logger.debug("Cannot watch %s: %s", path, error)
|
||||
continue
|
||||
monitor.connect("changed", self._onGitDirectoryChanged, repoRoot)
|
||||
monitors.append(monitor)
|
||||
if monitors:
|
||||
self._monitors[repoRoot] = monitors
|
||||
|
||||
def _onGitDirectoryChanged(self, monitor, gfile, otherFile, eventType, repoRoot):
|
||||
self._entries.pop(repoRoot, None)
|
||||
self._notify(repoRoot)
|
||||
|
||||
|
||||
repoCache = RepoCache(lambda: coreConfig.ttlSeconds)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""The snapshot of a repository that every plugin reads."""
|
||||
|
||||
from collections import namedtuple
|
||||
|
||||
LastCommit = namedtuple("LastCommit", "hash author relativeDate subject")
|
||||
|
||||
|
||||
class RepoInfo:
|
||||
"""Everything one `git status` and one `git log -1` can tell us.
|
||||
|
||||
Instances are immutable in practice: the cache hands the same object to
|
||||
every caller for the lifetime of a cache entry, so plugins must treat
|
||||
the fields as read-only.
|
||||
|
||||
When `error` is set, every other field is at its zero value and must not
|
||||
be displayed as fact.
|
||||
"""
|
||||
|
||||
__slots__ = ("root", "branch", "upstream", "ahead", "behind", "hasUpstream",
|
||||
"staged", "unstaged", "untracked", "lastCommit", "error")
|
||||
|
||||
def __init__(self, root=None):
|
||||
self.root = root
|
||||
self.branch = None
|
||||
self.upstream = None
|
||||
self.ahead = 0
|
||||
self.behind = 0
|
||||
self.hasUpstream = False
|
||||
self.staged = 0
|
||||
self.unstaged = 0
|
||||
self.untracked = 0
|
||||
self.lastCommit = None
|
||||
self.error = None
|
||||
|
||||
@property
|
||||
def dirty(self):
|
||||
return (self.staged + self.unstaged + self.untracked) > 0
|
||||
|
||||
@property
|
||||
def unpushed(self):
|
||||
"""Commits the remote does not have, including "there is no remote"."""
|
||||
return self.ahead > 0 or not self.hasUpstream
|
||||
|
||||
@property
|
||||
def detached(self):
|
||||
return self.branch in (None, "(detached)")
|
||||
|
||||
@property
|
||||
def branchLabel(self):
|
||||
return self.branch or "(detached)"
|
||||
|
||||
@property
|
||||
def summary(self):
|
||||
"""One ASCII line combining sync state and working tree state."""
|
||||
if self.error:
|
||||
return self.error
|
||||
parts = []
|
||||
if self.ahead:
|
||||
parts.append("%d ahead" % self.ahead)
|
||||
if self.behind:
|
||||
parts.append("%d behind" % self.behind)
|
||||
if not self.hasUpstream:
|
||||
parts.append("no upstream")
|
||||
treeParts = []
|
||||
if self.staged or self.unstaged:
|
||||
treeParts.append("%d changed" % (self.staged + self.unstaged))
|
||||
if self.untracked:
|
||||
treeParts.append("%d untracked" % self.untracked)
|
||||
if treeParts:
|
||||
parts.append(", ".join(treeParts))
|
||||
return ", ".join(parts) if parts else "clean"
|
||||
|
||||
def __repr__(self):
|
||||
return "<RepoInfo %s %s: %s>" % (self.root, self.branchLabel, self.summary)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""nemo-repo-core: the shared foundation for repository-aware Nemo plugins.
|
||||
|
||||
A plugin is a single .py file in ~/.local/share/nemo-python/extensions/ that
|
||||
imports this package and registers one Nemo provider. Everything expensive
|
||||
or fiddly lives here: the config file, the git query layer, the cache that
|
||||
keeps Nemo from forking git on every redraw, and the path handling that
|
||||
turns a Nemo.FileInfo into something git can be pointed at.
|
||||
|
||||
Minimal plugin:
|
||||
|
||||
from nemoRepoCore import (coreConfig, findRepoRoot, localDirectoryPath,
|
||||
repoCache, requireCore)
|
||||
requireCore("1.0.0")
|
||||
|
||||
coreConfig.registerDefaults({"indicators": {"mine": {"enabled": True}}})
|
||||
|
||||
class MyProvider(GObject.GObject, Nemo.InfoProvider):
|
||||
def update_file_info(self, file):
|
||||
path = localDirectoryPath(file)
|
||||
if path is None:
|
||||
return
|
||||
root = findRepoRoot(path)
|
||||
if root is None:
|
||||
return
|
||||
repoCache.registerFileObject(root, file)
|
||||
info = repoCache.get(root)
|
||||
...
|
||||
|
||||
Stability policy for everything listed in __all__:
|
||||
|
||||
* Within a major version, names are never removed and signatures only ever
|
||||
gain optional arguments. New helpers may appear; a plugin that asked for
|
||||
1.0.0 keeps working on 1.4.0.
|
||||
* A major bump may remove or reshape anything. requireCore() refuses to
|
||||
load a plugin against a different major, so a mismatch is a clear error
|
||||
at startup instead of a subtle misbehaviour later.
|
||||
* Names not in __all__, and anything with a leading underscore, are
|
||||
internal and may change in any release.
|
||||
"""
|
||||
|
||||
from .Config import Config, coreConfig, coreDefaults, defaultConfigPath, legacyConfigPath
|
||||
from .discovery import (findEnclosingRepoRoot, findRepoRoot, hasMarkerDirectory,
|
||||
isRepoRoot, repoInfoFor)
|
||||
from .gitQuery import computeRepoInfo, gitBinary, runGit
|
||||
from .launchers import (editorAvailable, launchEditor, launchTerminal,
|
||||
resolveTerminal)
|
||||
from .logs import pluginLogger
|
||||
from .nemoFiles import localDirectoryPath, localPath, singleLocalDirectory
|
||||
from .RepoCache import RepoCache, repoCache
|
||||
from .RepoInfo import LastCommit, RepoInfo
|
||||
from .version import CoreVersionError, coreVersion, requireCore
|
||||
|
||||
__all__ = [
|
||||
"Config",
|
||||
"CoreVersionError",
|
||||
"LastCommit",
|
||||
"RepoCache",
|
||||
"RepoInfo",
|
||||
"computeRepoInfo",
|
||||
"coreConfig",
|
||||
"coreDefaults",
|
||||
"coreVersion",
|
||||
"defaultConfigPath",
|
||||
"editorAvailable",
|
||||
"findEnclosingRepoRoot",
|
||||
"findRepoRoot",
|
||||
"gitBinary",
|
||||
"hasMarkerDirectory",
|
||||
"isRepoRoot",
|
||||
"launchEditor",
|
||||
"launchTerminal",
|
||||
"legacyConfigPath",
|
||||
"localDirectoryPath",
|
||||
"localPath",
|
||||
"pluginLogger",
|
||||
"repoCache",
|
||||
"repoInfoFor",
|
||||
"requireCore",
|
||||
"resolveTerminal",
|
||||
"runGit",
|
||||
"singleLocalDirectory",
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Deciding whether a directory is a repository, and finding which one."""
|
||||
|
||||
import os
|
||||
|
||||
from .RepoCache import repoCache
|
||||
|
||||
|
||||
def isRepoRoot(path):
|
||||
"""True when `path` itself holds the repository.
|
||||
|
||||
.git is a directory in a normal clone and a file in a worktree or
|
||||
submodule; both count.
|
||||
"""
|
||||
gitPath = os.path.join(path, ".git")
|
||||
return os.path.isdir(gitPath) or os.path.isfile(gitPath)
|
||||
|
||||
|
||||
def findRepoRoot(path):
|
||||
"""Repository root, but only if `path` is that root. Cheap: one stat.
|
||||
|
||||
This is what emblem and column plugins want, because they run for every
|
||||
folder Nemo draws and must not walk the filesystem upwards.
|
||||
"""
|
||||
return path if isRepoRoot(path) else None
|
||||
|
||||
|
||||
def findEnclosingRepoRoot(path, maximumDepth=64):
|
||||
"""Nearest repository root at or above `path`, or None.
|
||||
|
||||
For on-demand work such as a context menu, where walking a handful of
|
||||
parent directories is free and the user expects git actions to work from
|
||||
anywhere inside their checkout.
|
||||
"""
|
||||
current = os.path.abspath(path)
|
||||
for _ in range(maximumDepth):
|
||||
if isRepoRoot(current):
|
||||
return current
|
||||
parent = os.path.dirname(current)
|
||||
if parent == current:
|
||||
return None
|
||||
current = parent
|
||||
return None
|
||||
|
||||
|
||||
def hasMarkerDirectory(path, name):
|
||||
"""True when `path` directly contains a subdirectory called `name`."""
|
||||
return os.path.isdir(os.path.join(path, name))
|
||||
|
||||
|
||||
def repoInfoFor(path, walkUp=False):
|
||||
"""Cached RepoInfo for the repository at (or above) `path`, else None."""
|
||||
root = findEnclosingRepoRoot(path) if walkUp else findRepoRoot(path)
|
||||
if root is None:
|
||||
return None
|
||||
return repoCache.get(root)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""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
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Starting a terminal or an editor from a Nemo menu item.
|
||||
|
||||
Terminal emulators disagree about the flag that means "run this command",
|
||||
so the mapping below is explicit rather than assuming one form works
|
||||
everywhere. Unknown emulators fall back to -e, which is the oldest and most
|
||||
widely accepted spelling.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from gi.repository import GLib
|
||||
|
||||
from .Config import coreConfig
|
||||
from .logs import pluginLogger
|
||||
|
||||
logger = pluginLogger("launchers")
|
||||
|
||||
terminalCandidates = [
|
||||
"gnome-terminal",
|
||||
"x-terminal-emulator",
|
||||
"tilix",
|
||||
"konsole",
|
||||
"xfce4-terminal",
|
||||
"mate-terminal",
|
||||
"kitty",
|
||||
"alacritty",
|
||||
"xterm",
|
||||
]
|
||||
|
||||
terminalCommandFlags = {
|
||||
"gnome-terminal": ["--"],
|
||||
"mate-terminal": ["--"],
|
||||
"tilix": ["-e"],
|
||||
"konsole": ["-e"],
|
||||
"xfce4-terminal": ["-x"],
|
||||
"kitty": [],
|
||||
"alacritty": ["-e"],
|
||||
"xterm": ["-e"],
|
||||
"x-terminal-emulator": ["-e"],
|
||||
}
|
||||
|
||||
fallbackCommandFlags = ["-e"]
|
||||
|
||||
|
||||
def resolveTerminal():
|
||||
"""The terminal command to use: the configured one, else the first found."""
|
||||
configured = coreConfig.terminalCommand
|
||||
if configured and shutil.which(configured.split(" ")[0]):
|
||||
return configured
|
||||
for candidate in terminalCandidates:
|
||||
if shutil.which(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _commandFlagsFor(terminal):
|
||||
return terminalCommandFlags.get(os.path.basename(terminal), fallbackCommandFlags)
|
||||
|
||||
|
||||
def launchTerminal(path, shellCommand=None):
|
||||
"""Open a terminal in `path`, optionally running `shellCommand` first.
|
||||
|
||||
The shell stays open afterwards so the user can read the output of
|
||||
whatever was run and keep working in the same directory.
|
||||
"""
|
||||
terminal = resolveTerminal()
|
||||
if not terminal:
|
||||
logger.warning("No terminal emulator found; tried %s",
|
||||
", ".join(terminalCandidates))
|
||||
return False
|
||||
|
||||
parts = terminal.split(" ")
|
||||
try:
|
||||
if shellCommand:
|
||||
script = "cd %s && %s; exec \"${SHELL:-bash}\"" % (
|
||||
GLib.shell_quote(path), shellCommand)
|
||||
arguments = parts + _commandFlagsFor(parts[0]) + ["bash", "-c", script]
|
||||
else:
|
||||
arguments = parts
|
||||
subprocess.Popen(arguments, cwd=path)
|
||||
except OSError as error:
|
||||
logger.warning("Could not start %s: %s", terminal, error)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def launchEditor(path):
|
||||
"""Open `path` in the configured editor command."""
|
||||
editor = coreConfig.editorCommand
|
||||
parts = editor.split(" ")
|
||||
if not shutil.which(parts[0]):
|
||||
logger.warning("Editor command %r is not in PATH", editor)
|
||||
return False
|
||||
try:
|
||||
subprocess.Popen(parts + [path])
|
||||
except OSError as error:
|
||||
logger.warning("Could not start editor %r: %s", editor, error)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def editorAvailable():
|
||||
"""True when the configured editor command can actually be started."""
|
||||
return shutil.which(coreConfig.editorCommand.split(" ")[0]) is not None
|
||||
@@ -0,0 +1,54 @@
|
||||
"""One logging setup shared by the core and every plugin built on it."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
_rootLogger = logging.getLogger("nemo-repo")
|
||||
_handlerAttached = False
|
||||
|
||||
_levelNames = {
|
||||
"debug": logging.DEBUG,
|
||||
"info": logging.INFO,
|
||||
"warning": logging.WARNING,
|
||||
"warn": logging.WARNING,
|
||||
"error": logging.ERROR,
|
||||
"critical": logging.CRITICAL,
|
||||
}
|
||||
|
||||
|
||||
def _attachHandler():
|
||||
global _handlerAttached
|
||||
if _handlerAttached:
|
||||
return
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
handler.setFormatter(logging.Formatter("%(name)s: %(levelname)s: %(message)s"))
|
||||
_rootLogger.addHandler(handler)
|
||||
_rootLogger.propagate = False
|
||||
_rootLogger.setLevel(_levelFromEnvironment())
|
||||
_handlerAttached = True
|
||||
|
||||
|
||||
def _levelFromEnvironment():
|
||||
requested = os.environ.get("NEMO_REPO_LOG", "").strip().lower()
|
||||
return _levelNames.get(requested, logging.WARNING)
|
||||
|
||||
|
||||
def applyLogLevel(name):
|
||||
"""Set the level for the whole `nemo-repo` logger tree.
|
||||
|
||||
The NEMO_REPO_LOG environment variable always wins, so a user can raise
|
||||
verbosity for one Nemo session without editing the config file.
|
||||
"""
|
||||
_attachHandler()
|
||||
fromEnvironment = os.environ.get("NEMO_REPO_LOG", "").strip().lower()
|
||||
if fromEnvironment in _levelNames:
|
||||
_rootLogger.setLevel(_levelNames[fromEnvironment])
|
||||
return
|
||||
_rootLogger.setLevel(_levelNames.get(str(name).strip().lower(), logging.WARNING))
|
||||
|
||||
|
||||
def pluginLogger(name):
|
||||
"""Return the logger a plugin should use, e.g. pluginLogger("emblems")."""
|
||||
_attachHandler()
|
||||
return _rootLogger.getChild(name)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Turning the Nemo.FileInfo objects handed to a provider into local paths.
|
||||
|
||||
Every provider needs the same two guards before it can do anything useful:
|
||||
the item must live on the local filesystem, and for repository work it must
|
||||
be a directory. Getting either wrong means running git against a path that
|
||||
does not exist, or against an ftp:// URI.
|
||||
"""
|
||||
|
||||
from gi.repository import GLib
|
||||
|
||||
|
||||
def localPath(nemoFile):
|
||||
"""Filesystem path for `nemoFile`, or None if it is not a local file."""
|
||||
if nemoFile.get_uri_scheme() != "file":
|
||||
return None
|
||||
try:
|
||||
return GLib.filename_from_uri(nemoFile.get_uri())[0]
|
||||
except GLib.Error:
|
||||
return None
|
||||
|
||||
|
||||
def localDirectoryPath(nemoFile):
|
||||
"""Filesystem path for `nemoFile` when it is a local directory, else None."""
|
||||
if not nemoFile.is_directory():
|
||||
return None
|
||||
return localPath(nemoFile)
|
||||
|
||||
|
||||
def singleLocalDirectory(files):
|
||||
"""Path of the one selected local directory, or None for any other case.
|
||||
|
||||
Property pages and single-target menu items all want exactly this.
|
||||
"""
|
||||
if len(files) != 1:
|
||||
return None
|
||||
return localDirectoryPath(files[0])
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Version of the core library and the compatibility check plugins call."""
|
||||
|
||||
coreVersion = "1.0.0"
|
||||
|
||||
|
||||
class CoreVersionError(RuntimeError):
|
||||
"""Raised when the installed core cannot satisfy a plugin's requirement."""
|
||||
|
||||
|
||||
def parseVersion(text):
|
||||
"""Turn "1.2.3" into (1, 2, 3). Missing components count as zero."""
|
||||
parts = []
|
||||
for chunk in str(text).split("."):
|
||||
digits = "".join(character for character in chunk if character.isdigit())
|
||||
parts.append(int(digits) if digits else 0)
|
||||
while len(parts) < 3:
|
||||
parts.append(0)
|
||||
return tuple(parts[:3])
|
||||
|
||||
|
||||
def satisfies(installed, minimum):
|
||||
"""True when `installed` can serve a plugin that asked for `minimum`.
|
||||
|
||||
The rule is plain semantic versioning: the major component must match
|
||||
exactly, and the rest must be greater than or equal to the requirement.
|
||||
"""
|
||||
installedParts = parseVersion(installed)
|
||||
minimumParts = parseVersion(minimum)
|
||||
if installedParts[0] != minimumParts[0]:
|
||||
return False
|
||||
return installedParts >= minimumParts
|
||||
|
||||
|
||||
def requireCore(minimumVersion):
|
||||
"""Assert that this core is new enough for the calling plugin.
|
||||
|
||||
Call it once, at import time, before touching any other core symbol.
|
||||
Nemo logs the resulting exception and skips the extension, which is the
|
||||
behaviour we want: a plugin built against a newer core must not load
|
||||
half-working.
|
||||
"""
|
||||
if not satisfies(coreVersion, minimumVersion):
|
||||
raise CoreVersionError(
|
||||
"nemo-repo-core %s cannot satisfy the required version %s; "
|
||||
"update the core library" % (coreVersion, minimumVersion)
|
||||
)
|
||||
return coreVersion
|
||||
Reference in new issue
Block a user