collab-runtime 0.2.9__py3-none-any.whl
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.
- collab/__init__.py +77 -0
- collab/__main__.py +11 -0
- collab_runtime-0.2.9.dist-info/METADATA +218 -0
- collab_runtime-0.2.9.dist-info/RECORD +82 -0
- collab_runtime-0.2.9.dist-info/WHEEL +5 -0
- collab_runtime-0.2.9.dist-info/entry_points.txt +3 -0
- collab_runtime-0.2.9.dist-info/licenses/LICENSE +21 -0
- collab_runtime-0.2.9.dist-info/top_level.txt +10 -0
- scripts/cleanup.py +395 -0
- scripts/collab_git_hook.py +190 -0
- scripts/format_code.py +594 -0
- scripts/generate_tests.py +560 -0
- scripts/validate_code.py +1397 -0
- src/__init__.py +4 -0
- src/dashboard/index.html +1131 -0
- src/live_locks_watcher.py +1982 -0
- src/lock_client.py +4268 -0
- src/logging_config.py +259 -0
- src/main.py +436 -0
- tests/backend/__init__.py +0 -0
- tests/backend/functional/__init__.py +0 -0
- tests/backend/functional/test_package_imports.py +43 -0
- tests/backend/integration/__init__.py +0 -0
- tests/backend/integration/test_cli_contract_parity.py +220 -0
- tests/backend/performance/__init__.py +0 -0
- tests/backend/reliability/__init__.py +0 -0
- tests/backend/security/__init__.py +0 -0
- tests/backend/unit/live_locks_watcher/__init__.py +5 -0
- tests/backend/unit/live_locks_watcher/_helpers.py +123 -0
- tests/backend/unit/live_locks_watcher/conftest.py +18 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_dashboard.py +188 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_developer.py +56 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_graceful_shutdown.py +459 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_main.py +1925 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_module.py +187 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_multi_session.py +320 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_notify.py +67 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_parsing.py +155 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_process_helpers.py +684 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_processing.py +173 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_prompt_abort.py +71 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_reconcile.py +516 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_scan.py +296 -0
- tests/backend/unit/lock_client/__init__.py +1 -0
- tests/backend/unit/lock_client/_helpers.py +132 -0
- tests/backend/unit/lock_client/test_lock_client_acquire.py +214 -0
- tests/backend/unit/lock_client/test_lock_client_active.py +104 -0
- tests/backend/unit/lock_client/test_lock_client_api.py +63 -0
- tests/backend/unit/lock_client/test_lock_client_cli.py +682 -0
- tests/backend/unit/lock_client/test_lock_client_daemon.py +3730 -0
- tests/backend/unit/lock_client/test_lock_client_dashboard.py +438 -0
- tests/backend/unit/lock_client/test_lock_client_discover.py +241 -0
- tests/backend/unit/lock_client/test_lock_client_force_release.py +354 -0
- tests/backend/unit/lock_client/test_lock_client_helper_branches.py +1890 -0
- tests/backend/unit/lock_client/test_lock_client_history.py +301 -0
- tests/backend/unit/lock_client/test_lock_client_isolation.py +316 -0
- tests/backend/unit/lock_client/test_lock_client_pid.py +75 -0
- tests/backend/unit/lock_client/test_lock_client_reconcile.py +464 -0
- tests/backend/unit/lock_client/test_lock_client_release.py +77 -0
- tests/backend/unit/lock_client/test_lock_client_shutdown.py +1110 -0
- tests/backend/unit/lock_client/test_lock_client_utils.py +474 -0
- tests/backend/unit/lock_client/test_lock_client_watch.py +866 -0
- tests/backend/unit/scripts/__init__.py +1 -0
- tests/backend/unit/scripts/_helpers.py +42 -0
- tests/backend/unit/scripts/test_cleanup.py +285 -0
- tests/backend/unit/scripts/test_collab_git_hook.py +280 -0
- tests/backend/unit/scripts/test_collab_git_hook_ported.py +50 -0
- tests/backend/unit/scripts/test_format_code.py +368 -0
- tests/backend/unit/scripts/test_format_code_ported.py +177 -0
- tests/backend/unit/scripts/test_generate_tests.py +305 -0
- tests/backend/unit/scripts/test_hook_templates.py +357 -0
- tests/backend/unit/scripts/test_setup_hook_overlay.py +95 -0
- tests/backend/unit/scripts/test_validate_code.py +867 -0
- tests/backend/unit/scripts/test_validate_code_ported.py +237 -0
- tests/backend/unit/test_entrypoints_main_run.py +83 -0
- tests/backend/unit/test_logging_config.py +529 -0
- tests/backend/unit/test_main_watch_pid_file.py +278 -0
- tests/conftest.py +167 -0
- tests/frontend/__init__.py +0 -0
- tests/frontend/jest/__init__.py +0 -0
- tests/frontend/playwright/__init__.py +0 -0
- tests/packaging/test_smoke_install.py +76 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
"""Reconcile and git-status parsing tests for LockClient._reconcile()."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from ._helpers import (
|
|
9
|
+
FakeClient,
|
|
10
|
+
FakeResponse,
|
|
11
|
+
load_lock_client_module,
|
|
12
|
+
make_create_client,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
mod = load_lock_client_module()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_reconcile_stale_locks(monkeypatch, tmp_path):
|
|
19
|
+
"""Test _reconcile releases stale locks and acquires missing ones."""
|
|
20
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
21
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
22
|
+
|
|
23
|
+
monkeypatch.setattr(
|
|
24
|
+
mod.LockClient,
|
|
25
|
+
"_run_git_status",
|
|
26
|
+
staticmethod(lambda: " M src/new.py"),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
locks_data = [
|
|
30
|
+
{"file_path": "src/old.py", "developer_id": "test_user"},
|
|
31
|
+
{"file_path": "src/new.py", "developer_id": "test_user"},
|
|
32
|
+
]
|
|
33
|
+
response = FakeResponse(status=200, data=locks_data)
|
|
34
|
+
monkeypatch.setattr(mod, "_get_create_client", lambda: make_create_client(response))
|
|
35
|
+
|
|
36
|
+
lc = mod.LockClient(developer_id="test_user")
|
|
37
|
+
result = lc._reconcile()
|
|
38
|
+
assert "src/new.py" in result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_reconcile_git_error(monkeypatch, tmp_path):
|
|
42
|
+
"""Test _reconcile handles git status errors."""
|
|
43
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
44
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
45
|
+
|
|
46
|
+
def error_git_status():
|
|
47
|
+
raise RuntimeError("Git broken")
|
|
48
|
+
|
|
49
|
+
monkeypatch.setattr(
|
|
50
|
+
mod.LockClient, "_run_git_status", staticmethod(error_git_status)
|
|
51
|
+
)
|
|
52
|
+
monkeypatch.setattr(
|
|
53
|
+
mod, "_get_create_client", lambda: make_create_client(FakeResponse())
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
lc = mod.LockClient(developer_id="test_user")
|
|
57
|
+
result = lc._reconcile()
|
|
58
|
+
assert isinstance(result, set)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_reconcile_supabase_error(monkeypatch, tmp_path):
|
|
62
|
+
"""Test _reconcile handles Supabase errors."""
|
|
63
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
64
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
65
|
+
|
|
66
|
+
monkeypatch.setattr(
|
|
67
|
+
mod.LockClient, "_run_git_status", staticmethod(lambda: " M src/app.py")
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
class ErrorClient(FakeClient):
|
|
71
|
+
def execute(self):
|
|
72
|
+
raise RuntimeError("Supabase down")
|
|
73
|
+
|
|
74
|
+
monkeypatch.setattr(
|
|
75
|
+
mod, "_get_create_client", lambda: lambda url, key: ErrorClient(FakeResponse())
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
lc = mod.LockClient(developer_id="test_user")
|
|
79
|
+
result = lc._reconcile()
|
|
80
|
+
assert "src/app.py" in result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_run_git_status(monkeypatch):
|
|
84
|
+
"""Test _run_git_status runs git command."""
|
|
85
|
+
|
|
86
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
87
|
+
return b" M src/app.py\n M src/routes.py\n"
|
|
88
|
+
|
|
89
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
90
|
+
result = mod.LockClient._run_git_status()
|
|
91
|
+
assert "src/app.py" in result
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_parse_git_status_path_simple():
|
|
95
|
+
"""Test parsing simple modified file."""
|
|
96
|
+
assert mod.LockClient._parse_git_status_path(" M src/app.py") == "src/app.py"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_parse_git_status_path_rename():
|
|
100
|
+
"""Test parsing renamed file."""
|
|
101
|
+
result = mod.LockClient._parse_git_status_path("R old.py -> new.py")
|
|
102
|
+
assert result == "new.py"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_parse_git_status_path_quoted():
|
|
106
|
+
"""Test parsing quoted paths."""
|
|
107
|
+
result = mod.LockClient._parse_git_status_path('M "src/my file.py"')
|
|
108
|
+
assert "my file" in result
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_should_ignore_path_for_instance_runtime_dirs():
|
|
112
|
+
"""Runtime instance folders must never be lock candidates."""
|
|
113
|
+
assert mod.LockClient._should_ignore_path("instance") is True
|
|
114
|
+
assert mod.LockClient._should_ignore_path("apps/reporting/instance/") is True
|
|
115
|
+
assert mod.LockClient._should_ignore_path("apps/reporting/instance") is True
|
|
116
|
+
assert mod.LockClient._should_ignore_path("apps/planning/instance/state.db") is True
|
|
117
|
+
assert mod.LockClient._should_ignore_path("src/services/db_utils.py") is False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_run_git_status_unix(monkeypatch):
|
|
121
|
+
mod_local = load_lock_client_module()
|
|
122
|
+
monkeypatch.setattr(sys, "platform", "linux")
|
|
123
|
+
|
|
124
|
+
def fake_check_output(args, *a, **k):
|
|
125
|
+
return b" M src/foo.py\n"
|
|
126
|
+
|
|
127
|
+
monkeypatch.setattr(subprocess, "check_output", fake_check_output)
|
|
128
|
+
out = mod_local.LockClient._run_git_status()
|
|
129
|
+
assert "src/foo.py" in out
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_reconcile_supabase_lock_query_error(monkeypatch, tmp_path):
|
|
133
|
+
"""Test _reconcile handles Supabase lock query error."""
|
|
134
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
135
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
136
|
+
|
|
137
|
+
monkeypatch.setattr(
|
|
138
|
+
mod.LockClient,
|
|
139
|
+
"_run_git_status",
|
|
140
|
+
staticmethod(lambda: " M src/app.py"),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
call_count = [0]
|
|
144
|
+
|
|
145
|
+
class SelectiveErrorClient:
|
|
146
|
+
"""Errors only on the second execute call (active locks)."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, resp):
|
|
149
|
+
self._resp = resp
|
|
150
|
+
|
|
151
|
+
def rpc(self, *args, **kwargs):
|
|
152
|
+
return self
|
|
153
|
+
|
|
154
|
+
def table(self, *args, **kwargs):
|
|
155
|
+
return self
|
|
156
|
+
|
|
157
|
+
def select(self, *args, **kwargs):
|
|
158
|
+
call_count[0] += 1
|
|
159
|
+
if call_count[0] >= 1:
|
|
160
|
+
raise RuntimeError("Supabase query failed")
|
|
161
|
+
return self
|
|
162
|
+
|
|
163
|
+
def delete(self, *args, **kwargs):
|
|
164
|
+
return self
|
|
165
|
+
|
|
166
|
+
def eq(self, *args, **kwargs):
|
|
167
|
+
return self
|
|
168
|
+
|
|
169
|
+
def execute(self):
|
|
170
|
+
return self._resp
|
|
171
|
+
|
|
172
|
+
monkeypatch.setattr(
|
|
173
|
+
mod,
|
|
174
|
+
"_get_create_client",
|
|
175
|
+
lambda: lambda url, key: SelectiveErrorClient(FakeResponse()),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Make modified-files detection deterministic for tests
|
|
179
|
+
def _fixed_modified(self):
|
|
180
|
+
return ["src/app.py"]
|
|
181
|
+
|
|
182
|
+
monkeypatch.setattr(
|
|
183
|
+
mod.LockClient, "_get_modified_and_unpushed_files", _fixed_modified
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
lc = mod.LockClient(developer_id="test_user")
|
|
187
|
+
result = lc._reconcile()
|
|
188
|
+
assert "src/app.py" in result
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_get_current_branch_error_lock_client(monkeypatch):
|
|
192
|
+
"""Test _get_current_branch returns None when git command fails."""
|
|
193
|
+
|
|
194
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
195
|
+
raise subprocess.CalledProcessError(128, cmd)
|
|
196
|
+
|
|
197
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
198
|
+
|
|
199
|
+
result = mod.LockClient._get_current_branch()
|
|
200
|
+
assert result is None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_get_current_branch_win32(monkeypatch):
|
|
204
|
+
"""Ensure _get_current_branch uses the Windows code path when platform is win32."""
|
|
205
|
+
monkeypatch.setattr(sys, "platform", "win32")
|
|
206
|
+
|
|
207
|
+
def fake_check_output(cmd, *a, **k):
|
|
208
|
+
return b"feature/win-branch\n"
|
|
209
|
+
|
|
210
|
+
monkeypatch.setattr(subprocess, "check_output", fake_check_output)
|
|
211
|
+
got = mod.LockClient._get_current_branch()
|
|
212
|
+
assert got == "feature/win-branch"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_get_current_branch_non_win_error(monkeypatch):
|
|
216
|
+
"""When git command fails, _get_current_branch should return None."""
|
|
217
|
+
monkeypatch.setattr(sys, "platform", "linux")
|
|
218
|
+
|
|
219
|
+
def fail_check_output(cmd, *a, **k):
|
|
220
|
+
raise subprocess.CalledProcessError(2, cmd)
|
|
221
|
+
|
|
222
|
+
monkeypatch.setattr(subprocess, "check_output", fail_check_output)
|
|
223
|
+
got = mod.LockClient._get_current_branch()
|
|
224
|
+
assert got is None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_parse_git_status_path_unicode_escape():
|
|
228
|
+
"""Test _parse_git_status_path with unicode-escaped quoted path."""
|
|
229
|
+
result = mod.LockClient._parse_git_status_path(' M "src/file.py"')
|
|
230
|
+
assert "file" in result
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_parse_git_status_path_bad_unicode_escape():
|
|
234
|
+
"""Test _parse_git_status_path with invalid unicode escape."""
|
|
235
|
+
result = mod.LockClient._parse_git_status_path(' M "src/\\xZZfile.py"')
|
|
236
|
+
assert "file" in result
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def test_reconcile_returns_my_locks(monkeypatch):
|
|
240
|
+
"""_reconcile returns set containing only current developer locks."""
|
|
241
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
242
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
243
|
+
|
|
244
|
+
data = [
|
|
245
|
+
{"file_path": "src/app.py", "developer_id": "test_user"},
|
|
246
|
+
{"file_path": "src/other.py", "developer_id": "other_dev"},
|
|
247
|
+
]
|
|
248
|
+
monkeypatch.setattr(
|
|
249
|
+
mod,
|
|
250
|
+
"_get_create_client",
|
|
251
|
+
lambda: make_create_client(FakeResponse(status=200, data=data)),
|
|
252
|
+
)
|
|
253
|
+
monkeypatch.setattr(
|
|
254
|
+
mod.LockClient, "_run_git_status", staticmethod(lambda: " M src/app.py\n")
|
|
255
|
+
)
|
|
256
|
+
monkeypatch.setattr(
|
|
257
|
+
mod.LockClient,
|
|
258
|
+
"_get_modified_and_unpushed_files",
|
|
259
|
+
lambda self: ["src/app.py"],
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
client = mod.LockClient(developer_id="test_user")
|
|
263
|
+
result = client._reconcile()
|
|
264
|
+
assert "src/app.py" in result
|
|
265
|
+
assert "src/other.py" not in result
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_git_status_parsing_and_modified(monkeypatch):
|
|
269
|
+
sample = " M src/a.py\nR src/old.py -> src/new.py\n?? src/new_file.py\n"
|
|
270
|
+
monkeypatch.setattr(mod.LockClient, "_run_git_status", staticmethod(lambda: sample))
|
|
271
|
+
|
|
272
|
+
c = mod.LockClient(local_only=True)
|
|
273
|
+
out = c._get_modified_and_unpushed_files()
|
|
274
|
+
assert "src/a.py" in out
|
|
275
|
+
assert "src/new.py" in out
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_reconcile_modified_files_error_active_fallback_error(monkeypatch):
|
|
279
|
+
"""If modified detection fails and active() also fails, reconcile returns empty
|
|
280
|
+
set."""
|
|
281
|
+
c = mod.LockClient(local_only=True, developer_id="alice")
|
|
282
|
+
|
|
283
|
+
def _boom_modified():
|
|
284
|
+
raise RuntimeError("git exploded")
|
|
285
|
+
|
|
286
|
+
def _boom_active():
|
|
287
|
+
raise RuntimeError("active exploded")
|
|
288
|
+
|
|
289
|
+
monkeypatch.setattr(c, "_get_modified_and_unpushed_files", _boom_modified)
|
|
290
|
+
monkeypatch.setattr(c, "active", _boom_active)
|
|
291
|
+
|
|
292
|
+
assert c._reconcile() == set()
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_reconcile_handles_resume_multi_refresh_and_summary_cleanup_paths(monkeypatch):
|
|
296
|
+
"""Drive reconcile through resume/multi/refresh categories and cleanup error
|
|
297
|
+
path."""
|
|
298
|
+
c = mod.LockClient(local_only=True, developer_id="alice")
|
|
299
|
+
|
|
300
|
+
# Modified files: one resumed, one multi-session, one refreshed (no token),
|
|
301
|
+
# and one missing.
|
|
302
|
+
monkeypatch.setattr(
|
|
303
|
+
c,
|
|
304
|
+
"_get_modified_and_unpushed_files",
|
|
305
|
+
lambda: ["a.py", "b.py", "c.py", "d.py"],
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
active_rows = [
|
|
309
|
+
{"file_path": "a.py", "developer_id": "alice", "lock_token": "tok-current"},
|
|
310
|
+
{"file_path": "b.py", "developer_id": "alice", "lock_token": "tok-other"},
|
|
311
|
+
{"file_path": "c.py", "developer_id": "alice", "lock_token": ""},
|
|
312
|
+
{"file_path": "stale.py", "developer_id": "alice", "lock_token": "tok-current"},
|
|
313
|
+
]
|
|
314
|
+
monkeypatch.setattr(c, "active", lambda: active_rows)
|
|
315
|
+
monkeypatch.setattr(c, "_get_session_token", lambda: "tok-current")
|
|
316
|
+
monkeypatch.setattr(c, "_is_same_machine_token", lambda t: t == "tok-current")
|
|
317
|
+
|
|
318
|
+
# Force resumed token update exception branch
|
|
319
|
+
class _FailingUpdateClient:
|
|
320
|
+
def table(self, name):
|
|
321
|
+
return self
|
|
322
|
+
|
|
323
|
+
def update(self, *a, **k):
|
|
324
|
+
return self
|
|
325
|
+
|
|
326
|
+
def eq(self, *a, **k):
|
|
327
|
+
return self
|
|
328
|
+
|
|
329
|
+
def execute(self):
|
|
330
|
+
raise RuntimeError("update failed")
|
|
331
|
+
|
|
332
|
+
c._client = _FailingUpdateClient()
|
|
333
|
+
|
|
334
|
+
released = []
|
|
335
|
+
acquired_calls = []
|
|
336
|
+
monkeypatch.setattr(c, "release_multiple", lambda fps: released.extend(sorted(fps)))
|
|
337
|
+
monkeypatch.setattr(
|
|
338
|
+
c,
|
|
339
|
+
"acquire_multiple",
|
|
340
|
+
lambda fps, branch_name=None, reason=None: acquired_calls.append(
|
|
341
|
+
(sorted(fps), branch_name, reason)
|
|
342
|
+
),
|
|
343
|
+
)
|
|
344
|
+
monkeypatch.setattr(c, "_get_current_branch", lambda: "main")
|
|
345
|
+
|
|
346
|
+
# Trigger summary write + repo-summary write failure, then marker cleanup
|
|
347
|
+
# remove failure.
|
|
348
|
+
real_open = open
|
|
349
|
+
|
|
350
|
+
def _open_side_effect(path, mode="r", *args, **kwargs):
|
|
351
|
+
p = str(path)
|
|
352
|
+
if p.endswith(".collab\\.startup_summary.json") and "w" in mode:
|
|
353
|
+
raise RuntimeError("repo summary write failed")
|
|
354
|
+
return real_open(path, mode, *args, **kwargs)
|
|
355
|
+
|
|
356
|
+
monkeypatch.setattr(mod, "open", _open_side_effect, raising=False)
|
|
357
|
+
monkeypatch.setattr(mod.os.path, "exists", lambda p: True)
|
|
358
|
+
monkeypatch.setattr(
|
|
359
|
+
mod.os, "remove", lambda p: (_ for _ in ()).throw(RuntimeError("remove failed"))
|
|
360
|
+
)
|
|
361
|
+
monkeypatch.setattr(mod.time, "sleep", lambda s: None)
|
|
362
|
+
|
|
363
|
+
class _ImmediateThread:
|
|
364
|
+
def __init__(self, target, daemon=True):
|
|
365
|
+
self._target = target
|
|
366
|
+
|
|
367
|
+
def start(self):
|
|
368
|
+
self._target()
|
|
369
|
+
|
|
370
|
+
monkeypatch.setattr(mod.threading, "Thread", _ImmediateThread)
|
|
371
|
+
|
|
372
|
+
out = c._reconcile()
|
|
373
|
+
|
|
374
|
+
assert out == {"a.py", "b.py", "c.py", "d.py"}
|
|
375
|
+
assert "stale.py" in released
|
|
376
|
+
# Reconcile should acquire missing dirty files (d.py) and refresh missing
|
|
377
|
+
# token locks through acquire RPC (c.py).
|
|
378
|
+
assert len(acquired_calls) == 2
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def test_get_modified_and_unpushed_files_non_windows_paths(monkeypatch):
|
|
382
|
+
"""Cover non-Windows upstream check + diff code path and status-only fallback."""
|
|
383
|
+
c = mod.LockClient(local_only=True)
|
|
384
|
+
monkeypatch.setattr(mod.sys, "platform", "linux")
|
|
385
|
+
|
|
386
|
+
calls = {"n": 0}
|
|
387
|
+
|
|
388
|
+
def _check_output(args, *a, **k):
|
|
389
|
+
# status
|
|
390
|
+
if args[:3] == ["git", "status", "--porcelain"]:
|
|
391
|
+
return b" M src/dirty.py\n"
|
|
392
|
+
# upstream check
|
|
393
|
+
if args[:2] == ["git", "rev-parse"]:
|
|
394
|
+
calls["n"] += 1
|
|
395
|
+
if calls["n"] == 1:
|
|
396
|
+
return b"origin/main\n"
|
|
397
|
+
raise RuntimeError("no upstream")
|
|
398
|
+
# diff against upstream
|
|
399
|
+
if args[:3] == ["git", "diff", "--name-status"]:
|
|
400
|
+
return b"M\tsrc/unpushed.py\n"
|
|
401
|
+
return b""
|
|
402
|
+
|
|
403
|
+
monkeypatch.setattr(mod.subprocess, "check_output", _check_output)
|
|
404
|
+
monkeypatch.setattr(c, "_normalize_file_path", lambda p: p)
|
|
405
|
+
monkeypatch.setattr(c, "_should_ignore_path", lambda p: False)
|
|
406
|
+
|
|
407
|
+
first = set(c._get_modified_and_unpushed_files())
|
|
408
|
+
assert any(p.endswith("dirty.py") for p in first)
|
|
409
|
+
assert "src/unpushed.py" in first
|
|
410
|
+
|
|
411
|
+
# Second call exercises rev-parse failure -> except fallback to status-only
|
|
412
|
+
second = set(c._get_modified_and_unpushed_files())
|
|
413
|
+
assert any(p.endswith("dirty.py") for p in second)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def test_get_modified_and_unpushed_files_keeps_deleted_upstream_paths(monkeypatch):
|
|
417
|
+
"""Deleted files from unpushed history remain in-progress for locking."""
|
|
418
|
+
c = mod.LockClient(local_only=True)
|
|
419
|
+
monkeypatch.setattr(mod.sys, "platform", "linux")
|
|
420
|
+
|
|
421
|
+
def _check_output(args, *a, **k):
|
|
422
|
+
if args[:3] == ["git", "status", "--porcelain"]:
|
|
423
|
+
return b""
|
|
424
|
+
if args[:2] == ["git", "rev-parse"]:
|
|
425
|
+
return b"origin/main\n"
|
|
426
|
+
if args[:3] == ["git", "diff", "--name-status"]:
|
|
427
|
+
return (
|
|
428
|
+
b"D\t.collab/core/watcher.py\n"
|
|
429
|
+
b"D\t.collab/dashboard/server.py\n"
|
|
430
|
+
b"M\tsrc/live.py\n"
|
|
431
|
+
b"R100\told/name.py\tnew/name.py\n"
|
|
432
|
+
)
|
|
433
|
+
return b""
|
|
434
|
+
|
|
435
|
+
monkeypatch.setattr(mod.subprocess, "check_output", _check_output)
|
|
436
|
+
monkeypatch.setattr(c, "_normalize_file_path", lambda p: p.replace("\\", "/"))
|
|
437
|
+
monkeypatch.setattr(c, "_should_ignore_path", lambda p: False)
|
|
438
|
+
|
|
439
|
+
out = set(c._get_modified_and_unpushed_files())
|
|
440
|
+
assert ".collab/core/watcher.py" in out
|
|
441
|
+
assert ".collab/dashboard/server.py" in out
|
|
442
|
+
assert "src/live.py" in out
|
|
443
|
+
assert "new/name.py" in out
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def test_get_modified_and_unpushed_files_skips_status_dir_suffix(monkeypatch):
|
|
447
|
+
"""Directory-like status entries ending in '/' are ignored."""
|
|
448
|
+
c = mod.LockClient(local_only=True)
|
|
449
|
+
monkeypatch.setattr(mod.sys, "platform", "linux")
|
|
450
|
+
|
|
451
|
+
def _check_output(args, *a, **k):
|
|
452
|
+
if args[:3] == ["git", "status", "--porcelain"]:
|
|
453
|
+
return b" M apps/reporting/instance/\n M src/real.py\n"
|
|
454
|
+
if args[:2] == ["git", "rev-parse"]:
|
|
455
|
+
raise RuntimeError("no upstream")
|
|
456
|
+
return b""
|
|
457
|
+
|
|
458
|
+
monkeypatch.setattr(mod.subprocess, "check_output", _check_output)
|
|
459
|
+
monkeypatch.setattr(c, "_normalize_file_path", lambda p: p.replace("\\", "/"))
|
|
460
|
+
monkeypatch.setattr(c, "_should_ignore_path", lambda p: False)
|
|
461
|
+
|
|
462
|
+
out = set(c._get_modified_and_unpushed_files())
|
|
463
|
+
assert "apps/reporting/instance/" not in out
|
|
464
|
+
assert "src/real.py" in out
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Release error/edge-case tests for LockClient.release().
|
|
2
|
+
|
|
3
|
+
NOTE: Standard ephemeral release and success release are covered in
|
|
4
|
+
test_lock_client_api.py (test_release_ephemeral_and_success). All force_release /
|
|
5
|
+
force_release_all tests are covered in test_lock_client_force_release.py. This file
|
|
6
|
+
covers only unique error paths for release().
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ._helpers import FakeResponse, load_lock_client_module, make_create_client
|
|
12
|
+
|
|
13
|
+
mod = load_lock_client_module()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_release_api_error(monkeypatch):
|
|
17
|
+
"""Release that returns a 500 API error."""
|
|
18
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
19
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
20
|
+
monkeypatch.setattr(
|
|
21
|
+
mod,
|
|
22
|
+
"_get_create_client",
|
|
23
|
+
lambda: make_create_client(
|
|
24
|
+
FakeResponse(status=500, data=None, error={"message": "release fail"})
|
|
25
|
+
),
|
|
26
|
+
)
|
|
27
|
+
client = mod.LockClient(developer_id="releaser")
|
|
28
|
+
ok, msg = client.release("tmp/x")
|
|
29
|
+
assert ok is False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_release_api_exception(monkeypatch):
|
|
33
|
+
"""Release when supabase client raises an exception."""
|
|
34
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
35
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
36
|
+
|
|
37
|
+
class ExplodingClient:
|
|
38
|
+
def table(self, *a, **k):
|
|
39
|
+
return self
|
|
40
|
+
|
|
41
|
+
def delete(self, *a, **k):
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
def eq(self, *a, **k):
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def execute(self):
|
|
48
|
+
raise RuntimeError("Connection refused")
|
|
49
|
+
|
|
50
|
+
monkeypatch.setattr(
|
|
51
|
+
mod,
|
|
52
|
+
"_get_create_client",
|
|
53
|
+
lambda: (lambda url, key: ExplodingClient()),
|
|
54
|
+
)
|
|
55
|
+
monkeypatch.setattr(mod.time, "sleep", lambda x: None)
|
|
56
|
+
|
|
57
|
+
client = mod.LockClient(developer_id="releaser")
|
|
58
|
+
ok, msg = client.release("tmp/x")
|
|
59
|
+
assert ok is False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_release_not_acquired(monkeypatch):
|
|
63
|
+
"""Release when the file was not actually acquired (no supabase data)."""
|
|
64
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
65
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
66
|
+
|
|
67
|
+
monkeypatch.setattr(
|
|
68
|
+
mod,
|
|
69
|
+
"_get_create_client",
|
|
70
|
+
lambda: make_create_client(
|
|
71
|
+
FakeResponse(status=404, data=None, error="not found")
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
client = mod.LockClient(developer_id="releaser")
|
|
75
|
+
ok, msg = client.release("tmp/x")
|
|
76
|
+
assert ok is False
|
|
77
|
+
assert "API Error" in msg
|