claude-dev-env 8.83.0 → 8.83.2
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.
- package/docs/account-broker.md +1 -1
- package/docs/rule-guides/agent-board.md +27 -0
- package/docs/rule-guides/features-start-with-an-eval.md +4 -2
- package/docs/rule-guides/proof-before-pull-request.md +1 -1
- package/hooks/blocking/build_eval_before_eval_write_gate.py +102 -0
- package/hooks/blocking/pr_lifecycle_skill_gate.py +42 -16
- package/hooks/blocking/pull_request_proof.py +111 -2
- package/hooks/blocking/reply_length_gate.py +69 -13
- package/hooks/blocking/test_build_eval_before_eval_write_gate.py +162 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +25 -0
- package/hooks/blocking/test_pull_request_proof.py +115 -0
- package/hooks/blocking/test_reply_length_gate.py +98 -10
- package/hooks/context_budget/tests/test_model.py +65 -0
- package/hooks/features/conduct-gates.md +4 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/build_eval_before_eval_write_gate_constants.py +19 -0
- package/hooks/hooks_constants/pull_request_proof_constants.py +38 -0
- package/hooks/hooks_constants/reply_length_gate_constants.py +14 -0
- package/hooks/hooks_constants/test_reply_length_gate_constants.py +8 -0
- package/hooks/test_transcript_skill_scan.py +6 -2
- package/hooks/transcript_skill_scan.py +18 -7
- package/package.json +1 -1
- package/rules/agent-board.md +9 -0
- package/rules/index.md +1 -1
- package/scripts/Sync-RepoMain.ps1 +30 -1
- package/scripts/account_broker.py +1 -0
- package/scripts/account_broker_support.py +71 -24
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +11 -3
- package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +15 -0
- package/scripts/policy_lint/adapter_configuration.py +5 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/test_account_broker.py +19 -1
- package/scripts/test_account_broker_support.py +161 -3
- package/scripts/tests/Sync-RepoMain.Tests.ps1 +25 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
- package/scripts/tests/test_rule_load_scopes.py +1 -0
- package/scripts/tests/test_sync_repo_main_run_lock.py +168 -0
|
@@ -7,7 +7,7 @@ import shutil
|
|
|
7
7
|
import subprocess
|
|
8
8
|
import sys
|
|
9
9
|
import time
|
|
10
|
-
from datetime import datetime, timezone
|
|
10
|
+
from datetime import datetime, timedelta, timezone
|
|
11
11
|
from collections.abc import Callable
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from types import SimpleNamespace
|
|
@@ -341,10 +341,11 @@ def test_should_cap_the_meter_read_delay(monkeypatch: pytest.MonkeyPatch, tmp_pa
|
|
|
341
341
|
all_failures = [support.WeeklyUtilizationProbeError("down") for _ in range(6)]
|
|
342
342
|
_claude_meter_reads(monkeypatch, all_failures)
|
|
343
343
|
|
|
344
|
-
meters, unread_reason = support.read_meters_with_backoff(support.read_claude_meters, Account(Product.CLAUDE, "one", tmp_path))
|
|
344
|
+
meters, unread_reason, is_rate_limited = support.read_meters_with_backoff(support.read_claude_meters, Account(Product.CLAUDE, "one", tmp_path))
|
|
345
345
|
|
|
346
346
|
assert meters is None
|
|
347
347
|
assert unread_reason == "down"
|
|
348
|
+
assert not is_rate_limited
|
|
348
349
|
assert all_delays == [1.0, 2.0, 4.0, 8.0, 8.0]
|
|
349
350
|
|
|
350
351
|
|
|
@@ -356,10 +357,11 @@ def test_should_read_a_signed_out_claude_account_once(monkeypatch: pytest.Monkey
|
|
|
356
357
|
all_reads.append(account)
|
|
357
358
|
raise support.ClaudeSignedOutError("no usable bearer token")
|
|
358
359
|
|
|
359
|
-
meters, unread_reason = support.read_meters_with_backoff(signed_out_reader, Account(Product.CLAUDE, "main", tmp_path))
|
|
360
|
+
meters, unread_reason, is_rate_limited = support.read_meters_with_backoff(signed_out_reader, Account(Product.CLAUDE, "main", tmp_path))
|
|
360
361
|
|
|
361
362
|
assert meters is None
|
|
362
363
|
assert unread_reason == "no usable bearer token"
|
|
364
|
+
assert not is_rate_limited
|
|
363
365
|
assert len(all_reads) == 1
|
|
364
366
|
assert all_delays == []
|
|
365
367
|
|
|
@@ -385,6 +387,162 @@ def test_should_cache_account_reading_under_injected_state_path(
|
|
|
385
387
|
assert support.broker_state_path().is_file()
|
|
386
388
|
|
|
387
389
|
|
|
390
|
+
def _read_twice(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, all_results: list[object], gap: timedelta) -> object:
|
|
391
|
+
monkeypatch.setattr(support, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
|
|
392
|
+
_recorded_sleeps(monkeypatch)
|
|
393
|
+
account = Account(Product.CLAUDE, "second", tmp_path)
|
|
394
|
+
pending = list(all_results)
|
|
395
|
+
|
|
396
|
+
def reader(_selected: Account) -> Meters:
|
|
397
|
+
next_outcome = pending.pop(0)
|
|
398
|
+
if isinstance(next_outcome, Exception):
|
|
399
|
+
raise next_outcome
|
|
400
|
+
return next_outcome
|
|
401
|
+
|
|
402
|
+
adapter = ProductAdapter(lambda: (account,), reader, "CLAUDE_CONFIG_DIR", ())
|
|
403
|
+
state = support._load_state(support.broker_state_path())
|
|
404
|
+
first_read_at = datetime(2026, 10, 11, 19, 20, tzinfo=timezone.utc)
|
|
405
|
+
support.read_accounts(Product.CLAUDE, adapter, all_state=state, now=first_read_at)
|
|
406
|
+
return support.read_accounts(Product.CLAUDE, adapter, all_state=state, now=first_read_at + gap)[0]
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def test_should_keep_the_last_good_meters_when_the_usage_endpoint_rate_limits(
|
|
410
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
411
|
+
) -> None:
|
|
412
|
+
rate_limited = support.WeeklyUtilizationProbeError("usage probe failed: HTTP Error 429: Too Many Requests")
|
|
413
|
+
reading = _read_twice(monkeypatch, tmp_path, [Meters(74.0, None, 81.0, None), rate_limited], timedelta(minutes=5))
|
|
414
|
+
|
|
415
|
+
assert reading.meters == Meters(74.0, None, 81.0, None)
|
|
416
|
+
assert reading.unread_reason == str(rate_limited)
|
|
417
|
+
assert reading.measured_at == datetime(2026, 10, 11, 19, 20, tzinfo=timezone.utc)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def test_should_drop_last_good_meters_older_than_the_fallback_window(
|
|
421
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
422
|
+
) -> None:
|
|
423
|
+
rate_limited = support.WeeklyUtilizationProbeError("usage probe failed: HTTP Error 429: Too Many Requests")
|
|
424
|
+
reading = _read_twice(monkeypatch, tmp_path, [Meters(74.0, None, 81.0, None), rate_limited], timedelta(hours=2))
|
|
425
|
+
|
|
426
|
+
assert reading.meters is None
|
|
427
|
+
assert reading.unread_reason == "usage probe failed: HTTP Error 429: Too Many Requests"
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def test_should_keep_last_good_meters_at_the_fallback_boundary(
|
|
431
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
432
|
+
) -> None:
|
|
433
|
+
rate_limited = support.WeeklyUtilizationProbeError("usage probe failed: HTTP Error 429")
|
|
434
|
+
reading = _read_twice(
|
|
435
|
+
monkeypatch, tmp_path,
|
|
436
|
+
[Meters(74.0, None, 81.0, None), rate_limited], timedelta(minutes=30),
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
assert reading.meters == Meters(74.0, None, 81.0, None)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def test_should_drop_last_good_meters_after_a_window_reset(
|
|
443
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
444
|
+
) -> None:
|
|
445
|
+
first_read = datetime(2026, 10, 11, 19, 20, tzinfo=timezone.utc)
|
|
446
|
+
rate_limited = support.WeeklyUtilizationProbeError("usage probe failed: HTTP Error 429")
|
|
447
|
+
reading = _read_twice(
|
|
448
|
+
monkeypatch, tmp_path,
|
|
449
|
+
[Meters(3.0, first_read + timedelta(minutes=10), 81.0, None), rate_limited],
|
|
450
|
+
timedelta(minutes=25),
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
assert reading.meters is None
|
|
454
|
+
assert reading.unread_reason == str(rate_limited)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def test_should_drop_held_meters_when_a_window_resets(
|
|
458
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
459
|
+
) -> None:
|
|
460
|
+
monkeypatch.setattr(support, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
|
|
461
|
+
account = Account(Product.CLAUDE, "second", tmp_path)
|
|
462
|
+
start = datetime(2026, 10, 11, 19, 20, tzinfo=timezone.utc)
|
|
463
|
+
state = support._load_state(support.broker_state_path())
|
|
464
|
+
state["meters"][support._state_key(account)] = {
|
|
465
|
+
"read_at": start.timestamp(),
|
|
466
|
+
"measured_at": (start - timedelta(minutes=5)).timestamp(),
|
|
467
|
+
"meters": support._meter_payload(Meters(3.0, start + timedelta(minutes=1), 81.0, None)),
|
|
468
|
+
"unread_reason": "usage probe failed: HTTP Error 429",
|
|
469
|
+
"retry_at": (start + timedelta(minutes=5)).timestamp(),
|
|
470
|
+
}
|
|
471
|
+
adapter = ProductAdapter(lambda: (account,), lambda _: pytest.fail("probe during cooldown"), "CLAUDE_CONFIG_DIR", ())
|
|
472
|
+
|
|
473
|
+
reading = support.read_accounts(Product.CLAUDE, adapter, all_state=state, now=start + timedelta(minutes=2))[0]
|
|
474
|
+
|
|
475
|
+
assert reading.meters is None
|
|
476
|
+
assert reading.retry_at == start + timedelta(minutes=5)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def test_should_reject_a_last_good_entry_with_an_unrelated_failure() -> None:
|
|
480
|
+
now = datetime(2026, 10, 11, 19, 20, tzinfo=timezone.utc)
|
|
481
|
+
entry = {
|
|
482
|
+
"measured_at": now.timestamp(),
|
|
483
|
+
"meters": support._meter_payload(Meters(74.0, None, 81.0, None)),
|
|
484
|
+
"unread_reason": "credentials file missing",
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
assert support._last_good_meters(entry, now) is None
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def test_should_not_use_last_good_meters_after_a_file_failure(
|
|
491
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
492
|
+
) -> None:
|
|
493
|
+
reading = _read_twice(
|
|
494
|
+
monkeypatch, tmp_path,
|
|
495
|
+
[Meters(74.0, None, 81.0, None), *[OSError("credentials file missing") for _ in range(3)]],
|
|
496
|
+
timedelta(minutes=5),
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
assert reading.meters is None
|
|
500
|
+
assert reading.unread_reason == "credentials file missing"
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def test_should_keep_first_measurement_time_across_rate_limit_fallbacks(
|
|
504
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
505
|
+
) -> None:
|
|
506
|
+
monkeypatch.setattr(support, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
|
|
507
|
+
_recorded_sleeps(monkeypatch)
|
|
508
|
+
account = Account(Product.CLAUDE, "second", tmp_path)
|
|
509
|
+
rate_limited = support.WeeklyUtilizationProbeError("usage probe failed: HTTP Error 429")
|
|
510
|
+
pending = [Meters(74.0, None, 81.0, None), rate_limited, rate_limited]
|
|
511
|
+
calls = []
|
|
512
|
+
|
|
513
|
+
def reader(_selected: Account) -> Meters:
|
|
514
|
+
calls.append(1)
|
|
515
|
+
outcome = pending.pop(0)
|
|
516
|
+
if isinstance(outcome, Exception):
|
|
517
|
+
raise outcome
|
|
518
|
+
return outcome
|
|
519
|
+
|
|
520
|
+
adapter = ProductAdapter(lambda: (account,), reader, "CLAUDE_CONFIG_DIR", ())
|
|
521
|
+
state = support._load_state(support.broker_state_path())
|
|
522
|
+
first = datetime(2026, 10, 11, 19, 20, tzinfo=timezone.utc)
|
|
523
|
+
support.read_accounts(Product.CLAUDE, adapter, all_state=state, now=first)
|
|
524
|
+
second = support.read_accounts(Product.CLAUDE, adapter, all_state=state, now=first + timedelta(minutes=5))[0]
|
|
525
|
+
held = support.read_accounts(Product.CLAUDE, adapter, all_state=state, now=first + timedelta(minutes=6))[0]
|
|
526
|
+
third = support.read_accounts(Product.CLAUDE, adapter, all_state=state, now=first + timedelta(minutes=10))[0]
|
|
527
|
+
|
|
528
|
+
assert second.measured_at == first
|
|
529
|
+
assert held.measured_at == first
|
|
530
|
+
assert third.measured_at == first
|
|
531
|
+
assert len(calls) == 3
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def test_should_keep_newer_measurement_when_stale_fallback_saves_last(tmp_path: Path) -> None:
|
|
535
|
+
state_path = tmp_path / "broker" / "state.json"
|
|
536
|
+
account = Account(Product.CLAUDE, "second", tmp_path)
|
|
537
|
+
key = support._state_key(account)
|
|
538
|
+
old = {"meters": {key: {"read_at": 40.0, "measured_at": 10.0, "meters": support._meter_payload(Meters(74.0, None, 81.0, None)), "unread_reason": "HTTP Error 429"}}, "spent": {}, "affinity": {}}
|
|
539
|
+
newer = {"meters": {key: {"read_at": 30.0, "measured_at": 30.0, "meters": support._meter_payload(Meters(5.0, None, 81.0, None)), "unread_reason": None}}, "spent": {}, "affinity": {}}
|
|
540
|
+
support._save_state(state_path, newer)
|
|
541
|
+
support._save_state(state_path, old)
|
|
542
|
+
|
|
543
|
+
assert support._load_state(state_path)["meters"][key]["meters"]["session_percent_left"] == 5.0
|
|
544
|
+
|
|
545
|
+
|
|
388
546
|
def test_should_extract_session_id_and_restore_runner() -> None:
|
|
389
547
|
original = support.subprocess_runner
|
|
390
548
|
|
|
@@ -105,4 +105,29 @@ Describe 'Sync-RepoMain' {
|
|
|
105
105
|
[System.IO.File]::ReadAllText((Join-Path $fixture.MirrorPath 'added.txt')) | Should -Be "local only`n"
|
|
106
106
|
[System.IO.File]::ReadAllText((Join-Path $fixture.MirrorPath 'tracked.txt')) | Should -Be "new`n"
|
|
107
107
|
}
|
|
108
|
+
|
|
109
|
+
It 'skips without fetching while another sync of the same repo holds the run lock' {
|
|
110
|
+
$fixture = New-MirrorBehindRemote -Name 'lock-held'
|
|
111
|
+
$lockPath = [System.IO.Path]::TrimEndingDirectorySeparator([System.IO.Path]::GetFullPath($fixture.MirrorPath))
|
|
112
|
+
$pathBytes = [System.Text.Encoding]::UTF8.GetBytes($lockPath.ToLowerInvariant())
|
|
113
|
+
$lockName = 'Global\SyncRepoMain-' + [System.Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData($pathBytes))
|
|
114
|
+
$readyPath = Join-Path $TestDrive 'lock-ready'
|
|
115
|
+
$holderCommand = "`$lock = [System.Threading.Mutex]::new(`$true, '$lockName'); Set-Content -LiteralPath '$readyPath' -Value ready; Start-Sleep -Seconds 30"
|
|
116
|
+
$holder = Start-Process pwsh -ArgumentList '-NoProfile', '-Command', $holderCommand -PassThru
|
|
117
|
+
try {
|
|
118
|
+
$deadline = (Get-Date).AddSeconds(20)
|
|
119
|
+
while (-not (Test-Path -LiteralPath $readyPath) -and (Get-Date) -lt $deadline) {
|
|
120
|
+
Start-Sleep -Milliseconds 200
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
$exitCode = Invoke-SyncScript -Fixture $fixture
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
Stop-Process -Id $holder.Id -Force -ErrorAction SilentlyContinue
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
$exitCode | Should -Be 3
|
|
130
|
+
(Invoke-TestGit -RepoPath $fixture.MirrorPath -Arguments @('rev-parse', 'HEAD')) | Should -Be $fixture.OldTip
|
|
131
|
+
(Get-Content -LiteralPath $fixture.LogPath -Raw) | Should -Match 'still running'
|
|
132
|
+
}
|
|
108
133
|
}
|
|
@@ -130,6 +130,14 @@ def test_new_artifact_page_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
|
130
130
|
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
131
131
|
|
|
132
132
|
|
|
133
|
+
def test_new_eval_write_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
134
|
+
current_document = _hook_document(
|
|
135
|
+
["hooks/blocking/build_eval_before_eval_write_gate.py"],
|
|
136
|
+
'{"hooks": {}}',
|
|
137
|
+
)
|
|
138
|
+
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
139
|
+
|
|
140
|
+
|
|
133
141
|
def test_new_blocker_in_staged_change_is_rejected(tmp_path: Path) -> None:
|
|
134
142
|
current_document = _hook_document(
|
|
135
143
|
["hooks/blocking/new.py"],
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Specifications for Sync-RepoMain skipping while another run of the same repo is live."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
_SCRIPT_PATH = Path(__file__).resolve().parents[1] / "Sync-RepoMain.ps1"
|
|
13
|
+
_PWSH = shutil.which("pwsh") or "pwsh"
|
|
14
|
+
_SKIPPED_EXIT_CODE = 3
|
|
15
|
+
_SUCCESS_EXIT_CODE = 0
|
|
16
|
+
_LOCK_READY_TIMEOUT_SECONDS = 20
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _git(repository_path: Path, *arguments: str) -> str:
|
|
20
|
+
completed = subprocess.run(
|
|
21
|
+
[
|
|
22
|
+
"git",
|
|
23
|
+
"-C",
|
|
24
|
+
str(repository_path),
|
|
25
|
+
"-c",
|
|
26
|
+
"user.name=tester",
|
|
27
|
+
"-c",
|
|
28
|
+
"user.email=tester@example.com",
|
|
29
|
+
*arguments,
|
|
30
|
+
],
|
|
31
|
+
check=True,
|
|
32
|
+
capture_output=True,
|
|
33
|
+
text=True,
|
|
34
|
+
)
|
|
35
|
+
return completed.stdout.strip()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _mirror_one_commit_behind(root_path: Path) -> Path:
|
|
39
|
+
remote_path = root_path / "remote.git"
|
|
40
|
+
author_path = root_path / "author"
|
|
41
|
+
mirror_path = root_path / "mirror"
|
|
42
|
+
remote_path.mkdir()
|
|
43
|
+
author_path.mkdir()
|
|
44
|
+
_git(remote_path, "init", "-q", "--bare", "-b", "main")
|
|
45
|
+
_git(author_path, "init", "-q", "-b", "main")
|
|
46
|
+
(author_path / "tracked.txt").write_text("old\n", encoding="utf-8")
|
|
47
|
+
_git(author_path, "add", "-A")
|
|
48
|
+
_git(author_path, "commit", "-q", "-m", "first")
|
|
49
|
+
_git(author_path, "remote", "add", "origin", str(remote_path))
|
|
50
|
+
_git(author_path, "push", "-q", "origin", "main")
|
|
51
|
+
subprocess.run(
|
|
52
|
+
["git", "clone", "-q", str(remote_path), str(mirror_path)], check=True
|
|
53
|
+
)
|
|
54
|
+
(author_path / "tracked.txt").write_text("new\n", encoding="utf-8")
|
|
55
|
+
_git(author_path, "commit", "-q", "-am", "second")
|
|
56
|
+
_git(author_path, "push", "-q", "origin", "main")
|
|
57
|
+
return mirror_path
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _run_lock_name(mirror_path: Path) -> str:
|
|
61
|
+
normalized_path = os.path.normpath(str(mirror_path)).lower()
|
|
62
|
+
path_digest = hashlib.sha256(normalized_path.encode("utf-8")).hexdigest().upper()
|
|
63
|
+
return f"Global\\SyncRepoMain-{path_digest}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _run_sync(mirror_path: Path, log_path: Path, repo_path: str | None = None) -> int:
|
|
67
|
+
argument_path = str(mirror_path) if repo_path is None else repo_path
|
|
68
|
+
completed = subprocess.run(
|
|
69
|
+
[
|
|
70
|
+
_PWSH,
|
|
71
|
+
"-NoProfile",
|
|
72
|
+
"-File",
|
|
73
|
+
str(_SCRIPT_PATH),
|
|
74
|
+
"-RepoPath",
|
|
75
|
+
argument_path,
|
|
76
|
+
"-LogPath",
|
|
77
|
+
str(log_path),
|
|
78
|
+
],
|
|
79
|
+
capture_output=True,
|
|
80
|
+
text=True,
|
|
81
|
+
)
|
|
82
|
+
return completed.returncode
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_should_skip_without_fetching_while_another_run_holds_the_lock(
|
|
86
|
+
tmp_path: Path,
|
|
87
|
+
) -> None:
|
|
88
|
+
mirror_path = _mirror_one_commit_behind(tmp_path)
|
|
89
|
+
old_tip = _git(mirror_path, "rev-parse", "HEAD")
|
|
90
|
+
log_path = tmp_path / "sync.log"
|
|
91
|
+
ready_path = tmp_path / "lock-ready"
|
|
92
|
+
holder_command = (
|
|
93
|
+
f"$lock = [System.Threading.Mutex]::new($true, '{_run_lock_name(mirror_path)}'); "
|
|
94
|
+
f"Set-Content -LiteralPath '{ready_path}' -Value ready; Start-Sleep -Seconds 30"
|
|
95
|
+
)
|
|
96
|
+
holder = subprocess.Popen([_PWSH, "-NoProfile", "-Command", holder_command])
|
|
97
|
+
try:
|
|
98
|
+
deadline = time.monotonic() + _LOCK_READY_TIMEOUT_SECONDS
|
|
99
|
+
while not ready_path.exists() and time.monotonic() < deadline:
|
|
100
|
+
time.sleep(0.2)
|
|
101
|
+
held_exit_code = _run_sync(mirror_path, log_path)
|
|
102
|
+
finally:
|
|
103
|
+
holder.kill()
|
|
104
|
+
holder.wait()
|
|
105
|
+
|
|
106
|
+
assert held_exit_code == _SKIPPED_EXIT_CODE
|
|
107
|
+
assert _git(mirror_path, "rev-parse", "HEAD") == old_tip
|
|
108
|
+
assert "still running" in log_path.read_text(encoding="utf-8")
|
|
109
|
+
|
|
110
|
+
assert _run_sync(mirror_path, log_path) == _SUCCESS_EXIT_CODE
|
|
111
|
+
assert _git(mirror_path, "rev-parse", "HEAD") != old_tip
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_should_skip_a_second_spelling_of_the_same_repo(tmp_path: Path) -> None:
|
|
115
|
+
mirror_path = _mirror_one_commit_behind(tmp_path)
|
|
116
|
+
old_tip = _git(mirror_path, "rev-parse", "HEAD")
|
|
117
|
+
ready_path = tmp_path / "lock-ready"
|
|
118
|
+
holder_command = (
|
|
119
|
+
f"$lock = [System.Threading.Mutex]::new($true, '{_run_lock_name(mirror_path)}'); "
|
|
120
|
+
f"Set-Content -LiteralPath '{ready_path}' -Value ready; Start-Sleep -Seconds 30"
|
|
121
|
+
)
|
|
122
|
+
holder = subprocess.Popen([_PWSH, "-NoProfile", "-Command", holder_command])
|
|
123
|
+
try:
|
|
124
|
+
deadline = time.monotonic() + _LOCK_READY_TIMEOUT_SECONDS
|
|
125
|
+
while not ready_path.exists() and time.monotonic() < deadline:
|
|
126
|
+
time.sleep(0.2)
|
|
127
|
+
assert ready_path.exists()
|
|
128
|
+
spellings = (
|
|
129
|
+
f"{mirror_path}{os.sep}",
|
|
130
|
+
os.path.join(str(mirror_path), "."),
|
|
131
|
+
)
|
|
132
|
+
for spelling in spellings:
|
|
133
|
+
spelling_log = tmp_path / "spelling.log"
|
|
134
|
+
spelling_log.unlink(missing_ok=True)
|
|
135
|
+
exit_code = _run_sync(mirror_path, spelling_log, repo_path=spelling)
|
|
136
|
+
assert exit_code == _SKIPPED_EXIT_CODE
|
|
137
|
+
assert _git(mirror_path, "rev-parse", "HEAD") == old_tip
|
|
138
|
+
assert "still running" in spelling_log.read_text(encoding="utf-8")
|
|
139
|
+
finally:
|
|
140
|
+
holder.kill()
|
|
141
|
+
holder.wait()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_should_skip_when_the_repo_path_is_a_symlink(tmp_path: Path) -> None:
|
|
145
|
+
mirror_path = _mirror_one_commit_behind(tmp_path)
|
|
146
|
+
old_tip = _git(mirror_path, "rev-parse", "HEAD")
|
|
147
|
+
link_path = tmp_path / "mirror-link"
|
|
148
|
+
os.symlink(mirror_path, link_path, target_is_directory=True)
|
|
149
|
+
log_path = tmp_path / "sync.log"
|
|
150
|
+
ready_path = tmp_path / "lock-ready"
|
|
151
|
+
holder_command = (
|
|
152
|
+
f"$lock = [System.Threading.Mutex]::new($true, '{_run_lock_name(mirror_path)}'); "
|
|
153
|
+
f"Set-Content -LiteralPath '{ready_path}' -Value ready; Start-Sleep -Seconds 30"
|
|
154
|
+
)
|
|
155
|
+
holder = subprocess.Popen([_PWSH, "-NoProfile", "-Command", holder_command])
|
|
156
|
+
try:
|
|
157
|
+
deadline = time.monotonic() + _LOCK_READY_TIMEOUT_SECONDS
|
|
158
|
+
while not ready_path.exists() and time.monotonic() < deadline:
|
|
159
|
+
time.sleep(0.2)
|
|
160
|
+
assert ready_path.exists()
|
|
161
|
+
exit_code = _run_sync(mirror_path, log_path, repo_path=str(link_path))
|
|
162
|
+
finally:
|
|
163
|
+
holder.kill()
|
|
164
|
+
holder.wait()
|
|
165
|
+
|
|
166
|
+
assert exit_code == _SKIPPED_EXIT_CODE
|
|
167
|
+
assert _git(mirror_path, "rev-parse", "HEAD") == old_tip
|
|
168
|
+
assert "still running" in log_path.read_text(encoding="utf-8")
|