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
|
@@ -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")
|