diff --git a/CLAUDE.md b/CLAUDE.md
index b4c99a9..27f172f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -43,12 +43,28 @@ shared by `HACLI`'s docked panel, `GraphPreviewScreen`'s, and `DashboardScreen`'
(`app.keys_ctl` — owns the user's keybinding overrides and pushes the resulting keymap onto the
running app via `App.set_keymap`; every screen's `BINDINGS` is `bindings_for(scope)` from this
module's `REGISTRY`, the single source of truth for all ~220 bindings in the app, rebindable from
-Configuration ▸ Keybindings). Like `const.py`/`types.py`, `keybindings.py`'s registry half is
-cycle-safe (no `hatty.ui`/`hatty.main` imports) since it's imported at class-definition time by
-every screen module. **`HACLI` keeps its old attribute surface via property pairs**
-(`app.dashboards`, `app.current_list_name`, `app._detail_entity_id`, …) so screens and tests
-read/assign through the app unchanged; new UI code should call controllers directly instead
-(`self.app.dash_ctl.set_slot(...)`).
+Configuration ▸ Keybindings), `backup.py` (`app.backup_ctl` — Backup & Sync: owns the export-scope
+and git prefs, drives `backup.py`/`git_sync.py` against the app's live collections, and fires
+pull-on-start / the exit-time commit-and-push). Like `const.py`/`types.py`, `keybindings.py`'s
+registry half is cycle-safe (no `hatty.ui`/`hatty.main` imports) since it's imported at
+class-definition time by every screen module. **`HACLI` keeps its old attribute surface via
+property pairs** (`app.dashboards`, `app.current_list_name`, `app._detail_entity_id`, …) so screens
+and tests read/assign through the app unchanged; new UI code should call controllers directly
+instead (`self.app.dash_ctl.set_slot(...)`).
+
+**Single-object export/import.** Lists, dashboards, and saved graphs each have a matching pair of
+controller methods — `to_export_payload(name)` / `import_from_payload(payload)` on
+`ListController`/`DashboardController`/`GraphController` — producing one small versioned JSON file
+per object (`{"hatty_list": 1, ...}` / `{"hatty_dashboard": 1, ...}` / `{"hatty_graph": 1, ...}`),
+reachable from each object's popup (`x`/`i`). `src/hatty/backup.py`'s directory export (Configuration
+▸ Backup & Sync) is built entirely out of these same payloads — one file per object under
+`lists/`/`dashboards/`/`graphs/` plus a handful of whole-collection files (`entity_names.json`,
+`settings.json`, `keybindings.json`) and a `hatty-backup.json` manifest — so a file written by one
+path is always readable by the other, and dropping a hand-exported object into the backup directory
+just works. `src/hatty/git_sync.py` is a separate, git-agnostic layer that shells out to the `git`
+CLI (hardened against credential prompts and hangs — see its module docstring) to optionally treat
+that directory as a repo; neither module imports the other's caller, `controllers/backup.py` wires
+them together.
**Two-tier config persistence.** `config.yaml` is lean — connection settings and display
preferences only. The user-data collections (`lists`, `entity_names`, `dashboards`, `saved_graphs`,
@@ -77,6 +93,10 @@ params typed as `Entity` (not bare `dict`) and read `total=False` fields via `.g
- `src/hatty/config.py` / `storage.py` — YAML config and SQLite collection persistence.
- `src/hatty/const.py` / `types.py` / `service_calls.py` — shared constants, entity TypedDicts, and
the pure per-domain functions that build `call_service` data for entity controls.
+- `src/hatty/backup.py` — Backup & Sync's directory export/import: builds/writes/reads the JSON
+ files described above, no git involved.
+- `src/hatty/git_sync.py` — the git CLI layer for Backup & Sync: init/commit/pull/push over the
+ export directory, every invocation non-interactive and time-bounded.
- `src/hatty/ui/` — screens and popups, one module per surface (entity table, dashboard grid +
widgets, device/area tree, graph panel/fullscreen/preview, per-domain control screens, config,
onboarding). Each module's own docstring is the source of truth for its behavior — read the file
diff --git a/README.md b/README.md
index b92a7ce..93037a5 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@ Your whole smart home, live, in a terminal. hatty connects to Home Assistant ove
- Dedicated live-apply controls for lights, media players, and other entity attributes
- Sensor/thermostat history as sparklines or a fullscreen graph, with multi-entity comparison and saved configs
- Customizable **dashboards** of widgets — graphs, gauges, thermostats, panels — freely resized and split across tiles
+- **Backup & Sync** — export lists, dashboards, saved graphs, entity-name overrides, and settings as a directory of small JSON files, one per object, plus a manifest — pick which sections to include, from Configuration ▸ Backup & Sync. Any single list/dashboard/graph can also be exported/imported on its own from its popup (`x`/`i`), in the exact same format, so a hand-shared file just drops into the backup directory. Optionally track that directory as a git repo, with pull-on-start, commit/push-on-exit, and manual init/pull/push — all non-interactive, never prompting for git credentials or hanging on a stalled network
## Demo
diff --git a/config.example.yaml b/config.example.yaml
index c6adaf6..a303873 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -17,3 +17,16 @@ log_hours: 24
# keybindings:
# log.toggle: "A"
# nav.back: "backspace"
+# Backup & Sync, also editable from Configuration > Backup & Sync in the app.
+# Exports lists/dashboards/saved_graphs/entity_names/settings/keybindings as a
+# directory of JSON files under "path" (never the token above or the ntfy
+# password); optionally treats that directory as a git repo.
+# backup:
+# path: "/home/you/hatty-backup"
+# sections: ["lists", "dashboards", "saved_graphs", "entity_names", "settings", "keybindings"]
+# git_enabled: true
+# pull_on_start: true
+# import_on_pull: true
+# commit_on_exit: true
+# push_on_exit: true
+# pull_rebase: false
diff --git a/src/hatty/backup.py b/src/hatty/backup.py
new file mode 100644
index 0000000..0871721
--- /dev/null
+++ b/src/hatty/backup.py
@@ -0,0 +1,315 @@
+# hatty — MIT License. See LICENSE file for details.
+"""Directory export/import for the Backup & Sync feature: a directory of small
+JSON files — one per list/dashboard/saved graph, plus a handful of
+whole-collection files — that mirrors the single-object export format
+`to_export_payload`/`import_from_payload` already use (`controllers/lists.py`,
+`controllers/dashboards.py`, `controllers/graphs.py`), so any file here can
+also be hand-exported/imported through the normal popups, and vice versa.
+
+No git here — `git_sync.py` is the separate layer that treats this directory
+as an optional git working tree. This module only knows how to read and write
+the files.
+
+Layout:
+
+
/hatty-backup.json manifest: format version, which sections
+ have ever been exported here, and the
+ cross-object scalars default_list /
+ default_dashboard
+ /lists/.list.json one hatty_list export per list
+ /dashboards/.dashboard.json one hatty_dashboard export per dashboard
+ /graphs/.graph.json one hatty_graph export per saved graph
+ /entity_names.json the whole entity_names mapping
+ /settings.json display prefs (never the HA token or ntfy password)
+ /keybindings.json the keybinding overrides
+"""
+
+import json
+import os
+from collections.abc import Sequence
+from datetime import datetime, timezone
+from pathlib import Path
+
+from hatty import __version__
+from hatty.const import (
+ CONFIG_KEY_COLUMNS,
+ CONFIG_KEY_DEFAULT_DASHBOARD,
+ CONFIG_KEY_DEFAULT_LIST,
+ CONFIG_KEY_ENTITY_NAMES,
+ CONFIG_KEY_GRAPH_HOURS,
+ CONFIG_KEY_GRAPH_TYPE,
+ CONFIG_KEY_KEYBINDINGS,
+ CONFIG_KEY_LOG_HOURS,
+ CONFIG_KEY_NOTIFICATIONS,
+ CONFIG_KEY_TERMINAL_TITLE,
+ CONFIG_KEY_TERMINAL_TITLE_ENABLED,
+ CONFIG_KEY_THEME,
+)
+
+#: Bumped if the manifest/object-file shapes ever change incompatibly.
+BACKUP_FORMAT_VERSION = 1
+
+MANIFEST_FILENAME = "hatty-backup.json"
+
+SECTIONS: tuple[str, ...] = ("lists", "dashboards", "saved_graphs", "entity_names", "settings", "keybindings")
+
+SECTION_LABELS: dict[str, str] = {
+ "lists": "Lists",
+ "dashboards": "Dashboards",
+ "saved_graphs": "Saved Graphs",
+ "entity_names": "Entity Name Overrides",
+ "settings": "Display Settings",
+ "keybindings": "Keybindings",
+}
+
+# section id -> (subdirectory, single-object marker key). The three sections
+# with one file per object, mirroring each controller's export/import format.
+_OBJECT_SECTIONS: dict[str, tuple[str, str]] = {
+ "lists": ("lists", "hatty_list"),
+ "dashboards": ("dashboards", "hatty_dashboard"),
+ "saved_graphs": ("graphs", "hatty_graph"),
+}
+
+# The "settings" section's config keys — display prefs only, never the HA
+# token (not in this list) or the ntfy password (stripped from notifications
+# below).
+_SETTINGS_KEYS = (
+ CONFIG_KEY_COLUMNS,
+ CONFIG_KEY_THEME,
+ CONFIG_KEY_GRAPH_TYPE,
+ CONFIG_KEY_GRAPH_HOURS,
+ CONFIG_KEY_LOG_HOURS,
+ CONFIG_KEY_TERMINAL_TITLE_ENABLED,
+ CONFIG_KEY_TERMINAL_TITLE,
+)
+
+
+def slug(name: str) -> str:
+ """Mirrors the dashboard-export filename rule (`ui/dashboard/screen.py`)."""
+ return name.strip().lower().replace(" ", "-") or "export"
+
+
+def _validate_sections(sections: Sequence[str]) -> tuple[str, ...]:
+ unknown = set(sections) - set(SECTIONS)
+ if unknown:
+ raise ValueError(f"Unknown backup section(s): {', '.join(sorted(unknown))}")
+ return tuple(s for s in SECTIONS if s in sections)
+
+
+def _settings_payload(cfg: dict) -> dict:
+ settings = {key: cfg.get(key) for key in _SETTINGS_KEYS}
+ notifications = dict(cfg.get(CONFIG_KEY_NOTIFICATIONS) or {})
+ notifications.pop("ntfy_password", None)
+ settings[CONFIG_KEY_NOTIFICATIONS] = notifications
+ return settings
+
+
+def build_files(app, sections: Sequence[str]) -> dict[str, dict]:
+ """{relative path: JSON-able payload} for `sections`. Object sections
+ (lists/dashboards/saved_graphs) delegate to the matching controller's
+ `to_export_payload` so there is exactly one definition of each format."""
+ sections = _validate_sections(sections)
+ files: dict[str, dict] = {}
+
+ if "lists" in sections:
+ for name in app.list_ctl.list_names:
+ files[f"lists/{slug(name)}.list.json"] = app.list_ctl.to_export_payload(name)
+ if "dashboards" in sections:
+ temp = app.dash_ctl.temp_dashboard_names
+ for name in app.dash_ctl.dashboard_names:
+ if name in temp:
+ continue
+ files[f"dashboards/{slug(name)}.dashboard.json"] = app.dash_ctl.to_export_payload(name)
+ if "saved_graphs" in sections:
+ for name in app.graph_ctl.saved_graphs:
+ files[f"graphs/{slug(name)}.graph.json"] = app.graph_ctl.to_export_payload(name)
+ if "entity_names" in sections:
+ files["entity_names.json"] = {
+ "hatty_entity_names": BACKUP_FORMAT_VERSION,
+ "names": dict(app.app_config.get(CONFIG_KEY_ENTITY_NAMES) or {}),
+ }
+ if "settings" in sections:
+ files["settings.json"] = {
+ "hatty_settings": BACKUP_FORMAT_VERSION,
+ "settings": _settings_payload(app.app_config),
+ }
+ if "keybindings" in sections:
+ files["keybindings.json"] = {
+ "hatty_keybindings": BACKUP_FORMAT_VERSION,
+ "keybindings": dict(app.app_config.get(CONFIG_KEY_KEYBINDINGS) or {}),
+ }
+
+ # The manifest is a *patch*: write_export merges it over whatever manifest
+ # already exists, so exporting a subset of sections never forgets what a
+ # previous export (of other sections) recorded.
+ manifest: dict = {"hatty_backup": BACKUP_FORMAT_VERSION, "sections": list(sections)}
+ if "lists" in sections:
+ manifest[CONFIG_KEY_DEFAULT_LIST] = app.app_config.get(CONFIG_KEY_DEFAULT_LIST)
+ if "dashboards" in sections:
+ manifest[CONFIG_KEY_DEFAULT_DASHBOARD] = app.app_config.get(CONFIG_KEY_DEFAULT_DASHBOARD)
+ files[MANIFEST_FILENAME] = manifest
+
+ return files
+
+
+def _write_json_if_changed(path: Path, payload: dict) -> bool:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ text = json.dumps(payload, indent=2) + "\n"
+ if path.exists() and path.read_text() == text:
+ return False
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
+ with os.fdopen(fd, "w") as f:
+ f.write(text)
+ return True
+
+
+def _read_manifest_tolerant(directory: Path) -> dict:
+ """Best-effort read for merging — a missing/corrupt manifest just means
+ "nothing recorded yet", not an error (that's `read_export`'s job)."""
+ path = directory / MANIFEST_FILENAME
+ if not path.exists():
+ return {}
+ try:
+ data = json.loads(path.read_text())
+ except (OSError, ValueError):
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def _prune_stale(directory: Path, files: dict[str, dict], sections: Sequence[str]) -> list[str]:
+ removed: list[str] = []
+ for section in sections:
+ entry = _OBJECT_SECTIONS.get(section)
+ if entry is None:
+ continue
+ subdir_name, _marker = entry
+ subdir = directory / subdir_name
+ if not subdir.is_dir():
+ continue
+ keep = {Path(p).name for p in files if p.startswith(f"{subdir_name}/")}
+ for existing in subdir.glob("*.json"):
+ if existing.name not in keep:
+ existing.unlink()
+ removed.append(f"{subdir_name}/{existing.name}")
+ return removed
+
+
+#: Manifest keys that change on every export regardless of content, so they're
+#: excluded when deciding whether an export actually changed anything.
+_VOLATILE_MANIFEST_KEYS = ("exported_at", "hatty_version")
+
+
+def _manifest_content_equal(a: dict, b: dict) -> bool:
+ def _strip(d: dict) -> dict:
+ return {k: v for k, v in d.items() if k not in _VOLATILE_MANIFEST_KEYS}
+
+ return _strip(a) == _strip(b)
+
+
+def write_export(directory: Path, files: dict[str, dict], sections: Sequence[str]) -> tuple[list[str], list[str]]:
+ """Write `files` (from `build_files`) under `directory`, merge the
+ manifest patch over any existing manifest, and prune object files in the
+ exported sections whose object no longer exists. Only rewrites files whose
+ content changed, so git diffs stay minimal — including the manifest's
+ `exported_at`/`hatty_version`, which only move when something else in the
+ export actually did, so a no-op export (nothing but the clock) never looks
+ like a change to git and never triggers a commit/push on exit. Returns
+ (written, removed), both relative paths."""
+ sections = _validate_sections(sections)
+ directory = Path(directory)
+ directory.mkdir(parents=True, exist_ok=True)
+ os.chmod(directory, 0o700)
+
+ data_files = {k: v for k, v in files.items() if k != MANIFEST_FILENAME}
+ written = [path for path, payload in data_files.items() if _write_json_if_changed(directory / path, payload)]
+ removed = _prune_stale(directory, files, sections)
+
+ existing_manifest = _read_manifest_tolerant(directory)
+ manifest_patch = files.get(MANIFEST_FILENAME, {})
+ manifest = {**existing_manifest, **manifest_patch}
+ manifest["sections"] = sorted(set(existing_manifest.get("sections") or []) | set(sections))
+ manifest["hatty_backup"] = BACKUP_FORMAT_VERSION
+
+ if written or removed or not _manifest_content_equal(existing_manifest, manifest):
+ manifest["exported_at"] = datetime.now(timezone.utc).isoformat()
+ manifest["hatty_version"] = __version__
+ else:
+ manifest["exported_at"] = existing_manifest.get("exported_at", datetime.now(timezone.utc).isoformat())
+ manifest["hatty_version"] = existing_manifest.get("hatty_version", __version__)
+
+ if _write_json_if_changed(directory / MANIFEST_FILENAME, manifest):
+ written.append(MANIFEST_FILENAME)
+
+ return written, removed
+
+
+def _read_object(path: Path, marker_key: str) -> dict:
+ try:
+ payload = json.loads(path.read_text())
+ except (OSError, ValueError) as exc:
+ raise ValueError(f"Could not read {path}: {exc}") from exc
+ if not isinstance(payload, dict) or payload.get(marker_key) != BACKUP_FORMAT_VERSION:
+ raise ValueError(f"{path} is not a valid hatty export file.")
+ return payload
+
+
+def read_export(directory: Path, sections: Sequence[str]) -> tuple[dict, list[str]]:
+ """Read `sections` back from `directory`. Returns `(payloads, found)`:
+ `found` is the subset of `sections` actually present, and `payloads[id]`
+ is either a list of raw single-object export payloads — for "lists" /
+ "dashboards" / "saved_graphs", feed each to the matching controller's
+ `import_from_payload` — or, for "entity_names" / "settings" /
+ "keybindings", the config value ready to assign directly. `payloads
+ ["_manifest"]` carries the manifest dict (default_list/default_dashboard).
+ Raises `ValueError` (with a user-facing message) on a missing/bad manifest
+ or an unreadable object file."""
+ sections = _validate_sections(sections)
+ directory = Path(directory)
+ manifest_path = directory / MANIFEST_FILENAME
+ if not manifest_path.exists():
+ raise ValueError(f"No hatty backup found in {directory} (missing {MANIFEST_FILENAME}).")
+ manifest = _read_object(manifest_path, "hatty_backup")
+
+ payloads: dict = {"_manifest": manifest}
+ found: list[str] = []
+
+ for section, (subdir_name, marker) in _OBJECT_SECTIONS.items():
+ if section not in sections:
+ continue
+ subdir = directory / subdir_name
+ if not subdir.is_dir():
+ continue
+ payloads[section] = [_read_object(p, marker) for p in sorted(subdir.glob("*.json"))]
+ found.append(section)
+
+ if "entity_names" in sections:
+ path = directory / "entity_names.json"
+ if path.exists():
+ payload = _read_object(path, "hatty_entity_names")
+ names = payload.get("names")
+ if not isinstance(names, dict):
+ raise ValueError(f"{path} is missing its names.")
+ payloads["entity_names"] = dict(names)
+ found.append("entity_names")
+
+ if "settings" in sections:
+ path = directory / "settings.json"
+ if path.exists():
+ payload = _read_object(path, "hatty_settings")
+ settings = payload.get("settings")
+ if not isinstance(settings, dict):
+ raise ValueError(f"{path} is missing its settings.")
+ payloads["settings"] = dict(settings)
+ found.append("settings")
+
+ if "keybindings" in sections:
+ path = directory / "keybindings.json"
+ if path.exists():
+ payload = _read_object(path, "hatty_keybindings")
+ keybindings = payload.get("keybindings")
+ if not isinstance(keybindings, dict):
+ raise ValueError(f"{path} is missing its keybindings.")
+ payloads["keybindings"] = dict(keybindings)
+ found.append("keybindings")
+
+ return payloads, found
diff --git a/src/hatty/cli.py b/src/hatty/cli.py
index b08d304..1ee0721 100644
--- a/src/hatty/cli.py
+++ b/src/hatty/cli.py
@@ -30,7 +30,44 @@ def main() -> None:
from hatty.main import HACLI # imported here so TEXTUAL_LOG is set first
- HACLI(config_path=args.config, demo=args.demo).run()
+ app = HACLI(config_path=args.config, demo=args.demo)
+ try:
+ app.run()
+ finally:
+ _flush_pending_exit_sync(app)
+
+
+def _flush_pending_exit_sync(app) -> None:
+ """Last-resort fallback for an exit path that skipped both
+ HACLI.action_quit and HACLI._on_exit_app (a real SIGINT, or a panic) —
+ runs after app.run() returns, with no event loop, so it calls git_sync's
+ plain sync functions directly rather than through their asyncio.to_thread
+ wrappers. A save task started just before this path may have been
+ abandoned mid-flight, so the pushed data can be one save stale; that's
+ unavoidable without an event loop here. Never raises — a failed backup
+ sync must not turn into a crash on the way out."""
+ if app._exit_sync_done or not app.backup_ctl.exit_sync_pending():
+ return
+ app._exit_sync_done = True
+
+ from hatty import git_sync
+
+ try:
+ print("hatty: exporting backup…")
+ ok, msg = app.backup_ctl.export_now()
+ if not ok:
+ print(f"hatty: backup export failed: {msg}")
+ return
+ path = app.backup_ctl.prefs.get("path") or ""
+ message = git_sync.default_commit_message()
+ print("hatty: committing…")
+ ok, msg = git_sync.commit_all(path, message)
+ if ok and app.backup_ctl.prefs.get("push_on_exit"):
+ print("hatty: pushing…")
+ ok, msg = git_sync.push(path)
+ print(f"hatty: {msg}")
+ except Exception as e:
+ print(f"hatty: backup sync failed: {e}")
if __name__ == "__main__":
diff --git a/src/hatty/config.py b/src/hatty/config.py
index f0a0710..da14dec 100644
--- a/src/hatty/config.py
+++ b/src/hatty/config.py
@@ -5,6 +5,7 @@
import yaml
from hatty.const import (
+ CONFIG_KEY_BACKUP,
CONFIG_KEY_COLUMNS,
CONFIG_KEY_DASHBOARDS,
CONFIG_KEY_DEFAULT_DASHBOARD,
@@ -24,6 +25,7 @@
CONFIG_KEY_THEME,
CONFIG_KEY_TOKEN,
CONFIG_KEY_URL,
+ DEFAULT_BACKUP,
DEFAULT_COLUMNS,
DEFAULT_GRAPH_HOURS,
DEFAULT_LOG_HOURS,
@@ -82,6 +84,7 @@ def default_config() -> dict:
CONFIG_KEY_TERMINAL_TITLE_ENABLED: True,
CONFIG_KEY_TERMINAL_TITLE: DEFAULT_TERMINAL_TITLE,
CONFIG_KEY_KEYBINDINGS: {},
+ CONFIG_KEY_BACKUP: dict(DEFAULT_BACKUP),
}
diff --git a/src/hatty/const.py b/src/hatty/const.py
index 7f1fe6c..7348907 100644
--- a/src/hatty/const.py
+++ b/src/hatty/const.py
@@ -201,6 +201,7 @@ def binary_state_label(state: str, device_class: str) -> str:
CONFIG_KEY_TERMINAL_TITLE_ENABLED = "terminal_title_enabled"
CONFIG_KEY_TERMINAL_TITLE = "terminal_title"
CONFIG_KEY_KEYBINDINGS = "keybindings"
+CONFIG_KEY_BACKUP = "backup"
# Fallback/default value for the "terminal_title" config key (issue: set tmux
# title to hatty or pref).
@@ -225,3 +226,18 @@ def binary_state_label(state: str, device_class: str) -> str:
"ntfy_username": "",
"ntfy_password": "",
}
+
+# Default Backup & Sync preferences (config key "backup"), merged over by
+# BackupController whenever a config predates a given key. "sections" is
+# spelled out literally (matching backup.SECTIONS) rather than imported, so
+# const.py stays free of imports from the rest of the app.
+DEFAULT_BACKUP = {
+ "path": "", # export directory; "" = feature idle
+ "sections": ["lists", "dashboards", "saved_graphs", "entity_names", "settings", "keybindings"],
+ "git_enabled": False,
+ "pull_on_start": False, # pull + import at boot
+ "import_on_pull": True, # after a successful pull, load the files back in
+ "commit_on_exit": False, # export + commit at quit
+ "push_on_exit": False, # ...and push (implies commit)
+ "pull_rebase": False,
+}
diff --git a/src/hatty/controllers/backup.py b/src/hatty/controllers/backup.py
new file mode 100644
index 0000000..46c0561
--- /dev/null
+++ b/src/hatty/controllers/backup.py
@@ -0,0 +1,252 @@
+# hatty — MIT License. See LICENSE file for details.
+"""Backup & Sync: owns the user's export/git prefs and drives backup.py +
+git_sync.py against the running app's live state (`app.list_ctl`, `app.dash_ctl`,
+`app.graph_ctl`, `app.app_config`). `apply()` is called from HACLI._apply_config
+(boot, demo, and the post-onboarding restart) and again from _on_config_saved,
+mirroring KeybindingController."""
+
+import asyncio
+from collections.abc import Callable, Sequence
+from pathlib import Path
+
+from hatty import backup as backup_module
+from hatty import git_sync
+from hatty.const import (
+ CONFIG_KEY_BACKUP,
+ CONFIG_KEY_COLUMNS,
+ CONFIG_KEY_GRAPH_HOURS,
+ CONFIG_KEY_GRAPH_TYPE,
+ CONFIG_KEY_KEYBINDINGS,
+ CONFIG_KEY_LOG_HOURS,
+ CONFIG_KEY_NOTIFICATIONS,
+ CONFIG_KEY_TERMINAL_TITLE,
+ CONFIG_KEY_TERMINAL_TITLE_ENABLED,
+ CONFIG_KEY_THEME,
+ DEFAULT_BACKUP,
+)
+
+# section id -> attribute holding the controller with to_export_payload /
+# import_from_payload for that section's objects.
+_OBJECT_CONTROLLERS = {"lists": "list_ctl", "dashboards": "dash_ctl", "saved_graphs": "graph_ctl"}
+
+# section id -> storage.PERSISTED keys it needs saved after a replace.
+_OBJECT_PERSIST_KEYS = {
+ "lists": ("lists", "manual_lists", "notify_lists", "default_list"),
+ "dashboards": ("dashboards", "default_dashboard"),
+ "saved_graphs": ("saved_graphs",),
+}
+
+_SETTINGS_KEYS = (
+ CONFIG_KEY_COLUMNS,
+ CONFIG_KEY_THEME,
+ CONFIG_KEY_GRAPH_TYPE,
+ CONFIG_KEY_GRAPH_HOURS,
+ CONFIG_KEY_LOG_HOURS,
+ CONFIG_KEY_TERMINAL_TITLE_ENABLED,
+ CONFIG_KEY_TERMINAL_TITLE,
+)
+
+
+class BackupController:
+ def __init__(self, app) -> None:
+ self._app = app
+ self.prefs: dict = dict(DEFAULT_BACKUP)
+
+ def apply(self, cfg: dict) -> None:
+ """Merge cfg[backup] over the defaults, store the result, and
+ normalize cfg in place so a save right afterwards writes back the
+ merged prefs (mirrors KeybindingController.apply)."""
+ merged = dict(DEFAULT_BACKUP)
+ merged.update(cfg.get(CONFIG_KEY_BACKUP) or {})
+ merged["sections"] = [s for s in backup_module.SECTIONS if s in (merged.get("sections") or [])]
+ self.prefs = merged
+ cfg[CONFIG_KEY_BACKUP] = dict(merged)
+
+ # ── Export / import ──────────────────────────────────────────────────────
+
+ def export_now(self, path: str | None = None, sections: Sequence[str] | None = None) -> tuple[bool, str]:
+ """`path`/`sections` default to the saved prefs; the config screen
+ passes the currently-entered (unsaved) widget values instead, the
+ same "act on unsaved fields" precedent as action_test_connection."""
+ path = path if path is not None else self.prefs.get("path")
+ if not path:
+ return False, "No backup directory set."
+ sections = sections if sections is not None else (self.prefs.get("sections") or [])
+ sections = [s for s in backup_module.SECTIONS if s in sections]
+ if not sections:
+ return False, "No sections selected to export."
+ try:
+ files = backup_module.build_files(self._app, sections)
+ written, removed = backup_module.write_export(Path(path), files, sections)
+ except (OSError, ValueError) as exc:
+ return False, f"Export failed: {exc}"
+ parts = []
+ if written:
+ parts.append(f"{len(written)} file(s) written")
+ if removed:
+ parts.append(f"{len(removed)} stale file(s) removed")
+ detail = f" ({', '.join(parts)})" if parts else " (already up to date)"
+ return True, f"Exported to {path}{detail}."
+
+ def import_now(self, sections: Sequence[str], path: str | None = None) -> tuple[bool, str, list[str]]:
+ path = path if path is not None else self.prefs.get("path")
+ if not path:
+ return False, "No backup directory set.", []
+ sections = [s for s in backup_module.SECTIONS if s in sections]
+ if not sections:
+ return False, "No sections selected to import.", []
+ try:
+ payloads, found = backup_module.read_export(Path(path), sections)
+ except ValueError as exc:
+ return False, str(exc), []
+
+ self._apply_imported(payloads, found)
+
+ labels = [backup_module.SECTION_LABELS[s] for s in found]
+ return True, f"Imported {', '.join(labels)} from {path}.", found
+
+ def _apply_imported(self, payloads: dict, found: Sequence[str]) -> None:
+ app = self._app
+ manifest = payloads.get("_manifest") or {}
+ persist_keys: set[str] = set()
+
+ for section in ("lists", "dashboards", "saved_graphs"):
+ if section not in found:
+ continue
+ ctl = getattr(app, _OBJECT_CONTROLLERS[section])
+ self._replace_collection(section, ctl)
+ for payload in payloads[section]:
+ try:
+ ctl.import_from_payload(payload)
+ except ValueError:
+ continue # one bad object shouldn't abort the whole import
+ persist_keys.update(_OBJECT_PERSIST_KEYS[section])
+
+ if "lists" in found:
+ default_list = manifest.get("default_list")
+ app.list_ctl.default_list_name = default_list if default_list in app.list_ctl.entity_lists else None
+ if "dashboards" in found:
+ default_dashboard = manifest.get("default_dashboard")
+ app.dash_ctl.default_dashboard_name = (
+ default_dashboard if default_dashboard in app.dash_ctl.dashboards else None
+ )
+
+ if "entity_names" in found:
+ app.entity_names = payloads["entity_names"]
+ persist_keys.add("entity_names")
+
+ if "settings" in found:
+ self._apply_settings(payloads["settings"])
+
+ if "keybindings" in found:
+ app.app_config[CONFIG_KEY_KEYBINDINGS] = payloads["keybindings"]
+ app.keys_ctl.apply(app.app_config)
+
+ if persist_keys:
+ app.persist(*sorted(persist_keys))
+ elif "settings" in found or "keybindings" in found:
+ app.persist()
+
+ app._update_entities_display()
+
+ def _replace_collection(self, section: str, ctl) -> None:
+ """Wipe `section`'s in-memory collection ahead of a wholesale replace
+ (behind a confirm popup in the UI) — dashboards keeps any in-session
+ temp/preview dashboards, which are never part of an export."""
+ if section == "lists":
+ ctl.entity_lists.clear()
+ ctl.list_names.clear()
+ ctl.manual_lists.clear()
+ self._app.notify_ctl.notify_lists.clear()
+ elif section == "dashboards":
+ temp = ctl.temp_dashboard_names
+ for name in [n for n in ctl.dashboards if n not in temp]:
+ del ctl.dashboards[name]
+ ctl.dashboard_names[:] = [n for n in ctl.dashboard_names if n in temp]
+ if ctl.current_dashboard_name not in ctl.dashboards:
+ ctl.current_dashboard_name = ctl.dashboard_names[0] if ctl.dashboard_names else None
+ elif section == "saved_graphs":
+ ctl.saved_graphs.clear()
+
+ def _apply_settings(self, settings: dict) -> None:
+ app = self._app
+ for key in _SETTINGS_KEYS:
+ if key in settings:
+ app.app_config[key] = settings[key]
+ if CONFIG_KEY_NOTIFICATIONS in settings:
+ merged = dict(app.app_config.get(CONFIG_KEY_NOTIFICATIONS) or {})
+ merged.update(settings[CONFIG_KEY_NOTIFICATIONS]) # export never carries ntfy_password
+ app.app_config[CONFIG_KEY_NOTIFICATIONS] = merged
+
+ app.columns = app.app_config.get(CONFIG_KEY_COLUMNS, app.columns)
+ new_theme = app.app_config.get(CONFIG_KEY_THEME)
+ if new_theme and new_theme in app.available_themes:
+ app.theme = new_theme
+ app._apply_terminal_title(app.app_config)
+
+ # ── Git ───────────────────────────────────────────────────────────────────
+
+ def exit_sync_pending(self) -> bool:
+ if self._app._demo:
+ return False
+ if not self.prefs.get("git_enabled") or not self.prefs.get("path"):
+ return False
+ return bool(self.prefs.get("commit_on_exit") or self.prefs.get("push_on_exit"))
+
+ async def pull_on_start(self) -> None:
+ app = self._app
+ if app._demo or not self.prefs.get("git_enabled") or not self.prefs.get("pull_on_start"):
+ return
+ path = self.prefs.get("path")
+ if not path:
+ return
+ ok, msg = await git_sync.pull_async(path, rebase=bool(self.prefs.get("pull_rebase")))
+ if not ok:
+ app.notify(msg, title="Backup Pull Failed", severity="error")
+ return
+ if not self.prefs.get("import_on_pull"):
+ app.notify(msg, title="Backup Pulled")
+ return
+ ok, msg, _found = self.import_now(self.prefs.get("sections") or [])
+ title = "Backup Imported" if ok else "Backup Import Failed"
+ app.notify(msg, title=title, severity="information" if ok else "error")
+
+ async def sync_on_exit(
+ self, status: Callable[[str], None] | None = None, timeout: float = 75.0
+ ) -> tuple[bool, str]:
+ # 75s: room for git_sync's own NETWORK_TIMEOUT (60s) on the push plus a
+ # buffer for the local commit and export, as a belt-and-suspenders cap
+ # so a stalled network can't hang the exit-sync overlay indefinitely.
+ # `status`, if given, is called before each phase — ExitSyncScreen
+ # passes its own label so a slow push doesn't look identical to a
+ # slow commit (issue: show what's happening during a slow exit).
+ if not self.exit_sync_pending():
+ return True, ""
+ path = self.prefs.get("path") or ""
+
+ def _status(text: str) -> None:
+ if status is not None:
+ status(text)
+
+ _status("Exporting…")
+ # to_thread so the "Exporting…" frame actually paints before the
+ # (loop-blocking) export runs.
+ ok, msg = await asyncio.to_thread(self.export_now)
+ if not ok:
+ return False, msg
+
+ message = git_sync.default_commit_message()
+ push_on_exit = bool(self.prefs.get("push_on_exit"))
+
+ async def _run() -> tuple[bool, str]:
+ _status("Committing…")
+ ok, msg = await git_sync.commit_all_async(path, message)
+ if not ok or not push_on_exit:
+ return ok, msg
+ _status("Pushing…")
+ return await git_sync.push_async(path)
+
+ try:
+ return await asyncio.wait_for(_run(), timeout=timeout)
+ except asyncio.TimeoutError:
+ return False, "Timed out syncing with git."
diff --git a/src/hatty/controllers/graphs.py b/src/hatty/controllers/graphs.py
index 8d5bf20..4572195 100644
--- a/src/hatty/controllers/graphs.py
+++ b/src/hatty/controllers/graphs.py
@@ -2,6 +2,7 @@
"""Graph/history state, the detail panel rendering, and saved graphs,
extracted from HACLI."""
+import copy
from collections import deque
from datetime import datetime, timedelta, timezone
@@ -14,6 +15,9 @@
from hatty.types import Entity
from hatty.ui.graph.entity_detail import EntityDetailPanel
+#: Bumped if the export payload shape ever changes incompatibly.
+EXPORT_FORMAT_VERSION = 1
+
def _trim_history(buf: deque, hours: float, ts_of=lambda item: item[0]) -> None:
"""Evict entries older than `hours` before the newest entry in `buf`, in place.
@@ -269,6 +273,42 @@ def save_graph(
self._app.persist("saved_graphs")
self._app.notify(f"Graph saved as '{name}'.", title="Graph Saved")
+ # ── Export / import ──────────────────────────────────────────────────────
+
+ def to_export_payload(self, name: str) -> dict:
+ """A JSON-serializable snapshot of saved graph `name`, versioned so a
+ future format change can be detected on import."""
+ return {
+ "hatty_graph": EXPORT_FORMAT_VERSION,
+ "name": name,
+ "graph": copy.deepcopy(self.saved_graphs[name]),
+ }
+
+ def import_from_payload(self, payload: dict) -> str:
+ """Create a new saved graph from a previously exported payload,
+ deduplicating its name against the existing collection. Raises
+ `ValueError` (with a user-facing message) if `payload` isn't a
+ recognizable export. Returns the final graph name."""
+ if not isinstance(payload, dict) or payload.get("hatty_graph") != EXPORT_FORMAT_VERSION:
+ raise ValueError("Not a valid hatty saved graph export file.")
+ graph = payload.get("graph")
+ if not isinstance(graph, dict) or "entity_ids" not in graph:
+ raise ValueError("Saved graph export is missing entity_ids.")
+
+ final = self._unique_name(str(payload.get("name") or "Imported"))
+ self.saved_graphs[final] = copy.deepcopy(graph)
+ self._app.persist("saved_graphs")
+ return final
+
+ def _unique_name(self, name: str) -> str:
+ """`name`, or `name (2)`, `name (3)`, ... if it's already taken."""
+ final = name
+ suffix = 2
+ while final in self.saved_graphs:
+ final = f"{name} ({suffix})"
+ suffix += 1
+ return final
+
def handle_saved_graphs_popup_action(self, result: dict) -> None:
app = self._app
action = result.get("action")
diff --git a/src/hatty/controllers/keybindings.py b/src/hatty/controllers/keybindings.py
index d99b9a8..0828bfb 100644
--- a/src/hatty/controllers/keybindings.py
+++ b/src/hatty/controllers/keybindings.py
@@ -1223,6 +1223,20 @@ class KeySpec(NamedTuple):
action="delete_graph",
description="Delete",
),
+ KeySpec(
+ id="saved_graphs_popup.export_graph",
+ scope="saved_graphs_popup",
+ key="x",
+ action="export_graph",
+ description="Export",
+ ),
+ KeySpec(
+ id="saved_graphs_popup.import_graph",
+ scope="saved_graphs_popup",
+ key="i",
+ action="import_graph",
+ description="Import",
+ ),
KeySpec(
id="nav.back",
scope="saved_graphs_popup",
@@ -1277,6 +1291,20 @@ class KeySpec(NamedTuple):
action="view_as_dashboard",
description="View as Dashboard",
),
+ KeySpec(
+ id="list_popup.export_list",
+ scope="list_popup",
+ key="x",
+ action="export_list",
+ description="Export",
+ ),
+ KeySpec(
+ id="list_popup.import_list",
+ scope="list_popup",
+ key="i",
+ action="import_list",
+ description="Import",
+ ),
KeySpec(
id="nav.back",
scope="list_popup",
diff --git a/src/hatty/controllers/lists.py b/src/hatty/controllers/lists.py
index 7b48748..83158f5 100644
--- a/src/hatty/controllers/lists.py
+++ b/src/hatty/controllers/lists.py
@@ -4,6 +4,9 @@
from hatty.ui.confirm_popup import ConfirmPopup
from hatty.ui.dashboard.screen import DashboardScreen
+#: Bumped if the export payload shape ever changes incompatibly.
+EXPORT_FORMAT_VERSION = 1
+
class ListController:
"""Owns the entity-list collections, selection, and undo/redo for
@@ -90,6 +93,49 @@ def _do_delete(confirmed, _name=list_name):
elif action == "rename":
self.rename_list(list_name, result.get("new_name"))
+ # ── Export / import ──────────────────────────────────────────────────────
+
+ def to_export_payload(self, name: str) -> dict:
+ """A JSON-serializable snapshot of list `name`, versioned so a future
+ format change can be detected on import."""
+ return {
+ "hatty_list": EXPORT_FORMAT_VERSION,
+ "name": name,
+ "entities": list(self.entity_lists.get(name, [])),
+ "manual": name in self.manual_lists,
+ "notify": name in self._app.notify_ctl.notify_lists,
+ }
+
+ def import_from_payload(self, payload: dict) -> str:
+ """Create a new list from a previously exported payload, deduplicating
+ its name against the existing collection. Raises `ValueError` (with a
+ user-facing message) if `payload` isn't a recognizable export. Returns
+ the final list name."""
+ if not isinstance(payload, dict) or payload.get("hatty_list") != EXPORT_FORMAT_VERSION:
+ raise ValueError("Not a valid hatty list export file.")
+ entities = payload.get("entities")
+ if not isinstance(entities, list):
+ raise ValueError("List export is missing its entities.")
+
+ final = self._unique_name(str(payload.get("name") or "Imported"))
+ self.entity_lists[final] = list(entities)
+ self.list_names.append(final)
+ if payload.get("manual"):
+ self.manual_lists.add(final)
+ if payload.get("notify"):
+ self._app.notify_ctl.notify_lists.add(final)
+ self._app.persist("lists", "manual_lists", "notify_lists")
+ return final
+
+ def _unique_name(self, name: str) -> str:
+ """`name`, or `name (2)`, `name (3)`, ... if it's already taken."""
+ final = name
+ suffix = 2
+ while final in self.entity_lists:
+ final = f"{name} ({suffix})"
+ suffix += 1
+ return final
+
def rename_list(self, old_name: str | None, new_name: str | None) -> None:
app = self._app
new_name = (new_name or "").strip()
diff --git a/src/hatty/git_sync.py b/src/hatty/git_sync.py
new file mode 100644
index 0000000..356b2ad
--- /dev/null
+++ b/src/hatty/git_sync.py
@@ -0,0 +1,327 @@
+# hatty — MIT License. See LICENSE file for details.
+"""The git CLI layer for the Backup & Sync feature: pull-on-start and
+commit/push-on-exit for the directory `backup.py` writes. Shells out to the
+`git` binary — no library dependency — through a single chokepoint
+(`_run_git`, mirroring `terminal_title._run_tmux`) so every invocation is
+non-interactive and time-bounded: it must never prompt for credentials, open
+an editor, or hang the TUI.
+
+Every public function returns `(ok, message)` (or a `RepoInfo`) and never
+raises — the `client.probe_connection` / `notifications.send_test_ntfy`
+contract, so a config-screen status label or an exit-time overlay can just
+display whatever comes back.
+"""
+
+import asyncio
+import os
+import subprocess
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+
+#: Local, filesystem-only git operations (init, add, commit, status).
+LOCAL_TIMEOUT = 10.0
+#: Operations that talk to a remote (pull, push).
+NETWORK_TIMEOUT = 60.0
+
+#: Outside git's own return-code range, so they're unambiguous in _explain.
+_RC_NO_GIT = -101
+_RC_TIMEOUT = -102
+
+# Applied to every invocation. core.editor=true makes any editor launch exit 0
+# instantly instead of blocking on a TTY; commit.gpgsign=false stops a
+# passphrase prompt from hanging the TUI; gc.auto=0 keeps a commit from
+# triggering a slow background gc the first time timing matters.
+_GLOBAL_FLAGS = [
+ "-c",
+ "core.editor=true",
+ "-c",
+ "core.pager=cat",
+ "-c",
+ "commit.gpgsign=false",
+ "-c",
+ "gc.auto=0",
+]
+
+
+def _git_env() -> dict[str, str]:
+ env = dict(os.environ)
+ env.update(
+ {
+ "GIT_TERMINAL_PROMPT": "0", # no username/password TTY prompt; error instead
+ "GIT_ASKPASS": "true", # ...and no askpass fallback
+ "SSH_ASKPASS": "true",
+ "SSH_ASKPASS_REQUIRE": "never",
+ "GIT_SSH_COMMAND": ("ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10"),
+ "GIT_PAGER": "cat",
+ "GIT_OPTIONAL_LOCKS": "0", # `status` won't block on index.lock
+ "LC_ALL": "C", # stable stderr strings for _explain()
+ }
+ )
+ for var in ("DISPLAY", "WAYLAND_DISPLAY", "GIT_EDITOR", "EDITOR", "VISUAL"):
+ env.pop(var, None) # no GUI askpass window, no terminal editor
+ return env
+
+
+def _run_git(args: list[str], cwd: str, timeout: float = LOCAL_TIMEOUT) -> tuple[int, str, str]:
+ """THE chokepoint every git invocation goes through. Unit tests monkeypatch
+ this one function (cf. `terminal_title._run_tmux`) and assert on the
+ recorded argument lists."""
+ try:
+ p = subprocess.run(
+ ["git", *_GLOBAL_FLAGS, *args],
+ cwd=cwd,
+ env=_git_env(),
+ stdin=subprocess.DEVNULL, # git can never steal the TUI's stdin
+ capture_output=True,
+ text=True,
+ errors="replace",
+ timeout=timeout,
+ start_new_session=True, # own process group: no controlling TTY
+ )
+ except FileNotFoundError:
+ return (_RC_NO_GIT, "", "git executable not found")
+ except subprocess.TimeoutExpired:
+ return (_RC_TIMEOUT, "", f"timed out after {timeout:g}s")
+ except OSError as e:
+ return (_RC_NO_GIT, "", str(e))
+ return (p.returncode, p.stdout, p.stderr)
+
+
+def _explain(op: str, rc: int, out: str, err: str) -> str:
+ if rc == _RC_NO_GIT:
+ return "git is not installed (or not on PATH)."
+ if rc == _RC_TIMEOUT:
+ return f"git {op} timed out."
+ text = f"{out}\n{err}".lower()
+ if "not a git repository" in text:
+ return "Not a git repository."
+ auth_markers = (
+ "could not read username",
+ "authentication failed",
+ "permission denied (publickey",
+ "terminal prompts disabled",
+ )
+ if any(s in text for s in auth_markers):
+ return "git rejected the credentials, and hatty can't prompt. Set up an SSH key or a credential helper."
+ if any(s in text for s in ("non-fast-forward", "updates were rejected", "fetch first")):
+ return "The remote has commits you don't have. Pull first, or resolve it manually."
+ if any(s in text for s in ("conflict", "automatic merge failed", "needs merge")):
+ return "Merge conflict — resolve it with git, then sync again."
+ if any(s in text for s in ("could not resolve host", "connection timed out", "network is unreachable")):
+ return "Could not reach the remote."
+ first_line = next((line for line in (*err.splitlines(), *out.splitlines()) if line.strip()), None)
+ return (first_line or f"git {op} failed (exit {rc}).")[:200]
+
+
+def default_commit_message(now: datetime | None = None) -> str:
+ return f"hatty backup {(now or datetime.now()):%Y-%m-%d %H:%M:%S}"
+
+
+def _current_branch(cwd: str) -> str:
+ rc, out, _err = _run_git(["symbolic-ref", "--quiet", "--short", "HEAD"], cwd)
+ if rc == 0:
+ return out.strip()
+ rc, out, _err = _run_git(["rev-parse", "--short", "HEAD"], cwd)
+ return out.strip() if rc == 0 else ""
+
+
+def _primary_remote(cwd: str) -> str:
+ rc, out, _err = _run_git(["remote"], cwd)
+ if rc != 0:
+ return ""
+ remotes = [r.strip() for r in out.splitlines() if r.strip()]
+ if not remotes:
+ return ""
+ return "origin" if "origin" in remotes else remotes[0]
+
+
+def _upstream(cwd: str) -> str:
+ rc, out, _err = _run_git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], cwd)
+ return out.strip() if rc == 0 else ""
+
+
+def _changed_count(cwd: str) -> int:
+ rc, out, _err = _run_git(["status", "--porcelain", "--untracked-files=all"], cwd)
+ if rc != 0:
+ return 0
+ return len([line for line in out.splitlines() if line.strip()])
+
+
+def _identity_flags(cwd: str) -> list[str]:
+ """A fresh machine (or CI) with no git identity configured fails commit
+ with "Please tell me who you are" — supply a fallback, but never override
+ an identity the user already has."""
+ rc, out, _err = _run_git(["config", "--get", "user.email"], cwd)
+ if rc == 0 and out.strip():
+ return []
+ return ["-c", "user.name=hatty", "-c", "user.email=hatty@localhost"]
+
+
+@dataclass(frozen=True)
+class RepoInfo:
+ ok: bool
+ message: str
+ installed: bool = False
+ is_repo: bool = False
+ root: str = ""
+ branch: str = ""
+ remote: str = ""
+ upstream: str = ""
+ changed: int = 0
+
+
+def repo_info(path: str) -> RepoInfo:
+ p = Path(path)
+ if not p.is_dir():
+ return RepoInfo(ok=False, message=f"{path} does not exist.")
+ cwd = str(p)
+ rc, out, err = _run_git(["rev-parse", "--show-toplevel"], cwd)
+ if rc == _RC_NO_GIT:
+ return RepoInfo(ok=False, message=_explain("check", rc, out, err))
+ if rc != 0:
+ return RepoInfo(ok=True, installed=True, is_repo=False, message="Not a git repository yet.")
+
+ root = out.strip()
+ # --is-inside-work-tree would say True for a directory merely *inside* the
+ # user's dotfiles repo, silently committing there — compare roots instead.
+ if os.path.realpath(root) != os.path.realpath(cwd):
+ return RepoInfo(
+ ok=False,
+ installed=True,
+ is_repo=True,
+ root=root,
+ message=f"{path} is inside the repository at {root} — pick a dedicated directory.",
+ )
+
+ branch = _current_branch(cwd)
+ remote = _primary_remote(cwd)
+ upstream = _upstream(cwd)
+ changed = _changed_count(cwd)
+ summary = f"{branch or '(no commits yet)'} · {changed} changed" if branch or changed else "No commits yet."
+ return RepoInfo(
+ ok=True,
+ installed=True,
+ is_repo=True,
+ root=root,
+ branch=branch,
+ remote=remote,
+ upstream=upstream,
+ changed=changed,
+ message=summary,
+ )
+
+
+def init_repo(path: str) -> tuple[bool, str]:
+ p = Path(path)
+ if not p.is_dir():
+ return False, f"{path} does not exist."
+ cwd = str(p)
+ rc, out, err = _run_git(["init", "-b", "main"], cwd)
+ if rc != 0:
+ rc, out, err = _run_git(["init"], cwd) # older git without -b
+ if rc != 0:
+ return False, _explain("init", rc, out, err)
+ return True, f"Initialized a git repository in {path}."
+
+
+def commit_all(path: str, message: str) -> tuple[bool, str]:
+ p = Path(path)
+ if not p.is_dir():
+ return False, f"{path} does not exist."
+ cwd = str(p)
+
+ rc, out, err = _run_git(["add", "-A", "--", "."], cwd)
+ if rc != 0:
+ return False, _explain("add", rc, out, err)
+
+ rc, _out, _err = _run_git(["diff", "--cached", "--quiet", "--"], cwd)
+ if rc == 0:
+ return True, "Nothing to commit — export is already up to date."
+ if rc not in (0, 1):
+ # No commits yet (or another diff failure) — fall back to status.
+ rc2, status_out, _err2 = _run_git(["status", "--porcelain"], cwd)
+ if rc2 == 0 and not status_out.strip():
+ return True, "Nothing to commit — export is already up to date."
+
+ args = [*_identity_flags(cwd), "commit", "--no-verify", "--no-gpg-sign", "-m", message]
+ rc, out, err = _run_git(args, cwd)
+ if rc != 0:
+ return False, _explain("commit", rc, out, err)
+ return True, "Committed."
+
+
+def pull(path: str, *, rebase: bool = False) -> tuple[bool, str]:
+ p = Path(path)
+ if not p.is_dir():
+ return False, f"{path} does not exist."
+ cwd = str(p)
+
+ remote = _primary_remote(cwd)
+ if not remote:
+ return True, "No git remote configured; nothing to pull."
+
+ args = ["pull", "--rebase", "--autostash"] if rebase else ["pull", "--ff-only", "--no-edit"]
+ args += ["--no-stat", "--no-tags"]
+ if not _upstream(cwd):
+ args += [remote, _current_branch(cwd) or "HEAD"]
+ rc, out, err = _run_git(args, cwd, timeout=NETWORK_TIMEOUT)
+ if rc != 0:
+ return False, _explain("pull", rc, out, err)
+ return True, "Pulled the latest changes."
+
+
+def push(path: str) -> tuple[bool, str]:
+ p = Path(path)
+ if not p.is_dir():
+ return False, f"{path} does not exist."
+ cwd = str(p)
+
+ remote = _primary_remote(cwd)
+ if not remote:
+ return True, "No git remote configured; committed locally only."
+ branch = _current_branch(cwd)
+ if not branch:
+ return False, "No commits yet — nothing to push."
+
+ args = ["push", "--porcelain"]
+ if not _upstream(cwd):
+ args.append("-u")
+ args += [remote, f"HEAD:refs/heads/{branch}"]
+ rc, out, err = _run_git(args, cwd, timeout=NETWORK_TIMEOUT)
+ if rc != 0:
+ return False, _explain("push", rc, out, err)
+ return True, "Pushed to the remote."
+
+
+def commit_and_push(path: str, message: str) -> tuple[bool, str]:
+ # Deliberately no pull first — a conflict at quit time is the worst
+ # possible moment; the next start's pull handles a diverged remote.
+ ok, msg = commit_all(path, message)
+ if not ok:
+ return False, msg
+ return push(path)
+
+
+async def repo_info_async(path: str) -> RepoInfo:
+ return await asyncio.to_thread(repo_info, path)
+
+
+async def init_repo_async(path: str) -> tuple[bool, str]:
+ return await asyncio.to_thread(init_repo, path)
+
+
+async def commit_all_async(path: str, message: str) -> tuple[bool, str]:
+ return await asyncio.to_thread(commit_all, path, message)
+
+
+async def pull_async(path: str, *, rebase: bool = False) -> tuple[bool, str]:
+ return await asyncio.to_thread(pull, path, rebase=rebase)
+
+
+async def push_async(path: str) -> tuple[bool, str]:
+ return await asyncio.to_thread(push, path)
+
+
+async def commit_and_push_async(path: str, message: str) -> tuple[bool, str]:
+ return await asyncio.to_thread(commit_and_push, path, message)
diff --git a/src/hatty/main.py b/src/hatty/main.py
index 88e81b3..47cac8b 100644
--- a/src/hatty/main.py
+++ b/src/hatty/main.py
@@ -39,6 +39,7 @@
DEFAULT_TERMINAL_TITLE,
TOGGLABLE_DOMAINS,
)
+from hatty.controllers.backup import BackupController
from hatty.controllers.connection import ConnectionController
from hatty.controllers.dashboards import DashboardController
from hatty.controllers.graphs import GraphController, _trim_history # noqa: F401 (_trim_history re-exported for tests)
@@ -114,6 +115,7 @@ def __init__(self, config_path: str | None = None, demo: bool = False):
self.notify_ctl = NotificationController(self)
self.log_ctl = LogbookController(self)
self.keys_ctl = KeybindingController(self)
+ self.backup_ctl = BackupController(self)
self.all_entities: list = []
self.entity_registry: list = []
@@ -134,6 +136,10 @@ def __init__(self, config_path: str | None = None, demo: bool = False):
# Fire-and-forget tasks hold a reference here so asyncio can't GC them
# mid-flight; done tasks remove themselves.
self._bg_tasks: set[asyncio.Task] = set()
+ # Guards the exit-time git sync against running twice: action_quit is
+ # the primary path, _on_exit_app is a backstop for any exit() call
+ # that bypasses it.
+ self._exit_sync_done = False
# Config key -> the app attribute that is its in-memory working copy, derived
# from storage.PERSISTED so there is one source of truth (issue #168).
@@ -146,6 +152,25 @@ def spawn(self, coro) -> asyncio.Task:
task.add_done_callback(self._bg_tasks.discard)
return task
+ async def drain_bg_tasks(self, timeout: float = 5.0) -> bool:
+ """Wait for the fire-and-forget tasks spawn() tracks (notably
+ _save_config_async) to finish, so an exit-time git commit picks up the
+ very last save. True if all completed. Uses asyncio.wait rather than
+ wait_for(gather(...)): the latter would cancel a still-running
+ storage.save_all mid-transaction on timeout, and a half-written
+ hatty.db is worse than a slow quit — stragglers are just left running.
+ Excludes the current task, since the caller (the exit-sync flow) is
+ itself usually running as a spawned task and would otherwise wait on
+ itself forever."""
+ current = asyncio.current_task()
+ pending = {t for t in self._bg_tasks if t is not current and not t.done()}
+ if not pending:
+ return True
+ _done, still_pending = await asyncio.wait(pending, timeout=timeout)
+ if still_pending:
+ self.log.warning(f"{len(still_pending)} background task(s) still running at exit")
+ return not still_pending
+
def persist(self, *keys: str) -> None:
"""Mirror the named collections from their app attributes into
app_config and schedule an async save. With no keys, just schedules
@@ -249,6 +274,41 @@ def on_unmount(self) -> None:
except Exception as e:
self.log.error(f"Error closing storage: {e}")
+ async def action_quit(self) -> None:
+ """Overrides Textual's default (a bare `self.exit()`) so a pending
+ `commit_on_exit`/`push_on_exit` git sync gets a visible overlay
+ instead of silently blocking the message pump — this method is itself
+ awaited from inside the still-running pump (Textual's
+ `_check_bindings`), so anything it awaits directly would freeze
+ rendering. ExitSyncScreen does the actual awaiting, from its own
+ `on_mount`, via `self.spawn(...)`."""
+ if self._exit_sync_done or not self.backup_ctl.exit_sync_pending():
+ self.exit()
+ return
+ self._exit_sync_done = True
+ from hatty.ui.exit_sync_screen import ExitSyncScreen
+
+ self.push_screen(ExitSyncScreen())
+
+ async def _on_exit_app(self) -> None:
+ """Backstop for an `exit()` that bypasses action_quit (a future call
+ site, `--press`, test autopilot). Textual's MRO dispatch
+ (`message_pump.py`) invokes `App._on_exit_app` right after this one —
+ do NOT call `super()._on_exit_app()`, or the shutdown sentinel gets
+ posted twice. Unlike action_quit, this runs with no live UI to show
+ progress in, so it just awaits the sync directly (frozen rendering is
+ expected here — the pump is already winding down)."""
+ if self._exit_sync_done or self._demo:
+ return
+ self._exit_sync_done = True
+ if not self.backup_ctl.exit_sync_pending():
+ return
+ try:
+ await self.drain_bg_tasks(timeout=5.0)
+ await self.backup_ctl.sync_on_exit()
+ except Exception as e:
+ self.log.error(f"git sync on exit failed: {e}")
+
def _storage_db_path(self):
from pathlib import Path
@@ -316,6 +376,11 @@ def _apply_config(self, cfg: dict) -> None:
self._apply_terminal_title(cfg)
self.keys_ctl.apply(cfg)
+ self.backup_ctl.apply(cfg)
+ # Only at boot/restart (this method's three call sites), never on a
+ # plain reconnect from the config screen — pull_on_start() is itself a
+ # no-op in demo mode and when the pref is off.
+ self.spawn(self.backup_ctl.pull_on_start())
self.query_one("#detail_panel", EntityDetailPanel).apply_saved_graph_type(cfg.get(CONFIG_KEY_GRAPH_TYPE))
@@ -461,12 +526,49 @@ def action_show_list_selection_popup(self) -> None:
def callback(result) -> None:
if isinstance(result, dict):
- self.list_ctl.handle_popup_action(result)
+ action = result.get("action")
+ if action == "export":
+ self._export_list(result["list_name"])
+ elif action == "import":
+ self._import_list()
+ else:
+ self.list_ctl.handle_popup_action(result)
elif isinstance(result, str):
self.select_or_create_list(result)
self.push_screen(ListSelectionPopup(), callback)
+ def _export_list(self, name: str) -> None:
+ from hatty.ui.json_export import export_json, slugify
+
+ export_json(
+ self,
+ payload=self.list_ctl.to_export_payload(name),
+ default_filename=f"{slugify(name)}.list.json",
+ title="Export list",
+ save_button="Export",
+ filters_label="List JSON",
+ success=lambda path: f"Exported '{name}' to {path}.",
+ success_title="List Exported",
+ )
+
+ def _import_list(self) -> None:
+ from hatty.ui.json_export import import_json
+
+ def _apply(payload: dict) -> str:
+ final = self.list_ctl.import_from_payload(payload)
+ self.select_or_create_list(final)
+ return f"Imported list '{final}'."
+
+ import_json(
+ self,
+ title="Import list",
+ open_button="Import",
+ filters_label="List JSON",
+ apply=_apply,
+ success_title="List Imported",
+ )
+
def select_or_create_list(self, list_name: str) -> None:
self.list_ctl.select_or_create(list_name)
@@ -1064,10 +1166,46 @@ def action_show_saved_graphs_popup(self) -> None:
def callback(result) -> None:
if isinstance(result, dict):
- self.graph_ctl.handle_saved_graphs_popup_action(result)
+ action = result.get("action")
+ if action == "export":
+ self._export_saved_graph(result["name"])
+ elif action == "import":
+ self._import_saved_graph()
+ else:
+ self.graph_ctl.handle_saved_graphs_popup_action(result)
self.push_screen(SavedGraphsPopup(), callback)
+ def _export_saved_graph(self, name: str) -> None:
+ from hatty.ui.json_export import export_json, slugify
+
+ export_json(
+ self,
+ payload=self.graph_ctl.to_export_payload(name),
+ default_filename=f"{slugify(name)}.graph.json",
+ title="Export saved graph",
+ save_button="Export",
+ filters_label="Graph JSON",
+ success=lambda path: f"Exported '{name}' to {path}.",
+ success_title="Graph Exported",
+ )
+
+ def _import_saved_graph(self) -> None:
+ from hatty.ui.json_export import import_json
+
+ def _apply(payload: dict) -> str:
+ final = self.graph_ctl.import_from_payload(payload)
+ return f"Imported saved graph '{final}'."
+
+ import_json(
+ self,
+ title="Import saved graph",
+ open_button="Import",
+ filters_label="Graph JSON",
+ apply=_apply,
+ success_title="Graph Imported",
+ )
+
# ── Config persistence ───────────────────────────────────────────────────
def watch_theme(self, theme: str) -> None:
@@ -1577,6 +1715,7 @@ def _on_config_saved(self, result: dict | None) -> None:
self.columns = result.get(CONFIG_KEY_COLUMNS, list(DEFAULT_COLUMNS))
self.entity_names = result.get(CONFIG_KEY_ENTITY_NAMES, {})
self.keys_ctl.apply(result)
+ self.backup_ctl.apply(result)
self.set_title_based_on_focused_ui()
new_graph_type = result.get(CONFIG_KEY_GRAPH_TYPE)
self.query_one("#detail_panel", EntityDetailPanel).apply_saved_graph_type(new_graph_type)
diff --git a/src/hatty/ui/config_screen.py b/src/hatty/ui/config_screen.py
index fc56c5f..5a268d9 100644
--- a/src/hatty/ui/config_screen.py
+++ b/src/hatty/ui/config_screen.py
@@ -1,6 +1,7 @@
# hatty — MIT License. See LICENSE file for details.
import os
import subprocess
+from pathlib import Path
from typing import TYPE_CHECKING, cast
from rich.table import Table
@@ -23,11 +24,15 @@
Static,
)
from textual.widgets.selection_list import Selection
+from textual_fspicker import SelectDirectory
+from hatty import backup as backup_module
from hatty import config as config_module
+from hatty import git_sync
from hatty import storage as storage_module
from hatty.client import probe_connection
from hatty.const import (
+ CONFIG_KEY_BACKUP,
CONFIG_KEY_COLUMNS,
CONFIG_KEY_DASHBOARDS,
CONFIG_KEY_ENTITY_NAMES,
@@ -43,12 +48,14 @@
CONFIG_KEY_THEME,
CONFIG_KEY_TOKEN,
CONFIG_KEY_URL,
+ DEFAULT_BACKUP,
DEFAULT_GRAPH_HOURS,
DEFAULT_NOTIFICATIONS,
DEFAULT_TERMINAL_TITLE,
)
from hatty.controllers import keybindings as keybindings_module
from hatty.controllers.notifications import send_test_ntfy
+from hatty.ui.confirm_popup import ConfirmPopup
from hatty.ui.entity_table import COLUMNS
from hatty.ui.key_capture_popup import KeyCapturePopup
@@ -81,6 +88,18 @@
("Highlight changed entity", "highlight"),
]
+# Backup & Sync git toggles, shown as a SelectionList mirroring the
+# notification channel toggles above. Keys match DEFAULT_BACKUP exactly minus
+# "path"/"sections", which get their own widgets.
+_BACKUP_GIT_TOGGLES = [
+ ("Enable git", "git_enabled"),
+ ("Pull on start", "pull_on_start"),
+ ("Import after a successful pull", "import_on_pull"),
+ ("Commit on exit", "commit_on_exit"),
+ ("Push on exit", "push_on_exit"),
+ ("Rebase on pull (instead of fast-forward only)", "pull_rebase"),
+]
+
# Top-level category menu (issue #252). Each entry is (display name, pane id,
# one-line hint, first-focus widget id within the pane). "cat_menu" itself is
# the ContentSwitcher's built-in first pane and isn't listed here.
@@ -90,6 +109,7 @@
("Notifications", "cat_notifications", "Toast/beep/desktop/ntfy alerts", "#cfg_notify"),
("Data & Collections", "cat_data", "Lists, name overrides, dashboards, saved graphs", "#cat_data"),
("Keybindings", "cat_keybindings", "Rebind keys for navigation, lists, log and graph", "#cfg_keys"),
+ ("Backup & Sync", "cat_backup", "Export/import your data, optional git repo", "#cfg_backup_path"),
]
# Row key prefix for a non-interactive section-header row in #cfg_keys, so
@@ -187,6 +207,30 @@ class ConfigScreen(Screen):
#cfg_ntfy_status.-error {
color: $error;
}
+ #cfg_backup_path_row {
+ height: auto;
+ }
+ #cfg_backup_path_row Input {
+ width: 1fr;
+ }
+ #cfg_backup_status {
+ margin-top: 1;
+ color: $text;
+ }
+ #cfg_backup_status.-ok {
+ color: $success;
+ }
+ #cfg_backup_status.-error {
+ color: $error;
+ }
+ #cfg_backup_buttons {
+ height: auto;
+ margin-top: 1;
+ }
+ #cfg_backup_buttons Button {
+ margin-right: 2;
+ margin-top: 1;
+ }
"""
def __init__(self, raw_config: dict, config_path: str | None):
@@ -237,6 +281,8 @@ def compose(self) -> ComposeResult:
entity_names = self._raw_config.get(CONFIG_KEY_ENTITY_NAMES, {})
dashboards = self._raw_config.get(CONFIG_KEY_DASHBOARDS, {})
saved_graphs = self._raw_config.get(CONFIG_KEY_SAVED_GRAPHS, {})
+ backup_prefs = {**DEFAULT_BACKUP, **(self._raw_config.get(CONFIG_KEY_BACKUP) or {})}
+ backup_sections = set(backup_prefs.get("sections") or [])
themes = sorted(self.app.available_themes)
theme_kwargs: dict = {"allow_blank": True}
@@ -373,6 +419,33 @@ def compose(self) -> ComposeResult:
yield DataTable(id="cfg_keys", cursor_type="row")
yield Button("Reset all to defaults", id="cfg_keys_reset")
+ with VerticalScroll(id="cat_backup", classes="config-pane"):
+ yield Label("Backup Directory", classes="section-title")
+ with Horizontal(id="cfg_backup_path_row"):
+ yield Input(value=backup_prefs.get("path", ""), id="cfg_backup_path", placeholder="/path/to/backup")
+ yield Button("Browse…", id="cfg_backup_browse")
+
+ yield Label("Sections to back up", classes="field-label")
+ section_selections = [
+ Selection(backup_module.SECTION_LABELS[s], s, s in backup_sections) for s in backup_module.SECTIONS
+ ]
+ yield SelectionList(*section_selections, id="cfg_backup_sections")
+
+ yield Label("Git", classes="section-title")
+ git_selections = [
+ Selection(label, key, bool(backup_prefs.get(key))) for label, key in _BACKUP_GIT_TOGGLES
+ ]
+ yield SelectionList(*git_selections, id="cfg_backup_git")
+
+ yield Label("", id="cfg_backup_status")
+ with Horizontal(id="cfg_backup_buttons"):
+ yield Button("Export now", id="cfg_backup_export")
+ yield Button("Import now", id="cfg_backup_import")
+ yield Button("Init repo", id="cfg_backup_init")
+ yield Button("Pull", id="cfg_backup_pull")
+ yield Button("Push", id="cfg_backup_push")
+ yield Button("Check status", id="cfg_backup_status_check")
+
yield Footer()
def on_mount(self) -> None:
@@ -462,6 +535,20 @@ def on_button_pressed(self, event: Button.Pressed) -> None:
elif event.button.id == "cfg_keys_reset":
self._keybindings = {}
self._populate_keybindings_table()
+ elif event.button.id == "cfg_backup_browse":
+ self.action_backup_browse()
+ elif event.button.id == "cfg_backup_export":
+ self.action_backup_export()
+ elif event.button.id == "cfg_backup_import":
+ self.action_backup_import()
+ elif event.button.id == "cfg_backup_init":
+ self.action_backup_init()
+ elif event.button.id == "cfg_backup_pull":
+ self.action_backup_pull()
+ elif event.button.id == "cfg_backup_push":
+ self.action_backup_push()
+ elif event.button.id == "cfg_backup_status_check":
+ self.action_backup_status_check()
def action_stop_watching_all(self) -> None:
# A live write (like the l/d/s popups edit their own collections directly)
@@ -515,6 +602,123 @@ async def _do_test_ntfy(self, prefs: dict) -> None:
ok, message = await send_test_ntfy(prefs, "hatty", "🔔 Test notification from hatty")
self._set_ntfy_status(message, ok=ok)
+ # ── Backup & Sync ─────────────────────────────────────────────────────────
+ # Every action here acts on the currently entered (unsaved) path/sections/git
+ # toggles, the action_test_connection/action_test_ntfy precedent — not on
+ # self.app.backup_ctl.prefs, which only reflects the last *saved* config.
+
+ def _backup_path(self) -> str:
+ return self.query_one("#cfg_backup_path", Input).value.strip()
+
+ def _backup_sections(self) -> list[str]:
+ selected = set(self.query_one("#cfg_backup_sections", SelectionList).selected)
+ return [s for s in backup_module.SECTIONS if s in selected]
+
+ def _backup_git_prefs(self) -> dict:
+ selected = set(self.query_one("#cfg_backup_git", SelectionList).selected)
+ return {key: key in selected for _label, key in _BACKUP_GIT_TOGGLES}
+
+ def _set_backup_status(self, text: str, ok: bool | None = None) -> None:
+ status = self.query_one("#cfg_backup_status", Label)
+ status.update(text)
+ status.set_class(ok is True, "-ok")
+ status.set_class(ok is False, "-error")
+
+ def action_backup_browse(self) -> None:
+ current = self._backup_path() or str(Path.home())
+
+ def _picked(path: Path | None) -> None:
+ if path is not None:
+ self.query_one("#cfg_backup_path", Input).value = str(path)
+
+ self.app.push_screen(SelectDirectory(location=current, title="Backup directory"), _picked)
+
+ def action_backup_export(self) -> None:
+ path = self._backup_path()
+ sections = self._backup_sections()
+ if not path or not sections:
+ self._set_backup_status("Set a directory and at least one section first.", ok=False)
+ return
+ self._set_backup_status("Exporting…")
+ self.run_worker(self._do_backup_export(path, sections), exclusive=True)
+
+ async def _do_backup_export(self, path: str, sections: list[str]) -> None:
+ # Not asyncio.to_thread: export_now/import_now (via the object
+ # controllers' import_from_payload) call app.persist(), which does
+ # asyncio.create_task() — that needs the app's own running loop, which
+ # a thread-pool worker thread doesn't have.
+ ok, msg = self.app.backup_ctl.export_now(path, sections)
+ self._set_backup_status(msg, ok=ok)
+
+ def action_backup_import(self) -> None:
+ path = self._backup_path()
+ sections = self._backup_sections()
+ if not path or not sections:
+ self._set_backup_status("Set a directory and at least one section first.", ok=False)
+ return
+ labels = ", ".join(backup_module.SECTION_LABELS[s] for s in sections)
+
+ def _confirmed(confirmed: bool | None) -> None:
+ if not confirmed:
+ return
+ self._set_backup_status("Importing…")
+ self.run_worker(self._do_backup_import(path, sections), exclusive=True)
+
+ self.app.push_screen(ConfirmPopup(f"Replace {labels} with the backup in {path}?"), _confirmed)
+
+ async def _do_backup_import(self, path: str, sections: list[str]) -> None:
+ ok, msg, _found = self.app.backup_ctl.import_now(sections, path)
+ self._set_backup_status(msg, ok=ok)
+
+ def action_backup_init(self) -> None:
+ path = self._backup_path()
+ if not path:
+ self._set_backup_status("Set a directory first.", ok=False)
+ return
+ self._set_backup_status("Initializing…")
+ self.run_worker(self._do_backup_init(path), exclusive=True)
+
+ async def _do_backup_init(self, path: str) -> None:
+ ok, msg = await git_sync.init_repo_async(path)
+ self._set_backup_status(msg, ok=ok)
+
+ def action_backup_pull(self) -> None:
+ path = self._backup_path()
+ if not path:
+ self._set_backup_status("Set a directory first.", ok=False)
+ return
+ rebase = "pull_rebase" in self.query_one("#cfg_backup_git", SelectionList).selected
+ self._set_backup_status("Pulling…")
+ self.run_worker(self._do_backup_pull(path, rebase), exclusive=True)
+
+ async def _do_backup_pull(self, path: str, rebase: bool) -> None:
+ ok, msg = await git_sync.pull_async(path, rebase=rebase)
+ self._set_backup_status(msg, ok=ok)
+
+ def action_backup_push(self) -> None:
+ path = self._backup_path()
+ if not path:
+ self._set_backup_status("Set a directory first.", ok=False)
+ return
+ self._set_backup_status("Committing and pushing…")
+ self.run_worker(self._do_backup_push(path), exclusive=True)
+
+ async def _do_backup_push(self, path: str) -> None:
+ ok, msg = await git_sync.commit_and_push_async(path, git_sync.default_commit_message())
+ self._set_backup_status(msg, ok=ok)
+
+ def action_backup_status_check(self) -> None:
+ path = self._backup_path()
+ if not path:
+ self._set_backup_status("Set a directory first.", ok=False)
+ return
+ self._set_backup_status("Checking…")
+ self.run_worker(self._do_backup_status_check(path), exclusive=True)
+
+ async def _do_backup_status_check(self, path: str) -> None:
+ info = await git_sync.repo_info_async(path)
+ self._set_backup_status(info.message, ok=info.ok)
+
def action_save_and_close(self) -> None:
url = self.query_one("#cfg_url", Input).value.strip()
token = self.query_one("#cfg_token", Input).value.strip()
@@ -562,6 +766,11 @@ def action_save_and_close(self) -> None:
new_config[CONFIG_KEY_COLUMNS] = columns if columns else existing
new_config[CONFIG_KEY_NOTIFICATIONS] = notifications
new_config[CONFIG_KEY_KEYBINDINGS] = dict(self._keybindings)
+ new_config[CONFIG_KEY_BACKUP] = {
+ "path": self._backup_path(),
+ "sections": self._backup_sections(),
+ **self._backup_git_prefs(),
+ }
# Collections live in SQLite; this screen only edits connection settings +
# display preferences, so keep them out of the lean YAML it writes. The
diff --git a/src/hatty/ui/exit_sync_screen.py b/src/hatty/ui/exit_sync_screen.py
new file mode 100644
index 0000000..71d7cff
--- /dev/null
+++ b/src/hatty/ui/exit_sync_screen.py
@@ -0,0 +1,122 @@
+# hatty — MIT License. See LICENSE file for details.
+"""Full-screen overlay shown while an exit-time export + git sync
+(`commit_on_exit`/`push_on_exit`) runs, pushed from `HACLI.action_quit`.
+Never traps the user: escape or the (unrebindable, RESERVED_KEYS) quit key
+skips the sync and exits immediately, mirroring SplashScreen's "any keypress
+dismisses it" rule for a slow/failing connection."""
+
+import asyncio
+import time
+from typing import TYPE_CHECKING
+
+from textual.app import ComposeResult
+from textual.binding import Binding
+from textual.containers import Vertical
+from textual.screen import Screen
+from textual.widgets import Label, Static
+
+if TYPE_CHECKING:
+ from hatty.main import HACLI
+
+
+class ExitSyncScreen(Screen):
+ app: "HACLI" # narrow Textual's inherited attr for type-checkers; annotation only, no runtime effect
+
+ BINDINGS = [
+ Binding("escape", "skip", "Skip and quit now"),
+ Binding("ctrl+q", "skip", "Skip and quit now", show=False),
+ ]
+
+ DEFAULT_CSS = """
+ ExitSyncScreen {
+ align: center middle;
+ background: $background;
+ }
+ #exit_sync_body {
+ /* Not auto: an auto container with only 100%-width children measures 0 wide. */
+ width: 60;
+ max-width: 90%;
+ height: auto;
+ align: center middle;
+ }
+ #exit_sync_title {
+ width: 100%;
+ text-align: center;
+ text-style: bold;
+ color: $accent;
+ }
+ #exit_sync_status {
+ width: 100%;
+ text-align: center;
+ color: $text-muted;
+ margin-top: 1;
+ }
+ #exit_sync_hint {
+ width: 100%;
+ text-align: center;
+ color: $text-disabled;
+ margin-top: 1;
+ }
+ """
+
+ def __init__(self) -> None:
+ super().__init__()
+ self._done = False
+ self._phase = "Saving…"
+ self._phase_started: float = 0.0
+
+ def compose(self) -> ComposeResult:
+ with Vertical(id="exit_sync_body"):
+ yield Static("hatty — syncing", id="exit_sync_title")
+ yield Label("Saving…", id="exit_sync_status")
+ yield Label("escape to skip and quit now", id="exit_sync_hint")
+
+ def on_mount(self) -> None:
+ self.set_interval(1.0, self._render_status)
+ self.app.spawn(self._sync())
+
+ def _set_status(self, text: str, final: bool = False) -> None:
+ if self._done:
+ return
+ if final:
+ self._update_label(text)
+ return
+ self._phase = text
+ self._phase_started = time.monotonic()
+ self._render_status()
+
+ def _render_status(self) -> None:
+ if self._done:
+ return
+ elapsed = int(time.monotonic() - self._phase_started)
+ text = f"{self._phase} {elapsed}s" if elapsed >= 1 else self._phase
+ self._update_label(text)
+
+ def _update_label(self, text: str) -> None:
+ try:
+ self.query_one("#exit_sync_status", Label).update(text)
+ except Exception:
+ pass # the screen may already be torn down by a concurrent skip
+
+ async def _sync(self) -> None:
+ try:
+ self._set_status("Saving…")
+ await self.app.drain_bg_tasks(timeout=5.0)
+ # sync_on_exit calls this back before each of its own phases
+ # (Exporting…/Committing…/Pushing…), so the overlay always shows
+ # what's actually happening instead of one static message for
+ # however long the whole thing takes.
+ ok, msg = await self.app.backup_ctl.sync_on_exit(status=self._set_status)
+ if msg:
+ self._set_status(msg, final=True)
+ if not ok:
+ await asyncio.sleep(2.0) # let the user read the failure
+ except Exception as e:
+ self.app.log.error(f"git sync on exit failed: {e}")
+ finally:
+ self._done = True
+ self.app.exit()
+
+ def action_skip(self) -> None:
+ self._done = True
+ self.app.exit()
diff --git a/src/hatty/ui/graph/saved_graphs_popup.py b/src/hatty/ui/graph/saved_graphs_popup.py
index 87496f2..0768cd5 100644
--- a/src/hatty/ui/graph/saved_graphs_popup.py
+++ b/src/hatty/ui/graph/saved_graphs_popup.py
@@ -95,6 +95,13 @@ def action_delete_graph(self) -> None:
if self.selected_name:
self.dismiss({"action": "delete", "name": self.selected_name})
+ def action_export_graph(self) -> None:
+ if self.selected_name:
+ self.dismiss({"action": "export", "name": self.selected_name})
+
+ def action_import_graph(self) -> None:
+ self.dismiss({"action": "import"})
+
def action_cancel(self) -> None:
rename_input = self.query_one("#saved_graph_rename_input", Input)
if rename_input.display:
diff --git a/src/hatty/ui/json_export.py b/src/hatty/ui/json_export.py
new file mode 100644
index 0000000..f24c1c7
--- /dev/null
+++ b/src/hatty/ui/json_export.py
@@ -0,0 +1,106 @@
+# hatty — MIT License. See LICENSE file for details.
+"""Shared FileSave/FileOpen plumbing for hatty's single-object JSON export/import
+popups (lists, saved graphs — mirrors dashboards' pre-existing export/import,
+`ui/dashboard/screen.py`). One JSON file per object, always `{"hatty_":
+, "name": ..., : {...}}`, so any file this writes can be dropped
+into a directory another hatty instance treats as an import source."""
+
+import json
+from collections.abc import Callable
+from pathlib import Path
+
+from textual_fspicker import FileOpen, FileSave, Filters
+
+
+def json_filters(label: str) -> Filters:
+ return Filters(
+ (label, lambda p: p.suffix.lower() == ".json"),
+ ("All files", lambda _p: True),
+ )
+
+
+def slugify(name: str) -> str:
+ return name.strip().lower().replace(" ", "-") or "export"
+
+
+def export_json(
+ host,
+ *,
+ payload: dict,
+ default_filename: str,
+ title: str,
+ save_button: str,
+ success: Callable[[Path], str],
+ filters_label: str,
+ error_title: str = "Export Failed",
+ success_title: str = "Exported",
+) -> None:
+ """Push a FileSave dialog and write `payload` as indent=2 JSON to the chosen
+ path. `success(path)` builds the notify message on success. `host` is
+ anything with `push_screen`/`notify` directly — the App itself, or a Screen
+ via `self.app`."""
+
+ def _do_export(path: Path | None) -> None:
+ if path is None:
+ return
+ try:
+ path.expanduser().write_text(json.dumps(payload, indent=2))
+ except OSError as exc:
+ host.notify(f"Could not write '{path}': {exc}", title=error_title, severity="error")
+ return
+ host.notify(success(path), title=success_title)
+
+ host.push_screen(
+ FileSave(
+ location=str(Path.home()),
+ title=title,
+ save_button=save_button,
+ cancel_button="Cancel",
+ default_file=default_filename,
+ filters=json_filters(filters_label),
+ ),
+ _do_export,
+ )
+
+
+def import_json(
+ host,
+ *,
+ title: str,
+ open_button: str,
+ apply: Callable[[dict], str],
+ filters_label: str,
+ error_title: str = "Import Failed",
+ success_title: str = "Imported",
+) -> None:
+ """Push a FileOpen dialog, parse the chosen file as JSON, and hand it to
+ `apply(payload)`, which performs the import and returns the notify message
+ (raising `ValueError` with a user-facing message to reject it). `host` is
+ anything with `push_screen`/`notify` directly — the App itself, or a Screen
+ via `self.app`."""
+
+ def _do_import(path: Path | None) -> None:
+ if path is None:
+ return
+ try:
+ payload = json.loads(path.expanduser().read_text())
+ except (OSError, ValueError) as exc:
+ host.notify(f"Could not read '{path}': {exc}", title=error_title, severity="error")
+ return
+ try:
+ message = apply(payload)
+ except ValueError as exc:
+ host.notify(str(exc), title=error_title, severity="error")
+ return
+ host.notify(message, title=success_title)
+
+ host.push_screen(
+ FileOpen(
+ location=str(Path.home()),
+ title=title,
+ open_button=open_button,
+ cancel_button="Cancel",
+ filters=json_filters(filters_label),
+ ),
+ _do_import,
+ )
diff --git a/src/hatty/ui/list_selection_popup.py b/src/hatty/ui/list_selection_popup.py
index 9871102..9589238 100644
--- a/src/hatty/ui/list_selection_popup.py
+++ b/src/hatty/ui/list_selection_popup.py
@@ -143,6 +143,13 @@ def action_view_as_dashboard(self) -> None:
if self.selected_name and self.selected_name != "View All":
self.dismiss({"action": "view_as_dashboard", "list_name": self.selected_name})
+ def action_export_list(self) -> None:
+ if self.selected_name and self.selected_name != "View All":
+ self.dismiss({"action": "export", "list_name": self.selected_name})
+
+ def action_import_list(self) -> None:
+ self.dismiss({"action": "import"})
+
def action_toggle_notify(self) -> None:
# Acts in place (like _move below) rather than dismissing, so several
# lists can be toggled in one visit to the popup.
diff --git a/tests/conftest.py b/tests/conftest.py
index ed305d7..01b6c55 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -35,6 +35,15 @@ def _no_terminal_title_side_effects(monkeypatch):
monkeypatch.setattr("hatty.terminal_title.restore", lambda prev: None)
+@pytest.fixture(autouse=True)
+def _no_real_git_calls(monkeypatch):
+ """Once commit_on_exit/push_on_exit exist, quitting the real HACLI app can
+ shell out to git. Stub the chokepoint so no acceptance test ever touches a
+ real git binary or the network at quit time; a test that wants the real
+ thing (tests/test_backup_git.py) opts back in with its own monkeypatch."""
+ monkeypatch.setattr("hatty.git_sync._run_git", lambda args, cwd, timeout=None: (0, "", ""))
+
+
def notified(app, *, title=None, message_contains=None):
"""True if a currently-live notification matches the given title and/or
message substring. Prefer this over `len(app._notifications) > before`
diff --git a/tests/test_backup_exit_sync.py b/tests/test_backup_exit_sync.py
new file mode 100644
index 0000000..87c8167
--- /dev/null
+++ b/tests/test_backup_exit_sync.py
@@ -0,0 +1,182 @@
+# hatty — MIT License. See LICENSE file for details.
+"""Acceptance tests for the exit-time git sync (HACLI.action_quit +
+ExitSyncScreen): quitting with commit_on_exit set runs the export + git
+commit before the app actually exits, quitting with no backup configured
+never touches git, and escape on the overlay skips a slow sync instead of
+trapping the user."""
+
+import asyncio
+
+import pytest
+from textual.widgets import Label
+
+from hatty import git_sync
+from hatty.ui.exit_sync_screen import ExitSyncScreen
+from tests.conftest import make_config
+
+
+@pytest.fixture
+def git_spy(monkeypatch):
+ """Overrides tests/conftest.py's blanket _no_real_git_calls stub with one
+ that also records every invocation, so these tests can assert on it."""
+ calls = []
+
+ def fake(args, cwd, timeout=None):
+ calls.append(args)
+ if args[:3] == ["diff", "--cached", "--quiet"]:
+ return (1, "", "") # rc 1 = something staged, so commit_all proceeds
+ if args[0] == "remote":
+ return (0, "origin\n", "") # a configured remote, so push actually runs
+ if args[:2] == ["symbolic-ref", "--quiet"]:
+ return (0, "main\n", "")
+ return (0, "", "")
+
+ monkeypatch.setattr(git_sync, "_run_git", fake)
+ return calls
+
+
+def _backup_config(path, **overrides):
+ return {
+ **make_config(),
+ "backup": {
+ "path": str(path),
+ "git_enabled": True,
+ "sections": ["lists"],
+ **overrides,
+ },
+ }
+
+
+async def test_commit_on_exit_runs_git_before_quitting(make_app, git_spy, tmp_path):
+ backup_dir = tmp_path / "backup"
+ backup_dir.mkdir()
+ app = make_app(config_data=_backup_config(backup_dir, commit_on_exit=True))
+
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ await pilot.press("ctrl+q")
+ await pilot.pause()
+ await pilot.pause()
+
+ assert any(c[0] == "add" for c in git_spy)
+ assert any("commit" in c for c in git_spy)
+ assert not any(c[0] == "push" for c in git_spy) # commit_on_exit alone never pushes
+ assert app._exit_sync_done is True
+ assert app._exit is True
+
+
+async def test_push_on_exit_also_pushes(make_app, git_spy, tmp_path):
+ backup_dir = tmp_path / "backup"
+ backup_dir.mkdir()
+ app = make_app(config_data=_backup_config(backup_dir, push_on_exit=True))
+
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ await pilot.press("ctrl+q")
+ await pilot.pause()
+ await pilot.pause()
+
+ assert any("commit" in c for c in git_spy)
+ assert any(c[0] == "push" for c in git_spy)
+
+
+async def test_no_backup_configured_quits_without_touching_git(make_app, git_spy):
+ app = make_app(config_data=make_config())
+
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ await pilot.press("ctrl+q")
+ await pilot.pause()
+
+ assert git_spy == []
+ assert app._exit is True
+
+
+async def test_escape_skips_a_slow_sync_and_quits_immediately(make_app, tmp_path, monkeypatch):
+ backup_dir = tmp_path / "backup"
+ backup_dir.mkdir()
+ app = make_app(config_data=_backup_config(backup_dir, commit_on_exit=True))
+
+ async def _slow_sync(status=None, timeout=75.0):
+ await asyncio.sleep(10)
+ return True, "done"
+
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ monkeypatch.setattr(app.backup_ctl, "sync_on_exit", _slow_sync)
+
+ await pilot.press("ctrl+q")
+ await pilot.pause()
+ assert isinstance(app.screen, ExitSyncScreen)
+
+ await pilot.press("escape")
+ await pilot.pause()
+
+ # Reaching here (well under the 10s sync) proves escape didn't wait for it.
+ assert app._exit is True
+
+
+async def test_exit_sync_overlay_is_actually_visible(make_app, tmp_path, monkeypatch):
+ """Regression test for a body that measured 0-wide and rendered as a
+ blank black screen (an `auto`-width container whose only children were
+ all `100%` wide)."""
+ backup_dir = tmp_path / "backup"
+ backup_dir.mkdir()
+ app = make_app(config_data=_backup_config(backup_dir, commit_on_exit=True))
+
+ async def _slow_sync(status=None, timeout=75.0):
+ await asyncio.sleep(10)
+ return True, "done"
+
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ monkeypatch.setattr(app.backup_ctl, "sync_on_exit", _slow_sync)
+
+ await pilot.press("ctrl+q")
+ await pilot.pause()
+ assert isinstance(app.screen, ExitSyncScreen)
+
+ body = app.screen.query_one("#exit_sync_body")
+ status = app.screen.query_one("#exit_sync_status", Label)
+ assert body.size.width > 0
+ assert status.size.width > 0
+
+ await pilot.press("escape")
+ await pilot.pause()
+
+
+async def test_exit_sync_overlay_shows_each_phase(make_app, tmp_path, monkeypatch):
+ backup_dir = tmp_path / "backup"
+ backup_dir.mkdir()
+ app = make_app(config_data=_backup_config(backup_dir, commit_on_exit=True))
+
+ committing = asyncio.Event()
+ release_committing = asyncio.Event()
+
+ async def _phased_sync(status=None, timeout=75.0):
+ if status:
+ status("Exporting…")
+ await asyncio.sleep(0)
+ if status:
+ status("Committing…")
+ committing.set()
+ await release_committing.wait()
+ return True, "Committed."
+
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ monkeypatch.setattr(app.backup_ctl, "sync_on_exit", _phased_sync)
+
+ await pilot.press("ctrl+q")
+ await pilot.pause()
+
+ await asyncio.wait_for(committing.wait(), timeout=2.0)
+ await pilot.pause()
+ status_label = app.screen.query_one("#exit_sync_status", Label)
+ assert str(status_label.content) == "Committing…"
+
+ release_committing.set()
+ await pilot.pause()
+
+ # The sync ran to completion (final "Committed." message) and exited cleanly.
+ assert app._exit is True
diff --git a/tests/test_backup_git.py b/tests/test_backup_git.py
new file mode 100644
index 0000000..28cfe98
--- /dev/null
+++ b/tests/test_backup_git.py
@@ -0,0 +1,206 @@
+# hatty — MIT License. See LICENSE file for details.
+"""A handful of git_sync.py tests against a *real* git binary and a local
+bare remote — the unit tests in tests/unit/test_git_sync.py fake _run_git and
+prove the argument lists are right; these prove the hardening flags actually
+work against real git (identity fallback, ff-only rejection, a genuine
+clone-back round trip)."""
+
+import os
+import shutil
+import subprocess
+
+import pytest
+
+from hatty import backup, git_sync
+from hatty.controllers.lists import ListController
+
+pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="git not installed")
+
+# tests/conftest.py's autouse _no_real_git_calls stubs git_sync._run_git for
+# every test so the acceptance suite never shells out to git; captured here at
+# import time (before any monkeypatch has run) so this module's own autouse
+# fixture below can restore it.
+_REAL_RUN_GIT = git_sync._run_git
+
+
+@pytest.fixture(autouse=True)
+def _use_real_git(monkeypatch):
+ # Runs after tests/conftest.py's _no_real_git_calls (a parent-conftest
+ # autouse fixture is instantiated before a same-scoped one defined in the
+ # test module itself), so this un-stubs it back to the real binary — the
+ # whole point of this file.
+ monkeypatch.setattr(git_sync, "_run_git", _REAL_RUN_GIT)
+
+
+@pytest.fixture(autouse=True)
+def _isolated_git_config(monkeypatch):
+ # Never let the developer's (or CI runner's) ~/.gitconfig — commit.gpgsign,
+ # pull.rebase, init.defaultBranch, credential.helper — decide the outcome.
+ monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull)
+ monkeypatch.setenv("GIT_CONFIG_SYSTEM", os.devnull)
+
+
+def _git(*args, cwd) -> None:
+ subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True)
+
+
+def test_init_export_commit_push_clone_back(tmp_path):
+ remote = tmp_path / "remote.git"
+ work = tmp_path / "work"
+ work.mkdir()
+ _git("init", "--bare", "-b", "main", str(remote), cwd=tmp_path)
+
+ ok, msg = git_sync.init_repo(str(work))
+ assert ok, msg
+
+ (work / "hatty-backup.json").write_text('{"hatty_backup": 1}')
+ _git("remote", "add", "origin", str(remote), cwd=work)
+
+ ok, msg = git_sync.commit_and_push(str(work), "initial backup")
+ assert ok, msg
+
+ clone = tmp_path / "clone"
+ _git("clone", str(remote), str(clone), cwd=tmp_path)
+ assert (clone / "hatty-backup.json").exists()
+
+
+def test_second_push_with_no_changes_is_a_noop(tmp_path):
+ remote = tmp_path / "remote.git"
+ work = tmp_path / "work"
+ work.mkdir()
+ _git("init", "--bare", "-b", "main", str(remote), cwd=tmp_path)
+ git_sync.init_repo(str(work))
+ (work / "hatty-backup.json").write_text('{"hatty_backup": 1}')
+ _git("remote", "add", "origin", str(remote), cwd=work)
+ ok, msg = git_sync.commit_and_push(str(work), "initial")
+ assert ok, msg
+
+ log = subprocess.run(["git", "log", "--oneline"], cwd=work, capture_output=True, text=True, check=True)
+ commit_count_before = len(log.stdout.splitlines())
+
+ # commit_all itself reports "Nothing to commit" (checked at the unit-test
+ # level); at this level what matters is that no *new* commit was created
+ # and the push (of an already up-to-date branch) still succeeds.
+ ok, msg = git_sync.commit_and_push(str(work), "no changes")
+ assert ok, msg
+
+ log = subprocess.run(["git", "log", "--oneline"], cwd=work, capture_output=True, text=True, check=True)
+ assert len(log.stdout.splitlines()) == commit_count_before
+
+
+def test_push_rejected_when_remote_has_diverged(tmp_path):
+ remote = tmp_path / "remote.git"
+ work_a = tmp_path / "work_a"
+ work_b = tmp_path / "work_b"
+ work_a.mkdir()
+ work_b.mkdir()
+ _git("init", "--bare", "-b", "main", str(remote), cwd=tmp_path)
+
+ for work in (work_a, work_b):
+ git_sync.init_repo(str(work))
+ _git("remote", "add", "origin", str(remote), cwd=work)
+
+ (work_a / "hatty-backup.json").write_text('{"hatty_backup": 1}')
+ ok, msg = git_sync.commit_and_push(str(work_a), "first")
+ assert ok, msg
+
+ # work_b never pulled work_a's commit -> its push is rejected non-ff.
+ (work_b / "other.json").write_text('{"x": 1}')
+ ok, msg = git_sync.commit_and_push(str(work_b), "second")
+ assert ok is False
+ assert msg
+
+
+def test_commit_succeeds_with_no_identity_configured(tmp_path, monkeypatch):
+ # A fresh machine (or CI) may have no git identity at all — commit_all's
+ # fallback (-c user.name=hatty -c user.email=...) must still succeed.
+ for var in ("GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL"):
+ monkeypatch.delenv(var, raising=False)
+
+ work = tmp_path / "work"
+ work.mkdir()
+ git_sync.init_repo(str(work))
+ (work / "hatty-backup.json").write_text('{"hatty_backup": 1}')
+
+ ok, msg = git_sync.commit_all(str(work), "no identity")
+ assert ok, msg
+ assert msg == "Committed."
+
+
+def test_pull_ff_only_success(tmp_path):
+ remote = tmp_path / "remote.git"
+ work_a = tmp_path / "work_a"
+ work_b = tmp_path / "work_b"
+ work_a.mkdir()
+ _git("init", "--bare", "-b", "main", str(remote), cwd=tmp_path)
+
+ git_sync.init_repo(str(work_a))
+ _git("remote", "add", "origin", str(remote), cwd=work_a)
+ (work_a / "hatty-backup.json").write_text('{"hatty_backup": 1}')
+ ok, msg = git_sync.commit_and_push(str(work_a), "first")
+ assert ok, msg
+
+ _git("clone", str(remote), str(work_b), cwd=tmp_path)
+
+ (work_a / "hatty-backup.json").write_text('{"hatty_backup": 2}')
+ ok, msg = git_sync.commit_and_push(str(work_a), "second")
+ assert ok, msg
+
+ ok, msg = git_sync.pull(str(work_b))
+ assert ok, msg
+ assert '"hatty_backup": 2' in (work_b / "hatty-backup.json").read_text()
+
+
+class _StubNotifyCtl:
+ def __init__(self):
+ self.notify_lists: set[str] = set()
+
+
+class _StubApp:
+ """Just enough of the app surface for ListController.to_export_payload and
+ backup.build_files/write_export to run against real files on disk."""
+
+ def __init__(self, path):
+ self.app_config = {}
+ self.notify_ctl = _StubNotifyCtl()
+ self.list_ctl = ListController(self)
+ self.list_ctl.list_names = ["Kitchen"]
+ self.list_ctl.entity_lists = {"Kitchen": ["light.a"]}
+ self._path = path
+
+ def persist(self, *_keys):
+ pass
+
+ def notify(self, *_a, **_kw):
+ pass
+
+
+def test_commit_on_exit_creates_no_commit_when_only_the_export_date_changed(tmp_path):
+ # The actual bug this guards against: write_export used to bump the
+ # manifest's exported_at on every call, so re-exporting *identical* data
+ # still staged a change and commit_all would create a pointless commit on
+ # every quit, even when nothing the user did actually changed anything.
+ work = tmp_path / "work"
+ work.mkdir()
+ git_sync.init_repo(str(work))
+ app = _StubApp(work)
+
+ written, _removed = backup.write_export(work, backup.build_files(app, ["lists"]), ["lists"])
+ assert written # first export always writes something
+ ok, msg = git_sync.commit_all(str(work), "first")
+ assert ok, msg
+ log = subprocess.run(["git", "log", "--oneline"], cwd=work, capture_output=True, text=True, check=True)
+ commit_count = len(log.stdout.splitlines())
+ assert commit_count == 1
+
+ # Re-export the exact same data — nothing the user changed.
+ written_again, removed_again = backup.write_export(work, backup.build_files(app, ["lists"]), ["lists"])
+ assert written_again == []
+ assert removed_again == []
+
+ ok, msg = git_sync.commit_all(str(work), "second")
+ assert ok, msg
+ assert "Nothing to commit" in msg
+
+ log = subprocess.run(["git", "log", "--oneline"], cwd=work, capture_output=True, text=True, check=True)
+ assert len(log.stdout.splitlines()) == commit_count # no new commit was created
diff --git a/tests/test_config_screen_backup.py b/tests/test_config_screen_backup.py
new file mode 100644
index 0000000..07c15d7
--- /dev/null
+++ b/tests/test_config_screen_backup.py
@@ -0,0 +1,211 @@
+# hatty — MIT License. See LICENSE file for details.
+"""Config screen "Backup & Sync" category: navigation, saving prefs, and the
+export/import/git buttons acting on the currently-entered (unsaved) widget
+values — the action_test_connection precedent."""
+
+from textual.widgets import Input, SelectionList
+
+from hatty import backup as backup_module
+from hatty.ui.config_screen import ConfigScreen
+from hatty.ui.confirm_popup import ConfirmPopup
+from tests.conftest import make_config
+
+_CONFIG = {**make_config(), "lists": {}}
+
+
+async def _open_backup(app, pilot):
+ await pilot.pause()
+ app.action_show_config()
+ await pilot.pause()
+ assert isinstance(app.screen, ConfigScreen)
+ screen = app.screen
+ screen.show_category("cat_backup")
+ await pilot.pause()
+ return screen
+
+
+async def test_backup_category_listed_and_navigable(make_app, sample_entities):
+ app = make_app(entities=sample_entities, config_data=_CONFIG)
+ async with app.run_test() as pilot:
+ screen = await _open_backup(app, pilot)
+ assert screen.query_one("#cfg_backup_path", Input)
+ assert screen.focused is screen.query_one("#cfg_backup_path")
+
+
+async def test_save_persists_path_sections_and_git_toggles(make_app, sample_entities, tmp_path):
+ app = make_app(entities=sample_entities, config_data=_CONFIG)
+ async with app.run_test() as pilot:
+ screen = await _open_backup(app, pilot)
+
+ screen.query_one("#cfg_backup_path", Input).value = str(tmp_path)
+ sections = screen.query_one("#cfg_backup_sections", SelectionList)
+ sections.deselect_all()
+ sections.select("lists")
+ sections.select("dashboards")
+ git = screen.query_one("#cfg_backup_git", SelectionList)
+ git.select("git_enabled")
+ git.select("push_on_exit")
+
+ await pilot.press("ctrl+s")
+ await pilot.pause()
+
+ saved = app.app_config["backup"]
+ assert saved["path"] == str(tmp_path)
+ assert sorted(saved["sections"]) == ["dashboards", "lists"]
+ assert saved["git_enabled"] is True
+ assert saved["push_on_exit"] is True
+ assert saved["commit_on_exit"] is False
+
+
+async def test_export_now_writes_files_for_selected_sections(make_app, sample_entities, tmp_path):
+ config_data = {**_CONFIG, "lists": {"list_a": ["switch.fan"]}}
+ app = make_app(entities=sample_entities, config_data=config_data)
+ async with app.run_test() as pilot:
+ screen = await _open_backup(app, pilot)
+
+ screen.query_one("#cfg_backup_path", Input).value = str(tmp_path)
+ sections = screen.query_one("#cfg_backup_sections", SelectionList)
+ sections.deselect_all()
+ sections.select("lists")
+
+ await pilot.press("tab") # leave the Input so its .value is committed
+ await screen.run_worker(screen._do_backup_export(str(tmp_path), ["lists"])).wait()
+ await pilot.pause()
+
+ status = screen.query_one("#cfg_backup_status")
+ assert "Exported" in str(status.content)
+
+ assert (tmp_path / "lists" / "list_a.list.json").exists()
+ assert not (tmp_path / "dashboards").exists()
+
+
+async def test_export_requires_path_and_section(make_app, sample_entities):
+ app = make_app(entities=sample_entities, config_data=_CONFIG)
+ async with app.run_test() as pilot:
+ screen = await _open_backup(app, pilot)
+ screen.query_one("#cfg_backup_sections", SelectionList).deselect_all()
+
+ screen.action_backup_export()
+ await pilot.pause()
+
+ status = screen.query_one("#cfg_backup_status")
+ assert "directory and at least one section" in str(status.content)
+
+
+async def test_import_now_confirms_then_replaces(make_app, sample_entities, tmp_path):
+ config_data = {**_CONFIG, "lists": {"stale": ["light.old"]}}
+ app = make_app(entities=sample_entities, config_data=config_data)
+
+ export_dir = tmp_path / "export"
+ export_dir.mkdir()
+ files = {
+ "lists/kitchen.list.json": {
+ "hatty_list": 1,
+ "name": "Kitchen",
+ "entities": ["light.a"],
+ "manual": False,
+ "notify": False,
+ },
+ }
+ backup_module.write_export(export_dir, files, ["lists"])
+
+ async with app.run_test() as pilot:
+ screen = await _open_backup(app, pilot)
+ screen.query_one("#cfg_backup_path", Input).value = str(export_dir)
+ sections = screen.query_one("#cfg_backup_sections", SelectionList)
+ sections.deselect_all()
+ sections.select("lists")
+
+ screen.action_backup_import()
+ await pilot.pause()
+ assert isinstance(app.screen, ConfirmPopup)
+ await pilot.press("y")
+ await pilot.pause()
+ await pilot.pause()
+
+ assert "stale" not in app.entity_lists
+ assert app.entity_lists["Kitchen"] == ["light.a"]
+
+
+async def test_import_cancelled_leaves_data_untouched(make_app, sample_entities, tmp_path):
+ config_data = {**_CONFIG, "lists": {"stale": ["light.old"]}}
+ app = make_app(entities=sample_entities, config_data=config_data)
+
+ export_dir = tmp_path / "export"
+ export_dir.mkdir()
+ files = {
+ "lists/kitchen.list.json": {
+ "hatty_list": 1,
+ "name": "Kitchen",
+ "entities": ["light.a"],
+ "manual": False,
+ "notify": False,
+ },
+ }
+ backup_module.write_export(export_dir, files, ["lists"])
+
+ async with app.run_test() as pilot:
+ screen = await _open_backup(app, pilot)
+ screen.query_one("#cfg_backup_path", Input).value = str(export_dir)
+ sections = screen.query_one("#cfg_backup_sections", SelectionList)
+ sections.deselect_all()
+ sections.select("lists")
+
+ screen.action_backup_import()
+ await pilot.pause()
+ await pilot.press("n")
+ await pilot.pause()
+
+ assert "stale" in app.entity_lists
+ assert "Kitchen" not in app.entity_lists
+
+
+async def test_init_pull_push_status_buttons_drive_git_sync(make_app, sample_entities, tmp_path, monkeypatch):
+ from hatty import git_sync
+
+ calls = []
+
+ def fake_run_git(args, cwd, timeout=None):
+ calls.append(args)
+ if args[:3] == ["diff", "--cached", "--quiet"]:
+ return (1, "", "")
+ if args[0] == "remote":
+ return (0, "origin\n", "")
+ if args[:2] == ["symbolic-ref", "--quiet"]:
+ return (0, "main\n", "")
+ return (0, "", "")
+
+ monkeypatch.setattr(git_sync, "_run_git", fake_run_git)
+
+ app = make_app(entities=sample_entities, config_data=_CONFIG)
+ async with app.run_test() as pilot:
+ screen = await _open_backup(app, pilot)
+ screen.query_one("#cfg_backup_path", Input).value = str(tmp_path)
+
+ await screen.run_worker(screen._do_backup_init(str(tmp_path))).wait()
+ await pilot.pause()
+ assert any(c[0] == "init" for c in calls)
+
+ await screen.run_worker(screen._do_backup_pull(str(tmp_path), False)).wait()
+ await pilot.pause()
+ assert any(c[0] == "pull" for c in calls)
+
+ await screen.run_worker(screen._do_backup_push(str(tmp_path))).wait()
+ await pilot.pause()
+ assert any(c[0] == "push" for c in calls)
+
+ await screen.run_worker(screen._do_backup_status_check(str(tmp_path))).wait()
+ await pilot.pause()
+ assert any(c[:2] == ["rev-parse", "--show-toplevel"] for c in calls)
+
+
+async def test_git_buttons_require_path_first(make_app, sample_entities):
+ app = make_app(entities=sample_entities, config_data=_CONFIG)
+ async with app.run_test() as pilot:
+ screen = await _open_backup(app, pilot)
+ screen.query_one("#cfg_backup_path", Input).value = ""
+
+ screen.action_backup_init()
+ await pilot.pause()
+ status = screen.query_one("#cfg_backup_status")
+ assert "directory first" in str(status.content)
diff --git a/tests/test_list_management.py b/tests/test_list_management.py
index 197cf33..eebc3f1 100644
--- a/tests/test_list_management.py
+++ b/tests/test_list_management.py
@@ -1,5 +1,8 @@
# hatty — MIT License. See LICENSE file for details.
-from textual.widgets import ListView
+import json
+
+from textual.widgets import Input, ListView
+from textual_fspicker import FileOpen, FileSave
from hatty.ui.entity_table import EntitiesTable
from hatty.ui.list_selection_popup import ListSelectionPopup
@@ -425,3 +428,90 @@ async def test_reorder_refused_while_searching(make_app, sample_entities):
assert app.list_names == original_order
assert notified(app, message_contains="Clear the search")
+
+
+# ── export / import ──────────────────────────────────────────────────────────
+
+
+async def test_export_list_writes_file(make_app, tmp_path):
+ config_data = {
+ **make_config(),
+ "lists": {"list_a": ["switch.fan"]},
+ "manual_lists": ["list_a"],
+ "notify_lists": ["list_a"],
+ }
+ app = make_app(config_data=config_data)
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ await pilot.press("l")
+ await pilot.pause()
+ await pilot.press("down", "down")
+ await pilot.press("x")
+ await pilot.pause()
+ assert isinstance(app.screen, FileSave)
+
+ out_path = tmp_path / "export.json"
+ input_widget = app.screen.query_one(Input)
+ input_widget.value = str(out_path)
+ input_widget.focus()
+ await pilot.press("enter")
+ await pilot.pause()
+
+ payload = json.loads(out_path.read_text())
+ assert payload == {
+ "hatty_list": 1,
+ "name": "list_a",
+ "entities": ["switch.fan"],
+ "manual": True,
+ "notify": True,
+ }
+ assert notified(app, title="List Exported")
+
+
+async def test_import_list_from_file(make_app, tmp_path):
+ app = make_app(config_data=NO_LIST_CONFIG)
+ payload = {"hatty_list": 1, "name": "Imported List", "entities": ["switch.fan"], "manual": False, "notify": False}
+ in_path = tmp_path / "import.json"
+ in_path.write_text(json.dumps(payload))
+
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ await pilot.press("l")
+ await pilot.pause()
+ await pilot.press("i")
+ await pilot.pause()
+ assert isinstance(app.screen, FileOpen)
+
+ input_widget = app.screen.query_one(Input)
+ input_widget.value = str(in_path)
+ input_widget.focus()
+ await pilot.press("enter")
+ await pilot.pause()
+
+ assert "Imported List" in app.entity_lists
+ assert app.entity_lists["Imported List"] == ["switch.fan"]
+ assert app.current_list_name == "Imported List"
+ assert notified(app, title="List Imported")
+ assert not isinstance(app.screen, (FileOpen, ListSelectionPopup))
+
+
+async def test_import_list_rejects_malformed_file(make_app, tmp_path):
+ app = make_app(config_data=NO_LIST_CONFIG)
+ in_path = tmp_path / "bad.json"
+ in_path.write_text(json.dumps({"not": "a list export"}))
+
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ await pilot.press("l")
+ await pilot.pause()
+ await pilot.press("i")
+ await pilot.pause()
+
+ input_widget = app.screen.query_one(Input)
+ input_widget.value = str(in_path)
+ input_widget.focus()
+ await pilot.press("enter")
+ await pilot.pause()
+
+ assert notified(app, title="Import Failed")
+ assert app.current_list_name is None
diff --git a/tests/test_saved_graphs.py b/tests/test_saved_graphs.py
index 3661f70..4801931 100644
--- a/tests/test_saved_graphs.py
+++ b/tests/test_saved_graphs.py
@@ -1,11 +1,15 @@
# hatty — MIT License. See LICENSE file for details.
+import json
+
from textual.coordinate import Coordinate
+from textual.widgets import Input
+from textual_fspicker import FileOpen, FileSave
from hatty.ui.entity_table import EntitiesTable
from hatty.ui.graph.preview_screen import GraphPreviewScreen
from hatty.ui.graph.saved_graphs_popup import SavedGraphsPopup, SaveGraphNamePopup
from hatty.ui.list_selection_popup import ListSelectionPopup
-from tests.conftest import NO_LIST_CONFIG, make_config
+from tests.conftest import NO_LIST_CONFIG, make_config, notified
# Alphabetical order with no list:
# Row 0: Fan Switch (switch.fan, off)
@@ -485,3 +489,90 @@ async def test_l_on_fullscreen_graph_opens_picker_when_no_list(make_app, sample_
await pilot.pause()
assert isinstance(app.screen, ListSelectionPopup)
+
+
+# ── export / import ──────────────────────────────────────────────────────────
+
+
+async def test_export_saved_graph_writes_file(make_app, sample_entities, tmp_path):
+ config_data = {
+ **NO_LIST_CONFIG,
+ "saved_graphs": {
+ "Temp Trend": {"entity_ids": ["sensor.temperature"], "graph_type": "line", "hours": 4},
+ },
+ }
+ app = make_app(entities=sample_entities, config_data=config_data)
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ await pilot.press("s")
+ await pilot.pause()
+ await pilot.press("x")
+ await pilot.pause()
+ assert isinstance(app.screen, FileSave)
+
+ out_path = tmp_path / "export.json"
+ input_widget = app.screen.query_one(Input)
+ input_widget.value = str(out_path)
+ input_widget.focus()
+ await pilot.press("enter")
+ await pilot.pause()
+
+ payload = json.loads(out_path.read_text())
+ assert payload == {
+ "hatty_graph": 1,
+ "name": "Temp Trend",
+ "graph": app.saved_graphs["Temp Trend"],
+ }
+ assert notified(app, title="Graph Exported")
+
+
+async def test_import_saved_graph_from_file(make_app, sample_entities, tmp_path):
+ app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG)
+ payload = {
+ "hatty_graph": 1,
+ "name": "Imported Graph",
+ "graph": {"entity_ids": ["sensor.temperature"], "graph_type": "scatter", "hours": 12},
+ }
+ in_path = tmp_path / "import.json"
+ in_path.write_text(json.dumps(payload))
+
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ await pilot.press("s")
+ await pilot.pause()
+ await pilot.press("i")
+ await pilot.pause()
+ assert isinstance(app.screen, FileOpen)
+
+ input_widget = app.screen.query_one(Input)
+ input_widget.value = str(in_path)
+ input_widget.focus()
+ await pilot.press("enter")
+ await pilot.pause()
+
+ assert "Imported Graph" in app.saved_graphs
+ assert app.saved_graphs["Imported Graph"] == payload["graph"]
+ assert notified(app, title="Graph Imported")
+ assert not isinstance(app.screen, (FileOpen, SavedGraphsPopup))
+
+
+async def test_import_saved_graph_rejects_malformed_file(make_app, sample_entities, tmp_path):
+ app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG)
+ in_path = tmp_path / "bad.json"
+ in_path.write_text(json.dumps({"not": "a graph export"}))
+
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ await pilot.press("s")
+ await pilot.pause()
+ await pilot.press("i")
+ await pilot.pause()
+
+ input_widget = app.screen.query_one(Input)
+ input_widget.value = str(in_path)
+ input_widget.focus()
+ await pilot.press("enter")
+ await pilot.pause()
+
+ assert notified(app, title="Import Failed")
+ assert app.saved_graphs == {}
diff --git a/tests/unit/binding_snapshot.json b/tests/unit/binding_snapshot.json
index c27f8d6..2e4ad2f 100644
--- a/tests/unit/binding_snapshot.json
+++ b/tests/unit/binding_snapshot.json
@@ -1440,7 +1440,7 @@
"file": "src/hatty/ui/graph/saved_graphs_popup.py",
"class": "SavedGraphsPopup",
"lineno": 45,
- "count": 4,
+ "count": 6,
"entries": [
{
"form": "tuple",
@@ -1456,6 +1456,20 @@
"description": "Delete",
"__tuple_form__": true
},
+ {
+ "form": "tuple",
+ "key": "x",
+ "action": "export_graph",
+ "description": "Export",
+ "__tuple_form__": true
+ },
+ {
+ "form": "tuple",
+ "key": "i",
+ "action": "import_graph",
+ "description": "Import",
+ "__tuple_form__": true
+ },
{
"form": "tuple",
"key": "escape",
@@ -1528,7 +1542,7 @@
"file": "src/hatty/ui/list_selection_popup.py",
"class": "ListSelectionPopup",
"lineno": 20,
- "count": 10,
+ "count": 12,
"entries": [
{
"form": "tuple",
@@ -1565,6 +1579,20 @@
"description": "View as Dashboard",
"__tuple_form__": true
},
+ {
+ "form": "tuple",
+ "key": "x",
+ "action": "export_list",
+ "description": "Export",
+ "__tuple_form__": true
+ },
+ {
+ "form": "tuple",
+ "key": "i",
+ "action": "import_list",
+ "description": "Import",
+ "__tuple_form__": true
+ },
{
"form": "tuple",
"key": "escape",
diff --git a/tests/unit/test_backup.py b/tests/unit/test_backup.py
new file mode 100644
index 0000000..314c7d1
--- /dev/null
+++ b/tests/unit/test_backup.py
@@ -0,0 +1,270 @@
+# hatty — MIT License. See LICENSE file for details.
+"""Unit tests for backup.py: directory export/import, no git, no UI."""
+
+import json
+
+from hatty import backup
+from hatty.controllers.dashboards import DashboardController
+from hatty.controllers.graphs import GraphController
+from hatty.controllers.lists import ListController
+
+
+class _StubNotifyCtl:
+ def __init__(self):
+ self.notify_lists: set[str] = set()
+
+
+class _StubApp:
+ def __init__(self, app_config=None):
+ self.persist_calls = []
+ self.notifications = []
+ self.notify_ctl = _StubNotifyCtl()
+ self.app_config = app_config if app_config is not None else {}
+ self.list_ctl = ListController(self)
+ self.dash_ctl = DashboardController(self)
+ self.graph_ctl = GraphController(self)
+
+ def persist(self, *keys):
+ self.persist_calls.append(keys)
+
+ def notify(self, message, **kwargs):
+ self.notifications.append((message, kwargs))
+
+
+def _app(**config) -> _StubApp:
+ return _StubApp(app_config=config)
+
+
+# ── build_files ───────────────────────────────────────────────────────────────
+
+
+def test_build_files_lists_section():
+ app = _app()
+ app.list_ctl.list_names = ["Kitchen"]
+ app.list_ctl.entity_lists = {"Kitchen": ["light.a"]}
+ files = backup.build_files(app, ["lists"])
+ assert files["lists/kitchen.list.json"] == {
+ "hatty_list": 1,
+ "name": "Kitchen",
+ "entities": ["light.a"],
+ "manual": False,
+ "notify": False,
+ }
+ assert backup.MANIFEST_FILENAME in files
+ assert files[backup.MANIFEST_FILENAME]["sections"] == ["lists"]
+
+
+def test_build_files_dashboards_excludes_temp_dashboards():
+ app = _app()
+ app.dash_ctl.create("Main", 2, 2)
+ app.dash_ctl.dashboards["Preview"] = {"rows": 1, "cols": 1, "slots": []}
+ app.dash_ctl.dashboard_names.append("Preview")
+ app.dash_ctl.temp_dashboard_names.add("Preview")
+ files = backup.build_files(app, ["dashboards"])
+ assert "dashboards/main.dashboard.json" in files
+ assert "dashboards/preview.dashboard.json" not in files
+
+
+def test_build_files_saved_graphs_section():
+ app = _app()
+ app.graph_ctl.saved_graphs = {"Temps": {"entity_ids": ["sensor.a"], "graph_type": "line", "hours": 4}}
+ files = backup.build_files(app, ["saved_graphs"])
+ assert files["graphs/temps.graph.json"]["hatty_graph"] == 1
+ assert files["graphs/temps.graph.json"]["graph"] == app.graph_ctl.saved_graphs["Temps"]
+
+
+def test_build_files_entity_names_settings_keybindings_sections():
+ app = _app(
+ entity_names={"light.a": "Lamp"},
+ columns=["name", "value"],
+ theme="nord",
+ notifications={"toast": True, "ntfy_password": "secret"},
+ keybindings={"nav.search": "ctrl+f"},
+ )
+ files = backup.build_files(app, ["entity_names", "settings", "keybindings"])
+ assert files["entity_names.json"] == {"hatty_entity_names": 1, "names": {"light.a": "Lamp"}}
+ assert files["settings.json"]["settings"]["columns"] == ["name", "value"]
+ assert files["settings.json"]["settings"]["theme"] == "nord"
+ # The ntfy password never leaves the app.
+ assert "ntfy_password" not in files["settings.json"]["settings"]["notifications"]
+ assert files["keybindings.json"] == {"hatty_keybindings": 1, "keybindings": {"nav.search": "ctrl+f"}}
+
+
+def test_build_files_manifest_carries_defaults_only_for_exported_sections():
+ app = _app(default_list="Kitchen", default_dashboard="Main")
+ files = backup.build_files(app, ["lists"])
+ manifest = files[backup.MANIFEST_FILENAME]
+ assert manifest["default_list"] == "Kitchen"
+ assert "default_dashboard" not in manifest
+
+
+def test_build_files_rejects_unknown_section():
+ app = _app()
+ try:
+ backup.build_files(app, ["not_a_section"])
+ assert False, "expected ValueError"
+ except ValueError:
+ pass
+
+
+# ── write_export / read_export round trip ───────────────────────────────────
+
+
+def test_write_then_read_round_trip_lists(tmp_path):
+ app = _app()
+ app.list_ctl.list_names = ["Kitchen"]
+ app.list_ctl.entity_lists = {"Kitchen": ["light.a"]}
+ app.list_ctl.manual_lists = {"Kitchen"}
+ app.app_config["default_list"] = "Kitchen"
+
+ files = backup.build_files(app, ["lists"])
+ written, removed = backup.write_export(tmp_path, files, ["lists"])
+ assert removed == []
+ assert "lists/kitchen.list.json" in written
+ assert backup.MANIFEST_FILENAME in written
+
+ payloads, found = backup.read_export(tmp_path, ["lists"])
+ assert found == ["lists"]
+ assert payloads["lists"] == [
+ {"hatty_list": 1, "name": "Kitchen", "entities": ["light.a"], "manual": True, "notify": False}
+ ]
+ assert payloads["_manifest"]["default_list"] == "Kitchen"
+
+
+def test_write_export_is_byte_identical_to_dashboard_screen_export(tmp_path):
+ # The whole point of sharing the single-object format: a file this writes
+ # is exactly what "Export dashboard" already writes for the same object.
+ app = _app()
+ app.dash_ctl.create("Main", 2, 2)
+ app.dash_ctl.set_slot("Main", 0, 0, "sensor", "sensor.temp")
+
+ files = backup.build_files(app, ["dashboards"])
+ backup.write_export(tmp_path, files, ["dashboards"])
+
+ on_disk = json.loads((tmp_path / "dashboards" / "main.dashboard.json").read_text())
+ assert on_disk == app.dash_ctl.to_export_payload("Main")
+
+
+def test_write_export_only_rewrites_changed_files(tmp_path):
+ app = _app()
+ app.list_ctl.list_names = ["Kitchen"]
+ app.list_ctl.entity_lists = {"Kitchen": ["light.a"]}
+ files = backup.build_files(app, ["lists"])
+ backup.write_export(tmp_path, files, ["lists"])
+
+ written_again, removed_again = backup.write_export(tmp_path, files, ["lists"])
+ # Truly nothing changed -> nothing is rewritten, not even the manifest's
+ # exported_at/hatty_version, so an unattended exit-time export never looks
+ # like a change to git and never triggers a pointless commit.
+ assert written_again == []
+ assert removed_again == []
+
+
+def test_write_export_manifest_bumps_exported_at_only_when_something_changed(tmp_path):
+ app = _app()
+ app.list_ctl.list_names = ["Kitchen"]
+ app.list_ctl.entity_lists = {"Kitchen": []}
+ backup.write_export(tmp_path, backup.build_files(app, ["lists"]), ["lists"])
+ first_manifest = json.loads((tmp_path / backup.MANIFEST_FILENAME).read_text())
+
+ # A no-op export leaves the manifest byte-identical, including exported_at.
+ backup.write_export(tmp_path, backup.build_files(app, ["lists"]), ["lists"])
+ assert json.loads((tmp_path / backup.MANIFEST_FILENAME).read_text()) == first_manifest
+
+ # A real change (a new list) does bump it.
+ app.list_ctl.list_names.append("Office")
+ app.list_ctl.entity_lists["Office"] = []
+ written, _removed = backup.write_export(tmp_path, backup.build_files(app, ["lists"]), ["lists"])
+ assert backup.MANIFEST_FILENAME in written
+ second_manifest = json.loads((tmp_path / backup.MANIFEST_FILENAME).read_text())
+ assert second_manifest["exported_at"] != first_manifest["exported_at"]
+
+
+def test_write_export_prunes_deleted_object_but_leaves_other_sections(tmp_path):
+ app = _app()
+ app.list_ctl.list_names = ["Kitchen", "Office"]
+ app.list_ctl.entity_lists = {"Kitchen": [], "Office": []}
+ files = backup.build_files(app, ["lists"])
+ backup.write_export(tmp_path, files, ["lists"])
+ assert (tmp_path / "lists" / "office.list.json").exists()
+
+ app.list_ctl.list_names = ["Kitchen"]
+ del app.list_ctl.entity_lists["Office"]
+ files = backup.build_files(app, ["lists"])
+ written, removed = backup.write_export(tmp_path, files, ["lists"])
+ assert removed == ["lists/office.list.json"]
+ assert not (tmp_path / "lists" / "office.list.json").exists()
+ assert (tmp_path / "lists" / "kitchen.list.json").exists()
+
+
+def test_write_export_of_subset_preserves_other_sections_manifest_fields(tmp_path):
+ app = _app(default_list="Kitchen", default_dashboard="Main")
+ app.list_ctl.list_names = ["Kitchen"]
+ app.list_ctl.entity_lists = {"Kitchen": []}
+ app.dash_ctl.create("Main", 1, 1)
+
+ backup.write_export(tmp_path, backup.build_files(app, ["lists"]), ["lists"])
+ backup.write_export(tmp_path, backup.build_files(app, ["dashboards"]), ["dashboards"])
+
+ manifest = json.loads((tmp_path / backup.MANIFEST_FILENAME).read_text())
+ assert manifest["default_list"] == "Kitchen"
+ assert manifest["default_dashboard"] == "Main"
+ assert sorted(manifest["sections"]) == ["dashboards", "lists"]
+
+ payloads, found = backup.read_export(tmp_path, ["lists", "dashboards"])
+ assert sorted(found) == ["dashboards", "lists"]
+
+
+def test_read_export_settings_omits_ntfy_password(tmp_path):
+ app = _app(
+ columns=["name"],
+ theme=None,
+ graph_type="line",
+ graph_hours=4,
+ log_hours=24,
+ terminal_title_enabled=True,
+ terminal_title="hatty",
+ notifications={"toast": True, "ntfy_password": "secret"},
+ )
+ backup.write_export(tmp_path, backup.build_files(app, ["settings"]), ["settings"])
+ on_disk = (tmp_path / "settings.json").read_text()
+ assert "secret" not in on_disk
+
+ payloads, found = backup.read_export(tmp_path, ["settings"])
+ assert found == ["settings"]
+ assert "ntfy_password" not in payloads["settings"]["notifications"]
+
+
+def test_read_export_missing_manifest_raises():
+ import pytest
+
+ with pytest.raises(ValueError, match="No hatty backup found"):
+ backup.read_export("/nonexistent/hatty-backup-dir", ["lists"])
+
+
+def test_read_export_bad_object_file_raises(tmp_path):
+ import pytest
+
+ (tmp_path / "lists").mkdir()
+ (tmp_path / "lists" / "broken.list.json").write_text("not json")
+ (tmp_path / backup.MANIFEST_FILENAME).write_text(json.dumps({"hatty_backup": 1, "sections": ["lists"]}))
+
+ with pytest.raises(ValueError, match="Could not read"):
+ backup.read_export(tmp_path, ["lists"])
+
+
+def test_read_export_only_reads_requested_sections(tmp_path):
+ app = _app()
+ app.list_ctl.list_names = ["Kitchen"]
+ app.list_ctl.entity_lists = {"Kitchen": []}
+ app.graph_ctl.saved_graphs = {"Temps": {"entity_ids": ["sensor.a"], "graph_type": "line", "hours": 4}}
+ backup.write_export(tmp_path, backup.build_files(app, ["lists", "saved_graphs"]), ["lists", "saved_graphs"])
+
+ payloads, found = backup.read_export(tmp_path, ["lists"])
+ assert found == ["lists"]
+ assert "saved_graphs" not in payloads
+
+
+def test_slug_matches_dashboard_slug_rule():
+ assert backup.slug("Main Dashboard") == "main-dashboard"
+ assert backup.slug(" ") == "export"
diff --git a/tests/unit/test_backup_controller.py b/tests/unit/test_backup_controller.py
new file mode 100644
index 0000000..a7c3390
--- /dev/null
+++ b/tests/unit/test_backup_controller.py
@@ -0,0 +1,381 @@
+# hatty — MIT License. See LICENSE file for details.
+"""Unit tests for BackupController: prefs merging, export/import wiring
+against real (but disk-free) list/dashboard/graph controllers, and the
+git-facing exit_sync_pending/sync_on_exit/pull_on_start gating. backup.py's
+own directory read/write behavior is covered by tests/unit/test_backup.py;
+these tests fake backup.build_files/write_export/read_export and git_sync so
+they run without touching disk or a real git binary."""
+
+from hatty import backup as backup_module
+from hatty.const import CONFIG_KEY_BACKUP, DEFAULT_BACKUP
+from hatty.controllers.backup import BackupController
+from hatty.controllers.dashboards import DashboardController
+from hatty.controllers.graphs import GraphController
+from hatty.controllers.lists import ListController
+
+
+class _StubKeysCtl:
+ def __init__(self):
+ self.applied = []
+
+ def apply(self, cfg):
+ self.applied.append(dict(cfg))
+
+
+class _StubNotifyCtl:
+ def __init__(self):
+ self.notify_lists: set[str] = set()
+
+
+class _StubApp:
+ def __init__(self, app_config=None, demo=False):
+ self.persist_calls = []
+ self.notifications = []
+ self.display_updates = 0
+ self.terminal_title_calls = []
+ self._demo = demo
+ self.app_config = app_config if app_config is not None else {}
+ self.columns = self.app_config.get("columns", [])
+ self.theme = None
+ self.available_themes = {"nord", "textual-dark"}
+ self.entity_names = {}
+ self.notify_ctl = _StubNotifyCtl()
+ self.keys_ctl = _StubKeysCtl()
+ self.list_ctl = ListController(self)
+ self.dash_ctl = DashboardController(self)
+ self.graph_ctl = GraphController(self)
+
+ def persist(self, *keys):
+ self.persist_calls.append(keys)
+
+ def notify(self, message, **kwargs):
+ self.notifications.append((message, kwargs))
+
+ def _update_entities_display(self):
+ self.display_updates += 1
+
+ def _apply_terminal_title(self, cfg):
+ self.terminal_title_calls.append(dict(cfg))
+
+
+def _controller(**backup_prefs) -> tuple[BackupController, _StubApp]:
+ app = _StubApp(app_config={CONFIG_KEY_BACKUP: backup_prefs} if backup_prefs else {})
+ ctl = BackupController(app)
+ ctl.apply(app.app_config)
+ return ctl, app
+
+
+# ── apply / prefs merging ────────────────────────────────────────────────────
+
+
+def test_apply_merges_defaults():
+ ctl, _app = _controller(path="/tmp/x")
+ assert ctl.prefs["path"] == "/tmp/x"
+ assert ctl.prefs["git_enabled"] is False
+ assert ctl.prefs["sections"] == list(DEFAULT_BACKUP["sections"])
+
+
+def test_apply_drops_unknown_sections():
+ ctl, _app = _controller(sections=["lists", "not_a_section"])
+ assert ctl.prefs["sections"] == ["lists"]
+
+
+def test_apply_writes_normalized_prefs_back_into_cfg():
+ cfg = {CONFIG_KEY_BACKUP: {"path": "/tmp/x"}}
+ ctl = BackupController(_StubApp())
+ ctl.apply(cfg)
+ assert cfg[CONFIG_KEY_BACKUP]["git_enabled"] is False
+ assert cfg[CONFIG_KEY_BACKUP]["path"] == "/tmp/x"
+
+
+# ── export_now ────────────────────────────────────────────────────────────────
+
+
+def test_export_now_no_path_configured():
+ ctl, _app = _controller()
+ ok, msg = ctl.export_now()
+ assert ok is False
+ assert "No backup directory" in msg
+
+
+def test_export_now_no_sections_selected():
+ ctl, _app = _controller(path="/tmp/x", sections=[])
+ ok, msg = ctl.export_now()
+ assert ok is False
+ assert "No sections" in msg
+
+
+def test_export_now_reports_written_and_removed(monkeypatch, tmp_path):
+ ctl, app = _controller(path=str(tmp_path), sections=["lists"])
+ app.list_ctl.list_names = ["Kitchen"]
+ app.list_ctl.entity_lists = {"Kitchen": []}
+
+ monkeypatch.setattr(backup_module, "build_files", lambda app, sections: {"lists/kitchen.list.json": {}})
+ monkeypatch.setattr(
+ backup_module,
+ "write_export",
+ lambda path, files, sections: (["lists/kitchen.list.json"], ["lists/old.list.json"]),
+ )
+
+ ok, msg = ctl.export_now()
+ assert ok is True
+ assert "1 file(s) written" in msg
+ assert "1 stale file(s) removed" in msg
+
+
+def test_export_now_surfaces_errors(monkeypatch, tmp_path):
+ ctl, _app = _controller(path=str(tmp_path), sections=["lists"])
+
+ def _raise(*_a, **_kw):
+ raise OSError("disk full")
+
+ monkeypatch.setattr(backup_module, "build_files", lambda app, sections: {})
+ monkeypatch.setattr(backup_module, "write_export", _raise)
+ ok, msg = ctl.export_now()
+ assert ok is False
+ assert "disk full" in msg
+
+
+# ── import_now ────────────────────────────────────────────────────────────────
+
+
+def _fake_read_export(payloads, found):
+ def _read(path, sections):
+ return payloads, found
+
+ return _read
+
+
+def test_import_now_no_path_configured():
+ ctl, _app = _controller()
+ ok, msg, found = ctl.import_now(["lists"])
+ assert ok is False
+ assert found == []
+
+
+def test_import_now_replaces_lists_and_persists(monkeypatch, tmp_path):
+ ctl, app = _controller(path=str(tmp_path), sections=["lists"])
+ app.list_ctl.list_names = ["Stale"]
+ app.list_ctl.entity_lists = {"Stale": ["light.old"]}
+
+ payloads = {
+ "_manifest": {"default_list": "Kitchen"},
+ "lists": [
+ {"hatty_list": 1, "name": "Kitchen", "entities": ["light.a"], "manual": False, "notify": False},
+ ],
+ }
+ monkeypatch.setattr(backup_module, "read_export", _fake_read_export(payloads, ["lists"]))
+
+ ok, msg, found = ctl.import_now(["lists"])
+ assert ok is True
+ assert found == ["lists"]
+ assert "Stale" not in app.list_ctl.entity_lists
+ assert app.list_ctl.entity_lists["Kitchen"] == ["light.a"]
+ assert app.list_ctl.default_list_name == "Kitchen"
+ assert {"lists", "manual_lists", "notify_lists", "default_list"} in [set(c) for c in app.persist_calls]
+
+
+def test_import_now_dashboards_preserves_temp_dashboards(monkeypatch, tmp_path):
+ ctl, app = _controller(path=str(tmp_path), sections=["dashboards"])
+ app.dash_ctl.create("Stale", 1, 1)
+ app.dash_ctl.dashboards["Preview"] = {"rows": 1, "cols": 1, "slots": []}
+ app.dash_ctl.dashboard_names.append("Preview")
+ app.dash_ctl.temp_dashboard_names.add("Preview")
+
+ payloads = {
+ "_manifest": {},
+ "dashboards": [
+ {"hatty_dashboard": 1, "name": "Main", "dashboard": {"rows": 2, "cols": 2, "slots": []}},
+ ],
+ }
+ monkeypatch.setattr(backup_module, "read_export", _fake_read_export(payloads, ["dashboards"]))
+
+ ok, _msg, _found = ctl.import_now(["dashboards"])
+ assert ok is True
+ assert "Stale" not in app.dash_ctl.dashboards
+ assert "Preview" in app.dash_ctl.dashboards # temp dashboards survive a replace
+ assert "Main" in app.dash_ctl.dashboards
+
+
+def test_import_now_bad_object_is_skipped_not_fatal(monkeypatch, tmp_path):
+ ctl, app = _controller(path=str(tmp_path), sections=["saved_graphs"])
+ payloads = {
+ "_manifest": {},
+ "saved_graphs": [
+ {"hatty_graph": 1, "name": "Good", "graph": {"entity_ids": ["sensor.a"]}},
+ {"hatty_graph": 1, "name": "Bad"}, # missing "graph" -> ValueError, skipped
+ ],
+ }
+ monkeypatch.setattr(backup_module, "read_export", _fake_read_export(payloads, ["saved_graphs"]))
+
+ ok, _msg, _found = ctl.import_now(["saved_graphs"])
+ assert ok is True
+ assert "Good" in app.graph_ctl.saved_graphs
+ assert "Bad" not in app.graph_ctl.saved_graphs
+
+
+def test_import_now_entity_names_replaces_and_persists(monkeypatch, tmp_path):
+ ctl, app = _controller(path=str(tmp_path), sections=["entity_names"])
+ app.entity_names = {"light.old": "Old"}
+ payloads = {"_manifest": {}, "entity_names": {"light.a": "Lamp"}}
+ monkeypatch.setattr(backup_module, "read_export", _fake_read_export(payloads, ["entity_names"]))
+
+ ok, _msg, found = ctl.import_now(["entity_names"])
+ assert ok is True
+ assert found == ["entity_names"]
+ assert app.entity_names == {"light.a": "Lamp"}
+ assert ("entity_names",) in app.persist_calls
+
+
+def test_import_now_settings_applies_and_strips_password(monkeypatch, tmp_path):
+ ctl, app = _controller(path=str(tmp_path), sections=["settings"])
+ payloads = {
+ "_manifest": {},
+ "settings": {
+ "columns": ["name"],
+ "theme": "nord",
+ "notifications": {"toast": True},
+ },
+ }
+ monkeypatch.setattr(backup_module, "read_export", _fake_read_export(payloads, ["settings"]))
+
+ ok, _msg, found = ctl.import_now(["settings"])
+ assert ok is True
+ assert found == ["settings"]
+ assert app.app_config["columns"] == ["name"]
+ assert app.columns == ["name"]
+ assert app.theme == "nord"
+ assert app.terminal_title_calls # _apply_terminal_title was invoked
+ # No explicit sqlite key touched by settings -> falls back to a bare persist().
+ assert () in app.persist_calls
+
+
+def test_import_now_keybindings_calls_keys_ctl_apply(monkeypatch, tmp_path):
+ ctl, app = _controller(path=str(tmp_path), sections=["keybindings"])
+ payloads = {"_manifest": {}, "keybindings": {"nav.search": "ctrl+f"}}
+ monkeypatch.setattr(backup_module, "read_export", _fake_read_export(payloads, ["keybindings"]))
+
+ ok, _msg, found = ctl.import_now(["keybindings"])
+ assert ok is True
+ assert found == ["keybindings"]
+ assert app.app_config["keybindings"] == {"nav.search": "ctrl+f"}
+ assert app.keys_ctl.applied # apply() was called with the updated cfg
+
+
+def test_import_now_rejects_bad_export(monkeypatch, tmp_path):
+ ctl, _app = _controller(path=str(tmp_path), sections=["lists"])
+
+ def _raise(path, sections):
+ raise ValueError("No hatty backup found")
+
+ monkeypatch.setattr(backup_module, "read_export", _raise)
+ ok, msg, found = ctl.import_now(["lists"])
+ assert ok is False
+ assert "No hatty backup found" in msg
+ assert found == []
+
+
+# ── git gating ────────────────────────────────────────────────────────────────
+
+
+def test_exit_sync_pending_false_by_default():
+ ctl, _app = _controller(path="/tmp/x")
+ assert ctl.exit_sync_pending() is False
+
+
+def test_exit_sync_pending_requires_git_enabled_and_path():
+ ctl, _app = _controller(git_enabled=True, commit_on_exit=True) # no path
+ assert ctl.exit_sync_pending() is False
+
+
+def test_exit_sync_pending_true_when_commit_on_exit(tmp_path):
+ ctl, _app = _controller(path=str(tmp_path), git_enabled=True, commit_on_exit=True)
+ assert ctl.exit_sync_pending() is True
+
+
+def test_exit_sync_pending_false_in_demo_mode(tmp_path):
+ app = _StubApp(
+ app_config={CONFIG_KEY_BACKUP: {"path": str(tmp_path), "git_enabled": True, "push_on_exit": True}},
+ demo=True,
+ )
+ ctl = BackupController(app)
+ ctl.apply(app.app_config)
+ assert ctl.exit_sync_pending() is False
+
+
+async def test_sync_on_exit_noop_when_not_pending():
+ ctl, _app = _controller()
+ ok, msg = await ctl.sync_on_exit()
+ assert ok is True
+ assert msg == ""
+
+
+async def test_sync_on_exit_returns_export_failure_without_touching_git(monkeypatch, tmp_path):
+ ctl, _app = _controller(path=str(tmp_path), git_enabled=True, commit_on_exit=True, sections=[])
+ called = []
+ monkeypatch.setattr("hatty.git_sync.commit_all_async", lambda *a, **kw: called.append(a))
+ ok, msg = await ctl.sync_on_exit()
+ assert ok is False
+ assert "No sections" in msg
+ assert called == []
+
+
+async def test_sync_on_exit_reports_commit_only_phases(monkeypatch, tmp_path):
+ ctl, app = _controller(path=str(tmp_path), sections=["lists"], git_enabled=True, commit_on_exit=True)
+ app.list_ctl.list_names = []
+
+ async def _fake_commit(_path, _message):
+ return True, "Committed."
+
+ monkeypatch.setattr("hatty.git_sync.commit_all_async", _fake_commit)
+
+ phases = []
+ ok, msg = await ctl.sync_on_exit(status=phases.append)
+ assert ok is True
+ assert msg == "Committed."
+ assert phases == ["Exporting…", "Committing…"] # push_on_exit is off
+
+
+async def test_sync_on_exit_reports_push_phase_when_enabled(monkeypatch, tmp_path):
+ ctl, app = _controller(path=str(tmp_path), sections=["lists"], git_enabled=True, push_on_exit=True)
+ app.list_ctl.list_names = []
+
+ async def _fake_commit(_path, _message):
+ return True, "Committed."
+
+ async def _fake_push(_path):
+ return True, "Pushed to the remote."
+
+ monkeypatch.setattr("hatty.git_sync.commit_all_async", _fake_commit)
+ monkeypatch.setattr("hatty.git_sync.push_async", _fake_push)
+
+ phases = []
+ ok, msg = await ctl.sync_on_exit(status=phases.append)
+ assert ok is True
+ assert msg == "Pushed to the remote."
+ assert phases == ["Exporting…", "Committing…", "Pushing…"]
+
+
+async def test_sync_on_exit_skips_push_phase_when_commit_fails(monkeypatch, tmp_path):
+ ctl, app = _controller(path=str(tmp_path), sections=["lists"], git_enabled=True, push_on_exit=True)
+ app.list_ctl.list_names = []
+
+ async def _fake_commit(_path, _message):
+ return False, "git rejected the credentials."
+
+ push_called = []
+ monkeypatch.setattr("hatty.git_sync.commit_all_async", _fake_commit)
+ monkeypatch.setattr("hatty.git_sync.push_async", lambda *a: push_called.append(a))
+
+ phases = []
+ ok, msg = await ctl.sync_on_exit(status=phases.append)
+ assert ok is False
+ assert "credentials" in msg
+ assert phases == ["Exporting…", "Committing…"]
+ assert push_called == []
+
+
+async def test_pull_on_start_noop_when_disabled():
+ ctl, app = _controller(path="/tmp/x", git_enabled=True, pull_on_start=False)
+ await ctl.pull_on_start()
+ assert app.notifications == []
diff --git a/tests/unit/test_git_sync.py b/tests/unit/test_git_sync.py
new file mode 100644
index 0000000..cfaf154
--- /dev/null
+++ b/tests/unit/test_git_sync.py
@@ -0,0 +1,504 @@
+# hatty — MIT License. See LICENSE file for details.
+"""Unit tests for git_sync.py: the _run_git chokepoint, the _explain
+classifier, and every public git operation, all driven through a fake
+_run_git (cf. tests/unit/test_terminal_title.py's _run_tmux fakes) so no test
+here shells out to a real git binary."""
+
+import subprocess
+
+import pytest
+
+from hatty import git_sync
+
+# tests/conftest.py's autouse _no_real_git_calls stubs git_sync._run_git for
+# every test (so the acceptance suite never shells out to git); captured here
+# at import time, before any monkeypatch has run, so the fixture below can
+# restore it for this module, which needs the real chokepoint to exercise its
+# own subprocess.run wiring and to let each test install its own fake.
+_REAL_RUN_GIT = git_sync._run_git
+
+
+@pytest.fixture(autouse=True)
+def _use_real_run_git(monkeypatch):
+ # A parent-conftest autouse fixture is instantiated before a same-scoped
+ # one defined in the test module itself, so this runs after (and undoes)
+ # tests/conftest.py's stub. Individual tests below still monkeypatch
+ # _run_git or subprocess.run themselves as needed.
+ monkeypatch.setattr(git_sync, "_run_git", _REAL_RUN_GIT)
+
+
+# ── _git_env / _run_git ──────────────────────────────────────────────────────
+
+
+def test_git_env_hardens_prompting_and_strips_editor_vars(monkeypatch):
+ monkeypatch.setenv("DISPLAY", ":0")
+ monkeypatch.setenv("EDITOR", "vim")
+ env = git_sync._git_env()
+ assert env["GIT_TERMINAL_PROMPT"] == "0"
+ assert env["GIT_ASKPASS"] == "true"
+ assert env["SSH_ASKPASS_REQUIRE"] == "never"
+ assert "BatchMode=yes" in env["GIT_SSH_COMMAND"]
+ assert "DISPLAY" not in env
+ assert "EDITOR" not in env
+
+
+def test_run_git_missing_binary_returns_no_git_code(monkeypatch):
+ def fake_run(*_args, **_kwargs):
+ raise FileNotFoundError()
+
+ monkeypatch.setattr(subprocess, "run", fake_run)
+ rc, out, err = git_sync._run_git(["status"], "/tmp")
+ assert rc == git_sync._RC_NO_GIT
+ assert out == ""
+
+
+def test_run_git_timeout_returns_timeout_code(monkeypatch):
+ def fake_run(*_args, **kwargs):
+ raise subprocess.TimeoutExpired(cmd="git", timeout=kwargs.get("timeout", 1))
+
+ monkeypatch.setattr(subprocess, "run", fake_run)
+ rc, _out, err = git_sync._run_git(["status"], "/tmp")
+ assert rc == git_sync._RC_TIMEOUT
+ assert "timed out" in err
+
+
+def test_run_git_passes_hardening_flags_and_never_touches_stdin(monkeypatch):
+ captured = {}
+
+ def fake_run(cmd, **kwargs):
+ captured["cmd"] = cmd
+ captured["kwargs"] = kwargs
+ return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="")
+
+ monkeypatch.setattr(subprocess, "run", fake_run)
+ git_sync._run_git(["status", "--porcelain"], "/tmp")
+
+ assert captured["cmd"][0] == "git"
+ assert "-c" in captured["cmd"] and "core.editor=true" in captured["cmd"]
+ assert captured["cmd"][-2:] == ["status", "--porcelain"]
+ assert captured["kwargs"]["stdin"] == subprocess.DEVNULL
+ assert captured["kwargs"]["start_new_session"] is True
+ assert captured["kwargs"]["cwd"] == "/tmp"
+
+
+# ── _explain ──────────────────────────────────────────────────────────────────
+
+
+@pytest.mark.parametrize(
+ ("rc", "out", "err", "expect_contains"),
+ [
+ (git_sync._RC_NO_GIT, "", "git executable not found", "not installed"),
+ (git_sync._RC_TIMEOUT, "", "timed out after 10s", "timed out"),
+ (128, "", "fatal: not a git repository (or any of the parent directories)", "Not a git repository"),
+ (128, "", "fatal: could not read Username for 'https://example.com'", "credentials"),
+ (128, "", "remote: Permission denied (publickey).", "credentials"),
+ (1, "", "! [rejected] main -> main (non-fast-forward)", "commits you don't have"),
+ (1, "", "CONFLICT (content): Merge conflict in x.json", "Merge conflict"),
+ (128, "", "fatal: unable to access: Could not resolve host: example.com", "reach the remote"),
+ (1, "", "some other failure line", "some other failure line"),
+ ],
+)
+def test_explain_classifies_common_git_failures(rc, out, err, expect_contains):
+ assert expect_contains in git_sync._explain("op", rc, out, err)
+
+
+def test_explain_truncates_long_unclassified_output():
+ err = "x" * 500
+ assert len(git_sync._explain("op", 1, "", err)) <= 200
+
+
+# ── fake _run_git dispatcher for the higher-level functions ────────────────────
+
+
+class _FakeGit:
+ """Dispatches on an args prefix; unmatched calls fail loudly so a test
+ that forgets to stub a step doesn't silently pass."""
+
+ def __init__(self, responses: dict[tuple, tuple[int, str, str]]):
+ self.responses = responses
+ self.calls: list[list[str]] = []
+
+ def __call__(self, args, cwd, timeout=git_sync.LOCAL_TIMEOUT):
+ self.calls.append(args)
+ for prefix, result in self.responses.items():
+ n = len(prefix)
+ # Anywhere in args, not just a leading prefix — commit_all prepends
+ # -c user.name=... -c user.email=... ahead of "commit" itself.
+ if any(tuple(args[i : i + n]) == prefix for i in range(len(args) - n + 1)):
+ return result
+ raise AssertionError(f"unstubbed git invocation: {args}")
+
+
+def _install(monkeypatch, responses) -> _FakeGit:
+ fake = _FakeGit(responses)
+ monkeypatch.setattr(git_sync, "_run_git", fake)
+ return fake
+
+
+# ── repo_info ─────────────────────────────────────────────────────────────────
+
+
+def test_repo_info_missing_directory():
+ info = git_sync.repo_info("/does/not/exist")
+ assert info.ok is False
+ assert "does not exist" in info.message
+
+
+def test_repo_info_not_yet_a_repo(monkeypatch, tmp_path):
+ _install(monkeypatch, {("rev-parse", "--show-toplevel"): (128, "", "fatal: not a git repository")})
+ info = git_sync.repo_info(str(tmp_path))
+ assert info.ok is True
+ assert info.is_repo is False
+
+
+def test_repo_info_git_not_installed(monkeypatch, tmp_path):
+ _install(monkeypatch, {("rev-parse", "--show-toplevel"): (git_sync._RC_NO_GIT, "", "git executable not found")})
+ info = git_sync.repo_info(str(tmp_path))
+ assert info.ok is False
+ assert "not installed" in info.message
+
+
+def test_repo_info_inside_another_repo(monkeypatch, tmp_path):
+ outer = tmp_path.parent
+ _install(monkeypatch, {("rev-parse", "--show-toplevel"): (0, f"{outer}\n", "")})
+ info = git_sync.repo_info(str(tmp_path))
+ assert info.ok is False
+ assert info.is_repo is True
+ assert "pick a dedicated directory" in info.message
+
+
+def test_repo_info_healthy_repo(monkeypatch, tmp_path):
+ fake = _install(
+ monkeypatch,
+ {
+ ("rev-parse", "--show-toplevel"): (0, f"{tmp_path}\n", ""),
+ ("symbolic-ref",): (0, "main\n", ""),
+ ("remote",): (0, "origin\n", ""),
+ ("rev-parse", "--abbrev-ref"): (0, "origin/main\n", ""),
+ ("status",): (0, "?? untracked.txt\n", ""),
+ },
+ )
+ info = git_sync.repo_info(str(tmp_path))
+ assert info.ok is True
+ assert info.is_repo is True
+ assert info.branch == "main"
+ assert info.remote == "origin"
+ assert info.upstream == "origin/main"
+ assert info.changed == 1
+ assert fake.calls[0] == ["rev-parse", "--show-toplevel"]
+
+
+def test_repo_info_prefers_origin_among_multiple_remotes(monkeypatch, tmp_path):
+ _install(
+ monkeypatch,
+ {
+ ("rev-parse", "--show-toplevel"): (0, f"{tmp_path}\n", ""),
+ ("symbolic-ref",): (0, "main\n", ""),
+ ("remote",): (0, "upstream\norigin\n", ""),
+ ("rev-parse", "--abbrev-ref"): (1, "", ""),
+ ("status",): (0, "", ""),
+ },
+ )
+ info = git_sync.repo_info(str(tmp_path))
+ assert info.remote == "origin"
+
+
+# ── init_repo ─────────────────────────────────────────────────────────────────
+
+
+def test_init_repo_missing_directory():
+ ok, msg = git_sync.init_repo("/does/not/exist")
+ assert ok is False
+ assert "does not exist" in msg
+
+
+def test_init_repo_success(monkeypatch, tmp_path):
+ fake = _install(monkeypatch, {("init",): (0, "Initialized empty Git repository", "")})
+ ok, msg = git_sync.init_repo(str(tmp_path))
+ assert ok is True
+ assert "Initialized" in msg
+ assert fake.calls[0][0] == "init"
+
+
+def test_init_repo_falls_back_without_dash_b(monkeypatch, tmp_path):
+ calls = []
+
+ def fake(args, cwd, timeout=git_sync.LOCAL_TIMEOUT):
+ calls.append(args)
+ if "-b" in args:
+ return (2, "", "error: unknown switch `b'")
+ return (0, "Initialized empty Git repository", "")
+
+ monkeypatch.setattr(git_sync, "_run_git", fake)
+ ok, _msg = git_sync.init_repo(str(tmp_path))
+ assert ok is True
+ assert calls[0] == ["init", "-b", "main"]
+ assert calls[1] == ["init"]
+
+
+# ── commit_all ────────────────────────────────────────────────────────────────
+
+
+def test_commit_all_nothing_staged_is_ok(monkeypatch, tmp_path):
+ _install(monkeypatch, {("add",): (0, "", ""), ("diff",): (0, "", "")})
+ ok, msg = git_sync.commit_all(str(tmp_path), "msg")
+ assert ok is True
+ assert "Nothing to commit" in msg
+
+
+def test_commit_all_stages_and_commits(monkeypatch, tmp_path):
+ fake = _install(
+ monkeypatch,
+ {
+ ("add",): (0, "", ""),
+ ("diff",): (1, "", ""), # something staged
+ ("config", "--get", "user.email"): (0, "me@example.com\n", ""),
+ ("commit",): (0, "", ""),
+ },
+ )
+ ok, msg = git_sync.commit_all(str(tmp_path), "my message")
+ assert ok is True
+ assert msg == "Committed."
+ commit_call = next(c for c in fake.calls if c[0] == "commit")
+ assert commit_call == ["commit", "--no-verify", "--no-gpg-sign", "-m", "my message"]
+
+
+def test_commit_all_no_commits_yet_falls_back_to_status(monkeypatch, tmp_path):
+ _install(
+ monkeypatch,
+ {
+ ("add",): (0, "", ""),
+ ("diff",): (128, "", "fatal: ambiguous argument 'HEAD'"),
+ ("status",): (0, "", ""),
+ },
+ )
+ ok, msg = git_sync.commit_all(str(tmp_path), "msg")
+ assert ok is True
+ assert "Nothing to commit" in msg
+
+
+def test_commit_all_supplies_identity_when_missing(monkeypatch, tmp_path):
+ fake = _install(
+ monkeypatch,
+ {
+ ("add",): (0, "", ""),
+ ("diff",): (1, "", ""),
+ ("config", "--get", "user.email"): (1, "", ""),
+ ("commit",): (0, "", ""),
+ },
+ )
+ ok, _msg = git_sync.commit_all(str(tmp_path), "msg")
+ assert ok is True
+ commit_call = next(c for c in fake.calls if "commit" in c)
+ assert "-c" in commit_call and "user.email=hatty@localhost" in commit_call
+
+
+def test_commit_all_add_failure_is_reported(monkeypatch, tmp_path):
+ _install(monkeypatch, {("add",): (128, "", "fatal: pathspec broken")})
+ ok, msg = git_sync.commit_all(str(tmp_path), "msg")
+ assert ok is False
+ assert msg
+
+
+# ── pull ──────────────────────────────────────────────────────────────────────
+
+
+def test_pull_no_remote_is_ok(monkeypatch, tmp_path):
+ _install(monkeypatch, {("remote",): (0, "", "")})
+ ok, msg = git_sync.pull(str(tmp_path))
+ assert ok is True
+ assert "No git remote" in msg
+
+
+def test_pull_uses_ff_only_by_default(monkeypatch, tmp_path):
+ fake = _install(
+ monkeypatch,
+ {
+ ("remote",): (0, "origin\n", ""),
+ ("rev-parse", "--abbrev-ref"): (0, "origin/main\n", ""),
+ ("pull",): (0, "", ""),
+ },
+ )
+ ok, _msg = git_sync.pull(str(tmp_path))
+ assert ok is True
+ pull_call = next(c for c in fake.calls if c[0] == "pull")
+ assert "--ff-only" in pull_call
+ assert "--rebase" not in pull_call
+
+
+def test_pull_rebase_option(monkeypatch, tmp_path):
+ fake = _install(
+ monkeypatch,
+ {
+ ("remote",): (0, "origin\n", ""),
+ ("rev-parse", "--abbrev-ref"): (0, "origin/main\n", ""),
+ ("pull",): (0, "", ""),
+ },
+ )
+ git_sync.pull(str(tmp_path), rebase=True)
+ pull_call = next(c for c in fake.calls if c[0] == "pull")
+ assert "--rebase" in pull_call
+ assert "--autostash" in pull_call
+
+
+def test_pull_without_upstream_passes_explicit_remote_and_branch(monkeypatch, tmp_path):
+ fake = _install(
+ monkeypatch,
+ {
+ ("remote",): (0, "origin\n", ""),
+ ("rev-parse", "--abbrev-ref"): (1, "", ""), # no upstream configured
+ ("symbolic-ref",): (0, "main\n", ""),
+ ("pull",): (0, "", ""),
+ },
+ )
+ git_sync.pull(str(tmp_path))
+ pull_call = next(c for c in fake.calls if c[0] == "pull")
+ assert pull_call[-2:] == ["origin", "main"]
+
+
+def test_pull_failure_is_explained(monkeypatch, tmp_path):
+ _install(
+ monkeypatch,
+ {
+ ("remote",): (0, "origin\n", ""),
+ ("rev-parse", "--abbrev-ref"): (0, "origin/main\n", ""),
+ ("pull",): (1, "", "fatal: Could not resolve host: example.com"),
+ },
+ )
+ ok, msg = git_sync.pull(str(tmp_path))
+ assert ok is False
+ assert "reach the remote" in msg
+
+
+# ── push ──────────────────────────────────────────────────────────────────────
+
+
+def test_push_no_remote_is_ok(monkeypatch, tmp_path):
+ _install(
+ monkeypatch,
+ {("remote",): (0, "", ""), ("symbolic-ref",): (0, "main\n", "")},
+ )
+ ok, msg = git_sync.push(str(tmp_path))
+ assert ok is True
+ assert "committed locally only" in msg
+
+
+def test_push_no_commits_yet(monkeypatch, tmp_path):
+ _install(
+ monkeypatch,
+ {
+ ("remote",): (0, "origin\n", ""),
+ ("symbolic-ref",): (1, "", ""),
+ ("rev-parse", "--short"): (1, "", ""),
+ },
+ )
+ ok, msg = git_sync.push(str(tmp_path))
+ assert ok is False
+ assert "No commits yet" in msg
+
+
+def test_push_adds_dash_u_when_no_upstream(monkeypatch, tmp_path):
+ fake = _install(
+ monkeypatch,
+ {
+ ("remote",): (0, "origin\n", ""),
+ ("symbolic-ref",): (0, "main\n", ""),
+ ("rev-parse", "--abbrev-ref"): (1, "", ""),
+ ("push",): (0, "", ""),
+ },
+ )
+ ok, _msg = git_sync.push(str(tmp_path))
+ assert ok is True
+ push_call = next(c for c in fake.calls if c[0] == "push")
+ assert "-u" in push_call
+ assert push_call[-2:] == ["origin", "HEAD:refs/heads/main"]
+
+
+def test_push_omits_dash_u_when_upstream_tracked(monkeypatch, tmp_path):
+ fake = _install(
+ monkeypatch,
+ {
+ ("remote",): (0, "origin\n", ""),
+ ("symbolic-ref",): (0, "main\n", ""),
+ ("rev-parse", "--abbrev-ref"): (0, "origin/main\n", ""),
+ ("push",): (0, "", ""),
+ },
+ )
+ git_sync.push(str(tmp_path))
+ push_call = next(c for c in fake.calls if c[0] == "push")
+ assert "-u" not in push_call
+
+
+def test_push_rejected_non_fast_forward(monkeypatch, tmp_path):
+ _install(
+ monkeypatch,
+ {
+ ("remote",): (0, "origin\n", ""),
+ ("symbolic-ref",): (0, "main\n", ""),
+ ("rev-parse", "--abbrev-ref"): (0, "origin/main\n", ""),
+ ("push",): (1, "", "! [rejected] main -> main (non-fast-forward)"),
+ },
+ )
+ ok, msg = git_sync.push(str(tmp_path))
+ assert ok is False
+ assert "commits you don't have" in msg
+
+
+# ── commit_and_push ───────────────────────────────────────────────────────────
+
+
+def test_commit_and_push_stops_after_failed_commit(monkeypatch, tmp_path):
+ fake = _install(monkeypatch, {("add",): (128, "", "fatal: broken")})
+ ok, msg = git_sync.commit_and_push(str(tmp_path), "msg")
+ assert ok is False
+ assert msg
+ assert not any(c[0] == "push" for c in fake.calls)
+
+
+def test_commit_and_push_happy_path(monkeypatch, tmp_path):
+ _install(
+ monkeypatch,
+ {
+ ("add",): (0, "", ""),
+ ("diff",): (1, "", ""),
+ ("config", "--get", "user.email"): (0, "me@example.com\n", ""),
+ ("commit",): (0, "", ""),
+ ("remote",): (0, "origin\n", ""),
+ ("symbolic-ref",): (0, "main\n", ""),
+ ("rev-parse", "--abbrev-ref"): (0, "origin/main\n", ""),
+ ("push",): (0, "", ""),
+ },
+ )
+ ok, msg = git_sync.commit_and_push(str(tmp_path), "msg")
+ assert ok is True
+ assert "Pushed" in msg
+
+
+def test_commit_and_push_never_pulls_first(monkeypatch, tmp_path):
+ # A conflict at quit time is the worst possible moment; the next start's
+ # pull handles a diverged remote instead.
+ fake = _install(
+ monkeypatch,
+ {
+ ("add",): (0, "", ""),
+ ("diff",): (1, "", ""),
+ ("config", "--get", "user.email"): (0, "me@example.com\n", ""),
+ ("commit",): (0, "", ""),
+ ("remote",): (0, "origin\n", ""),
+ ("symbolic-ref",): (0, "main\n", ""),
+ ("rev-parse", "--abbrev-ref"): (0, "origin/main\n", ""),
+ ("push",): (0, "", ""),
+ },
+ )
+ git_sync.commit_and_push(str(tmp_path), "msg")
+ assert not any(c[0] == "pull" for c in fake.calls)
+
+
+# ── default_commit_message ───────────────────────────────────────────────────
+
+
+def test_default_commit_message_format():
+ from datetime import datetime
+
+ msg = git_sync.default_commit_message(datetime(2026, 8, 19, 14, 3, 11))
+ assert msg == "hatty backup 2026-08-19 14:03:11"
diff --git a/tests/unit/test_graphs_controller.py b/tests/unit/test_graphs_controller.py
index 34c85ef..5f21139 100644
--- a/tests/unit/test_graphs_controller.py
+++ b/tests/unit/test_graphs_controller.py
@@ -173,3 +173,59 @@ def test_record_state_skips_unmapped_binary_state():
def test_record_state_missing_entity_id_is_noop():
ctl = _controller()
ctl.record_state({"state": "21.5"}) # no entity_id -> early return, no error
+
+
+# ── export / import ──────────────────────────────────────────────────────────
+
+
+def test_export_payload_shape():
+ ctl = _controller()
+ ctl.saved_graphs = {"Temps": {"entity_ids": ["sensor.a"], "graph_type": "line", "hours": 4.0}}
+ payload = ctl.to_export_payload("Temps")
+ assert payload["hatty_graph"] == 1
+ assert payload["name"] == "Temps"
+ assert payload["graph"] == ctl.saved_graphs["Temps"]
+ # A deep copy, not a live reference.
+ assert payload["graph"] is not ctl.saved_graphs["Temps"]
+
+
+def test_import_round_trip_creates_matching_graph():
+ ctl = _controller()
+ ctl.saved_graphs = {"Temps": {"entity_ids": ["sensor.a"], "graph_type": "line", "hours": 4.0}}
+ payload = ctl.to_export_payload("Temps")
+
+ ctl2 = _controller()
+ final = ctl2.import_from_payload(payload)
+ assert final == "Temps"
+ assert ctl2.saved_graphs["Temps"] == ctl.saved_graphs["Temps"]
+ assert ("saved_graphs",) in ctl2._app.persist_calls
+
+
+def test_import_dedupes_name_on_collision():
+ ctl = _controller()
+ ctl.saved_graphs = {"Temps": {"entity_ids": ["sensor.a"], "graph_type": "line", "hours": 4.0}}
+ payload = ctl.to_export_payload("Temps")
+ final = ctl.import_from_payload(payload)
+ assert final == "Temps (2)"
+ assert "Temps" in ctl.saved_graphs and "Temps (2)" in ctl.saved_graphs
+
+
+def test_import_rejects_wrong_version():
+ ctl = _controller()
+ for bad in ({"hatty_graph": 2, "name": "A", "graph": {"entity_ids": []}}, {}, "not a dict"):
+ try:
+ ctl.import_from_payload(bad)
+ assert False, "expected ValueError"
+ except ValueError:
+ pass
+
+
+def test_import_rejects_missing_entity_ids():
+ ctl = _controller()
+ for bad_graph in (None, "nope", {}, {"graph_type": "line"}):
+ payload = {"hatty_graph": 1, "name": "A", "graph": bad_graph}
+ try:
+ ctl.import_from_payload(payload)
+ assert False, "expected ValueError"
+ except ValueError:
+ pass
diff --git a/tests/unit/test_lists_controller.py b/tests/unit/test_lists_controller.py
index 509711e..ce8936c 100644
--- a/tests/unit/test_lists_controller.py
+++ b/tests/unit/test_lists_controller.py
@@ -337,3 +337,79 @@ def test_handle_popup_rename_routes_to_rename_list():
ctl.entity_lists = {"Kitchen": []}
ctl.handle_popup_action({"action": "rename", "list_name": "Kitchen", "new_name": "Study"})
assert ctl.list_names == ["Study"]
+
+
+# ── export / import ──────────────────────────────────────────────────────────
+
+
+def test_export_payload_shape():
+ ctl = _controller()
+ ctl.list_names = ["Kitchen"]
+ ctl.entity_lists = {"Kitchen": ["light.a", "light.b"]}
+ ctl.manual_lists = {"Kitchen"}
+ ctl._app.notify_ctl.notify_lists = {"Kitchen"}
+ payload = ctl.to_export_payload("Kitchen")
+ assert payload == {
+ "hatty_list": 1,
+ "name": "Kitchen",
+ "entities": ["light.a", "light.b"],
+ "manual": True,
+ "notify": True,
+ }
+
+
+def test_export_payload_defaults_manual_and_notify_false():
+ ctl = _controller()
+ ctl.list_names = ["Kitchen"]
+ ctl.entity_lists = {"Kitchen": []}
+ payload = ctl.to_export_payload("Kitchen")
+ assert payload["manual"] is False
+ assert payload["notify"] is False
+
+
+def test_import_round_trip_creates_matching_list():
+ ctl = _controller()
+ ctl.list_names = ["Kitchen"]
+ ctl.entity_lists = {"Kitchen": ["light.a"]}
+ ctl.manual_lists = {"Kitchen"}
+ ctl._app.notify_ctl.notify_lists = {"Kitchen"}
+ payload = ctl.to_export_payload("Kitchen")
+
+ ctl2 = _controller()
+ final = ctl2.import_from_payload(payload)
+ assert final == "Kitchen"
+ assert ctl2.entity_lists["Kitchen"] == ["light.a"]
+ assert ctl2.manual_lists == {"Kitchen"}
+ assert ctl2._app.notify_ctl.notify_lists == {"Kitchen"}
+ assert ("lists", "manual_lists", "notify_lists") in ctl2._app.persist_calls
+
+
+def test_import_dedupes_name_on_collision():
+ ctl = _controller()
+ ctl.list_names = ["Kitchen"]
+ ctl.entity_lists = {"Kitchen": []}
+ payload = ctl.to_export_payload("Kitchen")
+ final = ctl.import_from_payload(payload)
+ assert final == "Kitchen (2)"
+ assert "Kitchen" in ctl.entity_lists and "Kitchen (2)" in ctl.entity_lists
+
+
+def test_import_rejects_wrong_version():
+ ctl = _controller()
+ for bad in ({"hatty_list": 2, "name": "A", "entities": []}, {}, "not a dict"):
+ try:
+ ctl.import_from_payload(bad)
+ assert False, "expected ValueError"
+ except ValueError:
+ pass
+
+
+def test_import_rejects_missing_entities():
+ ctl = _controller()
+ for bad_entities in (None, "nope", {}):
+ payload = {"hatty_list": 1, "name": "A", "entities": bad_entities}
+ try:
+ ctl.import_from_payload(payload)
+ assert False, "expected ValueError"
+ except ValueError:
+ pass