claude-dev-env 8.83.1 → 8.84.0

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.
@@ -6,7 +6,7 @@ Claude's main home is `~/.claude`. Its `claude-chain.json` file contains a `chai
6
6
 
7
7
  The optional `claude-account-order.json` file in the main home sets the Claude pick priority. It is a JSON list of account names, first choice first, such as `["claude-second", "claude"]`. An entry matches an account's name, its command, or its `claude-<name>` launcher, and `claude` names the main account. A new job goes to the first listed account with room. When no listed account has room, the broker picks the unlisted account with the most room, and it waits when no account has room. A resumed session stays on its bound account while that account has room. A listed name absent from the roster is skipped. Without the file, every job goes to the account with the most room. Edit the file to change the order; the next pick reads it.
8
8
 
9
- Codex uses its configured account roster. When no Codex roster is configured, the broker chooses the default Codex home.
9
+ Codex uses its configured account roster. When no Codex roster is configured, the broker chooses the default Codex home. A new Codex job goes to an account on a Pro plan before any other account in the same tier. The plan comes from the account's meter reply, and `ALL_PRO_PLAN_TYPES` lists the Pro plans: `pro`, `prolite`, and `promax`. Among accounts of the same kind, the one with the most room wins. A Pro account below the `normal` floor still loses to any account with room in the `normal` tier.
10
10
 
11
11
  ## Commands
12
12
 
@@ -91,8 +91,8 @@ A window that reports no length counts as weekly.
91
91
 
92
92
  | Condition | Answer |
93
93
  |---|---|
94
- | One or more accounts with more than 10% left | `normal` on the account with the most room |
95
- | No account over 10%, one or more over 1% | `luna` on the account with the most room |
94
+ | One or more accounts with more than 10% left | `normal` on a Pro plan account (`pro`, `prolite` and `promax`) before any other account in this tier; among accounts of the same kind, the one with the most room |
95
+ | No account over 10%, one or more over 1% | `luna` on a Pro plan account (`pro`, `prolite` and `promax`) before any other account in this tier; among accounts of the same kind, the one with the most room |
96
96
  | An account that reports a window shorter than a week, such as the 5-hour window, with under 20% of that window left | never takes `luna` |
97
97
  | No account over 1% | `wait`, naming the next reset when known |
98
98
  | A meter read fails | The broker reads it again, up to 3 reads, waiting 1 second and then 2 seconds |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.83.1",
3
+ "version": "8.84.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -189,6 +189,35 @@ function Invoke-AdoptCopiedTree {
189
189
  return $false
190
190
  }
191
191
 
192
+ function Get-RunLockName {
193
+ $lockPath = [System.IO.Path]::TrimEndingDirectorySeparator([System.IO.Path]::GetFullPath($RepoPath))
194
+ $resolveLinkTarget = [System.IO.Directory].GetMethod('ResolveLinkTarget', [type[]]([string], [bool]))
195
+ if ((Test-Path -LiteralPath $lockPath) -and ($null -ne $resolveLinkTarget)) {
196
+ $finalTarget = [System.IO.Directory]::ResolveLinkTarget($lockPath, $true)
197
+ if ($null -ne $finalTarget) {
198
+ $lockPath = [System.IO.Path]::TrimEndingDirectorySeparator($finalTarget.FullName)
199
+ }
200
+ }
201
+ $pathBytes = [System.Text.Encoding]::UTF8.GetBytes($lockPath.ToLowerInvariant())
202
+ $pathHash = [System.Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData($pathBytes))
203
+ return "Global\SyncRepoMain-$pathHash"
204
+ }
205
+
206
+ $runLock = [System.Threading.Mutex]::new($false, (Get-RunLockName))
207
+ try {
208
+ $hasRunLock = $runLock.WaitOne(0)
209
+ }
210
+ catch [System.Threading.AbandonedMutexException] {
211
+ $hasRunLock = $true
212
+ }
213
+ if (-not $hasRunLock) {
214
+ Write-SyncLog -Level 'SKIP' -Message "another sync of $RepoPath is still running"
215
+ exit 3
216
+ }
217
+
218
+ $env:GIT_TERMINAL_PROMPT = '0'
219
+ $env:GCM_INTERACTIVE = 'never'
220
+
192
221
  try {
193
222
  if (-not (Test-Path -LiteralPath $RepoPath)) {
194
223
  Write-SyncLog -Level 'ERROR' -Message "repo path missing: $RepoPath"
@@ -212,7 +241,7 @@ try {
212
241
  exit 3
213
242
  }
214
243
 
215
- $null = Invoke-GitOrExit -Arguments @('fetch', $Remote, $Branch) `
244
+ $null = Invoke-GitOrExit -Arguments @('-c', 'http.lowSpeedLimit=1000', '-c', 'http.lowSpeedTime=60', 'fetch', $Remote, $Branch) `
216
245
  -FailureMessage "fetch $Remote $Branch failed" -FailureExitCode 1
217
246
 
218
247
  $tipOutput = Invoke-GitOrExit -Arguments @('rev-parse', $Branch, "$Remote/$Branch") `
@@ -315,6 +315,7 @@ def read_codex_account_meters(account: Account) -> Meters:
315
315
  short_window.resets_at if short_window else None,
316
316
  FULL_PERCENT - weekly_window.used_percent if weekly_window else None,
317
317
  weekly_window.resets_at if weekly_window else None,
318
+ usage.plan_type,
318
319
  )
319
320
 
320
321
 
@@ -500,6 +501,7 @@ def _meter_payload(meters: Meters | None) -> dict[str, object] | None:
500
501
  "session_resets_at": utc_time_text(meters.session_resets_at),
501
502
  "weekly_percent_left": meters.weekly_percent_left,
502
503
  "weekly_resets_at": utc_time_text(meters.weekly_resets_at),
504
+ "plan_type": meters.plan_type,
503
505
  }
504
506
 
505
507
 
@@ -509,7 +511,14 @@ def _meters_from_payload(raw_meters: object) -> Meters | None:
509
511
  all_percentages = (raw_meters.get("session_percent_left"), raw_meters.get("weekly_percent_left"))
510
512
  if any(each_percent is not None and not isinstance(each_percent, (int, float)) for each_percent in all_percentages):
511
513
  return None
512
- return Meters(all_percentages[0], parse_utc_time(raw_meters.get("session_resets_at")), all_percentages[1], parse_utc_time(raw_meters.get("weekly_resets_at")))
514
+ plan_type = raw_meters.get("plan_type")
515
+ return Meters(
516
+ all_percentages[0],
517
+ parse_utc_time(raw_meters.get("session_resets_at")),
518
+ all_percentages[1],
519
+ parse_utc_time(raw_meters.get("weekly_resets_at")),
520
+ plan_type if isinstance(plan_type, str) else None,
521
+ )
513
522
 
514
523
 
515
524
  def _read_one_account(account: Account, adapter: ProductAdapter, all_cache: dict[str, object], now: datetime) -> tuple[Reading, bool]:
@@ -4,10 +4,13 @@
4
4
  Codex reports usage through ``codex app-server`` and the JSON-RPC method
5
5
  ``account/rateLimits/read``. The reply names up to two windows, a short one
6
6
  (5 hours) and a weekly one. The account broker counts the account's room as
7
- the smaller of the two::
7
+ the smaller of the two. The reply also names the account's plan, such as
8
+ ``pro`` or ``team``::
8
9
 
9
10
  5-hour window 30% used, week 85% used -> 15% left
10
11
  week window only, 40% used -> 60% left
12
+ planType "promax" -> plan_type "promax"
13
+ no planType -> plan_type None
11
14
  no window, error reply, no reply -> CodexMeterUnreadError
12
15
 
13
16
  Every read failure raises ``CodexMeterUnreadError``. A missing Codex
@@ -92,9 +95,10 @@ class UsageWindow:
92
95
 
93
96
  @dataclass(frozen=True)
94
97
  class CodexAccountMeters:
95
- """One account's windows, with its tightest short window and its tightest weekly window."""
98
+ """One account's windows and plan, with its tightest short window and its tightest weekly window."""
96
99
 
97
100
  all_windows: tuple[UsageWindow, ...]
101
+ plan_type: str | None = None
98
102
 
99
103
  @property
100
104
  def short_window(self) -> UsageWindow | None:
@@ -176,14 +180,16 @@ def parse_rate_limits_reply(reply_text: str) -> CodexAccountMeters:
176
180
  ::
177
181
 
178
182
  {"id": 2, "result": {"rateLimits": {"primary": {"usedPercent": 30, ...},
179
- "secondary": {"usedPercent": 85, ...}}}}
180
- -> CodexAccountMeters with two windows, 15% left
183
+ "secondary": {"usedPercent": 85, ...},
184
+ "planType": "promax"}}}
185
+ -> CodexAccountMeters with two windows, 15% left, plan_type "promax"
186
+ the same reply without "planType" -> plan_type None
181
187
 
182
188
  Args:
183
189
  reply_text: One line the ``codex app-server`` process wrote.
184
190
 
185
191
  Returns:
186
- The windows the reply carries.
192
+ The windows and the plan type the reply carries.
187
193
 
188
194
  Raises:
189
195
  CodexMeterUnreadError: The line is no reply, an error reply, or names
@@ -197,7 +203,8 @@ def parse_rate_limits_reply(reply_text: str) -> CodexAccountMeters:
197
203
  )
198
204
  if not all_windows:
199
205
  raise CodexMeterUnreadError("rate-limit reply names no usage window")
200
- return CodexAccountMeters(all_windows=all_windows)
206
+ plan_type = rate_limits.get("planType")
207
+ return CodexAccountMeters(all_windows=all_windows, plan_type=plan_type if isinstance(plan_type, str) else None)
201
208
 
202
209
 
203
210
  def request_messages() -> list[dict[str, object]]:
@@ -20,6 +20,7 @@ from dev_env_scripts_constants.claude_account_constants import (
20
20
  SECOND_WEEKLY_USED_CEILING_PERCENT,
21
21
  )
22
22
  from dev_env_scripts_constants.codex_account_constants import (
23
+ ALL_PRO_PLAN_TYPES,
23
24
  CODEX_HOME_ENVIRONMENT_VARIABLE,
24
25
  LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT,
25
26
  LUNA_TIER_STOP_PERCENT_LEFT,
@@ -193,6 +194,7 @@ class Meters:
193
194
  session_resets_at: datetime | None
194
195
  weekly_percent_left: float | None
195
196
  weekly_resets_at: datetime | None
197
+ plan_type: str | None = None
196
198
 
197
199
  @property
198
200
  def tightest_percent_left(self) -> float | None:
@@ -313,10 +315,11 @@ def _account_reset(reading: Reading, is_spent: bool = False) -> datetime | None:
313
315
  return max(all_known_resets, default=None)
314
316
 
315
317
 
316
- def _rank_key(reading: Reading) -> tuple[bool, int, float]:
318
+ def _rank_key(reading: Reading) -> tuple[bool, int, bool, float]:
317
319
  room = reading.meters.tightest_percent_left if reading.meters else None
318
320
  priority = reading.account.priority
319
- return (priority is None, priority or 0, -(room if room is not None else -1.0))
321
+ is_pro_plan = reading.meters is not None and reading.meters.plan_type in ALL_PRO_PLAN_TYPES
322
+ return (priority is None, priority or 0, not is_pro_plan, -(room if room is not None else -1.0))
320
323
 
321
324
 
322
325
  def _main_has_room(meters: Meters | None) -> bool:
@@ -99,6 +99,9 @@ LUNA_TIER_STOP_PERCENT_LEFT: float = 1.0
99
99
  LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT: float = 20.0
100
100
  """An account with a window shorter than a week runs Luna only with this much of it left."""
101
101
 
102
+ ALL_PRO_PLAN_TYPES: frozenset[str] = frozenset({"pro", "prolite", "promax"})
103
+ """Plan types of a personal Codex Pro subscription. The broker tries these accounts first."""
104
+
102
105
  WEEKLY_WINDOW_MINUTES: int = 10080
103
106
  """Length of the weekly window. Any shorter window is a short window, such as 5 hours."""
104
107
 
@@ -86,9 +86,14 @@ def test_tier_bars_match_the_documented_table() -> None:
86
86
  normal_bar = documented_percent(constants.NORMAL_TIER_MINIMUM_PERCENT_LEFT)
87
87
  luna_stop = documented_percent(constants.LUNA_TIER_STOP_PERCENT_LEFT)
88
88
  luna_short_window_floor = documented_percent(constants.LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT)
89
+ pro_plans = documented_list(sorted(constants.ALL_PRO_PLAN_TYPES))
90
+ pro_preference = (
91
+ f"on a Pro plan account ({pro_plans}) before any other account in this tier; "
92
+ "among accounts of the same kind, the one with the most room"
93
+ )
89
94
  all_expected_rows = [
90
- f"| One or more accounts with more than {normal_bar} left | `{constants.TIER_NORMAL}` on the account with the most room |",
91
- f"| No account over {normal_bar}, one or more over {luna_stop} | `{constants.TIER_LUNA}` on the account with the most room |",
95
+ f"| One or more accounts with more than {normal_bar} left | `{constants.TIER_NORMAL}` {pro_preference} |",
96
+ f"| No account over {normal_bar}, one or more over {luna_stop} | `{constants.TIER_LUNA}` {pro_preference} |",
92
97
  f"| An account that reports a window shorter than a week, such as the 5-hour window, with under {luna_short_window_floor} of that window left | never takes `{constants.TIER_LUNA}` |",
93
98
  f"| No account over {luna_stop} | `{constants.TIER_WAIT}`, naming the next reset when known |",
94
99
  ]
@@ -58,12 +58,14 @@ def _meters(
58
58
  *,
59
59
  short_reset: datetime | None = None,
60
60
  weekly_reset: datetime | None = None,
61
+ plan_type: str | None = None,
61
62
  ) -> Meters:
62
63
  return Meters(
63
64
  short_left,
64
65
  short_reset or NOW + timedelta(hours=5),
65
66
  weekly_left,
66
67
  weekly_reset or NOW + timedelta(days=3),
68
+ plan_type,
67
69
  )
68
70
 
69
71
 
@@ -125,6 +127,30 @@ def test_should_put_unread_accounts_after_readable_accounts() -> None:
125
127
  assert decision.account == readings[1].account
126
128
 
127
129
 
130
+ def test_should_pick_a_pro_plan_account_over_a_roomier_team_account() -> None:
131
+ readings = (
132
+ Reading(_account("team-mel"), _meters(90, 85, plan_type="team")),
133
+ Reading(_account("personal"), _meters(40, 30, plan_type="promax")),
134
+ )
135
+
136
+ decision = choose_from_readings(Product.CODEX, readings, now=NOW)
137
+
138
+ assert decision.account == readings[1].account
139
+ assert decision.tier == "normal"
140
+
141
+
142
+ def test_should_pick_a_team_account_in_the_normal_tier_over_a_pro_plan_account_below_the_floor() -> None:
143
+ readings = (
144
+ Reading(_account("personal"), _meters(50, 8, plan_type="pro")),
145
+ Reading(_account("team-jon"), _meters(40, 30, plan_type="team")),
146
+ )
147
+
148
+ decision = choose_from_readings(Product.CODEX, readings, now=NOW)
149
+
150
+ assert decision.account == readings[1].account
151
+ assert decision.tier == "normal"
152
+
153
+
128
154
  def test_should_keep_list_order_when_tighter_windows_tie() -> None:
129
155
  readings = (
130
156
  Reading(_account("first"), _meters(40, 60)),
@@ -192,7 +192,8 @@ def test_should_read_codex_meters_through_injected_reader(
192
192
  UsageWindow(300, 20.0, short_reset),
193
193
  UsageWindow(10080, 30.0, None),
194
194
  UsageWindow(None, 50.0, unknown_length_reset),
195
- )
195
+ ),
196
+ plan_type="promax",
196
197
  )
197
198
  monkeypatch.setattr(support.codex_account_meters, "resolve_codex_path", lambda _: tmp_path / "codex")
198
199
  monkeypatch.setattr(support.codex_account_meters, "read_codex_meters", lambda *_: usage)
@@ -203,6 +204,19 @@ def test_should_read_codex_meters_through_injected_reader(
203
204
  assert meters.session_resets_at == short_reset
204
205
  assert meters.weekly_percent_left == 50.0
205
206
  assert meters.weekly_resets_at == unknown_length_reset
207
+ assert meters.plan_type == "promax"
208
+
209
+
210
+ def test_should_keep_the_plan_through_the_cached_meter_payload() -> None:
211
+ meters = Meters(80.0, None, 70.0, None, "pro")
212
+
213
+ assert support._meters_from_payload(support._meter_payload(meters)) == meters
214
+
215
+
216
+ def test_should_load_a_cached_meter_payload_saved_without_a_plan() -> None:
217
+ old_payload = {"session_percent_left": 80.0, "session_resets_at": None, "weekly_percent_left": 70.0, "weekly_resets_at": None}
218
+
219
+ assert support._meters_from_payload(old_payload) == Meters(80.0, None, 70.0, None, None)
206
220
 
207
221
 
208
222
  def _codex_meter_reads(
@@ -65,6 +65,16 @@ class TestParseRateLimitsReply:
65
65
  )
66
66
  assert [each.used_percent for each in meters.all_windows] == [40.0]
67
67
 
68
+ def should_read_the_plan_type_beside_the_windows(self) -> None:
69
+ meters = parse_rate_limits_reply(
70
+ reply_line({"primary": {"usedPercent": 40}, "planType": "promax"})
71
+ )
72
+ assert meters.plan_type == "promax"
73
+
74
+ def should_read_a_reply_without_a_plan_type(self) -> None:
75
+ meters = parse_rate_limits_reply(reply_line({"primary": {"usedPercent": 40}}))
76
+ assert meters.plan_type is None
77
+
68
78
  def should_clamp_used_percent_past_full(self) -> None:
69
79
  meters = parse_rate_limits_reply(reply_line({"primary": {"usedPercent": 130}}))
70
80
  assert [each.used_percent for each in meters.all_windows] == [100.0]
@@ -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
  }
@@ -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")