diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0650fbc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+__pycache__/
+*.py[cod]
+*.egg-info/
+build/
+dist/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..7f75fac
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 DualFroz
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/config.default.json b/config.default.json
deleted file mode 100644
index 5ec9de2..0000000
--- a/config.default.json
+++ /dev/null
@@ -1,34 +0,0 @@
-{
- "cache_ttl_seconds": 5,
- "indicators": {
- "git_repo": {
- "enabled": true,
- "emblem": "emblem-repo-git",
- "description": "Folder contains a .git directory (or worktree file)"
- },
- "claude_dir": {
- "enabled": true,
- "emblem": "emblem-repo-claude",
- "description": "Folder contains a .claude directory"
- },
- "uncommitted_changes": {
- "enabled": true,
- "emblem": "emblem-repo-dirty",
- "description": "git status shows staged/unstaged/untracked changes"
- },
- "unpushed_commits": {
- "enabled": true,
- "emblem": "emblem-repo-unpushed",
- "description": "Current branch has commits ahead of its upstream (or no upstream at all)"
- }
- },
- "actions": {
- "open_terminal": true,
- "open_editor": true,
- "git_status": true,
- "git_log": true,
- "git_pull": true
- },
- "editor_command": "code",
- "terminal_command": null
-}
diff --git a/emblems/emblem-repo-claude.svg b/emblems/emblem-repo-claude.svg
deleted file mode 100644
index 484fc84..0000000
--- a/emblems/emblem-repo-claude.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
diff --git a/emblems/emblem-repo-dirty.svg b/emblems/emblem-repo-dirty.svg
deleted file mode 100644
index 9eac45f..0000000
--- a/emblems/emblem-repo-dirty.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
diff --git a/emblems/emblem-repo-git.svg b/emblems/emblem-repo-git.svg
deleted file mode 100644
index aa07951..0000000
--- a/emblems/emblem-repo-git.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-
diff --git a/emblems/emblem-repo-unpushed.svg b/emblems/emblem-repo-unpushed.svg
deleted file mode 100644
index bad5946..0000000
--- a/emblems/emblem-repo-unpushed.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-
diff --git a/install.sh b/install.sh
deleted file mode 100755
index a5b155e..0000000
--- a/install.sh
+++ /dev/null
@@ -1,40 +0,0 @@
-#!/usr/bin/env bash
-# Installs the nemo-repo-emblems extension for the current user:
-# - symlinks the extension .py into ~/.local/share/nemo-python/extensions/
-# - symlinks the bundled emblem SVGs into ~/.local/share/icons/hicolor/scalable/emblems/
-# - creates ~/.config/nemo-repo-emblems.json from the bundled default if missing
-# - refreshes the icon cache and restarts nemo
-set -euo pipefail
-
-SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-
-EXT_DIR="$HOME/.local/share/nemo-python/extensions"
-ICON_DIR="$HOME/.local/share/icons/hicolor/scalable/emblems"
-CONFIG_PATH="$HOME/.config/nemo-repo-emblems.json"
-
-mkdir -p "$EXT_DIR" "$ICON_DIR" "$(dirname "$CONFIG_PATH")"
-
-ln -sf "$SRC_DIR/nemo_repo_emblems.py" "$EXT_DIR/nemo_repo_emblems.py"
-echo "Linked extension -> $EXT_DIR/nemo_repo_emblems.py"
-
-for svg in "$SRC_DIR"/emblems/*.svg; do
- ln -sf "$svg" "$ICON_DIR/$(basename "$svg")"
-done
-echo "Linked emblem icons -> $ICON_DIR"
-
-if [ ! -f "$CONFIG_PATH" ]; then
- cp "$SRC_DIR/config.default.json" "$CONFIG_PATH"
- echo "Created default config -> $CONFIG_PATH"
-else
- echo "Config already exists, left untouched -> $CONFIG_PATH"
-fi
-
-if command -v gtk-update-icon-cache >/dev/null 2>&1; then
- gtk-update-icon-cache -f -t "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
-fi
-
-echo "Restarting nemo..."
-nemo -q || true
-(nemo --no-desktop >/dev/null 2>&1 &) || true
-
-echo "Done. Edit $CONFIG_PATH to toggle indicators/actions (live-reloaded, no restart needed)."
diff --git a/nemoRepoCore/Config.py b/nemoRepoCore/Config.py
new file mode 100644
index 0000000..bc61a7b
--- /dev/null
+++ b/nemoRepoCore/Config.py
@@ -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()
diff --git a/nemoRepoCore/RepoCache.py b/nemoRepoCore/RepoCache.py
new file mode 100644
index 0000000..7a2afe8
--- /dev/null
+++ b/nemoRepoCore/RepoCache.py
@@ -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)
diff --git a/nemoRepoCore/RepoInfo.py b/nemoRepoCore/RepoInfo.py
new file mode 100644
index 0000000..1606e67
--- /dev/null
+++ b/nemoRepoCore/RepoInfo.py
@@ -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 "" % (self.root, self.branchLabel, self.summary)
diff --git a/nemoRepoCore/__init__.py b/nemoRepoCore/__init__.py
new file mode 100644
index 0000000..66cce49
--- /dev/null
+++ b/nemoRepoCore/__init__.py
@@ -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",
+]
diff --git a/nemoRepoCore/discovery.py b/nemoRepoCore/discovery.py
new file mode 100644
index 0000000..8996bb7
--- /dev/null
+++ b/nemoRepoCore/discovery.py
@@ -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)
diff --git a/nemoRepoCore/gitQuery.py b/nemoRepoCore/gitQuery.py
new file mode 100644
index 0000000..5014b5b
--- /dev/null
+++ b/nemoRepoCore/gitQuery.py
@@ -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
diff --git a/nemoRepoCore/launchers.py b/nemoRepoCore/launchers.py
new file mode 100644
index 0000000..fb9c1ad
--- /dev/null
+++ b/nemoRepoCore/launchers.py
@@ -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
diff --git a/nemoRepoCore/logs.py b/nemoRepoCore/logs.py
new file mode 100644
index 0000000..42d263f
--- /dev/null
+++ b/nemoRepoCore/logs.py
@@ -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)
diff --git a/nemoRepoCore/nemoFiles.py b/nemoRepoCore/nemoFiles.py
new file mode 100644
index 0000000..505c44b
--- /dev/null
+++ b/nemoRepoCore/nemoFiles.py
@@ -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])
diff --git a/nemoRepoCore/version.py b/nemoRepoCore/version.py
new file mode 100644
index 0000000..a600c13
--- /dev/null
+++ b/nemoRepoCore/version.py
@@ -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
diff --git a/nemo_repo_emblems.py b/nemo_repo_emblems.py
deleted file mode 100644
index 00b91f5..0000000
--- a/nemo_repo_emblems.py
+++ /dev/null
@@ -1,607 +0,0 @@
-#!/usr/bin/python3
-# -*- coding: utf-8 -*-
-"""
-nemo-repo-emblems
-==================
-
-A single nemo-python extension that gives Nemo (Cinnamon's file manager)
-real awareness of git repositories:
-
- * Nemo.InfoProvider -> configurable emblem overlays (git repo / .claude
- dir / uncommitted changes / unpushed commits)
- * Nemo.ColumnProvider -> "Git Branch" / "Git Status" columns for list view
- * Nemo.PropertyPageProvider -> a "Git" tab in the folder's Properties
- dialog with branch, ahead/behind, dirty counts,
- untracked count and last commit
- * Nemo.MenuProvider -> right-click actions: open terminal here, open in
- editor, git status / git log / git pull in a
- terminal
-
-Config file: ~/.config/nemo-repo-emblems.json (created from
-config.default.json next to this file on first run if missing). Edits are
-picked up live via a file monitor -- no Nemo restart needed.
-
-Performance: every git-derived fact for a repo root is cached with a TTL
-(default 5s, see "cache_ttl_seconds") AND eagerly invalidated by watching
-.git/HEAD, .git/index and .git/refs/heads for changes, so emblems update
-promptly after a commit/checkout/pull without shelling out to git on every
-Nemo redraw.
-"""
-
-import json
-import logging
-import os
-import shutil
-import subprocess
-import time
-import weakref
-
-import gi
-gi.require_version("Nemo", "3.0")
-gi.require_version("Gtk", "3.0")
-from gi.repository import GObject, Gio, GLib, Gtk, Nemo
-
-logger = logging.getLogger("nemo-repo-emblems")
-logging.basicConfig(level=logging.WARNING)
-
-CONFIG_PATH = os.path.expanduser("~/.config/nemo-repo-emblems.json")
-DEFAULT_CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.default.json")
-
-GIT_BIN = shutil.which("git")
-CODE_BIN = shutil.which("code")
-TERMINAL_CANDIDATES = ["gnome-terminal", "x-terminal-emulator", "tilix", "konsole",
- "xfce4-terminal", "mate-terminal", "xterm"]
-
-
-# --------------------------------------------------------------------------
-# Config
-# --------------------------------------------------------------------------
-
-DEFAULT_CONFIG = {
- "cache_ttl_seconds": 5,
- "indicators": {
- "git_repo": {"enabled": True, "emblem": "emblem-repo-git"},
- "claude_dir": {"enabled": True, "emblem": "emblem-repo-claude"},
- "uncommitted_changes": {"enabled": True, "emblem": "emblem-repo-dirty"},
- "unpushed_commits": {"enabled": True, "emblem": "emblem-repo-unpushed"},
- },
- "actions": {
- "open_terminal": True,
- "open_editor": True,
- "git_status": True,
- "git_log": True,
- "git_pull": True,
- },
- "editor_command": "code",
- "terminal_command": None,
-}
-
-
-class Config:
- """Loads ~/.config/nemo-repo-emblems.json and reloads it live on change."""
-
- def __init__(self):
- self.data = dict(DEFAULT_CONFIG)
- self._ensure_file_exists()
- self._load()
- self._monitor = None
- self._watch()
-
- def _ensure_file_exists(self):
- if os.path.exists(CONFIG_PATH):
- return
- os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
- try:
- if os.path.exists(DEFAULT_CONFIG_PATH):
- shutil.copyfile(DEFAULT_CONFIG_PATH, CONFIG_PATH)
- else:
- with open(CONFIG_PATH, "w") as f:
- json.dump(DEFAULT_CONFIG, f, indent=2)
- except OSError as e:
- logger.warning("Could not create default config at %s: %s", CONFIG_PATH, e)
-
- def _load(self):
- try:
- with open(CONFIG_PATH) as f:
- loaded = json.load(f)
- merged = dict(DEFAULT_CONFIG)
- merged["indicators"] = {**DEFAULT_CONFIG["indicators"], **loaded.get("indicators", {})}
- merged["actions"] = {**DEFAULT_CONFIG["actions"], **loaded.get("actions", {})}
- merged["cache_ttl_seconds"] = loaded.get("cache_ttl_seconds", DEFAULT_CONFIG["cache_ttl_seconds"])
- merged["editor_command"] = loaded.get("editor_command", DEFAULT_CONFIG["editor_command"])
- merged["terminal_command"] = loaded.get("terminal_command", DEFAULT_CONFIG["terminal_command"])
- self.data = merged
- logger.info("Loaded config from %s", CONFIG_PATH)
- except (OSError, ValueError) as e:
- logger.warning("Failed to load %s (%s), using defaults", CONFIG_PATH, e)
- self.data = dict(DEFAULT_CONFIG)
-
- def _watch(self):
- try:
- gfile = Gio.File.new_for_path(CONFIG_PATH)
- self._monitor = gfile.monitor_file(Gio.FileMonitorFlags.NONE, None)
- self._monitor.connect("changed", self._on_changed)
- except GLib.Error as e:
- logger.warning("Could not watch config file: %s", e)
-
- def _on_changed(self, monitor, file, other_file, event_type):
- if event_type in (Gio.FileMonitorEvent.CHANGES_DONE_HINT, Gio.FileMonitorEvent.CREATED):
- self._load()
-
- def indicator(self, name):
- return self.data["indicators"].get(name, {"enabled": False, "emblem": ""})
-
- def indicator_enabled(self, name):
- return bool(self.indicator(name).get("enabled"))
-
- def indicator_emblem(self, name):
- return self.indicator(name).get("emblem", "")
-
- def action_enabled(self, name):
- return bool(self.data["actions"].get(name, False))
-
- @property
- def ttl(self):
- try:
- return float(self.data.get("cache_ttl_seconds", 5))
- except (TypeError, ValueError):
- return 5.0
-
- @property
- def editor_command(self):
- return self.data.get("editor_command") or "code"
-
- @property
- def terminal_command(self):
- return self.data.get("terminal_command")
-
-
-CONFIG = Config()
-
-
-# --------------------------------------------------------------------------
-# Git status gathering + cache
-# --------------------------------------------------------------------------
-
-class RepoInfo:
- __slots__ = ("branch", "upstream", "ahead", "behind", "has_upstream",
- "staged", "unstaged", "untracked", "last_commit", "error")
-
- def __init__(self):
- self.branch = None
- self.upstream = None
- self.ahead = 0
- self.behind = 0
- self.has_upstream = False
- self.staged = 0
- self.unstaged = 0
- self.untracked = 0
- self.last_commit = None # (hash, author, relative_date, subject)
- self.error = None
-
- @property
- def dirty(self):
- return (self.staged + self.unstaged + self.untracked) > 0
-
- @property
- def unpushed(self):
- # No upstream at all is also surfaced as "unpushed" (nothing to push TO)
- return self.ahead > 0 or not self.has_upstream
-
-
-def _run_git(args, cwd):
- env = dict(os.environ)
- env["GIT_OPTIONAL_LOCKS"] = "0"
- try:
- result = subprocess.run(
- [GIT_BIN] + args,
- cwd=cwd,
- stdout=subprocess.PIPE,
- stderr=subprocess.DEVNULL,
- env=env,
- timeout=3,
- )
- return result.stdout.decode("utf-8", "replace")
- except (subprocess.TimeoutExpired, OSError):
- return None
-
-
-def compute_repo_info(repo_root):
- info = RepoInfo()
- if not GIT_BIN:
- info.error = "git not found in PATH"
- return info
-
- out = _run_git(["status", "--porcelain=2", "--branch"], repo_root)
- if out is None:
- info.error = "git status failed"
- return info
-
- for line in out.splitlines():
- if line.startswith("# branch.head "):
- info.branch = line.split(" ", 2)[2]
- elif line.startswith("# branch.upstream "):
- info.upstream = line.split(" ", 2)[2]
- info.has_upstream = True
- elif line.startswith("# branch.ab "):
- parts = line.split(" ")
- for p in parts[2:]:
- if p.startswith("+"):
- info.ahead = int(p[1:])
- elif p.startswith("-"):
- info.behind = int(p[1:])
- elif line.startswith("1 ") or line.startswith("2 "):
- # ordinary / renamed-copied change: XY ...
- xy = line.split(" ", 2)[1]
- x, y = xy[0], xy[1]
- if x != ".":
- info.staged += 1
- if y != ".":
- info.unstaged += 1
- elif line.startswith("u "):
- info.staged += 1 # unmerged conflict, counts as needing attention
- elif line.startswith("? "):
- info.untracked += 1
-
- log_out = _run_git(["log", "-1", "--format=%h%x09%an%x09%ar%x09%s"], repo_root)
- if log_out:
- parts = log_out.strip("\n").split("\t")
- if len(parts) == 4:
- info.last_commit = tuple(parts)
-
- return info
-
-
-class RepoCache:
- """TTL cache + git-directory file-watch invalidation, keyed by repo root path."""
-
- def __init__(self, ttl_fn):
- self._ttl_fn = ttl_fn
- self._entries = {} # repo_root -> (RepoInfo, timestamp)
- self._monitors = {} # repo_root -> [Gio.FileMonitor, ...]
- self._watchers = {} # repo_root -> weakref.WeakSet[Nemo.FileInfo]
-
- def get(self, repo_root):
- now = time.monotonic()
- entry = self._entries.get(repo_root)
- if entry is not None:
- info, ts = entry
- if (now - ts) < self._ttl_fn():
- return info
- info = compute_repo_info(repo_root)
- self._entries[repo_root] = (info, now)
- self._ensure_watch(repo_root)
- return info
-
- def register_file_object(self, repo_root, nemo_file):
- """Track a live Nemo.FileInfo so we can force a re-query on git changes."""
- watchers = self._watchers.setdefault(repo_root, weakref.WeakSet())
- try:
- watchers.add(nemo_file)
- except TypeError:
- pass
-
- def _ensure_watch(self, repo_root):
- if repo_root in self._monitors:
- return
- git_dir = os.path.join(repo_root, ".git")
- # .git can be a file (worktrees / submodules) instead of a directory
- if os.path.isfile(git_dir):
- return
- if not os.path.isdir(git_dir):
- return
-
- monitors = []
- watch_paths = [
- git_dir, # HEAD, index, MERGE_HEAD, ...
- os.path.join(git_dir, "refs", "heads"), # local branch tip updates
- ]
- for path in watch_paths:
- if not os.path.exists(path):
- continue
- try:
- gfile = Gio.File.new_for_path(path)
- monitor = gfile.monitor_directory(Gio.FileMonitorFlags.NONE, None)
- monitor.connect("changed", self._on_git_dir_changed, repo_root)
- monitors.append(monitor)
- except GLib.Error as e:
- logger.debug("Could not watch %s: %s", path, e)
- if monitors:
- self._monitors[repo_root] = monitors
-
- def _on_git_dir_changed(self, monitor, gfile, other_file, event_type, repo_root):
- # Invalidate immediately rather than waiting for TTL expiry.
- self._entries.pop(repo_root, None)
- for nemo_file in list(self._watchers.get(repo_root, ())):
- try:
- nemo_file.invalidate_extension_info()
- except Exception:
- pass
-
-
-CACHE = RepoCache(lambda: CONFIG.ttl)
-
-
-def find_repo_root(path):
- """A directory is a repo root, for our purposes, if it directly contains .git."""
- if os.path.isdir(os.path.join(path, ".git")) or os.path.isfile(os.path.join(path, ".git")):
- return path
- return None
-
-
-def has_claude_dir(path):
- return os.path.isdir(os.path.join(path, ".claude"))
-
-
-# --------------------------------------------------------------------------
-# InfoProvider: emblems
-# --------------------------------------------------------------------------
-
-class RepoEmblemProvider(GObject.GObject, Nemo.InfoProvider, Nemo.NameAndDescProvider):
-
- def update_file_info(self, file):
- if file.get_uri_scheme() != "file":
- return
- if not file.is_directory():
- return
-
- path = GLib.filename_from_uri(file.get_uri())[0]
-
- if CONFIG.indicator_enabled("claude_dir") and has_claude_dir(path):
- emblem = CONFIG.indicator_emblem("claude_dir")
- if emblem:
- file.add_emblem(emblem)
-
- repo_root = find_repo_root(path)
- if repo_root is None:
- return
-
- if CONFIG.indicator_enabled("git_repo"):
- emblem = CONFIG.indicator_emblem("git_repo")
- if emblem:
- file.add_emblem(emblem)
-
- # Only shell out / consult the cache if we actually need dirty/unpushed state.
- if CONFIG.indicator_enabled("uncommitted_changes") or CONFIG.indicator_enabled("unpushed_commits"):
- CACHE.register_file_object(repo_root, file)
- info = CACHE.get(repo_root)
-
- if info.error is None:
- if CONFIG.indicator_enabled("uncommitted_changes") and info.dirty:
- emblem = CONFIG.indicator_emblem("uncommitted_changes")
- if emblem:
- file.add_emblem(emblem)
- if CONFIG.indicator_enabled("unpushed_commits") and info.unpushed:
- emblem = CONFIG.indicator_emblem("unpushed_commits")
- if emblem:
- file.add_emblem(emblem)
-
- def get_name_and_desc(self):
- return [("nemo-repo-emblems:::Configurable git-status emblems (.git / .claude / dirty / unpushed)")]
-
-
-# --------------------------------------------------------------------------
-# ColumnProvider: extra list-view columns
-# --------------------------------------------------------------------------
-
-class RepoColumnProvider(GObject.GObject, Nemo.ColumnProvider, Nemo.InfoProvider, Nemo.NameAndDescProvider):
-
- def get_columns(self):
- return [
- Nemo.Column(name="NemoRepoEmblems::branch_column",
- attribute="repo_branch",
- label="Git Branch",
- description="Current branch of the git repository"),
- Nemo.Column(name="NemoRepoEmblems::status_column",
- attribute="repo_status",
- label="Git Status",
- description="Ahead/behind + working tree status"),
- ]
-
- def update_file_info(self, file):
- if file.get_uri_scheme() != "file" or not file.is_directory():
- return
-
- path = GLib.filename_from_uri(file.get_uri())[0]
- repo_root = find_repo_root(path)
- if repo_root is None:
- file.add_string_attribute("repo_branch", "")
- file.add_string_attribute("repo_status", "")
- return
-
- CACHE.register_file_object(repo_root, file)
- info = CACHE.get(repo_root)
-
- if info.error:
- file.add_string_attribute("repo_branch", "?")
- file.add_string_attribute("repo_status", info.error)
- return
-
- file.add_string_attribute("repo_branch", info.branch or "(detached)")
-
- bits = []
- if info.ahead:
- bits.append(f"↑{info.ahead}")
- if info.behind:
- bits.append(f"↓{info.behind}")
- if not info.has_upstream:
- bits.append("no upstream")
- dirty_bits = []
- if info.staged or info.unstaged:
- dirty_bits.append(f"{info.staged + info.unstaged} changed")
- if info.untracked:
- dirty_bits.append(f"{info.untracked} untracked")
- if dirty_bits:
- bits.append("+".join(dirty_bits))
- file.add_string_attribute("repo_status", ", ".join(bits) if bits else "clean")
-
- def get_name_and_desc(self):
- return [("nemo-repo-emblems-columns:::Git Branch / Git Status list-view columns")]
-
-
-# --------------------------------------------------------------------------
-# PropertyPageProvider: "Git" tab in Properties dialog
-# --------------------------------------------------------------------------
-
-class RepoPropertyPageProvider(GObject.GObject, Nemo.PropertyPageProvider, Nemo.NameAndDescProvider):
-
- def get_property_pages(self, files):
- if len(files) != 1:
- return []
- file = files[0]
- if file.get_uri_scheme() != "file" or not file.is_directory():
- return []
-
- path = GLib.filename_from_uri(file.get_uri())[0]
- repo_root = find_repo_root(path)
- if repo_root is None:
- return []
-
- info = CACHE.get(repo_root)
-
- grid = Gtk.Grid()
- grid.set_row_spacing(6)
- grid.set_column_spacing(12)
- grid.set_margin_start(12)
- grid.set_margin_end(12)
- grid.set_margin_top(12)
- grid.set_margin_bottom(12)
-
- def add_row(row, label_text, value_text):
- lbl = Gtk.Label(label=label_text)
- lbl.set_xalign(0)
- lbl.get_style_context().add_class("dim-label")
- val = Gtk.Label(label=value_text)
- val.set_xalign(0)
- val.set_selectable(True)
- val.set_line_wrap(True)
- grid.attach(lbl, 0, row, 1, 1)
- grid.attach(val, 1, row, 1, 1)
-
- if info.error:
- add_row(0, "Status", info.error)
- else:
- row = 0
- add_row(row, "Repository", repo_root); row += 1
- add_row(row, "Branch", info.branch or "(detached HEAD)"); row += 1
- add_row(row, "Upstream", info.upstream if info.has_upstream else "(none)"); row += 1
- add_row(row, "Ahead / Behind", f"{info.ahead} ahead, {info.behind} behind"); row += 1
- add_row(row, "Staged changes", str(info.staged)); row += 1
- add_row(row, "Unstaged changes", str(info.unstaged)); row += 1
- add_row(row, "Untracked files", str(info.untracked)); row += 1
- if info.last_commit:
- h, author, when, subject = info.last_commit
- add_row(row, "Last commit", f"{h} — {subject}"); row += 1
- add_row(row, "", f"by {author}, {when}"); row += 1
-
- grid.show_all()
- label = Gtk.Label(label="Git")
- return [Nemo.PropertyPage(name="NemoRepoEmblems::git_page", label=label, page=grid)]
-
- def get_name_and_desc(self):
- return [("nemo-repo-emblems-properties:::\"Git\" tab in the folder Properties dialog")]
-
-
-# --------------------------------------------------------------------------
-# MenuProvider: right-click actions
-# --------------------------------------------------------------------------
-
-def _resolve_terminal():
- configured = CONFIG.terminal_command
- if configured and shutil.which(configured.split(" ")[0]):
- return configured
- for candidate in TERMINAL_CANDIDATES:
- if shutil.which(candidate):
- return candidate
- return None
-
-
-def _launch_terminal(path, shell_command=None):
- term = _resolve_terminal()
- if not term:
- logger.warning("No terminal emulator found")
- return
- try:
- if shell_command:
- inner = f"cd {GLib.shell_quote(path)} && {shell_command}; exec bash"
- subprocess.Popen([term, "--", "bash", "-c", inner], cwd=path)
- else:
- subprocess.Popen([term], cwd=path)
- except OSError as e:
- logger.warning("Failed to launch terminal (%s): %s", term, e)
-
-
-def _launch_editor(path):
- editor = CONFIG.editor_command
- binary = shutil.which(editor.split(" ")[0]) if editor else None
- if not binary:
- logger.warning("Editor command %r not found in PATH", editor)
- return
- try:
- subprocess.Popen(editor.split(" ") + [path])
- except OSError as e:
- logger.warning("Failed to launch editor: %s", e)
-
-
-class RepoMenuProvider(GObject.GObject, Nemo.MenuProvider, Nemo.NameAndDescProvider):
-
- def _build_items(self, path, repo_root):
- items = []
- is_repo = repo_root is not None
-
- if CONFIG.action_enabled("open_terminal"):
- item = Nemo.MenuItem(name="NemoRepoEmblems::open_terminal",
- label="Open Terminal Here",
- tip=f"Open a terminal in {path}")
- item.connect("activate", lambda *_: _launch_terminal(path))
- items.append(item)
-
- if CONFIG.action_enabled("open_editor") and CODE_BIN:
- item = Nemo.MenuItem(name="NemoRepoEmblems::open_editor",
- label="Open in Editor",
- tip=f"Open {path} in {CONFIG.editor_command}")
- item.connect("activate", lambda *_: _launch_editor(path))
- items.append(item)
-
- if is_repo and GIT_BIN:
- if CONFIG.action_enabled("git_status"):
- item = Nemo.MenuItem(name="NemoRepoEmblems::git_status",
- label="Git Status (terminal)",
- tip="Run git status in a terminal")
- item.connect("activate", lambda *_: _launch_terminal(repo_root, "git status"))
- items.append(item)
-
- if CONFIG.action_enabled("git_log"):
- item = Nemo.MenuItem(name="NemoRepoEmblems::git_log",
- label="Git Log (terminal)",
- tip="Run git log in a terminal")
- item.connect("activate", lambda *_: _launch_terminal(
- repo_root, "git log --oneline --graph --decorate -30"))
- items.append(item)
-
- if CONFIG.action_enabled("git_pull"):
- item = Nemo.MenuItem(name="NemoRepoEmblems::git_pull",
- label="Git Pull (terminal)",
- tip="Run git pull in a terminal")
- item.connect("activate", lambda *_: _launch_terminal(repo_root, "git pull"))
- items.append(item)
-
- return items
-
- def get_file_items(self, window, files):
- if len(files) != 1 or not files[0].is_directory():
- return []
- file = files[0]
- if file.get_uri_scheme() != "file":
- return []
- path = GLib.filename_from_uri(file.get_uri())[0]
- return self._build_items(path, find_repo_root(path))
-
- def get_background_items(self, window, current_folder):
- if current_folder.get_uri_scheme() != "file":
- return []
- path = GLib.filename_from_uri(current_folder.get_uri())[0]
- return self._build_items(path, find_repo_root(path))
-
- def get_name_and_desc(self):
- return [("nemo-repo-emblems-menu:::Repo-aware right-click actions (terminal / editor / git status-log-pull)")]