navig-github 0.2.0__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. {navig_github-0.2.0 → navig_github-0.2.4}/PKG-INFO +15 -1
  2. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/commands/github.py +15 -6
  3. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/engine/analytics.py +37 -15
  4. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/engine/cli.py +61 -45
  5. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/engine/config.py +63 -19
  6. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/engine/diff.py +12 -8
  7. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/engine/gists.py +2 -2
  8. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/engine/git_utils.py +17 -13
  9. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/engine/incremental.py +17 -9
  10. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/engine/mirror.py +32 -0
  11. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/engine/notifications.py +18 -9
  12. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/engine/scheduler.py +90 -26
  13. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/engine/templates.py +54 -20
  14. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/engine/verify.py +12 -6
  15. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github.egg-info/PKG-INFO +15 -1
  16. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github.egg-info/SOURCES.txt +9 -1
  17. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github.egg-info/requires.txt +1 -0
  18. navig_github-0.2.4/pyproject.toml +54 -0
  19. navig_github-0.2.4/tests/test_analytics_no_wipe.py +58 -0
  20. navig_github-0.2.4/tests/test_backup_history_wire.py +67 -0
  21. navig_github-0.2.4/tests/test_config.py +81 -0
  22. navig_github-0.2.4/tests/test_debrand_stores.py +55 -0
  23. navig_github-0.2.4/tests/test_incremental.py +45 -0
  24. navig_github-0.2.4/tests/test_scheduler_migration.py +42 -0
  25. navig_github-0.2.4/tests/test_scheduler_no_wipe.py +150 -0
  26. navig_github-0.2.4/tests/test_templates_no_wipe.py +73 -0
  27. navig_github-0.2.0/pyproject.toml +0 -33
  28. {navig_github-0.2.0 → navig_github-0.2.4}/LICENSE +0 -0
  29. {navig_github-0.2.0 → navig_github-0.2.4}/README.md +0 -0
  30. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/__init__.py +0 -0
  31. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/commands/__init__.py +0 -0
  32. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/engine/__init__.py +0 -0
  33. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/engine/__main__.py +0 -0
  34. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/engine/attachments.py +0 -0
  35. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/engine/github_api.py +0 -0
  36. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/engine/models.py +0 -0
  37. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/engine/restore.py +0 -0
  38. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/engine/rich_utils.py +0 -0
  39. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/engine/transfer.py +0 -0
  40. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/engine/validation.py +0 -0
  41. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/plugin.py +0 -0
  42. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github/skills/github-ops/SKILL.md +0 -0
  43. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github.egg-info/dependency_links.txt +0 -0
  44. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github.egg-info/entry_points.txt +0 -0
  45. {navig_github-0.2.0 → navig_github-0.2.4}/navig_github.egg-info/top_level.txt +0 -0
  46. {navig_github-0.2.0 → navig_github-0.2.4}/setup.cfg +0 -0
@@ -1,11 +1,25 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: navig-github
3
- Version: 0.2.0
3
+ Version: 0.2.4
4
4
  Summary: NAVIG GitHub module — the full GitHub toolbox (backup, mirror, export, manage) wired into `navig github`. A first-party navig plugin (free, toggleable).
5
+ Author-email: NAVIG Development Team <opensource@navig.run>
5
6
  License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://navig.run
8
+ Project-URL: Documentation, https://navig.run/docs
9
+ Keywords: navig,cli,agent,automation,plugin,github,git,backup,mirror
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Utilities
6
19
  Requires-Python: >=3.10
7
20
  Description-Content-Type: text/markdown
8
21
  License-File: LICENSE
22
+ Requires-Dist: navig>=3.24.0
9
23
  Requires-Dist: requests>=2.31.0
10
24
  Requires-Dist: python-dotenv>=1.0.0
11
25
  Requires-Dist: schedule>=1.2.0
@@ -185,12 +185,12 @@ def _fallback_git_clone(repo_url: str, dest: str) -> None:
185
185
  result = subprocess.run(
186
186
  ["git", "-C", str(clone_dest), "pull", "--ff-only"],
187
187
  capture_output=True,
188
- text=True,
188
+ text=True, encoding="utf-8", errors="replace",
189
189
  )
190
190
  else:
191
191
  ch.info(f"Cloning {repo_url} → {clone_dest} …")
192
192
  result = subprocess.run(
193
- ["git", "clone", repo_url, str(clone_dest)], capture_output=True, text=True
193
+ ["git", "clone", repo_url, str(clone_dest)], capture_output=True, text=True, encoding="utf-8", errors="replace"
194
194
  )
195
195
 
196
196
  if result.returncode == 0:
@@ -339,10 +339,10 @@ def gh_clone(
339
339
  token: Annotated[str | None, typer.Option("--token", "-t", help="GitHub token")] = None,
340
340
  ):
341
341
  """
342
- ⬇️ Clone a single repository (with the engine if available, else plain git).
342
+ ⬇️ Clone a single repository (with the full toolbox if available, else plain git).
343
343
 
344
344
  Always FLAT — lands directly as <dest>/<repo> (like `git clone`). Falls back
345
- to a plain `git clone` when no token is configured and the engine is unavailable.
345
+ to a plain `git clone` when no token is configured and the full toolbox is unavailable.
346
346
 
347
347
  Examples:
348
348
  navig github clone owner/my-repo -d ./mirrors
@@ -390,7 +390,7 @@ def token_set(
390
390
  ] = True,
391
391
  ):
392
392
  """
393
- 🔑 Save a GitHub token for the engine to use automatically.
393
+ 🔑 Save a GitHub token for navig github to use automatically.
394
394
 
395
395
  Stores in the navig vault by default (preferred). Falls back to
396
396
  writing to ~/.navig/config.yaml when the vault is unavailable.
@@ -465,7 +465,7 @@ def token_show():
465
465
  ch.warning(
466
466
  "No GitHub token configured.\n"
467
467
  "Set one with: navig github token set <your-token>\n"
468
- "Without a token, the engine only accesses public repos at low rate limits."
468
+ "Without a token, navig github only accesses public repos at low rate limits."
469
469
  )
470
470
 
471
471
 
@@ -520,6 +520,7 @@ def gh_status():
520
520
  navig github status
521
521
  """
522
522
  # engine availability (bundled engine — a hard dependency of navig-github)
523
+ engine_missing = False
523
524
  if _engine_available():
524
525
  try:
525
526
  import navig_github.engine as fm # type: ignore
@@ -531,6 +532,7 @@ def gh_status():
531
532
  else:
532
533
  ch.error("GitHub engine MISSING (it is a dependency of navig-github).")
533
534
  ch.info("Reinstall the plugin: pip install navig-github")
535
+ engine_missing = True
534
536
 
535
537
  # Token check
536
538
  token = _resolve_github_token()
@@ -543,6 +545,13 @@ def gh_status():
543
545
  "Set one with: navig github token set <your-token>"
544
546
  )
545
547
 
548
+ if engine_missing:
549
+ # The engine is a hard dependency, so this is a BROKEN install, not a
550
+ # state the operator can be in on purpose. Raised at the end so the token
551
+ # check above still prints — a status command should show everything it
552
+ # knows before it exits.
553
+ raise typer.Exit(1)
554
+
546
555
 
547
556
  # ---------------------------------------------------------------------------
548
557
  # Full engine mount — every engine command becomes `navig github <command>`
@@ -12,6 +12,8 @@ from datetime import datetime
12
12
  from pathlib import Path
13
13
  from typing import Any
14
14
 
15
+ from navig.core.json_io import JsonReadError, atomic_write_json, load_json_for_update
16
+
15
17
 
16
18
  @dataclass
17
19
  class RepositoryStats:
@@ -151,22 +153,42 @@ class BackupAnalytics:
151
153
  """Initialize the analytics engine."""
152
154
  self.backup_dir = backup_dir or Path("backups")
153
155
  self._history: list[BackupHistory] = []
156
+ # True when history.json existed but was transiently UNREADABLE at load (a Windows
157
+ # AV/backup lock, or a truncated write). While set, _save_history REFUSES to write —
158
+ # _history is empty by accident, not because there are no backups, and overwriting it
159
+ # would wipe the recorded history. A per-run engine re-reads on the next construction.
160
+ self._history_load_failed = False
154
161
  self._load_history()
155
162
 
156
163
  def _load_history(self) -> None:
157
164
  """Load backup history from disk."""
158
165
  history_path = self.backup_dir / self.HISTORY_FILE
159
- if history_path.exists():
160
- try:
161
- data = json.loads(history_path.read_text())
162
- self._history = [
163
- BackupHistory(**h) for h in data.get("history", [])
164
- ]
165
- except (json.JSONDecodeError, TypeError):
166
- self._history = []
167
-
166
+ if not history_path.exists():
167
+ self._history_load_failed = False
168
+ return
169
+ try:
170
+ # load_json_for_update RAISES on a transiently-unreadable-but-populated file (a
171
+ # lock — the old narrow except missed PermissionError and crashed construction) and
172
+ # quarantines a corrupt/truncated one to *.corrupt instead of reading it as empty.
173
+ data = load_json_for_update(history_path, default={"history": []})
174
+ except JsonReadError:
175
+ self._history_load_failed = True
176
+ self._history = []
177
+ return
178
+ self._history_load_failed = False
179
+ try:
180
+ self._history = [
181
+ BackupHistory(**h) for h in data.get("history", [])
182
+ ]
183
+ except (TypeError, KeyError):
184
+ self._history = []
185
+
168
186
  def _save_history(self) -> None:
169
187
  """Save backup history to disk."""
188
+ if self._history_load_failed:
189
+ # History was unreadable at load, so _history is empty by accident — writing it
190
+ # would wipe the recorded backups. Refuse until a clean load.
191
+ return
170
192
  history_path = self.backup_dir / self.HISTORY_FILE
171
193
  history_path.parent.mkdir(parents=True, exist_ok=True)
172
194
  data = {
@@ -174,7 +196,7 @@ class BackupAnalytics:
174
196
  "updated_at": datetime.now().isoformat(),
175
197
  "history": [h.to_dict() for h in self._history],
176
198
  }
177
- history_path.write_text(json.dumps(data, indent=2))
199
+ atomic_write_json(data, history_path)
178
200
 
179
201
  def record_backup(
180
202
  self,
@@ -247,7 +269,7 @@ class BackupAnalytics:
247
269
  cwd=repo_path,
248
270
  capture_output=True,
249
271
  text=True,
250
- timeout=30,
272
+ timeout=30, encoding="utf-8", errors="replace",
251
273
  )
252
274
  if result.returncode == 0:
253
275
  stats.commit_count = int(result.stdout.strip())
@@ -261,7 +283,7 @@ class BackupAnalytics:
261
283
  cwd=repo_path,
262
284
  capture_output=True,
263
285
  text=True,
264
- timeout=30,
286
+ timeout=30, encoding="utf-8", errors="replace",
265
287
  )
266
288
  if result.returncode == 0:
267
289
  stats.branch_count = len([
@@ -278,7 +300,7 @@ class BackupAnalytics:
278
300
  cwd=repo_path,
279
301
  capture_output=True,
280
302
  text=True,
281
- timeout=30,
303
+ timeout=30, encoding="utf-8", errors="replace",
282
304
  )
283
305
  if result.returncode == 0:
284
306
  stats.tag_count = len([t for t in result.stdout.strip().split("\n") if t.strip()])
@@ -292,7 +314,7 @@ class BackupAnalytics:
292
314
  cwd=repo_path,
293
315
  capture_output=True,
294
316
  text=True,
295
- timeout=30,
317
+ timeout=30, encoding="utf-8", errors="replace",
296
318
  )
297
319
  if result.returncode == 0 and result.stdout.strip():
298
320
  stats.last_commit_date = result.stdout.strip()
@@ -304,7 +326,7 @@ class BackupAnalytics:
304
326
  if lfs_config.exists() or (repo_path / ".gitattributes").exists():
305
327
  gitattributes = repo_path / ".gitattributes"
306
328
  if gitattributes.exists():
307
- content = gitattributes.read_text()
329
+ content = gitattributes.read_text(encoding="utf-8")
308
330
  stats.has_lfs = "filter=lfs" in content
309
331
 
310
332
  # Get language statistics (for non-bare repos)
@@ -251,7 +251,7 @@ def export_repository_data(
251
251
  for issue in issues_list
252
252
  ],
253
253
  }
254
- with open(dest, "w") as f:
254
+ with open(dest, "w", encoding="utf-8") as f:
255
255
  json.dump(issues_data, f, indent=2)
256
256
  console.print(f" [green]✓ Issues exported: {owner}/{repo_name} ({len(issues_list)} issues)[/green]")
257
257
 
@@ -278,7 +278,7 @@ def export_repository_data(
278
278
  for pr in prs_list
279
279
  ],
280
280
  }
281
- with open(dest, "w") as f:
281
+ with open(dest, "w", encoding="utf-8") as f:
282
282
  json.dump(prs_data, f, indent=2)
283
283
  console.print(f" [green]✓ Pull requests exported: {owner}/{repo_name} ({len(prs_list)} PRs)[/green]")
284
284
 
@@ -291,7 +291,7 @@ def export_repository_data(
291
291
  if workflows_list:
292
292
  for wf_file in workflow_files:
293
293
  file_path = dest / Path(wf_file["path"]).name
294
- with open(file_path, "w") as f:
294
+ with open(file_path, "w", encoding="utf-8") as f:
295
295
  f.write(wf_file["content"])
296
296
 
297
297
  metadata = {
@@ -300,7 +300,7 @@ def export_repository_data(
300
300
  "exported_at": datetime.now().isoformat(),
301
301
  "workflows": [{"name": wf.name, "path": wf.path} for wf in workflows_list],
302
302
  }
303
- with open(dest / "metadata.json", "w") as f:
303
+ with open(dest / "metadata.json", "w", encoding="utf-8") as f:
304
304
  json.dump(metadata, f, indent=2)
305
305
  console.print(f" [green]✓ Workflows exported: {owner}/{repo_name} ({len(workflows_list)} workflows)[/green]")
306
306
 
@@ -325,7 +325,7 @@ def export_repository_data(
325
325
  for release in releases_list
326
326
  ],
327
327
  }
328
- with open(dest / "metadata.json", "w") as f:
328
+ with open(dest / "metadata.json", "w", encoding="utf-8") as f:
329
329
  json.dump(metadata, f, indent=2)
330
330
  console.print(f" [green]✓ Releases exported: {owner}/{repo_name} ({len(releases_list)} releases)[/green]")
331
331
 
@@ -345,7 +345,7 @@ def export_repository_data(
345
345
  capture_output=True,
346
346
  text=True,
347
347
  check=True,
348
- timeout=300,
348
+ timeout=300, encoding="utf-8", errors="replace",
349
349
  )
350
350
  console.print(f" [green]✓ Wiki cloned: {owner}/{repo_name}[/green]")
351
351
  except subprocess.CalledProcessError:
@@ -358,7 +358,7 @@ def export_repository_data(
358
358
  capture_output=True,
359
359
  text=True,
360
360
  check=True,
361
- timeout=120,
361
+ timeout=120, encoding="utf-8", errors="replace",
362
362
  )
363
363
  console.print(f" [green]✓ Wiki updated: {owner}/{repo_name}[/green]")
364
364
  except subprocess.CalledProcessError:
@@ -1184,7 +1184,7 @@ def secrets(
1184
1184
  ⚠️ Note: GitHub API does NOT expose secret values, only names and metadata.
1185
1185
 
1186
1186
  Example:
1187
- navig github secrets miztizm/the engine
1187
+ navig github secrets miztizm/navig-github
1188
1188
  navig github secrets miztizm/hello-world --format yaml
1189
1189
  navig github secrets miztizm/navig github --dest ./custom_secrets.json
1190
1190
  """
@@ -1546,7 +1546,7 @@ def clone(
1546
1546
  drops the repo right where you are, defaults to HTTPS (no SSH keys needed
1547
1547
  for public repos), and needs no token or GitHub API call for public repos.
1548
1548
 
1549
- "Sometimes you just want the code, not the whole archive." — the engine
1549
+ "Sometimes you just want the code, not the whole archive." — navig github
1550
1550
 
1551
1551
  Example:
1552
1552
  navig github clone microsoft/vscode
@@ -1707,10 +1707,10 @@ def issues(
1707
1707
 
1708
1708
  # Export to file
1709
1709
  if format == "yaml":
1710
- with open(dest, "w") as f:
1710
+ with open(dest, "w", encoding="utf-8") as f:
1711
1711
  yaml.dump(issues_data, f, default_flow_style=False, sort_keys=False)
1712
1712
  else:
1713
- with open(dest, "w") as f:
1713
+ with open(dest, "w", encoding="utf-8") as f:
1714
1714
  json.dump(issues_data, f, indent=2)
1715
1715
 
1716
1716
  # Create summary table
@@ -1840,10 +1840,10 @@ def pulls(
1840
1840
 
1841
1841
  # Export to file
1842
1842
  if format == "yaml":
1843
- with open(dest, "w") as f:
1843
+ with open(dest, "w", encoding="utf-8") as f:
1844
1844
  yaml.dump(prs_data, f, default_flow_style=False, sort_keys=False)
1845
1845
  else:
1846
- with open(dest, "w") as f:
1846
+ with open(dest, "w", encoding="utf-8") as f:
1847
1847
  json.dump(prs_data, f, indent=2)
1848
1848
 
1849
1849
  # Create summary table
@@ -1930,7 +1930,7 @@ def workflows(
1930
1930
  # Save workflow files
1931
1931
  for wf_file in workflow_files:
1932
1932
  file_path = dest / Path(wf_file["path"]).name
1933
- with open(file_path, "w") as f:
1933
+ with open(file_path, "w", encoding="utf-8") as f:
1934
1934
  f.write(wf_file["content"])
1935
1935
 
1936
1936
  # Save metadata
@@ -1971,7 +1971,7 @@ def workflows(
1971
1971
 
1972
1972
  # Save metadata
1973
1973
  metadata_path = dest / "metadata.json"
1974
- with open(metadata_path, "w") as f:
1974
+ with open(metadata_path, "w", encoding="utf-8") as f:
1975
1975
  json.dump(metadata, f, indent=2)
1976
1976
 
1977
1977
  console.print(f"\n[green]✅ Workflows backed up to: {dest}[/green]")
@@ -2070,7 +2070,7 @@ def releases(
2070
2070
  }
2071
2071
 
2072
2072
  metadata_path = dest / "metadata.json"
2073
- with open(metadata_path, "w") as f:
2073
+ with open(metadata_path, "w", encoding="utf-8") as f:
2074
2074
  json.dump(metadata, f, indent=2)
2075
2075
 
2076
2076
  # Download assets if requested
@@ -2095,7 +2095,7 @@ def releases(
2095
2095
  "author": release.author,
2096
2096
  "html_url": release.html_url,
2097
2097
  }
2098
- with open(release_dir / "release.json", "w") as f:
2098
+ with open(release_dir / "release.json", "w", encoding="utf-8") as f:
2099
2099
  json.dump(release_metadata, f, indent=2)
2100
2100
 
2101
2101
  # Download each asset
@@ -2196,7 +2196,7 @@ def wiki(
2196
2196
  capture_output=True,
2197
2197
  text=True,
2198
2198
  check=True,
2199
- timeout=120,
2199
+ timeout=120, encoding="utf-8", errors="replace",
2200
2200
  )
2201
2201
  console.print(f"[green]✅ Wiki updated: {dest}[/green]")
2202
2202
  except subprocess.CalledProcessError as e:
@@ -2210,7 +2210,7 @@ def wiki(
2210
2210
  capture_output=True,
2211
2211
  text=True,
2212
2212
  check=True,
2213
- timeout=300,
2213
+ timeout=300, encoding="utf-8", errors="replace",
2214
2214
  )
2215
2215
  console.print(f"[green]✅ Wiki cloned to: {dest}[/green]")
2216
2216
  except subprocess.CalledProcessError as e:
@@ -2593,7 +2593,7 @@ def attachments(
2593
2593
  issues and pull requests, including images, documents, and other attachments.
2594
2594
 
2595
2595
  Example:
2596
- navig github attachments miztizm/the engine
2596
+ navig github attachments miztizm/navig-github
2597
2597
  navig github attachments myorg/myrepo --source issues
2598
2598
  navig github attachments myorg/myrepo --source pulls --dest ./my-attachments
2599
2599
  """
@@ -2739,7 +2739,7 @@ def labels(
2739
2739
 
2740
2740
 
2741
2741
  Example:
2742
- navig github labels miztizm/the engine
2742
+ navig github labels miztizm/navig-github
2743
2743
  navig github labels myorg/myrepo --format yaml
2744
2744
  navig github labels myorg/myrepo --dest ./my-labels.json
2745
2745
  """
@@ -2791,10 +2791,10 @@ def labels(
2791
2791
 
2792
2792
  # Export to file
2793
2793
  if format.lower() == "yaml":
2794
- with open(dest, "w") as f:
2794
+ with open(dest, "w", encoding="utf-8") as f:
2795
2795
  yaml.dump(labels_data, f, default_flow_style=False, sort_keys=False)
2796
2796
  else:
2797
- with open(dest, "w") as f:
2797
+ with open(dest, "w", encoding="utf-8") as f:
2798
2798
  json.dump(labels_data, f, indent=2)
2799
2799
 
2800
2800
  # Create summary table
@@ -2857,7 +2857,7 @@ def milestones(
2857
2857
 
2858
2858
 
2859
2859
  Example:
2860
- navig github milestones miztizm/the engine
2860
+ navig github milestones miztizm/navig-github
2861
2861
  navig github milestones myorg/myrepo --state open
2862
2862
  navig github milestones myorg/myrepo --format yaml
2863
2863
  """
@@ -2918,10 +2918,10 @@ def milestones(
2918
2918
 
2919
2919
  # Export to file
2920
2920
  if format.lower() == "yaml":
2921
- with open(dest, "w") as f:
2921
+ with open(dest, "w", encoding="utf-8") as f:
2922
2922
  yaml.dump(milestones_data, f, default_flow_style=False, sort_keys=False)
2923
2923
  else:
2924
- with open(dest, "w") as f:
2924
+ with open(dest, "w", encoding="utf-8") as f:
2925
2925
  json.dump(milestones_data, f, indent=2)
2926
2926
 
2927
2927
  # Create summary table
@@ -2987,7 +2987,7 @@ def webhooks(
2987
2987
  ⚠️ Note: Requires admin access to the repository. Secret values are redacted.
2988
2988
 
2989
2989
  Example:
2990
- navig github webhooks miztizm/the engine
2990
+ navig github webhooks miztizm/navig-github
2991
2991
  navig github webhooks myorg/myrepo --format yaml
2992
2992
  """
2993
2993
  if format.lower() not in ["json", "yaml"]:
@@ -3042,10 +3042,10 @@ def webhooks(
3042
3042
 
3043
3043
  # Export to file
3044
3044
  if format.lower() == "yaml":
3045
- with open(dest, "w") as f:
3045
+ with open(dest, "w", encoding="utf-8") as f:
3046
3046
  yaml.dump(webhooks_data, f, default_flow_style=False, sort_keys=False)
3047
3047
  else:
3048
- with open(dest, "w") as f:
3048
+ with open(dest, "w", encoding="utf-8") as f:
3049
3049
  json.dump(webhooks_data, f, indent=2)
3050
3050
 
3051
3051
  # Create summary table
@@ -3188,10 +3188,10 @@ def followers(
3188
3188
 
3189
3189
  # Export to file
3190
3190
  if format.lower() == "yaml":
3191
- with open(dest, "w") as f:
3191
+ with open(dest, "w", encoding="utf-8") as f:
3192
3192
  yaml.dump(export_data, f, default_flow_style=False, sort_keys=False)
3193
3193
  else:
3194
- with open(dest, "w") as f:
3194
+ with open(dest, "w", encoding="utf-8") as f:
3195
3195
  json.dump(export_data, f, indent=2)
3196
3196
 
3197
3197
  # Create summary table
@@ -3244,7 +3244,7 @@ def discussions(
3244
3244
  Uses the GitHub GraphQL API to fetch discussions data.
3245
3245
 
3246
3246
  Example:
3247
- navig github discussions miztizm/the engine
3247
+ navig github discussions miztizm/navig-github
3248
3248
  navig github discussions myorg/myrepo --format yaml
3249
3249
  """
3250
3250
  if format.lower() not in ["json", "yaml"]:
@@ -3304,10 +3304,10 @@ def discussions(
3304
3304
 
3305
3305
  # Export to file
3306
3306
  if format.lower() == "yaml":
3307
- with open(dest, "w") as f:
3307
+ with open(dest, "w", encoding="utf-8") as f:
3308
3308
  yaml.dump(discussions_data, f, default_flow_style=False, sort_keys=False)
3309
3309
  else:
3310
- with open(dest, "w") as f:
3310
+ with open(dest, "w", encoding="utf-8") as f:
3311
3311
  json.dump(discussions_data, f, indent=2)
3312
3312
 
3313
3313
  # Create summary table
@@ -3672,7 +3672,7 @@ def schedule_add(
3672
3672
  navig github schedule-add weekly-full --profile full-backup --interval weekly --on monday --at 03:00
3673
3673
  navig github schedule-add frequent --profile quick --interval "every 6 hours"
3674
3674
  """
3675
- from .scheduler import BackupScheduler, create_scheduled_backup
3675
+ from .scheduler import BackupScheduler, ScheduleStoreError, create_scheduled_backup
3676
3676
 
3677
3677
  scheduler = BackupScheduler()
3678
3678
  backup = create_scheduled_backup(
@@ -3683,7 +3683,12 @@ def schedule_add(
3683
3683
  on_day=on_day,
3684
3684
  )
3685
3685
 
3686
- scheduler.add_backup(backup)
3686
+ try:
3687
+ scheduler.add_backup(backup)
3688
+ except ScheduleStoreError as e:
3689
+ print_error(f"Could not read existing schedules — not saving, to avoid losing them: {e}")
3690
+ console.print(" [dim]The schedule store is temporarily locked; try again in a moment.[/dim]")
3691
+ sys.exit(1)
3687
3692
  print_success(f"Scheduled backup '{name}' added!")
3688
3693
  console.print(f" [dim]Interval: {interval}[/dim]")
3689
3694
  if at_time:
@@ -3748,10 +3753,16 @@ def schedule_remove(
3748
3753
  Example:
3749
3754
  navig github schedule-remove daily-backup
3750
3755
  """
3751
- from .scheduler import BackupScheduler
3756
+ from .scheduler import BackupScheduler, ScheduleStoreError
3752
3757
 
3753
3758
  scheduler = BackupScheduler()
3754
- if scheduler.remove_backup(name):
3759
+ try:
3760
+ removed = scheduler.remove_backup(name)
3761
+ except ScheduleStoreError as e:
3762
+ print_error(f"Could not read existing schedules — not saving, to avoid losing them: {e}")
3763
+ console.print(" [dim]The schedule store is temporarily locked; try again in a moment.[/dim]")
3764
+ sys.exit(1)
3765
+ if removed:
3755
3766
  print_success(f"Schedule '{name}' removed")
3756
3767
  else:
3757
3768
  print_error(f"Schedule '{name}' not found")
@@ -4185,10 +4196,10 @@ def projects(
4185
4196
 
4186
4197
  # Export to file
4187
4198
  if format.lower() == "yaml":
4188
- with open(dest, "w") as f:
4199
+ with open(dest, "w", encoding="utf-8") as f:
4189
4200
  yaml.dump(projects_data, f, default_flow_style=False, sort_keys=False)
4190
4201
  else:
4191
- with open(dest, "w") as f:
4202
+ with open(dest, "w", encoding="utf-8") as f:
4192
4203
  json.dump(projects_data, f, indent=2)
4193
4204
 
4194
4205
  # Create summary table
@@ -4260,7 +4271,7 @@ def analytics_report(
4260
4271
  report = analytics.generate_report(format=format.lower())
4261
4272
 
4262
4273
  if output:
4263
- output.write_text(report)
4274
+ output.write_text(report, encoding="utf-8")
4264
4275
  print_success(f"Report saved to: {output}")
4265
4276
  else:
4266
4277
  console.print(report)
@@ -4385,7 +4396,7 @@ def diff_backups(
4385
4396
  report = compare.generate_diff_report(diff, format=format.lower())
4386
4397
 
4387
4398
  if output:
4388
- output.write_text(report)
4399
+ output.write_text(report, encoding="utf-8")
4389
4400
  print_success(f"Diff report saved to: {output}")
4390
4401
  else:
4391
4402
  console.print(report)
@@ -4535,9 +4546,14 @@ def show_template(
4535
4546
  console.print(table)
4536
4547
 
4537
4548
  if template.schedule_interval:
4538
- console.print(f"\n [cyan]📅 Schedule: {template.schedule_interval}")
4549
+ # Rich parses each print() independently, so a style opened in one call does not
4550
+ # carry into the next. Splitting `[cyan]…[/cyan]` across two prints left the
4551
+ # second holding a closing tag with no opener, and Rich raises MarkupError on
4552
+ # that rather than ignoring it — `template-show` crashed for any template with
4553
+ # both an interval and a time. Each line now opens and closes its own tag.
4554
+ console.print(f"\n [cyan]📅 Schedule: {template.schedule_interval}[/cyan]")
4539
4555
  if template.schedule_time:
4540
- console.print(f" At: {template.schedule_time}[/cyan]")
4556
+ console.print(f" [cyan]At: {template.schedule_time}[/cyan]")
4541
4557
 
4542
4558
 
4543
4559
  @app.command("template-use")
@@ -4725,7 +4741,7 @@ def test_notifications() -> None:
4725
4741
 
4726
4742
  if not manager.providers:
4727
4743
  print_warning("No notification providers configured")
4728
- console.print(" [dim]Configure notifications in ~/.config/the engine/.the engine_notifications.json[/dim]")
4744
+ console.print(" [dim]Configure notifications in ~/.config/navig-github/.navig-github_notifications.json[/dim]")
4729
4745
  return
4730
4746
 
4731
4747
  console.print("\n[cyan]🔔 Testing notification providers...[/cyan]")