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.
- {mooring-0.2.3 → mooring-0.2.4}/PKG-INFO +1 -1
- {mooring-0.2.3 → mooring-0.2.4}/pyproject.toml +1 -1
- {mooring-0.2.3 → mooring-0.2.4}/src/mooring/__init__.py +1 -1
- {mooring-0.2.3 → mooring-0.2.4}/src/mooring/auth.py +20 -0
- {mooring-0.2.3 → mooring-0.2.4}/src/mooring/cli.py +51 -8
- {mooring-0.2.3 → mooring-0.2.4}/src/mooring/config_store.py +25 -0
- {mooring-0.2.3 → mooring-0.2.4}/src/mooring/hub/server.py +5 -3
- mooring-0.2.4/src/mooring/paths.py +64 -0
- {mooring-0.2.3 → mooring-0.2.4}/src/mooring/sync.py +100 -25
- {mooring-0.2.3 → mooring-0.2.4}/tests/test_auth.py +29 -0
- {mooring-0.2.3 → mooring-0.2.4}/tests/test_cli_repo.py +77 -0
- {mooring-0.2.3 → mooring-0.2.4}/tests/test_config_store.py +26 -0
- mooring-0.2.4/tests/test_paths.py +33 -0
- {mooring-0.2.3 → mooring-0.2.4}/tests/test_sync.py +119 -5
- {mooring-0.2.3 → mooring-0.2.4}/uv.lock +1 -1
- mooring-0.2.3/src/mooring/paths.py +0 -33
- {mooring-0.2.3 → mooring-0.2.4}/.github/workflows/docs.yml +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/.github/workflows/release.yml +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/.gitignore +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/README.md +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/docs/admins/build-and-distribute.md +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/docs/admins/configuration.md +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/docs/admins/github-setup.md +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/docs/admins/index.md +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/docs/assets/images/anchor-mark.svg +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/docs/assets/images/favicon.svg +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/docs/assets/javascripts/landing.js +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/docs/assets/stylesheets/landing.css +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/docs/assets/stylesheets/oah-theme.css +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/docs/developers/contributing.md +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/docs/developers/index.md +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/docs/index.md +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/docs/users/cli.md +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/docs/users/conflicts.md +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/docs/users/daily-workflow.md +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/docs/users/index.md +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/docs/users/power-bi.md +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/overrides/home.html +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/scripts/release.ps1 +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/src/mooring/config.py +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/src/mooring/config_default.toml +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/src/mooring/editor.py +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/src/mooring/githost.py +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/src/mooring/github.py +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/src/mooring/gitsha.py +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/src/mooring/hub/__init__.py +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/src/mooring/hub/static/app.js +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/src/mooring/hub/static/index.html +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/src/mooring/hub/static/style.css +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/src/mooring/manifest.py +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/src/mooring/notebook_template.py +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/src/mooring/pbip.py +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/src/mooring/telemetry.py +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/tests/conftest.py +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/tests/manual_editor_check.py +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/tests/test_config.py +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/tests/test_githost.py +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/tests/test_github.py +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/tests/test_gitsha.py +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/tests/test_hub.py +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/tests/test_manifest.py +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/tests/test_pbip.py +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/tests/test_telemetry.py +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/tests/test_truststore.py +0 -0
- {mooring-0.2.3 → mooring-0.2.4}/zensical.toml +0 -0
|
@@ -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
|
-
|
|
147
|
-
if
|
|
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
|
-
|
|
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
|
|
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,
|
|
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":
|
|
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(
|
|
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} (
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
387
|
-
base_sha=
|
|
432
|
+
target,
|
|
433
|
+
base_sha=base,
|
|
388
434
|
)
|
|
389
435
|
except RemoteConflict:
|
|
390
436
|
result.blocked_conflicts.append(f.path)
|
|
391
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
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
|
-
|
|
513
|
-
|
|
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
|
|
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") #
|
|
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
|
-
|
|
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 --------------------------------------------------------------------
|
|
@@ -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
|
|
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
|