claude-dev-env 8.98.7 → 8.98.9
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/bin/install-pstack-plugin.mjs +71 -21
- package/bin/install-pstack-plugin.test.mjs +46 -9
- package/bin/install.test.mjs +9 -9
- package/docs/account-broker.md +1 -1
- package/hooks/blocking/resolve_thread_reply_gate.py +203 -41
- package/hooks/blocking/test_resolve_thread_reply_gate.py +341 -25
- package/hooks/features/conduct-gates.md +3 -3
- package/hooks/hooks.json +1 -1
- package/hooks/hooks_constants/resolve_thread_reply_gate_constants.py +18 -3
- package/package.json +1 -1
- package/scripts/Sync-RepoMain.ps1 +102 -4
- package/scripts/account_broker.py +12 -4
- package/scripts/test_account_broker.py +35 -0
- package/scripts/tests/Sync-RepoMain.Tests.ps1 +43 -0
- package/scripts/tests/test_sync_repo_main_mirror.py +226 -0
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
a remote, such as a read-only mirror kept current by a scheduled task.
|
|
9
9
|
|
|
10
10
|
Safety rules:
|
|
11
|
-
- Only runs when the checked-out branch is the named branch
|
|
11
|
+
- Only runs when the checked-out branch is the named branch, unless
|
|
12
|
+
-Mirror is passed
|
|
12
13
|
- Only runs when there are no tracked changes (untracked files are fine),
|
|
13
|
-
unless -StashDirty is passed
|
|
14
|
+
unless -StashDirty is passed. A -Mirror return stashes the tree the old
|
|
15
|
+
checkout left whatever -StashDirty says
|
|
14
16
|
- Uses merge --ff-only (never force, never hard reset, never clean)
|
|
15
17
|
- When the fast-forward fails because the files were already copied in,
|
|
16
18
|
moves HEAD with a mixed reset, which writes no files. It keeps that move
|
|
@@ -42,6 +44,20 @@
|
|
|
42
44
|
missing; when that pop also fails, the log names the stash entry so the
|
|
43
45
|
operator can recover it by hand.
|
|
44
46
|
|
|
47
|
+
.PARAMETER Mirror
|
|
48
|
+
Marks the tree as a mirror that must always sit on the named branch. With
|
|
49
|
+
the switch, a checkout found on another branch or a detached HEAD is moved
|
|
50
|
+
back: HEAD points at the local named branch again and a mixed reset moves
|
|
51
|
+
the index there. Every path that still holds the old checkout's content,
|
|
52
|
+
including files only the old checkout tracked, is stashed as "mirror
|
|
53
|
+
return <yyyy-MM-dd>: tree of <checkout> at <tip>", which puts the named
|
|
54
|
+
branch's files back. This stash runs with or without -StashDirty, because
|
|
55
|
+
a mirror's tree belongs to the named branch. The log names the checkout
|
|
56
|
+
and tip it left. A checkout with staged changes is skipped, so no staged
|
|
57
|
+
content is lost. When a step of the return fails, HEAD and the index go
|
|
58
|
+
back to the old checkout and the run exits 1. Without the switch, any
|
|
59
|
+
other checkout is skipped.
|
|
60
|
+
|
|
45
61
|
.PARAMETER LogPath
|
|
46
62
|
Absolute path to the run log (default: $HOME\.claude\logs\sync-repo-main.log).
|
|
47
63
|
#>
|
|
@@ -52,6 +68,7 @@ param(
|
|
|
52
68
|
[string]$Remote = 'origin',
|
|
53
69
|
[string]$Branch = 'main',
|
|
54
70
|
[switch]$StashDirty,
|
|
71
|
+
[switch]$Mirror,
|
|
55
72
|
[string]$LogPath = (Join-Path $HOME '.claude\logs\sync-repo-main.log')
|
|
56
73
|
)
|
|
57
74
|
|
|
@@ -131,6 +148,84 @@ function Invoke-GitOrExit {
|
|
|
131
148
|
return $result.Output
|
|
132
149
|
}
|
|
133
150
|
|
|
151
|
+
function Invoke-MirrorReturn {
|
|
152
|
+
<#
|
|
153
|
+
Moves a mirror found on another branch or a detached HEAD back to the
|
|
154
|
+
named branch. HEAD and the index move first, then every path where the
|
|
155
|
+
tree still holds the old checkout's content is stashed, including files
|
|
156
|
+
only the old checkout tracked. Any failure puts HEAD and the index back
|
|
157
|
+
on the old checkout, so the next run meets the same state and tries again.
|
|
158
|
+
#>
|
|
159
|
+
param(
|
|
160
|
+
[Parameter(Mandatory)]
|
|
161
|
+
[AllowEmptyString()]
|
|
162
|
+
[string]$LeftBranch
|
|
163
|
+
)
|
|
164
|
+
$leftCheckoutName = if ($LeftBranch) { "branch '$LeftBranch'" } else { 'detached HEAD' }
|
|
165
|
+
$stagedResult = Invoke-Git -Arguments @('diff', '--cached', '--quiet')
|
|
166
|
+
if ($stagedResult.ExitCode -ne 0) {
|
|
167
|
+
Write-SyncLog -Level 'SKIP' -Message "mirror return refused: $leftCheckoutName has staged changes"
|
|
168
|
+
exit 3
|
|
169
|
+
}
|
|
170
|
+
$leftTip = Invoke-GitOrExit -Arguments @('rev-parse', 'HEAD') `
|
|
171
|
+
-FailureMessage 'cannot read the tip of the checkout being left' -FailureExitCode 2
|
|
172
|
+
$null = Invoke-GitOrExit -Arguments @('rev-parse', '--verify', '-q', "refs/heads/$Branch") `
|
|
173
|
+
-FailureMessage "mirror has no local $Branch branch to return to" -FailureExitCode 2
|
|
174
|
+
$leftOnlyResult = Invoke-Git -Arguments @('diff', '--name-only', '--no-renames', '-z', '--diff-filter=A', "refs/heads/$Branch", $leftTip)
|
|
175
|
+
if ($leftOnlyResult.ExitCode -ne 0) {
|
|
176
|
+
Write-SyncLog -Level 'ERROR' -Message "mirror return skipped: cannot list files only $leftCheckoutName tracks: $($leftOnlyResult.Output)"
|
|
177
|
+
exit 1
|
|
178
|
+
}
|
|
179
|
+
$null = Invoke-GitOrExit -Arguments @('symbolic-ref', 'HEAD', "refs/heads/$Branch") `
|
|
180
|
+
-FailureMessage "cannot point HEAD at $Branch" -FailureExitCode 1
|
|
181
|
+
|
|
182
|
+
$resetResult = Invoke-Git -Arguments @('reset', '-q')
|
|
183
|
+
$changedResult = if ($resetResult.ExitCode -eq 0) { Invoke-Git -Arguments @('diff', '--name-only', '--no-renames', '-z') } else { $resetResult }
|
|
184
|
+
$failure = if ($resetResult.ExitCode -ne 0) { "mixed reset onto $Branch failed: $($resetResult.Output)" }
|
|
185
|
+
elseif ($changedResult.ExitCode -ne 0) { "cannot list the tree left by $leftCheckoutName`: $($changedResult.Output)" }
|
|
186
|
+
$leftTreeNote = 'the tree already matched'
|
|
187
|
+
if (-not $failure) {
|
|
188
|
+
$allLeftPaths = [System.Collections.Generic.List[string]]::new()
|
|
189
|
+
foreach ($changedPath in Get-NulSeparatedPaths -Output $changedResult.Output) { $allLeftPaths.Add($changedPath) }
|
|
190
|
+
foreach ($leftOnlyPath in Get-NulSeparatedPaths -Output $leftOnlyResult.Output) {
|
|
191
|
+
if (Test-Path -LiteralPath (Join-Path $RepoPath $leftOnlyPath)) { $allLeftPaths.Add($leftOnlyPath) }
|
|
192
|
+
}
|
|
193
|
+
if ($allLeftPaths.Count -gt 0) {
|
|
194
|
+
$leftTreeStashMessage = 'mirror return {0}: tree of {1} at {2}' -f (Get-Date -Format 'yyyy-MM-dd'), $leftCheckoutName, $leftTip
|
|
195
|
+
$pathspecFile = $null
|
|
196
|
+
try {
|
|
197
|
+
$pathspecFile = New-TemporaryFile
|
|
198
|
+
$pathspecText = (($allLeftPaths | Select-Object -Unique | ForEach-Object { ":(literal)$_" }) -join "`0") + "`0"
|
|
199
|
+
[System.IO.File]::WriteAllText($pathspecFile.FullName, $pathspecText, [System.Text.UTF8Encoding]::new($false))
|
|
200
|
+
$stashResult = Invoke-Git -Arguments @('stash', 'push', '--all', '-m', $leftTreeStashMessage, "--pathspec-from-file=$($pathspecFile.FullName)", '--pathspec-file-nul')
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
$stashResult = [pscustomobject]@{ ExitCode = 1; Output = $_.Exception.Message }
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
if ($pathspecFile) {
|
|
207
|
+
Remove-Item -LiteralPath $pathspecFile.FullName -Force -ErrorAction SilentlyContinue
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if ($stashResult.ExitCode -ne 0) {
|
|
211
|
+
$failure = "stashing the tree of $leftCheckoutName failed: $($stashResult.Output)"
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
$leftTreeNote = "its tree is stashed as '$leftTreeStashMessage'"
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if ($failure) {
|
|
219
|
+
$restoreArguments = if ($LeftBranch) { @('symbolic-ref', 'HEAD', "refs/heads/$LeftBranch") } else { @('update-ref', '--no-deref', 'HEAD', $leftTip) }
|
|
220
|
+
$restoreResult = Invoke-Git -Arguments $restoreArguments
|
|
221
|
+
$indexResult = if ($resetResult.ExitCode -eq 0) { Invoke-Git -Arguments @('reset', '-q') } else { $restoreResult }
|
|
222
|
+
$restoreNote = if ($restoreResult.ExitCode -eq 0 -and $indexResult.ExitCode -eq 0) { "HEAD and index are back on $leftCheckoutName at $leftTip" } else { "putting HEAD back on $leftCheckoutName at $leftTip also failed: $($restoreResult.Output) $($indexResult.Output)" }
|
|
223
|
+
Write-SyncLog -Level 'ERROR' -Message "mirror return failed, $failure; $restoreNote"
|
|
224
|
+
exit 1
|
|
225
|
+
}
|
|
226
|
+
Write-SyncLog -Level 'MIRROR' -Message "left $leftCheckoutName at $leftTip; HEAD is on $Branch and $leftTreeNote"
|
|
227
|
+
}
|
|
228
|
+
|
|
134
229
|
function Get-NulSeparatedPaths {
|
|
135
230
|
param(
|
|
136
231
|
[Parameter(Mandatory)]
|
|
@@ -229,8 +324,11 @@ try {
|
|
|
229
324
|
$currentBranch = Invoke-GitOrExit -Arguments @('branch', '--show-current') `
|
|
230
325
|
-FailureMessage 'cannot read current branch' -FailureExitCode 2
|
|
231
326
|
if ($currentBranch -ne $Branch) {
|
|
232
|
-
|
|
233
|
-
|
|
327
|
+
if (-not $Mirror) {
|
|
328
|
+
Write-SyncLog -Level 'SKIP' -Message "checkout is '$currentBranch', not '$Branch' (remote=$Remote)"
|
|
329
|
+
exit 3
|
|
330
|
+
}
|
|
331
|
+
Invoke-MirrorReturn -LeftBranch $currentBranch
|
|
234
332
|
}
|
|
235
333
|
|
|
236
334
|
$statusOutput = Invoke-GitOrExit -Arguments @('status', '--porcelain') `
|
|
@@ -168,6 +168,11 @@ def _mark_spent(all_state: dict[str, object], reading: Reading, now: datetime) -
|
|
|
168
168
|
return until
|
|
169
169
|
|
|
170
170
|
|
|
171
|
+
def _is_no_roster_name_beside_a_roster(product: Product, name: str, all_readings: Sequence[Reading]) -> bool:
|
|
172
|
+
all_roster_names = {each_reading.account.name for each_reading in all_readings}
|
|
173
|
+
return product is Product.CODEX and bool(all_roster_names) and name == NO_ROSTER_ACCOUNT_NAME and name not in all_roster_names
|
|
174
|
+
|
|
175
|
+
|
|
171
176
|
def _outside_roster_resets(product: Product, all_readings: Sequence[Reading], all_state: Mapping[str, object], now: datetime) -> dict[str, datetime]:
|
|
172
177
|
marks = all_state.get("spent") if isinstance(all_state.get("spent"), dict) else {}
|
|
173
178
|
all_roster_names = {each_reading.account.name for each_reading in all_readings}
|
|
@@ -178,7 +183,7 @@ def _outside_roster_resets(product: Product, all_readings: Sequence[Reading], al
|
|
|
178
183
|
continue
|
|
179
184
|
name = each_key[len(prefix):-len(placeholder_suffix)]
|
|
180
185
|
reset = _stored_reset(each_raw, now)
|
|
181
|
-
if name not in all_roster_names and reset is not None:
|
|
186
|
+
if name not in all_roster_names and reset is not None and not _is_no_roster_name_beside_a_roster(product, name, all_readings):
|
|
182
187
|
all_resets_by_name[name] = reset
|
|
183
188
|
return all_resets_by_name
|
|
184
189
|
|
|
@@ -395,13 +400,16 @@ def _save_spent_arguments(all_marks: Sequence[str], all_readings: Sequence[Readi
|
|
|
395
400
|
all_accounts_by_profile = {each_reading.account.profile: each_reading for each_reading in all_readings if each_reading.account.profile}
|
|
396
401
|
all_accounts_by_name = all_accounts_by_profile | {each_reading.account.name: each_reading for each_reading in all_readings}
|
|
397
402
|
all_parsed_marks = [_parse_spent_mark(all_accounts_by_name, each_mark, product) for each_mark in all_marks]
|
|
398
|
-
for
|
|
403
|
+
all_saved_marks = [each_mark for each_mark in all_parsed_marks if not _is_no_roster_name_beside_a_roster(product, each_mark[0].account.name, all_readings)]
|
|
404
|
+
for each_reading, each_reset in all_saved_marks:
|
|
399
405
|
_record_spent(all_state, each_reading, now, each_reset)
|
|
400
|
-
if
|
|
406
|
+
if all_saved_marks:
|
|
401
407
|
_save_state(broker_state_path(), all_state)
|
|
402
408
|
for each_reading, _ in all_parsed_marks:
|
|
403
409
|
name = each_reading.account.name
|
|
404
|
-
if
|
|
410
|
+
if _is_no_roster_name_beside_a_roster(product, name, all_readings):
|
|
411
|
+
print(f"warning: spent mark names {name}, the home used only with no roster, so it is outside the roster; choose ignores it", file=sys.stderr)
|
|
412
|
+
elif name not in all_accounts_by_name and (all_accounts_by_name or name != NO_ROSTER_ACCOUNT_NAME):
|
|
405
413
|
print(f"warning: spent mark names {name}, an account outside the roster; choose waits until its reset", file=sys.stderr)
|
|
406
414
|
|
|
407
415
|
|
|
@@ -947,6 +947,41 @@ def test_should_wait_for_spent_default_home_until_the_mark_passes(
|
|
|
947
947
|
assert running_decision["home"] == str(home.resolve())
|
|
948
948
|
|
|
949
949
|
|
|
950
|
+
def test_should_run_on_a_roster_account_while_a_no_roster_default_mark_is_live(
|
|
951
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
952
|
+
) -> None:
|
|
953
|
+
account = _account("personal")
|
|
954
|
+
monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"personal": _meters(99, 99)}))
|
|
955
|
+
_freeze_clock(monkeypatch, NOW)
|
|
956
|
+
no_roster_account = Account(Product.CODEX, "default", Path(), False)
|
|
957
|
+
account_broker._save_state(
|
|
958
|
+
account_broker.broker_state_path(),
|
|
959
|
+
{"meters": {}, "spent": {account_broker._state_key(no_roster_account): (NOW + timedelta(hours=1)).timestamp()}, "affinity": {}},
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
code = account_broker.main(("choose", "--product", "codex"))
|
|
963
|
+
|
|
964
|
+
decision = json.loads(capsys.readouterr().out)["decision"]
|
|
965
|
+
assert code == 0
|
|
966
|
+
assert decision["action"] == "run"
|
|
967
|
+
assert decision["account"] == "personal"
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def test_should_not_save_a_default_spent_mark_while_a_roster_exists(
|
|
971
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
972
|
+
) -> None:
|
|
973
|
+
account = _account("personal")
|
|
974
|
+
monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"personal": _meters(99, 99)}))
|
|
975
|
+
_freeze_clock(monkeypatch, NOW)
|
|
976
|
+
|
|
977
|
+
code = account_broker.main(("choose", "--product", "codex", "--spent", "default"))
|
|
978
|
+
|
|
979
|
+
decision = json.loads(capsys.readouterr().out)["decision"]
|
|
980
|
+
assert code == 0
|
|
981
|
+
assert decision["account"] == "personal"
|
|
982
|
+
assert account_broker._load_state(account_broker.broker_state_path())["spent"] == {}
|
|
983
|
+
|
|
984
|
+
|
|
950
985
|
def test_should_keep_a_spent_mark_without_a_reset_for_one_hour(
|
|
951
986
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
952
987
|
) -> None:
|
|
@@ -68,9 +68,52 @@ BeforeAll {
|
|
|
68
68
|
& pwsh -NoProfile -File $scriptUnderTest -RepoPath $Fixture.MirrorPath -StashDirty -LogPath $Fixture.LogPath | Out-Null
|
|
69
69
|
return $LASTEXITCODE
|
|
70
70
|
}
|
|
71
|
+
|
|
72
|
+
function Invoke-MirrorSyncScript {
|
|
73
|
+
param([pscustomobject]$Fixture)
|
|
74
|
+
& pwsh -NoProfile -File $scriptUnderTest -RepoPath $Fixture.MirrorPath -StashDirty -Mirror -LogPath $Fixture.LogPath | Out-Null
|
|
75
|
+
return $LASTEXITCODE
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function Switch-MirrorToFeatureBranch {
|
|
79
|
+
<#
|
|
80
|
+
Leaves the mirror on a feature branch whose commit edits tracked.txt,
|
|
81
|
+
the way a session that checked out a pull request in the mirror does.
|
|
82
|
+
#>
|
|
83
|
+
param([pscustomobject]$Fixture)
|
|
84
|
+
Invoke-TestGit -RepoPath $Fixture.MirrorPath -Arguments @('switch', '-q', '-c', 'feature') | Out-Null
|
|
85
|
+
Write-TestFile -RepoPath $Fixture.MirrorPath -RelativePath 'tracked.txt' -Content "feature`n"
|
|
86
|
+
Invoke-TestGit -RepoPath $Fixture.MirrorPath -Arguments @('commit', '-q', '-am', 'feature edit') | Out-Null
|
|
87
|
+
return Invoke-TestGit -RepoPath $Fixture.MirrorPath -Arguments @('rev-parse', 'HEAD')
|
|
88
|
+
}
|
|
71
89
|
}
|
|
72
90
|
|
|
73
91
|
Describe 'Sync-RepoMain' {
|
|
92
|
+
It 'skips a mirror left on another branch when -Mirror is absent' {
|
|
93
|
+
$fixture = New-MirrorBehindRemote -Name 'feature-no-mirror'
|
|
94
|
+
$featureTip = Switch-MirrorToFeatureBranch -Fixture $fixture
|
|
95
|
+
|
|
96
|
+
$exitCode = Invoke-SyncScript -Fixture $fixture
|
|
97
|
+
|
|
98
|
+
$exitCode | Should -Be 3
|
|
99
|
+
(Invoke-TestGit -RepoPath $fixture.MirrorPath -Arguments @('rev-parse', 'HEAD')) | Should -Be $featureTip
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
It 'returns a mirror left on another branch to main and fast-forwards it with -Mirror' {
|
|
103
|
+
$fixture = New-MirrorBehindRemote -Name 'feature-mirror'
|
|
104
|
+
$featureTip = Switch-MirrorToFeatureBranch -Fixture $fixture
|
|
105
|
+
|
|
106
|
+
$exitCode = Invoke-MirrorSyncScript -Fixture $fixture
|
|
107
|
+
|
|
108
|
+
$exitCode | Should -Be 0
|
|
109
|
+
(Invoke-TestGit -RepoPath $fixture.MirrorPath -Arguments @('branch', '--show-current')) | Should -Be 'main'
|
|
110
|
+
(Invoke-TestGit -RepoPath $fixture.MirrorPath -Arguments @('rev-parse', 'HEAD')) | Should -Be $fixture.RemoteTip
|
|
111
|
+
[System.IO.File]::ReadAllText((Join-Path $fixture.MirrorPath 'tracked.txt')) | Should -Be "new`n"
|
|
112
|
+
(Invoke-TestGit -RepoPath $fixture.MirrorPath -Arguments @('status', '--porcelain')) | Should -BeNullOrEmpty
|
|
113
|
+
(Invoke-TestGit -RepoPath $fixture.MirrorPath -Arguments @('rev-parse', 'feature')) | Should -Be $featureTip
|
|
114
|
+
(Get-Content -LiteralPath $fixture.LogPath -Raw) | Should -Match "left branch 'feature' at $featureTip"
|
|
115
|
+
}
|
|
116
|
+
|
|
74
117
|
It 'fast-forwards a clean mirror that is behind the remote' {
|
|
75
118
|
$fixture = New-MirrorBehindRemote -Name 'clean-behind'
|
|
76
119
|
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Specifications for Sync-RepoMain returning a mirror left on another branch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
_SCRIPT_PATH = Path(__file__).resolve().parents[1] / "Sync-RepoMain.ps1"
|
|
10
|
+
_PWSH = shutil.which("pwsh") or "pwsh"
|
|
11
|
+
_SKIPPED_EXIT_CODE = 3
|
|
12
|
+
_SUCCESS_EXIT_CODE = 0
|
|
13
|
+
_FAILURE_EXIT_CODE = 1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _git(repository_path: Path, *arguments: str) -> str:
|
|
17
|
+
completed = subprocess.run(
|
|
18
|
+
[
|
|
19
|
+
"git",
|
|
20
|
+
"-C",
|
|
21
|
+
str(repository_path),
|
|
22
|
+
"-c",
|
|
23
|
+
"user.name=tester",
|
|
24
|
+
"-c",
|
|
25
|
+
"user.email=tester@example.com",
|
|
26
|
+
"-c",
|
|
27
|
+
"core.autocrlf=false",
|
|
28
|
+
*arguments,
|
|
29
|
+
],
|
|
30
|
+
check=True,
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
)
|
|
34
|
+
return completed.stdout.strip()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _mirror_on_feature_branch_behind_remote(
|
|
38
|
+
root_path: Path, should_advance_remote: bool = True
|
|
39
|
+
) -> tuple[Path, str, str]:
|
|
40
|
+
remote_path = root_path / "remote.git"
|
|
41
|
+
author_path = root_path / "author"
|
|
42
|
+
mirror_path = root_path / "mirror"
|
|
43
|
+
remote_path.mkdir()
|
|
44
|
+
author_path.mkdir()
|
|
45
|
+
_git(remote_path, "init", "-q", "--bare", "-b", "main")
|
|
46
|
+
_git(author_path, "init", "-q", "-b", "main")
|
|
47
|
+
(author_path / "tracked.txt").write_text("old\n", encoding="utf-8")
|
|
48
|
+
_git(author_path, "add", "-A")
|
|
49
|
+
_git(author_path, "commit", "-q", "-m", "first")
|
|
50
|
+
_git(author_path, "remote", "add", "origin", str(remote_path))
|
|
51
|
+
_git(author_path, "push", "-q", "origin", "main")
|
|
52
|
+
subprocess.run(
|
|
53
|
+
[
|
|
54
|
+
"git",
|
|
55
|
+
"-c",
|
|
56
|
+
"core.autocrlf=false",
|
|
57
|
+
"clone",
|
|
58
|
+
"-q",
|
|
59
|
+
str(remote_path),
|
|
60
|
+
str(mirror_path),
|
|
61
|
+
],
|
|
62
|
+
check=True,
|
|
63
|
+
)
|
|
64
|
+
_git(mirror_path, "config", "core.autocrlf", "false")
|
|
65
|
+
_git(mirror_path, "switch", "-q", "-c", "feature")
|
|
66
|
+
(mirror_path / "tracked.txt").write_text("feature\n", encoding="utf-8")
|
|
67
|
+
_git(mirror_path, "commit", "-q", "-am", "feature edit")
|
|
68
|
+
feature_tip = _git(mirror_path, "rev-parse", "HEAD")
|
|
69
|
+
if should_advance_remote:
|
|
70
|
+
(author_path / "tracked.txt").write_text("new\n", encoding="utf-8")
|
|
71
|
+
_git(author_path, "commit", "-q", "-am", "second")
|
|
72
|
+
_git(author_path, "push", "-q", "origin", "main")
|
|
73
|
+
remote_tip = _git(author_path, "rev-parse", "HEAD")
|
|
74
|
+
return mirror_path, feature_tip, remote_tip
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _run_sync(mirror_path: Path, log_path: Path, *switches: str) -> int:
|
|
78
|
+
completed = subprocess.run(
|
|
79
|
+
[
|
|
80
|
+
_PWSH,
|
|
81
|
+
"-NoProfile",
|
|
82
|
+
"-File",
|
|
83
|
+
str(_SCRIPT_PATH),
|
|
84
|
+
"-RepoPath",
|
|
85
|
+
str(mirror_path),
|
|
86
|
+
*switches,
|
|
87
|
+
"-LogPath",
|
|
88
|
+
str(log_path),
|
|
89
|
+
],
|
|
90
|
+
capture_output=True,
|
|
91
|
+
text=True,
|
|
92
|
+
check=False,
|
|
93
|
+
)
|
|
94
|
+
return completed.returncode
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_should_skip_a_feature_branch_checkout_without_mirror(tmp_path: Path) -> None:
|
|
98
|
+
mirror_path, feature_tip, _ = _mirror_on_feature_branch_behind_remote(tmp_path)
|
|
99
|
+
log_path = tmp_path / "sync.log"
|
|
100
|
+
|
|
101
|
+
assert _run_sync(mirror_path, log_path, "-StashDirty") == _SKIPPED_EXIT_CODE
|
|
102
|
+
assert _git(mirror_path, "branch", "--show-current") == "feature"
|
|
103
|
+
assert _git(mirror_path, "rev-parse", "HEAD") == feature_tip
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_should_return_a_feature_branch_checkout_to_main_with_mirror(
|
|
107
|
+
tmp_path: Path,
|
|
108
|
+
) -> None:
|
|
109
|
+
mirror_path, feature_tip, remote_tip = _mirror_on_feature_branch_behind_remote(
|
|
110
|
+
tmp_path
|
|
111
|
+
)
|
|
112
|
+
log_path = tmp_path / "sync.log"
|
|
113
|
+
|
|
114
|
+
assert (
|
|
115
|
+
_run_sync(mirror_path, log_path, "-StashDirty", "-Mirror")
|
|
116
|
+
== _SUCCESS_EXIT_CODE
|
|
117
|
+
)
|
|
118
|
+
assert _git(mirror_path, "branch", "--show-current") == "main"
|
|
119
|
+
assert _git(mirror_path, "rev-parse", "HEAD") == remote_tip
|
|
120
|
+
assert (mirror_path / "tracked.txt").read_text(encoding="utf-8") == "new\n"
|
|
121
|
+
assert _git(mirror_path, "status", "--porcelain") == ""
|
|
122
|
+
assert _git(mirror_path, "rev-parse", "feature") == feature_tip
|
|
123
|
+
assert f"left branch 'feature' at {feature_tip}" in log_path.read_text(
|
|
124
|
+
encoding="utf-8"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_should_restore_main_files_when_main_is_already_current(
|
|
129
|
+
tmp_path: Path,
|
|
130
|
+
) -> None:
|
|
131
|
+
mirror_path, feature_tip, remote_tip = _mirror_on_feature_branch_behind_remote(
|
|
132
|
+
tmp_path, should_advance_remote=False
|
|
133
|
+
)
|
|
134
|
+
log_path = tmp_path / "sync.log"
|
|
135
|
+
|
|
136
|
+
assert (
|
|
137
|
+
_run_sync(mirror_path, log_path, "-StashDirty", "-Mirror")
|
|
138
|
+
== _SUCCESS_EXIT_CODE
|
|
139
|
+
)
|
|
140
|
+
assert _git(mirror_path, "rev-parse", "HEAD") == remote_tip
|
|
141
|
+
assert (mirror_path / "tracked.txt").read_text(encoding="utf-8") == "old\n"
|
|
142
|
+
assert _git(mirror_path, "status", "--porcelain") == ""
|
|
143
|
+
assert f"tree of branch 'feature' at {feature_tip}" in _git(
|
|
144
|
+
mirror_path, "stash", "list"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_should_return_to_main_without_stash_dirty(tmp_path: Path) -> None:
|
|
149
|
+
mirror_path, _, remote_tip = _mirror_on_feature_branch_behind_remote(tmp_path)
|
|
150
|
+
log_path = tmp_path / "sync.log"
|
|
151
|
+
|
|
152
|
+
assert _run_sync(mirror_path, log_path, "-Mirror") == _SUCCESS_EXIT_CODE
|
|
153
|
+
assert _git(mirror_path, "rev-parse", "HEAD") == remote_tip
|
|
154
|
+
assert _git(mirror_path, "status", "--porcelain") == ""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_should_name_a_detached_head_it_leaves(tmp_path: Path) -> None:
|
|
158
|
+
mirror_path, feature_tip, remote_tip = _mirror_on_feature_branch_behind_remote(
|
|
159
|
+
tmp_path
|
|
160
|
+
)
|
|
161
|
+
_git(mirror_path, "switch", "-q", "--detach", feature_tip)
|
|
162
|
+
log_path = tmp_path / "sync.log"
|
|
163
|
+
|
|
164
|
+
assert _run_sync(mirror_path, log_path, "-Mirror") == _SUCCESS_EXIT_CODE
|
|
165
|
+
assert _git(mirror_path, "rev-parse", "HEAD") == remote_tip
|
|
166
|
+
assert f"left detached HEAD at {feature_tip}" in log_path.read_text(
|
|
167
|
+
encoding="utf-8"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_should_stash_a_file_only_the_left_checkout_tracks(tmp_path: Path) -> None:
|
|
172
|
+
mirror_path, _, remote_tip = _mirror_on_feature_branch_behind_remote(tmp_path)
|
|
173
|
+
(mirror_path / "feature-only.txt").write_text("feature only\n", encoding="utf-8")
|
|
174
|
+
_git(mirror_path, "add", "feature-only.txt")
|
|
175
|
+
_git(mirror_path, "commit", "-q", "-m", "feature-only file")
|
|
176
|
+
(mirror_path / "local-note.txt").write_text("untracked note\n", encoding="utf-8")
|
|
177
|
+
log_path = tmp_path / "sync.log"
|
|
178
|
+
|
|
179
|
+
assert _run_sync(mirror_path, log_path, "-Mirror") == _SUCCESS_EXIT_CODE
|
|
180
|
+
assert _git(mirror_path, "rev-parse", "HEAD") == remote_tip
|
|
181
|
+
assert not (mirror_path / "feature-only.txt").exists()
|
|
182
|
+
assert (mirror_path / "local-note.txt").exists()
|
|
183
|
+
assert _git(mirror_path, "status", "--porcelain") == "?? local-note.txt"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_should_stash_a_left_file_that_main_ignores(tmp_path: Path) -> None:
|
|
187
|
+
mirror_path, _, _ = _mirror_on_feature_branch_behind_remote(tmp_path)
|
|
188
|
+
(mirror_path / "build.out").write_text("feature build\n", encoding="utf-8")
|
|
189
|
+
_git(mirror_path, "add", "build.out")
|
|
190
|
+
_git(mirror_path, "commit", "-q", "-m", "feature build output")
|
|
191
|
+
(mirror_path / ".git" / "info" / "exclude").write_text(
|
|
192
|
+
"build.out\n", encoding="utf-8"
|
|
193
|
+
)
|
|
194
|
+
log_path = tmp_path / "sync.log"
|
|
195
|
+
|
|
196
|
+
assert _run_sync(mirror_path, log_path, "-Mirror") == _SUCCESS_EXIT_CODE
|
|
197
|
+
assert not (mirror_path / "build.out").exists()
|
|
198
|
+
assert "tree of branch 'feature'" in _git(mirror_path, "stash", "list")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_should_put_head_back_when_the_return_cannot_reset(tmp_path: Path) -> None:
|
|
202
|
+
mirror_path, feature_tip, _ = _mirror_on_feature_branch_behind_remote(tmp_path)
|
|
203
|
+
lock_path = mirror_path / ".git" / "index.lock"
|
|
204
|
+
lock_path.write_text("", encoding="utf-8")
|
|
205
|
+
log_path = tmp_path / "sync.log"
|
|
206
|
+
|
|
207
|
+
assert _run_sync(mirror_path, log_path, "-Mirror") == _FAILURE_EXIT_CODE
|
|
208
|
+
assert _git(mirror_path, "branch", "--show-current") == "feature"
|
|
209
|
+
assert _git(mirror_path, "rev-parse", "HEAD") == feature_tip
|
|
210
|
+
assert "HEAD and index are back on branch 'feature'" in log_path.read_text(
|
|
211
|
+
encoding="utf-8"
|
|
212
|
+
)
|
|
213
|
+
lock_path.unlink()
|
|
214
|
+
assert _git(mirror_path, "status", "--porcelain") == ""
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_should_skip_a_checkout_with_staged_changes(tmp_path: Path) -> None:
|
|
218
|
+
mirror_path, feature_tip, _ = _mirror_on_feature_branch_behind_remote(tmp_path)
|
|
219
|
+
(mirror_path / "tracked.txt").write_text("staged\n", encoding="utf-8")
|
|
220
|
+
_git(mirror_path, "add", "tracked.txt")
|
|
221
|
+
log_path = tmp_path / "sync.log"
|
|
222
|
+
|
|
223
|
+
assert _run_sync(mirror_path, log_path, "-Mirror") == _SKIPPED_EXIT_CODE
|
|
224
|
+
assert _git(mirror_path, "branch", "--show-current") == "feature"
|
|
225
|
+
assert _git(mirror_path, "rev-parse", "HEAD") == feature_tip
|
|
226
|
+
assert _git(mirror_path, "diff", "--cached", "--name-only") == "tracked.txt"
|