mooring 0.2.3__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 (65) hide show
  1. {mooring-0.2.3 → mooring-0.2.4}/PKG-INFO +1 -1
  2. {mooring-0.2.3 → mooring-0.2.4}/pyproject.toml +1 -1
  3. {mooring-0.2.3 → mooring-0.2.4}/src/mooring/__init__.py +1 -1
  4. {mooring-0.2.3 → mooring-0.2.4}/src/mooring/auth.py +20 -0
  5. {mooring-0.2.3 → mooring-0.2.4}/src/mooring/cli.py +51 -8
  6. {mooring-0.2.3 → mooring-0.2.4}/src/mooring/config_store.py +25 -0
  7. {mooring-0.2.3 → mooring-0.2.4}/src/mooring/hub/server.py +5 -3
  8. mooring-0.2.4/src/mooring/paths.py +64 -0
  9. {mooring-0.2.3 → mooring-0.2.4}/src/mooring/sync.py +100 -25
  10. {mooring-0.2.3 → mooring-0.2.4}/tests/test_auth.py +29 -0
  11. {mooring-0.2.3 → mooring-0.2.4}/tests/test_cli_repo.py +77 -0
  12. {mooring-0.2.3 → mooring-0.2.4}/tests/test_config_store.py +26 -0
  13. mooring-0.2.4/tests/test_paths.py +33 -0
  14. {mooring-0.2.3 → mooring-0.2.4}/tests/test_sync.py +119 -5
  15. {mooring-0.2.3 → mooring-0.2.4}/uv.lock +1 -1
  16. mooring-0.2.3/src/mooring/paths.py +0 -33
  17. {mooring-0.2.3 → mooring-0.2.4}/.github/workflows/docs.yml +0 -0
  18. {mooring-0.2.3 → mooring-0.2.4}/.github/workflows/release.yml +0 -0
  19. {mooring-0.2.3 → mooring-0.2.4}/.gitignore +0 -0
  20. {mooring-0.2.3 → mooring-0.2.4}/README.md +0 -0
  21. {mooring-0.2.3 → mooring-0.2.4}/docs/admins/build-and-distribute.md +0 -0
  22. {mooring-0.2.3 → mooring-0.2.4}/docs/admins/configuration.md +0 -0
  23. {mooring-0.2.3 → mooring-0.2.4}/docs/admins/github-setup.md +0 -0
  24. {mooring-0.2.3 → mooring-0.2.4}/docs/admins/index.md +0 -0
  25. {mooring-0.2.3 → mooring-0.2.4}/docs/assets/images/anchor-mark.svg +0 -0
  26. {mooring-0.2.3 → mooring-0.2.4}/docs/assets/images/favicon.svg +0 -0
  27. {mooring-0.2.3 → mooring-0.2.4}/docs/assets/javascripts/landing.js +0 -0
  28. {mooring-0.2.3 → mooring-0.2.4}/docs/assets/stylesheets/landing.css +0 -0
  29. {mooring-0.2.3 → mooring-0.2.4}/docs/assets/stylesheets/oah-theme.css +0 -0
  30. {mooring-0.2.3 → mooring-0.2.4}/docs/developers/contributing.md +0 -0
  31. {mooring-0.2.3 → mooring-0.2.4}/docs/developers/index.md +0 -0
  32. {mooring-0.2.3 → mooring-0.2.4}/docs/index.md +0 -0
  33. {mooring-0.2.3 → mooring-0.2.4}/docs/users/cli.md +0 -0
  34. {mooring-0.2.3 → mooring-0.2.4}/docs/users/conflicts.md +0 -0
  35. {mooring-0.2.3 → mooring-0.2.4}/docs/users/daily-workflow.md +0 -0
  36. {mooring-0.2.3 → mooring-0.2.4}/docs/users/index.md +0 -0
  37. {mooring-0.2.3 → mooring-0.2.4}/docs/users/power-bi.md +0 -0
  38. {mooring-0.2.3 → mooring-0.2.4}/overrides/home.html +0 -0
  39. {mooring-0.2.3 → mooring-0.2.4}/scripts/release.ps1 +0 -0
  40. {mooring-0.2.3 → mooring-0.2.4}/src/mooring/config.py +0 -0
  41. {mooring-0.2.3 → mooring-0.2.4}/src/mooring/config_default.toml +0 -0
  42. {mooring-0.2.3 → mooring-0.2.4}/src/mooring/editor.py +0 -0
  43. {mooring-0.2.3 → mooring-0.2.4}/src/mooring/githost.py +0 -0
  44. {mooring-0.2.3 → mooring-0.2.4}/src/mooring/github.py +0 -0
  45. {mooring-0.2.3 → mooring-0.2.4}/src/mooring/gitsha.py +0 -0
  46. {mooring-0.2.3 → mooring-0.2.4}/src/mooring/hub/__init__.py +0 -0
  47. {mooring-0.2.3 → mooring-0.2.4}/src/mooring/hub/static/app.js +0 -0
  48. {mooring-0.2.3 → mooring-0.2.4}/src/mooring/hub/static/index.html +0 -0
  49. {mooring-0.2.3 → mooring-0.2.4}/src/mooring/hub/static/style.css +0 -0
  50. {mooring-0.2.3 → mooring-0.2.4}/src/mooring/manifest.py +0 -0
  51. {mooring-0.2.3 → mooring-0.2.4}/src/mooring/notebook_template.py +0 -0
  52. {mooring-0.2.3 → mooring-0.2.4}/src/mooring/pbip.py +0 -0
  53. {mooring-0.2.3 → mooring-0.2.4}/src/mooring/telemetry.py +0 -0
  54. {mooring-0.2.3 → mooring-0.2.4}/tests/conftest.py +0 -0
  55. {mooring-0.2.3 → mooring-0.2.4}/tests/manual_editor_check.py +0 -0
  56. {mooring-0.2.3 → mooring-0.2.4}/tests/test_config.py +0 -0
  57. {mooring-0.2.3 → mooring-0.2.4}/tests/test_githost.py +0 -0
  58. {mooring-0.2.3 → mooring-0.2.4}/tests/test_github.py +0 -0
  59. {mooring-0.2.3 → mooring-0.2.4}/tests/test_gitsha.py +0 -0
  60. {mooring-0.2.3 → mooring-0.2.4}/tests/test_hub.py +0 -0
  61. {mooring-0.2.3 → mooring-0.2.4}/tests/test_manifest.py +0 -0
  62. {mooring-0.2.3 → mooring-0.2.4}/tests/test_pbip.py +0 -0
  63. {mooring-0.2.3 → mooring-0.2.4}/tests/test_telemetry.py +0 -0
  64. {mooring-0.2.3 → mooring-0.2.4}/tests/test_truststore.py +0 -0
  65. {mooring-0.2.3 → mooring-0.2.4}/zensical.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mooring
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Git-free marimo notebook sharing via GitHub
5
5
  Requires-Python: <3.13,>=3.12
6
6
  Requires-Dist: altair
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mooring"
3
- version = "0.2.3"
3
+ version = "0.2.4"
4
4
  description = "Git-free marimo notebook sharing via GitHub"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<3.13"
@@ -1,3 +1,3 @@
1
1
  """Mooring: git-free marimo notebook sharing via GitHub."""
2
2
 
3
- __version__ = "0.2.3"
3
+ __version__ = "0.2.4"
@@ -40,6 +40,26 @@ class AuthError(Exception):
40
40
  pass
41
41
 
42
42
 
43
+ def device_flow_hint(host: str, exc: Exception) -> str:
44
+ """A friendly one-line explanation for a failed device-code request.
45
+
46
+ Names the host (and HTTP status, if any) so a misrouted login is obvious,
47
+ and only suggests setting a host when the request went to the default
48
+ github.com — a real GHE host that 404s has a different cause (device flow
49
+ disabled, or a client_id from the wrong instance).
50
+ """
51
+ status = getattr(getattr(exc, "response", None), "status_code", None)
52
+ head = f"Couldn't start GitHub login against {host}"
53
+ head += f" (HTTP {status})." if status else f": {exc}"
54
+ if host == githost.DEFAULT_HOST:
55
+ head += (
56
+ " If this repo is on GitHub Enterprise, set its host: run "
57
+ '`mooring login --host ghe.example.com`, or add `host = "ghe.example.com"` '
58
+ "under [github] in your config."
59
+ )
60
+ return head
61
+
62
+
43
63
  @dataclass
44
64
  class DeviceCode:
45
65
  device_code: str
@@ -73,7 +73,13 @@ def _build_parser() -> argparse.ArgumentParser:
73
73
  hub.add_argument("--no-browser", action="store_true", help="don't open a browser tab")
74
74
  hub.add_argument("--port", type=int, default=None, help="fixed port for the hub server")
75
75
 
76
- sub.add_parser("login", help="log in to GitHub via device flow")
76
+ login = sub.add_parser("login", help="log in to GitHub via device flow")
77
+ login.add_argument(
78
+ "--host",
79
+ default=None,
80
+ help="GitHub host or URL for GitHub Enterprise (e.g. ghe.example.com); "
81
+ "saved as the global host before logging in",
82
+ )
77
83
  sub.add_parser("logout", help="forget the stored GitHub token")
78
84
  sub.add_parser("whoami", help="show the logged-in GitHub user")
79
85
  status = sub.add_parser("status", help="show sync status of workspace files")
@@ -98,7 +104,10 @@ def _build_parser() -> argparse.ArgumentParser:
98
104
  repo_use = repo_sub.add_parser("use", help="switch the active repo")
99
105
  repo_use.add_argument("alias")
100
106
  repo_rm = repo_sub.add_parser("remove", help="forget a repo (local files are kept)")
101
- repo_rm.add_argument("alias")
107
+ repo_rm.add_argument("alias", nargs="?", default=None, help="alias to remove (omit when using --all)")
108
+ repo_rm.add_argument(
109
+ "--all", dest="all_repos", action="store_true", help="remove every registered repo"
110
+ )
102
111
 
103
112
  pull = sub.add_parser("pull", help="download changes from the team repo")
104
113
  pull_grp = pull.add_mutually_exclusive_group()
@@ -143,8 +152,8 @@ def _print_paths(cfg: config.Config) -> None:
143
152
  print(f" config file : {paths.user_config_file()}")
144
153
  print(f" workspace : {cfg.workspace()}")
145
154
  print(f" logs : {paths.user_log_dir()}")
146
- hint = legacy_workspace_hint(cfg)
147
- if hint:
155
+ hints = (legacy_workspace_hint(cfg), paths.synced_folder_hint(cfg.workspace()))
156
+ for hint in (h for h in hints if h):
148
157
  print(f" note : {hint}")
149
158
 
150
159
 
@@ -162,6 +171,13 @@ def legacy_workspace_hint(cfg: config.Config) -> str:
162
171
  return ""
163
172
 
164
173
 
174
+ def workspace_hint(cfg: config.Config) -> str:
175
+ """Combined workspace warnings (legacy location + cloud-sync folder) for the
176
+ hub and selftest, joined into one line."""
177
+ hints = (legacy_workspace_hint(cfg), paths.synced_folder_hint(cfg.workspace()))
178
+ return " ".join(h for h in hints if h)
179
+
180
+
165
181
  def cmd_selftest(app_cfg: config.AppConfig, cfg: config.Config) -> int:
166
182
  import importlib.metadata
167
183
 
@@ -220,15 +236,28 @@ def _client(cfg: config.Config):
220
236
  return GitHubClient(_require_token(cfg), cfg.owner, cfg.repo, host=cfg.host)
221
237
 
222
238
 
223
- def cmd_login(cfg: config.Config) -> int:
224
- from mooring import auth
239
+ def cmd_login(cfg: config.Config, host: str | None = None) -> int:
240
+ import requests
225
241
 
242
+ from mooring import auth, config_store
243
+
244
+ if host is not None:
245
+ try:
246
+ new_host = config_store.set_host(host)
247
+ except ValueError as exc:
248
+ sys.exit(str(exc))
249
+ print(f"Saved GitHub host: {new_host}")
250
+ cfg = config.load_config() # pick up the host just written
226
251
  if not cfg.client_id:
227
252
  sys.exit(
228
253
  "No OAuth client_id configured. Set [github] client_id in "
229
254
  f"{paths.user_config_file()}."
230
255
  )
231
- device = auth.start_device_flow(cfg.client_id, host=cfg.host)
256
+ print(f"Requesting device code from {cfg.host}…")
257
+ try:
258
+ device = auth.start_device_flow(cfg.client_id, host=cfg.host)
259
+ except (auth.AuthError, requests.RequestException) as exc:
260
+ sys.exit(auth.device_flow_hint(cfg.host, exc))
232
261
  print(f"Open {device.verification_uri} and enter code: {device.user_code}")
233
262
  print("Waiting for authorization...")
234
263
  token = auth.poll_for_token(cfg.client_id, device)
@@ -414,6 +443,20 @@ def cmd_repo(app_cfg: config.AppConfig, args: argparse.Namespace) -> int:
414
443
  print(f"Active repo is now {args.alias!r}.")
415
444
  return 0
416
445
  if args.repo_command == "remove":
446
+ if getattr(args, "all_repos", False):
447
+ aliases = list(app_cfg.aliases)
448
+ if not aliases:
449
+ print("No repos registered.")
450
+ return 0
451
+ config_store.remove_all_repos()
452
+ telemetry.log_event("repo_remove", alias="*")
453
+ print(
454
+ f"Removed all {len(aliases)} repo(s): {', '.join(aliases)}. "
455
+ "Workspace folders were kept; delete them manually."
456
+ )
457
+ return 0
458
+ if not args.alias:
459
+ sys.exit("Specify a repo alias to remove, or use --all.")
417
460
  try:
418
461
  ws = app_cfg.config_for(args.alias).workspace()
419
462
  config_store.remove_repo(args.alias)
@@ -451,7 +494,7 @@ def _dispatch(
451
494
  port = getattr(args, "port", None)
452
495
  return run_hub(app_cfg, open_browser=not no_browser, port=port)
453
496
  if command == "login":
454
- return cmd_login(cfg)
497
+ return cmd_login(cfg, getattr(args, "host", None))
455
498
  if command == "logout":
456
499
  return cmd_logout(cfg)
457
500
  if command == "whoami":
@@ -83,6 +83,19 @@ def add_repo(
83
83
  write_user_data(data)
84
84
 
85
85
 
86
+ def set_host(host: str) -> str:
87
+ """Persist the global GitHub host; returns the normalized value.
88
+
89
+ Host is a single [github] setting shared by every repo, independent of the
90
+ [repos] registry, so this writes [github].host without materializing repos.
91
+ """
92
+ normalized = githost.normalize_host(host)
93
+ data = read_user_data()
94
+ data.setdefault("github", {})["host"] = normalized
95
+ write_user_data(data)
96
+ return normalized
97
+
98
+
86
99
  def remove_repo(alias: str) -> None:
87
100
  data = _materialized(read_user_data())
88
101
  if alias not in data["repos"] or alias in RESERVED_ALIASES:
@@ -97,6 +110,18 @@ def remove_repo(alias: str) -> None:
97
110
  write_user_data(data)
98
111
 
99
112
 
113
+ def remove_all_repos() -> None:
114
+ """Clear the entire repo registry. Workspaces and the saved token are kept.
115
+
116
+ An explicit empty [repos] is authoritative — it also overrides any
117
+ owner/repo baked into the packaged default (repo_specs_from_data treats a
118
+ present [repos] section as the whole truth).
119
+ """
120
+ data = read_user_data()
121
+ data["repos"] = {}
122
+ write_user_data(data)
123
+
124
+
100
125
  def set_active(alias: str) -> None:
101
126
  data = _materialized(read_user_data())
102
127
  if alias not in data["repos"] or alias in RESERVED_ALIASES:
@@ -23,7 +23,7 @@ from starlette.routing import Mount, Route
23
23
  from starlette.staticfiles import StaticFiles
24
24
 
25
25
  from mooring import __version__, auth, config, config_store, pbip, sync, telemetry
26
- from mooring.cli import SELFTEST_PACKAGES, legacy_workspace_hint
26
+ from mooring.cli import SELFTEST_PACKAGES, workspace_hint
27
27
  from mooring.editor import EditorServer, _free_port
28
28
  from mooring.github import AuthFailed, GitHubClient, GitHubError, compare_url
29
29
 
@@ -88,7 +88,7 @@ class Hub:
88
88
  "branch": cfg.branch,
89
89
  "host": cfg.host,
90
90
  "workspace": str(cfg.workspace()),
91
- "workspace_hint": legacy_workspace_hint(cfg),
91
+ "workspace_hint": workspace_hint(cfg),
92
92
  "repos": [
93
93
  {
94
94
  "alias": s.alias,
@@ -211,7 +211,9 @@ class Hub:
211
211
  try:
212
212
  device = auth.start_device_flow(self.cfg.client_id, host=self.cfg.host)
213
213
  except Exception as exc: # noqa: BLE001 - shown in the UI
214
- return JSONResponse({"error": str(exc)}, status_code=502)
214
+ return JSONResponse(
215
+ {"error": auth.device_flow_hint(self.cfg.host, exc)}, status_code=502
216
+ )
215
217
  with self._lock:
216
218
  self._device = device
217
219
  self._poll_interval = device.interval
@@ -0,0 +1,64 @@
1
+ """Filesystem locations for config, logs, and the notebook workspace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import platformdirs
8
+
9
+ APP_NAME = "mooring"
10
+
11
+
12
+ def user_config_dir() -> Path:
13
+ # roaming=True so the config follows the user profile on managed Windows networks
14
+ return Path(platformdirs.user_config_dir(APP_NAME, appauthor=False, roaming=True))
15
+
16
+
17
+ def user_config_file() -> Path:
18
+ return user_config_dir() / "config.toml"
19
+
20
+
21
+ def user_log_dir() -> Path:
22
+ return Path(platformdirs.user_log_dir(APP_NAME, appauthor=False))
23
+
24
+
25
+ def default_workspace(owner: str, repo: str) -> Path:
26
+ # Keyed by owner AND repo so same-named repos under different owners
27
+ # don't share a workspace.
28
+ return Path(platformdirs.user_documents_dir()) / APP_NAME / owner / repo
29
+
30
+
31
+ def legacy_workspace(repo: str) -> Path:
32
+ """The pre-multi-repo default (keyed by repo name only), kept for hints."""
33
+ return Path(platformdirs.user_documents_dir()) / APP_NAME / repo
34
+
35
+
36
+ def synced_folder_provider(workspace: Path) -> str:
37
+ """Name of the cloud-sync service the workspace sits inside, or "" — these
38
+ revert/merge files (including .mooring/manifest.json) behind mooring's back,
39
+ which corrupts sync state. Windows redirects Documents into OneDrive, so the
40
+ default workspace silently lands there. Matched conservatively per path
41
+ component to avoid false positives (e.g. "sandbox", "toolbox")."""
42
+ for part in (p.lower() for p in workspace.parts):
43
+ if part.startswith("onedrive"): # "OneDrive", "OneDrive - Contoso"
44
+ return "OneDrive"
45
+ if part == "dropbox":
46
+ return "Dropbox"
47
+ if part in ("google drive", "googledrive", "my drive"):
48
+ return "Google Drive"
49
+ if part in ("box", "box sync"):
50
+ return "Box"
51
+ if "icloud" in part: # "iCloudDrive", "com~apple~CloudDocs"
52
+ return "iCloud"
53
+ return ""
54
+
55
+
56
+ def synced_folder_hint(workspace: Path) -> str:
57
+ provider = synced_folder_provider(workspace)
58
+ if not provider:
59
+ return ""
60
+ return (
61
+ f"This workspace is inside {provider}. Cloud sync can revert or merge "
62
+ "mooring's files behind its back and corrupt sync state — move it to a "
63
+ "local folder (set MOORING_WORKSPACE, or the repo's 'workspace' path)."
64
+ )
@@ -183,6 +183,17 @@ def _remote_entries(
183
183
  return {e.path: e.sha for e in client.get_tree(head, cfg.folders) if is_synced_path(e.path)}
184
184
 
185
185
 
186
+ def _review_tree(client: GitHubClient, cfg: Config, branch: str) -> dict[str, str]:
187
+ """The synced-file blob shas currently on an existing review branch, keyed by
188
+ path — the base shas needed to write further commits onto it."""
189
+ review_head = client.get_branch_head(branch)
190
+ return {
191
+ e.path: e.sha
192
+ for e in client.get_tree(review_head, cfg.folders)
193
+ if is_synced_path(e.path)
194
+ }
195
+
196
+
186
197
  def compute_status(
187
198
  mft: manifest_mod.Manifest,
188
199
  local: dict[str, str],
@@ -222,7 +233,15 @@ def _reconcile_review(
222
233
  return False
223
234
  changed = False
224
235
  for path, sent in list(mft.review_files.items()):
225
- if remote.get(path) == sent: # blob shas are content-addressed
236
+ if remote.get(path) == sent: # blob shas are content-addressed: merged
237
+ # The proposal landed on cfg.branch, so it is now the sync base.
238
+ # Advance the base too: otherwise it stays at the pre-proposal blob,
239
+ # and any edits made after the merge classify as a spurious CONFLICT
240
+ # that neither pull (skips) nor push (blocks) can clear.
241
+ if sent is None:
242
+ mft.files.pop(path, None)
243
+ else:
244
+ mft.files[path] = sent
226
245
  del mft.review_files[path]
227
246
  changed = True
228
247
  if not mft.review_files:
@@ -355,19 +374,46 @@ def push(
355
374
  for f in report.by_state(FileState.IN_REVIEW):
356
375
  if f.path in wanted:
357
376
  result.lines.append(
358
- f"in review {f.path} (already proposededit it again to push directly)"
377
+ f"in review {f.path} (no local changes already in the proposal)"
359
378
  )
360
379
 
380
+ # A candidate that belongs to an open proposal keeps going to the review
381
+ # branch, so the (still-unapproved) PR picks up the new edits instead of them
382
+ # landing on cfg.branch behind the reviewer's back. Reaching cfg.branch means
383
+ # merging/closing the PR first — _reconcile_review then clears the state.
384
+ review_tree = (
385
+ _review_tree(client, cfg, mft.review_branch)
386
+ if mft.review_branch and any(f.path in mft.review_files for f in candidates)
387
+ else {}
388
+ )
389
+
361
390
  last_commit = ""
391
+ touched_review = False
392
+ stale_remote = False
362
393
  for index, f in enumerate(candidates):
363
394
  if index > 0 and throttle:
364
395
  sleep(throttle) # contents-API writes trip secondary rate limits if rapid
396
+ in_review = bool(mft.review_branch) and f.path in mft.review_files
397
+ target = mft.review_branch if in_review else cfg.branch
398
+ base = review_tree.get(f.path) if in_review else f.base_sha
399
+ dest = " → review branch (PR)" if in_review else ""
400
+ response: dict | None = None
365
401
  if f.state is FileState.DELETED_LOCAL:
366
- response = client.delete_file(
367
- f.path, message or f"Delete {f.path} via mooring", cfg.branch, f.base_sha
368
- )
369
- mft.files.pop(f.path, None)
370
- result.lines.append(f"deleted {f.path}")
402
+ if not in_review:
403
+ response = client.delete_file(
404
+ f.path, message or f"Delete {f.path} via mooring", target, base
405
+ )
406
+ mft.files.pop(f.path, None)
407
+ result.lines.append(f"deleted {f.path}")
408
+ elif base is not None:
409
+ response = client.delete_file(
410
+ f.path, message or f"Propose deleting {f.path} via mooring", target, base
411
+ )
412
+ mft.review_files[f.path] = None
413
+ result.lines.append(f"deleted {f.path}{dest}")
414
+ else:
415
+ mft.review_files[f.path] = None
416
+ result.lines.append(f"deleted {f.path} (already absent on review branch)")
371
417
  else:
372
418
  data = gitsha.read_for_push(workspace / f.path, f.path)
373
419
  size_mb = len(data) / (1024 * 1024)
@@ -383,25 +429,50 @@ def push(
383
429
  f.path,
384
430
  data,
385
431
  message or f"Update {f.path} via mooring",
386
- cfg.branch,
387
- base_sha=f.base_sha,
432
+ target,
433
+ base_sha=base,
388
434
  )
389
435
  except RemoteConflict:
390
436
  result.blocked_conflicts.append(f.path)
391
- result.lines.append(f"conflict {f.path} (remote changed — pull first)")
437
+ if base is None:
438
+ # We tried to *create* the file but it already exists on the
439
+ # target — our cached remote view is stale (manifest out of
440
+ # sync with cfg.branch). Force the next pull to refetch.
441
+ stale_remote = True
442
+ reason = "already on the remote — pull first"
443
+ elif in_review:
444
+ reason = "review branch changed — refresh and retry"
445
+ else:
446
+ reason = "remote changed — pull first"
447
+ result.lines.append(f"conflict {f.path} ({reason})")
392
448
  continue
393
- mft.files[f.path] = response["content"]["sha"]
394
- result.lines.append(f"pushed {f.path}")
395
- mft.review_files.pop(f.path, None) # a direct push supersedes a stale proposal
396
- commit = response.get("commit", {}).get("sha", "")
397
- if commit:
398
- last_commit = commit
449
+ if in_review:
450
+ mft.review_files[f.path] = response["content"]["sha"]
451
+ else:
452
+ mft.files[f.path] = response["content"]["sha"]
453
+ mft.review_files.pop(f.path, None)
454
+ result.lines.append(f"pushed {f.path}{dest}")
455
+ if in_review:
456
+ touched_review = True
457
+ else: # only cfg.branch writes advance the sync base
458
+ commit = (response or {}).get("commit", {}).get("sha", "")
459
+ if commit:
460
+ last_commit = commit
399
461
  result.pushed += 1
400
462
 
401
463
  if not mft.review_files:
402
464
  mft.review_branch = ""
465
+ if touched_review and mft.review_branch:
466
+ result.review_branch = mft.review_branch
467
+ result.compare_url = compare_url(
468
+ cfg.owner, cfg.repo, cfg.branch, mft.review_branch, host=cfg.host
469
+ )
403
470
  if last_commit:
404
471
  mft.head_commit = last_commit
472
+ if stale_remote:
473
+ # Drop the head-commit short-circuit in _remote_entries so the next pull
474
+ # refetches the live tree and rebuilds a consistent manifest.
475
+ mft.head_commit = ""
405
476
  mft.branch = cfg.branch
406
477
  manifest_mod.save(workspace, mft)
407
478
  return result
@@ -447,12 +518,7 @@ def propose(
447
518
 
448
519
  branch_name = mft.review_branch
449
520
  if branch_name:
450
- review_head = client.get_branch_head(branch_name)
451
- review_tree = {
452
- e.path: e.sha
453
- for e in client.get_tree(review_head, cfg.folders)
454
- if is_synced_path(e.path)
455
- }
521
+ review_tree = _review_tree(client, cfg, branch_name)
456
522
  else:
457
523
  # A fresh branch forks from head, so its tree is exactly `remote`.
458
524
  review_tree = dict(remote)
@@ -509,9 +575,18 @@ def propose(
509
575
  )
510
576
  except RemoteConflict:
511
577
  result.blocked_conflicts.append(f.path)
512
- result.lines.append(
513
- f"conflict {f.path} (review branch changed refresh and retry)"
514
- )
578
+ if base is None:
579
+ # Creating a file that already exists on cfg.branch (and thus
580
+ # on the freshly-forked review branch): our cached remote view
581
+ # is stale. Invalidate it so the next pull refetches and heals.
582
+ mft.head_commit = ""
583
+ result.lines.append(
584
+ f"conflict {f.path} (already on the remote — pull first)"
585
+ )
586
+ else:
587
+ result.lines.append(
588
+ f"conflict {f.path} (review branch changed — refresh and retry)"
589
+ )
515
590
  continue
516
591
  mft.review_files[f.path] = response["content"]["sha"]
517
592
  result.lines.append(f"proposed {f.path}")
@@ -58,6 +58,35 @@ def test_device_flow_on_enterprise_host():
58
58
  assert auth.poll_once("client123", device).token == "gho_ghe"
59
59
 
60
60
 
61
+ class _Resp:
62
+ def __init__(self, status_code):
63
+ self.status_code = status_code
64
+
65
+
66
+ def test_device_flow_hint_default_host_suggests_enterprise():
67
+ exc = Exception("boom")
68
+ exc.response = _Resp(404)
69
+ msg = auth.device_flow_hint("github.com", exc)
70
+ assert "github.com" in msg
71
+ assert "404" in msg
72
+ assert "GitHub Enterprise" in msg
73
+ assert "--host" in msg
74
+
75
+
76
+ def test_device_flow_hint_enterprise_host_no_suggestion():
77
+ exc = Exception("boom")
78
+ exc.response = _Resp(404)
79
+ msg = auth.device_flow_hint("ghe.example", exc)
80
+ assert "ghe.example" in msg
81
+ assert "404" in msg
82
+ assert "GitHub Enterprise" not in msg
83
+
84
+
85
+ def test_device_flow_hint_without_status_uses_message():
86
+ msg = auth.device_flow_hint("ghe.example", Exception("connection refused"))
87
+ assert "connection refused" in msg
88
+
89
+
61
90
  @responses.activate
62
91
  def test_poll_until_token_with_slow_down():
63
92
  responses.add(responses.POST, auth.token_url(), json={"error": "authorization_pending"})
@@ -58,6 +58,83 @@ def test_repo_use_unknown_alias_exits():
58
58
  assert "Unknown repo alias" in str(exc.value)
59
59
 
60
60
 
61
+ def test_repo_remove_all(capsys):
62
+ cli.main(["repo", "add", "acme/nbs"])
63
+ cli.main(["repo", "add", "acme/lab", "--no-use"])
64
+ assert cli.main(["repo", "remove", "--all"]) == 0
65
+ out = capsys.readouterr().out
66
+ assert "Removed all 2 repo(s)" in out
67
+ data = tomllib.loads(paths.user_config_file().read_text("utf-8"))
68
+ assert data["repos"] == {}
69
+
70
+
71
+ def test_repo_remove_all_when_empty(capsys):
72
+ assert cli.main(["repo", "remove", "--all"]) == 0
73
+ assert "No repos registered." in capsys.readouterr().out
74
+
75
+
76
+ def test_repo_remove_requires_alias_or_all():
77
+ cli.main(["repo", "add", "acme/nbs"])
78
+ with pytest.raises(SystemExit) as exc:
79
+ cli.main(["repo", "remove"])
80
+ assert "Specify a repo alias" in str(exc.value)
81
+
82
+
83
+ def test_login_with_host_persists_and_uses_it(capsys, monkeypatch):
84
+ from mooring import auth, github
85
+
86
+ monkeypatch.setenv("MOORING_CLIENT_ID", "cid")
87
+ seen = {}
88
+
89
+ def fake_start(client_id, host="github.com", **kw):
90
+ seen["host"] = host
91
+ return auth.DeviceCode("d", "ABCD-1234", "https://x/login/device", 5, 900, host=host)
92
+
93
+ monkeypatch.setattr(auth, "start_device_flow", fake_start)
94
+ monkeypatch.setattr(auth, "poll_for_token", lambda *a, **k: "gho_tok")
95
+ monkeypatch.setattr(auth, "save_token", lambda *a, **k: None)
96
+
97
+ class FakeClient:
98
+ def __init__(self, *a, **k):
99
+ pass
100
+
101
+ def get_user(self):
102
+ return {"login": "octo"}
103
+
104
+ monkeypatch.setattr(github, "GitHubClient", FakeClient)
105
+
106
+ assert cli.main(["login", "--host", "https://GHE.Example/"]) == 0
107
+ assert seen["host"] == "ghe.example" # normalized host passed to the flow
108
+ data = tomllib.loads(paths.user_config_file().read_text("utf-8"))
109
+ assert data["github"]["host"] == "ghe.example" # and persisted
110
+ out = capsys.readouterr().out
111
+ assert "Saved GitHub host: ghe.example" in out
112
+ assert "Requesting device code from ghe.example" in out
113
+
114
+
115
+ def test_login_failure_shows_enterprise_hint(monkeypatch):
116
+ import requests
117
+
118
+ from mooring import auth
119
+
120
+ monkeypatch.setenv("MOORING_CLIENT_ID", "cid")
121
+
122
+ class Resp:
123
+ status_code = 404
124
+
125
+ def boom(*a, **k):
126
+ err = requests.HTTPError("404 ...")
127
+ err.response = Resp()
128
+ raise err
129
+
130
+ monkeypatch.setattr(auth, "start_device_flow", boom)
131
+ with pytest.raises(SystemExit) as exc:
132
+ cli.main(["login"]) # no --host → default github.com
133
+ msg = str(exc.value)
134
+ assert "github.com" in msg
135
+ assert "GitHub Enterprise" in msg
136
+
137
+
61
138
  def test_repo_add_malformed_slug_exits():
62
139
  with pytest.raises(SystemExit):
63
140
  cli.main(["repo", "add", "just-a-name"])
@@ -97,3 +97,29 @@ def test_set_active_and_unknown_alias():
97
97
  def test_alias_validation_rejects(alias):
98
98
  with pytest.raises(ValueError):
99
99
  config_store.add_repo(alias, "acme", "nbs")
100
+
101
+
102
+ def test_set_host_normalizes_and_persists():
103
+ config_store.add_repo("team", "acme", "nbs", client_id="cid")
104
+ assert config_store.set_host("https://GHE.Example.com/") == "ghe.example.com"
105
+ data = tomllib.loads(paths.user_config_file().read_text("utf-8"))
106
+ assert data["github"]["host"] == "ghe.example.com"
107
+ assert data["repos"]["active"] == "team" # registry untouched
108
+ assert config.load_app_config().host == "ghe.example.com"
109
+
110
+
111
+ def test_set_host_rejects_junk():
112
+ with pytest.raises(ValueError):
113
+ config_store.set_host("not a host!")
114
+
115
+
116
+ def test_remove_all_repos_clears_registry_keeps_github():
117
+ config_store.add_repo("team", "acme", "nbs", client_id="cid", host="ghe.example")
118
+ config_store.add_repo("lab", "acme", "lab", make_active=False)
119
+ config_store.remove_all_repos()
120
+ app = config.load_app_config()
121
+ assert app.repos == ()
122
+ assert not app.config_for(None).is_configured
123
+ # [github] (client_id + host) survives the registry wipe
124
+ assert app.client_id == "cid"
125
+ assert app.host == "ghe.example"
@@ -0,0 +1,33 @@
1
+ """Workspace-location hints (cloud-sync folder detection)."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from mooring import paths
8
+
9
+
10
+ @pytest.mark.parametrize(
11
+ ("workspace", "provider"),
12
+ [
13
+ ("C:/Users/phil/OneDrive/Documents/mooring/acme/nbs", "OneDrive"),
14
+ ("C:/Users/phil/OneDrive - Contoso/Documents/mooring/nbs", "OneDrive"),
15
+ ("C:/Users/phil/Dropbox/mooring/nbs", "Dropbox"),
16
+ ("G:/My Drive/mooring/nbs", "Google Drive"),
17
+ ("C:/Users/phil/Box/mooring/nbs", "Box"),
18
+ ("C:/Users/phil/iCloudDrive/mooring/nbs", "iCloud"),
19
+ # local paths and lookalikes must NOT trip the heuristic
20
+ ("C:/Users/phil/Documents/mooring/nbs", ""),
21
+ ("/home/phil/projects/sandbox/mooring/nbs", ""), # 'sandbox' != 'box'
22
+ ("C:/dev/toolbox/mooring/nbs", ""),
23
+ ],
24
+ )
25
+ def test_synced_folder_provider(workspace, provider):
26
+ assert paths.synced_folder_provider(Path(workspace)) == provider
27
+
28
+
29
+ def test_synced_folder_hint_text():
30
+ hint = paths.synced_folder_hint(Path("C:/Users/phil/OneDrive/Documents/mooring/nbs"))
31
+ assert "OneDrive" in hint
32
+ assert "MOORING_WORKSPACE" in hint
33
+ assert paths.synced_folder_hint(Path("C:/dev/mooring/nbs")) == ""
@@ -260,6 +260,27 @@ def test_merge_observed_clears_review_and_next_propose_is_fresh(cfg):
260
260
  assert result.review_branch == "mooring/phil/20260612-1030"
261
261
 
262
262
 
263
+ def test_merge_then_keep_editing_pushes_cleanly(cfg):
264
+ """After a proposal merges, editing the notebook again and pushing must go
265
+ straight to main without a spurious conflict — the sync base advanced to the
266
+ merged content rather than staying at the pre-proposal blob."""
267
+ client = FakeClient({"notebooks/a.py": b"v1\n"})
268
+ sync.pull(client, cfg)
269
+ write_local(cfg, "notebooks/a.py", "v2\n")
270
+ sync.propose(client, cfg, sleep=lambda s: None, now=NOW1)
271
+ client.merge(BRANCH1) # PR merged to main
272
+ write_local(cfg, "notebooks/a.py", "v3\n") # keep working on the same notebook
273
+ report = sync.status(client, cfg)
274
+ assert [f.state for f in report.files] == [FileState.MODIFIED] # not CONFLICT
275
+ result = sync.push(client, cfg, sleep=lambda s: None)
276
+ assert result.blocked_conflicts == []
277
+ assert result.pushed == 1
278
+ assert client.blobs[client.tree["notebooks/a.py"]] == b"v3\n"
279
+ mft = manifest.load(cfg.workspace())
280
+ assert mft.review_branch == ""
281
+ assert mft.files["notebooks/a.py"] == client.tree["notebooks/a.py"]
282
+
283
+
263
284
  def test_deleted_review_branch_clears_review(cfg):
264
285
  client = FakeClient({"notebooks/a.py": b"v1\n"})
265
286
  sync.pull(client, cfg)
@@ -337,18 +358,111 @@ def test_push_skips_in_review_files(cfg):
337
358
  assert client.blobs[client.tree["notebooks/a.py"]] == b"v1\n"
338
359
 
339
360
 
340
- def test_direct_push_supersedes_proposal(cfg):
361
+ def test_push_routes_in_review_edits_to_review_branch(cfg):
341
362
  client = FakeClient({"notebooks/a.py": b"v1\n"})
342
363
  sync.pull(client, cfg)
343
364
  write_local(cfg, "notebooks/a.py", "v2\n")
344
365
  sync.propose(client, cfg, sleep=lambda s: None, now=NOW1)
345
- write_local(cfg, "notebooks/a.py", "v3\n") # edited again: pushable directly
366
+ write_local(cfg, "notebooks/a.py", "v3\n") # further edit while the PR is open
346
367
  result = sync.push(client, cfg, sleep=lambda s: None)
347
368
  assert result.pushed == 1
348
- assert client.blobs[client.tree["notebooks/a.py"]] == b"v3\n"
369
+ # main is untouched; the edit lands on the review branch (the open PR)
370
+ assert client.blobs[client.tree["notebooks/a.py"]] == b"v1\n"
371
+ assert client.blobs[client.trees[BRANCH1]["notebooks/a.py"]] == b"v3\n"
372
+ # review state is preserved and updated, sync base unchanged
349
373
  mft = manifest.load(cfg.workspace())
350
- assert mft.review_branch == ""
351
- assert mft.review_files == {}
374
+ assert mft.review_branch == BRANCH1
375
+ assert mft.review_files["notebooks/a.py"] == client.trees[BRANCH1]["notebooks/a.py"]
376
+ assert mft.files["notebooks/a.py"] == client.tree["notebooks/a.py"]
377
+ # the PR link is surfaced and the file settles back to in-review
378
+ assert result.review_branch == BRANCH1
379
+ assert result.compare_url
380
+ report = sync.status(client, cfg)
381
+ assert [f.state for f in report.files] == [FileState.IN_REVIEW]
382
+
383
+
384
+ def test_push_in_review_does_not_advance_main(cfg):
385
+ client = FakeClient({"notebooks/a.py": b"v1\n"})
386
+ sync.pull(client, cfg)
387
+ write_local(cfg, "notebooks/a.py", "v2\n")
388
+ sync.propose(client, cfg, sleep=lambda s: None, now=NOW1)
389
+ head_before = manifest.load(cfg.workspace()).head_commit
390
+ write_local(cfg, "notebooks/a.py", "v3\n")
391
+ sync.push(client, cfg, sleep=lambda s: None)
392
+ assert manifest.load(cfg.workspace()).head_commit == head_before
393
+
394
+
395
+ def test_push_mixed_routes_each_to_its_branch(cfg):
396
+ client = FakeClient({"notebooks/a.py": b"v1\n"})
397
+ sync.pull(client, cfg)
398
+ write_local(cfg, "notebooks/a.py", "v2\n")
399
+ sync.propose(client, cfg, paths=["notebooks/a.py"], sleep=lambda s: None, now=NOW1)
400
+ write_local(cfg, "notebooks/a.py", "v3\n") # in-review file, edited again
401
+ write_local(cfg, "notebooks/b.py", "new\n") # brand-new, not part of the PR
402
+ result = sync.push(client, cfg, sleep=lambda s: None)
403
+ assert result.pushed == 2
404
+ # in-review edit went to the PR branch; the new file went straight to main
405
+ assert client.blobs[client.trees[BRANCH1]["notebooks/a.py"]] == b"v3\n"
406
+ assert client.blobs[client.tree["notebooks/a.py"]] == b"v1\n"
407
+ assert client.blobs[client.tree["notebooks/b.py"]] == b"new\n"
408
+ assert "notebooks/b.py" not in client.trees[BRANCH1]
409
+
410
+
411
+ def test_push_in_review_delete_targets_review_branch(cfg):
412
+ client = FakeClient({"notebooks/a.py": b"v1\n", "notebooks/b.py": b"v1\n"})
413
+ sync.pull(client, cfg)
414
+ write_local(cfg, "notebooks/b.py", "v2\n")
415
+ sync.propose(client, cfg, paths=["notebooks/b.py"], sleep=lambda s: None, now=NOW1)
416
+ (cfg.workspace() / "notebooks/b.py").unlink() # delete the proposed file
417
+ result = sync.push(client, cfg, sleep=lambda s: None)
418
+ assert result.pushed == 1
419
+ assert "notebooks/b.py" not in client.trees[BRANCH1] # removed on the PR branch
420
+ assert "notebooks/b.py" in client.tree # main untouched
421
+ mft = manifest.load(cfg.workspace())
422
+ assert mft.review_branch == BRANCH1
423
+ assert mft.review_files["notebooks/b.py"] is None
424
+
425
+
426
+ def test_propose_create_conflict_on_stale_manifest_self_heals(cfg):
427
+ """A manifest whose `files` lost a path that is still on cfg.branch (e.g. an
428
+ external tool like OneDrive reverted it) made propose mis-see the file as new,
429
+ fork a branch that already had it, and fail to create it. The conflict must
430
+ now say 'pull first' and invalidate the head cache so the next pull heals it."""
431
+ client = FakeClient({"notebooks/a.py": b"v1\n"})
432
+ sync.pull(client, cfg)
433
+ # Corrupt the manifest: drop a.py but keep head_commit pointing at the same
434
+ # commit, so _remote_entries serves the stale cache (no live refetch).
435
+ mft = manifest.load(cfg.workspace())
436
+ assert mft.head_commit == client.head
437
+ del mft.files["notebooks/a.py"]
438
+ manifest.save(cfg.workspace(), mft)
439
+
440
+ result = sync.propose(client, cfg, sleep=lambda s: None, now=NOW1)
441
+ assert result.proposed == 0
442
+ assert result.blocked_conflicts == ["notebooks/a.py"]
443
+ assert any("already on the remote" in line for line in result.lines)
444
+ assert manifest.load(cfg.workspace()).head_commit == "" # cache invalidated
445
+
446
+ # The next pull refetches the live tree and rebuilds a consistent manifest.
447
+ sync.pull(client, cfg)
448
+ healed = manifest.load(cfg.workspace())
449
+ assert "notebooks/a.py" in healed.files
450
+ assert healed.head_commit == client.head
451
+ assert sync.status(client, cfg).by_state(FileState.CONFLICT) == []
452
+
453
+
454
+ def test_push_create_conflict_on_stale_manifest_invalidates_cache(cfg):
455
+ client = FakeClient({"notebooks/a.py": b"v1\n"})
456
+ sync.pull(client, cfg)
457
+ mft = manifest.load(cfg.workspace())
458
+ del mft.files["notebooks/a.py"]
459
+ manifest.save(cfg.workspace(), mft)
460
+
461
+ result = sync.push(client, cfg, sleep=lambda s: None)
462
+ assert result.pushed == 0
463
+ assert result.blocked_conflicts == ["notebooks/a.py"]
464
+ assert any("already on the remote" in line for line in result.lines)
465
+ assert manifest.load(cfg.workspace()).head_commit == ""
352
466
 
353
467
 
354
468
  # -- resolve --------------------------------------------------------------------
@@ -450,7 +450,7 @@ wheels = [
450
450
 
451
451
  [[package]]
452
452
  name = "mooring"
453
- version = "0.2.3"
453
+ version = "0.2.4"
454
454
  source = { editable = "." }
455
455
  dependencies = [
456
456
  { name = "altair" },
@@ -1,33 +0,0 @@
1
- """Filesystem locations for config, logs, and the notebook workspace."""
2
-
3
- from __future__ import annotations
4
-
5
- from pathlib import Path
6
-
7
- import platformdirs
8
-
9
- APP_NAME = "mooring"
10
-
11
-
12
- def user_config_dir() -> Path:
13
- # roaming=True so the config follows the user profile on managed Windows networks
14
- return Path(platformdirs.user_config_dir(APP_NAME, appauthor=False, roaming=True))
15
-
16
-
17
- def user_config_file() -> Path:
18
- return user_config_dir() / "config.toml"
19
-
20
-
21
- def user_log_dir() -> Path:
22
- return Path(platformdirs.user_log_dir(APP_NAME, appauthor=False))
23
-
24
-
25
- def default_workspace(owner: str, repo: str) -> Path:
26
- # Keyed by owner AND repo so same-named repos under different owners
27
- # don't share a workspace.
28
- return Path(platformdirs.user_documents_dir()) / APP_NAME / owner / repo
29
-
30
-
31
- def legacy_workspace(repo: str) -> Path:
32
- """The pre-multi-repo default (keyed by repo name only), kept for hints."""
33
- return Path(platformdirs.user_documents_dir()) / APP_NAME / repo
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes