claude-dev-env 8.83.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.83.1",
3
+ "version": "8.83.2",
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") `
@@ -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")