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,516 @@
|
|
|
1
|
+
"""Reconciliation startup tests for live_locks_watcher."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from ._helpers import load_watcher_module
|
|
8
|
+
|
|
9
|
+
# ---- Auto-migrated from migrated_remaining ----
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_reconcile_readopts_dirty_locked_file(monkeypatch):
|
|
13
|
+
"""§8c: Dirty file with existing lock is re-adopted, no acquire RPC.
|
|
14
|
+
|
|
15
|
+
When startup reconciliation finds a file that is dirty AND already locked by this
|
|
16
|
+
developer (same session token or no token), it should re-adopt the lock without
|
|
17
|
+
calling acquire_lock RPC.
|
|
18
|
+
"""
|
|
19
|
+
mod = load_watcher_module()
|
|
20
|
+
monkeypatch.setattr(watcher, "DEVELOPER_ID", "alice")
|
|
21
|
+
monkeypatch.setattr(watcher, "_is_ephemeral_dev", lambda d: False)
|
|
22
|
+
|
|
23
|
+
# Clean up state
|
|
24
|
+
mod._local_owned_locks.clear()
|
|
25
|
+
mod._active_conflicts.clear()
|
|
26
|
+
|
|
27
|
+
# Mock git status: src/app.py is dirty
|
|
28
|
+
monkeypatch.setattr(watcher, "_run_git_status_porcelain", lambda: {"src/app.py"})
|
|
29
|
+
monkeypatch.setattr(watcher, "_get_current_branch", lambda: "main")
|
|
30
|
+
|
|
31
|
+
# Existing lock for src/app.py with matching SESSION_TOKEN
|
|
32
|
+
current_token = mod.SESSION_TOKEN
|
|
33
|
+
|
|
34
|
+
class FakeResponse:
|
|
35
|
+
data = [
|
|
36
|
+
{
|
|
37
|
+
"file_path": "src/app.py",
|
|
38
|
+
"developer_id": "alice",
|
|
39
|
+
"lock_token": current_token,
|
|
40
|
+
"branch_name": "main",
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
rpc_called = []
|
|
45
|
+
|
|
46
|
+
class FakeClient:
|
|
47
|
+
def table(self, name):
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
def select(self, *args):
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def eq(self, *args):
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def execute(self):
|
|
57
|
+
return FakeResponse()
|
|
58
|
+
|
|
59
|
+
def rpc(self, name, params):
|
|
60
|
+
rpc_called.append(name)
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
client = FakeClient()
|
|
64
|
+
mod._reconcile_on_startup(client)
|
|
65
|
+
|
|
66
|
+
# File should be re-adopted (in _local_owned_locks)
|
|
67
|
+
assert "src/app.py" in mod._local_owned_locks
|
|
68
|
+
# acquire_lock RPC should NOT have been called
|
|
69
|
+
assert "acquire_lock" not in rpc_called
|
|
70
|
+
|
|
71
|
+
# Clean up
|
|
72
|
+
mod._local_owned_locks.clear()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_reconcile_releases_stale_clean_lock(monkeypatch):
|
|
76
|
+
"""§8d: Locked file that is now clean is released as stale.
|
|
77
|
+
|
|
78
|
+
When startup reconciliation finds a file locked by this developer but the file is
|
|
79
|
+
NOT in git status (clean), it should delete the lock.
|
|
80
|
+
"""
|
|
81
|
+
mod = load_watcher_module()
|
|
82
|
+
monkeypatch.setattr(watcher, "DEVELOPER_ID", "alice")
|
|
83
|
+
monkeypatch.setattr(watcher, "_is_ephemeral_dev", lambda d: False)
|
|
84
|
+
|
|
85
|
+
mod._local_owned_locks.clear()
|
|
86
|
+
mod._active_conflicts.clear()
|
|
87
|
+
|
|
88
|
+
# Mock git status: NO dirty files
|
|
89
|
+
monkeypatch.setattr(watcher, "_run_git_status_porcelain", lambda: set())
|
|
90
|
+
monkeypatch.setattr(watcher, "_get_current_branch", lambda: "main")
|
|
91
|
+
|
|
92
|
+
# Existing lock for src/old.py (stale)
|
|
93
|
+
class FakeSelectResponse:
|
|
94
|
+
data = [
|
|
95
|
+
{
|
|
96
|
+
"file_path": "src/old.py",
|
|
97
|
+
"developer_id": "alice",
|
|
98
|
+
"lock_token": "old-token",
|
|
99
|
+
"branch_name": "main",
|
|
100
|
+
}
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
deleted_files = []
|
|
104
|
+
|
|
105
|
+
class FakeTable:
|
|
106
|
+
def __init__(self):
|
|
107
|
+
self._file_path = None
|
|
108
|
+
|
|
109
|
+
def select(self, *args):
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
def delete(self):
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
def eq(self, field, value):
|
|
116
|
+
if field == "file_path":
|
|
117
|
+
self._file_path = value
|
|
118
|
+
return self
|
|
119
|
+
|
|
120
|
+
def execute(self):
|
|
121
|
+
if self._file_path:
|
|
122
|
+
deleted_files.append(self._file_path)
|
|
123
|
+
return None
|
|
124
|
+
return FakeSelectResponse()
|
|
125
|
+
|
|
126
|
+
class FakeClient:
|
|
127
|
+
def table(self, name):
|
|
128
|
+
return FakeTable()
|
|
129
|
+
|
|
130
|
+
client = FakeClient()
|
|
131
|
+
mod._reconcile_on_startup(client)
|
|
132
|
+
|
|
133
|
+
# src/old.py should have been released
|
|
134
|
+
assert "src/old.py" in deleted_files
|
|
135
|
+
# Should NOT be in _local_owned_locks
|
|
136
|
+
assert "src/old.py" not in mod._local_owned_locks
|
|
137
|
+
|
|
138
|
+
mod._local_owned_locks.clear()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_reconcile_acquires_lock_for_new_dirty_file(monkeypatch):
|
|
142
|
+
"""§8e: Dirty file with no existing lock is acquired at startup.
|
|
143
|
+
|
|
144
|
+
When startup reconciliation finds a dirty file that has no existing lock in
|
|
145
|
+
Supabase, it should call acquire_lock RPC to lock it.
|
|
146
|
+
"""
|
|
147
|
+
mod = load_watcher_module()
|
|
148
|
+
monkeypatch.setattr(watcher, "DEVELOPER_ID", "alice")
|
|
149
|
+
monkeypatch.setattr(watcher, "_is_ephemeral_dev", lambda d: False)
|
|
150
|
+
|
|
151
|
+
mod._local_owned_locks.clear()
|
|
152
|
+
mod._active_conflicts.clear()
|
|
153
|
+
|
|
154
|
+
# Mock git status: src/new.py is dirty
|
|
155
|
+
monkeypatch.setattr(watcher, "_run_git_status_porcelain", lambda: {"src/new.py"})
|
|
156
|
+
monkeypatch.setattr(watcher, "_get_current_branch", lambda: "main")
|
|
157
|
+
monkeypatch.setattr(watcher, "_should_ignore_path", lambda p: False)
|
|
158
|
+
|
|
159
|
+
# No existing locks
|
|
160
|
+
class FakeSelectResponse:
|
|
161
|
+
data = []
|
|
162
|
+
|
|
163
|
+
class FakeRPCResponse:
|
|
164
|
+
data = [{"status": "ok"}]
|
|
165
|
+
|
|
166
|
+
rpc_calls = []
|
|
167
|
+
|
|
168
|
+
class FakeRPCChain:
|
|
169
|
+
def execute(self):
|
|
170
|
+
return FakeRPCResponse()
|
|
171
|
+
|
|
172
|
+
class FakeClient:
|
|
173
|
+
def table(self, name):
|
|
174
|
+
return self
|
|
175
|
+
|
|
176
|
+
def select(self, *args):
|
|
177
|
+
return self
|
|
178
|
+
|
|
179
|
+
def eq(self, *args):
|
|
180
|
+
return self
|
|
181
|
+
|
|
182
|
+
def execute(self):
|
|
183
|
+
return FakeSelectResponse()
|
|
184
|
+
|
|
185
|
+
def rpc(self, name, params):
|
|
186
|
+
rpc_calls.append({"name": name, "params": params})
|
|
187
|
+
return FakeRPCChain()
|
|
188
|
+
|
|
189
|
+
client = FakeClient()
|
|
190
|
+
mod._reconcile_on_startup(client)
|
|
191
|
+
|
|
192
|
+
# acquire_lock RPC should have been called for src/new.py
|
|
193
|
+
assert len(rpc_calls) == 1
|
|
194
|
+
assert rpc_calls[0]["name"] == "acquire_lock"
|
|
195
|
+
assert rpc_calls[0]["params"]["p_file_path"] == "src/new.py"
|
|
196
|
+
# File should now be in _local_owned_locks
|
|
197
|
+
assert "src/new.py" in mod._local_owned_locks
|
|
198
|
+
|
|
199
|
+
mod._local_owned_locks.clear()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_reconcile_post_restart_conflict_non_interactive(monkeypatch):
|
|
203
|
+
"""§8f: Post-restart conflict in non-TTY defaults to continue.
|
|
204
|
+
|
|
205
|
+
When a dirty file is locked by another developer and stdin is not a TTY, the watcher
|
|
206
|
+
should add the file to _active_conflicts and continue running (no interactive
|
|
207
|
+
prompt).
|
|
208
|
+
"""
|
|
209
|
+
mod = load_watcher_module()
|
|
210
|
+
monkeypatch.setattr(watcher, "DEVELOPER_ID", "alice")
|
|
211
|
+
monkeypatch.setattr(watcher, "_is_ephemeral_dev", lambda d: False)
|
|
212
|
+
|
|
213
|
+
mod._local_owned_locks.clear()
|
|
214
|
+
mod._active_conflicts.clear()
|
|
215
|
+
|
|
216
|
+
# Mock git status: src/shared.py is dirty
|
|
217
|
+
monkeypatch.setattr(watcher, "_run_git_status_porcelain", lambda: {"src/shared.py"})
|
|
218
|
+
monkeypatch.setattr(watcher, "_get_current_branch", lambda: "main")
|
|
219
|
+
monkeypatch.setattr(watcher, "_should_ignore_path", lambda p: False)
|
|
220
|
+
# Non-interactive
|
|
221
|
+
monkeypatch.setattr(sys, "stdin", type("F", (), {"isatty": lambda s: False})())
|
|
222
|
+
|
|
223
|
+
# No existing locks for alice
|
|
224
|
+
class FakeSelectResponse:
|
|
225
|
+
data = []
|
|
226
|
+
|
|
227
|
+
# RPC returns conflict
|
|
228
|
+
class FakeConflictResponse:
|
|
229
|
+
data = [{"status": "conflict", "owner": "bob"}]
|
|
230
|
+
|
|
231
|
+
class FakeRPCChain:
|
|
232
|
+
def execute(self):
|
|
233
|
+
return FakeConflictResponse()
|
|
234
|
+
|
|
235
|
+
notify_calls = []
|
|
236
|
+
monkeypatch.setattr(watcher, "_notify", lambda t, m: notify_calls.append((t, m)))
|
|
237
|
+
|
|
238
|
+
class FakeClient:
|
|
239
|
+
def table(self, name):
|
|
240
|
+
return self
|
|
241
|
+
|
|
242
|
+
def select(self, *args):
|
|
243
|
+
return self
|
|
244
|
+
|
|
245
|
+
def eq(self, *args):
|
|
246
|
+
return self
|
|
247
|
+
|
|
248
|
+
def execute(self):
|
|
249
|
+
return FakeSelectResponse()
|
|
250
|
+
|
|
251
|
+
def rpc(self, name, params):
|
|
252
|
+
return FakeRPCChain()
|
|
253
|
+
|
|
254
|
+
client = FakeClient()
|
|
255
|
+
mod._reconcile_on_startup(client)
|
|
256
|
+
|
|
257
|
+
# File should be in _active_conflicts
|
|
258
|
+
assert "src/shared.py" in mod._active_conflicts
|
|
259
|
+
# Should NOT be in _local_owned_locks (conflict)
|
|
260
|
+
assert "src/shared.py" not in mod._local_owned_locks
|
|
261
|
+
# Notification should have been sent
|
|
262
|
+
assert any("Post-restart" in t for t, m in notify_calls)
|
|
263
|
+
|
|
264
|
+
mod._active_conflicts.clear()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_reconcile_multi_session_different_token_non_interactive(monkeypatch):
|
|
268
|
+
"""§8g: Different session token in non-TTY defaults to leave lock.
|
|
269
|
+
|
|
270
|
+
When startup reconciliation finds a dirty file locked by this developer but with a
|
|
271
|
+
different session token, and stdin is not a TTY, it should leave the lock untouched
|
|
272
|
+
(safe default).
|
|
273
|
+
"""
|
|
274
|
+
mod = load_watcher_module()
|
|
275
|
+
monkeypatch.setattr(watcher, "DEVELOPER_ID", "alice")
|
|
276
|
+
monkeypatch.setattr(watcher, "_is_ephemeral_dev", lambda d: False)
|
|
277
|
+
|
|
278
|
+
mod._local_owned_locks.clear()
|
|
279
|
+
mod._active_conflicts.clear()
|
|
280
|
+
|
|
281
|
+
# Mock git status: src/multi.py is dirty
|
|
282
|
+
monkeypatch.setattr(watcher, "_run_git_status_porcelain", lambda: {"src/multi.py"})
|
|
283
|
+
monkeypatch.setattr(watcher, "_get_current_branch", lambda: "main")
|
|
284
|
+
# Non-interactive
|
|
285
|
+
monkeypatch.setattr(sys, "stdin", type("F", (), {"isatty": lambda s: False})())
|
|
286
|
+
|
|
287
|
+
# Existing lock with DIFFERENT token
|
|
288
|
+
class FakeSelectResponse:
|
|
289
|
+
data = [
|
|
290
|
+
{
|
|
291
|
+
"file_path": "src/multi.py",
|
|
292
|
+
"developer_id": "alice",
|
|
293
|
+
"lock_token": "other-machine-token-12345",
|
|
294
|
+
"branch_name": "main",
|
|
295
|
+
}
|
|
296
|
+
]
|
|
297
|
+
|
|
298
|
+
rpc_calls = []
|
|
299
|
+
|
|
300
|
+
class FakeClient:
|
|
301
|
+
def table(self, name):
|
|
302
|
+
return self
|
|
303
|
+
|
|
304
|
+
def select(self, *args):
|
|
305
|
+
return self
|
|
306
|
+
|
|
307
|
+
def eq(self, *args):
|
|
308
|
+
return self
|
|
309
|
+
|
|
310
|
+
def execute(self):
|
|
311
|
+
return FakeSelectResponse()
|
|
312
|
+
|
|
313
|
+
def rpc(self, name, params):
|
|
314
|
+
rpc_calls.append(name)
|
|
315
|
+
return self
|
|
316
|
+
|
|
317
|
+
client = FakeClient()
|
|
318
|
+
mod._reconcile_on_startup(client)
|
|
319
|
+
|
|
320
|
+
# Lock should NOT be re-adopted (different token, non-interactive → leave)
|
|
321
|
+
assert "src/multi.py" not in mod._local_owned_locks
|
|
322
|
+
assert "acquire_lock" not in rpc_calls
|
|
323
|
+
|
|
324
|
+
mod._local_owned_locks.clear()
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
watcher = load_watcher_module()
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_reconcile_stale_release_exception_and_acquire_exception(monkeypatch):
|
|
331
|
+
"""Reconcile should continue when stale-release or acquire raises exceptions."""
|
|
332
|
+
mod = load_watcher_module()
|
|
333
|
+
monkeypatch.setattr(mod, "DEVELOPER_ID", "alice")
|
|
334
|
+
monkeypatch.setattr(mod, "_is_ephemeral_dev", lambda d: False)
|
|
335
|
+
monkeypatch.setattr(mod, "_get_current_branch", lambda: "main")
|
|
336
|
+
monkeypatch.setattr(mod, "_run_git_status_porcelain", lambda: {"src/new.py"})
|
|
337
|
+
monkeypatch.setattr(mod, "_should_ignore_path", lambda p: False)
|
|
338
|
+
|
|
339
|
+
# Existing stale lock is clean (not in dirty set), and unlocking it raises.
|
|
340
|
+
existing = [{"file_path": "src/stale.py", "lock_token": "x"}]
|
|
341
|
+
|
|
342
|
+
class FakeClient:
|
|
343
|
+
def __init__(self):
|
|
344
|
+
self._mode = "select"
|
|
345
|
+
|
|
346
|
+
def table(self, name):
|
|
347
|
+
return self
|
|
348
|
+
|
|
349
|
+
def select(self, *args):
|
|
350
|
+
self._mode = "select"
|
|
351
|
+
return self
|
|
352
|
+
|
|
353
|
+
def update(self, *args, **kwargs):
|
|
354
|
+
self._mode = "update"
|
|
355
|
+
return self
|
|
356
|
+
|
|
357
|
+
def delete(self):
|
|
358
|
+
self._mode = "delete"
|
|
359
|
+
return self
|
|
360
|
+
|
|
361
|
+
def eq(self, *args):
|
|
362
|
+
return self
|
|
363
|
+
|
|
364
|
+
def execute(self):
|
|
365
|
+
if self._mode == "select":
|
|
366
|
+
return type("R", (), {"data": existing})()
|
|
367
|
+
raise RuntimeError("db failure")
|
|
368
|
+
|
|
369
|
+
def rpc(self, *args, **kwargs):
|
|
370
|
+
return self
|
|
371
|
+
|
|
372
|
+
# Should swallow both stale-release and acquire exceptions.
|
|
373
|
+
mod._reconcile_on_startup(FakeClient())
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def test_reconcile_skips_ignored_unlocked_dirty_files(monkeypatch):
|
|
377
|
+
"""Ignored dirty files should not trigger acquire_lock RPC."""
|
|
378
|
+
mod = load_watcher_module()
|
|
379
|
+
monkeypatch.setattr(mod, "DEVELOPER_ID", "alice")
|
|
380
|
+
monkeypatch.setattr(mod, "_is_ephemeral_dev", lambda d: False)
|
|
381
|
+
monkeypatch.setattr(mod, "_get_current_branch", lambda: "main")
|
|
382
|
+
monkeypatch.setattr(mod, "_run_git_status_porcelain", lambda: {".git/config"})
|
|
383
|
+
monkeypatch.setattr(mod, "_should_ignore_path", lambda p: True)
|
|
384
|
+
|
|
385
|
+
rpc_calls = []
|
|
386
|
+
|
|
387
|
+
class FakeClient:
|
|
388
|
+
def table(self, name):
|
|
389
|
+
return self
|
|
390
|
+
|
|
391
|
+
def select(self, *args):
|
|
392
|
+
return self
|
|
393
|
+
|
|
394
|
+
def eq(self, *args):
|
|
395
|
+
return self
|
|
396
|
+
|
|
397
|
+
def execute(self):
|
|
398
|
+
return type("R", (), {"data": []})()
|
|
399
|
+
|
|
400
|
+
def rpc(self, *args, **kwargs):
|
|
401
|
+
rpc_calls.append(True)
|
|
402
|
+
return self
|
|
403
|
+
|
|
404
|
+
mod._reconcile_on_startup(FakeClient())
|
|
405
|
+
assert rpc_calls == []
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def test_get_modified_and_unpushed_files_status_exception(monkeypatch):
|
|
409
|
+
"""If git status fails, helper should continue and return best-effort set."""
|
|
410
|
+
mod = load_watcher_module()
|
|
411
|
+
|
|
412
|
+
def _check_output(cmd, *args, **kwargs):
|
|
413
|
+
if cmd[:3] == ["git", "status", "--porcelain"]:
|
|
414
|
+
raise RuntimeError("status fail")
|
|
415
|
+
if cmd[:2] == ["git", "rev-parse"]:
|
|
416
|
+
return b"origin/main\n"
|
|
417
|
+
if cmd[:3] == ["git", "diff", "--name-only"]:
|
|
418
|
+
return b"src/from_diff.py\n"
|
|
419
|
+
return b""
|
|
420
|
+
|
|
421
|
+
monkeypatch.setattr(mod.subprocess, "check_output", _check_output)
|
|
422
|
+
monkeypatch.setattr(mod, "_normalize_path", lambda p, root: p)
|
|
423
|
+
monkeypatch.setattr(mod, "_should_ignore_path", lambda p: False)
|
|
424
|
+
out = mod._get_modified_and_unpushed_files()
|
|
425
|
+
assert "src/from_diff.py" in out
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def test_reconcile_on_startup_git_status_exception(monkeypatch):
|
|
429
|
+
"""_reconcile_on_startup exits cleanly when git status fails."""
|
|
430
|
+
mod = load_watcher_module()
|
|
431
|
+
monkeypatch.setattr(
|
|
432
|
+
mod,
|
|
433
|
+
"_run_git_status_porcelain",
|
|
434
|
+
lambda: (_ for _ in ()).throw(RuntimeError("git fail")),
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
class _Client:
|
|
438
|
+
def table(self, _name):
|
|
439
|
+
return self
|
|
440
|
+
|
|
441
|
+
def select(self, *_a, **_k):
|
|
442
|
+
return self
|
|
443
|
+
|
|
444
|
+
def eq(self, *_a, **_k):
|
|
445
|
+
return self
|
|
446
|
+
|
|
447
|
+
def execute(self):
|
|
448
|
+
return type("R", (), {"data": []})()
|
|
449
|
+
|
|
450
|
+
mod._reconcile_on_startup(_Client()) # should not raise
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def test_reconcile_ephemeral_developer_short_circuit(monkeypatch):
|
|
454
|
+
"""Ephemeral developers should skip startup reconciliation entirely."""
|
|
455
|
+
mod = load_watcher_module()
|
|
456
|
+
monkeypatch.setattr(mod, "DEVELOPER_ID", "test_dev_1")
|
|
457
|
+
monkeypatch.setattr(mod, "_is_ephemeral_dev", lambda d: True)
|
|
458
|
+
|
|
459
|
+
class FakeClient:
|
|
460
|
+
def table(self, name):
|
|
461
|
+
raise AssertionError("should not query DB for ephemeral dev")
|
|
462
|
+
|
|
463
|
+
mod._reconcile_on_startup(FakeClient())
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def test_reconcile_same_machine_re_adopt_update_exception(monkeypatch):
|
|
467
|
+
"""Same-machine token mismatch should re-adopt even if token update fails."""
|
|
468
|
+
mod = load_watcher_module()
|
|
469
|
+
monkeypatch.setattr(mod, "DEVELOPER_ID", "alice")
|
|
470
|
+
monkeypatch.setattr(mod, "_is_ephemeral_dev", lambda d: False)
|
|
471
|
+
monkeypatch.setattr(mod, "_run_git_status_porcelain", lambda: {"src/file.py"})
|
|
472
|
+
monkeypatch.setattr(mod, "_get_current_branch", lambda: "main")
|
|
473
|
+
monkeypatch.setattr(mod, "_is_same_machine_token", lambda token: True)
|
|
474
|
+
|
|
475
|
+
mod._local_owned_locks.clear()
|
|
476
|
+
|
|
477
|
+
class FakeClient:
|
|
478
|
+
def __init__(self):
|
|
479
|
+
self._mode = "select"
|
|
480
|
+
|
|
481
|
+
def table(self, name):
|
|
482
|
+
return self
|
|
483
|
+
|
|
484
|
+
def select(self, *args):
|
|
485
|
+
self._mode = "select"
|
|
486
|
+
return self
|
|
487
|
+
|
|
488
|
+
def update(self, *args, **kwargs):
|
|
489
|
+
self._mode = "update"
|
|
490
|
+
return self
|
|
491
|
+
|
|
492
|
+
def eq(self, *args):
|
|
493
|
+
return self
|
|
494
|
+
|
|
495
|
+
def execute(self):
|
|
496
|
+
if self._mode == "select":
|
|
497
|
+
return type(
|
|
498
|
+
"R",
|
|
499
|
+
(),
|
|
500
|
+
{
|
|
501
|
+
"data": [
|
|
502
|
+
{
|
|
503
|
+
"file_path": "src/file.py",
|
|
504
|
+
"developer_id": "alice",
|
|
505
|
+
"lock_token": "other-token",
|
|
506
|
+
}
|
|
507
|
+
]
|
|
508
|
+
},
|
|
509
|
+
)()
|
|
510
|
+
raise RuntimeError("update failed")
|
|
511
|
+
|
|
512
|
+
def rpc(self, *args, **kwargs):
|
|
513
|
+
return self
|
|
514
|
+
|
|
515
|
+
mod._reconcile_on_startup(FakeClient())
|
|
516
|
+
assert "src/file.py" in mod._local_owned_locks
|