-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgit-commit-lock.ps1
More file actions
2043 lines (1994 loc) · 119 KB
/
Copy pathgit-commit-lock.ps1
File metadata and controls
2043 lines (1994 loc) · 119 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# git-commit-lock.ps1 - the git-commit-lock mutex (PowerShell port).
# Reachable at runtime as ~/.local/bin/git-commit-lock.ps1
# (symlinked there by this repo's install.sh).
#
# Works on PowerShell 7+ (pwsh) and on Windows PowerShell 5.1 - 5.1 is
# covered by an interop smoke lane (tests/git-commit-lock.interop.test.sh Test 17),
# not just claimed. The file is plain ASCII, so the BOM-less encoding parses
# identically on both engines - keep it ASCII.
#
# PowerShell port of git-commit-lock.sh, for agents whose native shell is PowerShell
# (notably Codex on Windows). It is WIRE-COMPATIBLE with git-commit-lock.sh: the lock
# is the same regular FILE created with an atomic create-or-fail open, whose CONTENT
# is the ownership token (line 1, "tok."-prefixed; line 2 the informational
# "pid=<pid> host=<host>" owner), with the same file-mtime staleness /
# CLAIM-SERIALIZED steal / token-compare-release protocol - so a .ps1 holder and a
# .sh holder in the SAME working tree (e.g. Codex and Claude) correctly serialise
# against EACH OTHER. The claim file (`${AGENT_LOCK_PATH}.next`, identical wire
# format, the claimant's OWN per-attempt token) is shared wire format too: to
# steal a stale lock either implementation must first win the O_EXCL claim
# create; the claim is touched fresh and renamed OVER the stale lock; both
# sides parse, age (AGENT_LOCK_CLAIM_STALE_SECS) and clear each other's
# claims. git-commit-lock.sh remains the authoritative design (its header
# carries the full protocol: lock/claim file format, staleness, the steal
# protocol's ordered install sequence, token-checked claim deletion, the
# ownership-discovery rule, the leaked-token memory, per-attempt tokens,
# acquire verification, fail-open lease ceiling, known residual races);
# docs/git-commit-lock.md is the "why". Keep the two in lock-step.
#
# WHY A SEPARATE PS PORT (instead of Codex calling git-commit-lock.sh):
# On Windows the bare name `bash` on the plain PATH resolves to
# C:\Windows\system32\bash.exe = the WSL launcher, whose Linux git cannot reach
# the Windows SSH signer (the private key isn't in WSL, and SSH-agent
# forwarding into WSL typically only fires in *interactive* shells, not an
# agent's `bash -c`).
# So a bash-wrapped commit under Codex runs WSL git and fails to sign
# ("No private key found ... fatal: failed to write commit object"). Codex's
# native shell is PowerShell, where `git` = Git-for-Windows and signs fine, so
# running the lock + commit in PowerShell avoids bash/WSL entirely. Claude keeps
# using git-commit-lock.sh (it ships its own MINGW64 Git-Bash, immune to this).
#
# PORT-SPECIFIC NOTES (where this implementation differs in MECHANISM, never
# in protocol, from git-commit-lock.sh):
# * Acquire is one [IO.File]::Open(CreateNew) - atomic create-or-fail - and
# the token+owner content is written, flushed and closed THROUGH that
# creation handle, so the write is bound to the file object we created and
# cannot land on a successor's file. ANY exception on the open means
# "contended/refused", not an error: an existing directory at the path
# throws UnauthorizedAccessException (verified, pwsh 7.5), not IOException,
# and must degrade to the wait loop's config warning, never throw out.
# * The pre-create type guard is LOAD-BEARING here on Windows, not just
# symmetry with bash: CreateFile resolves a symlink at the final path
# component, so CreateNew on a DANGLING symlink tunnels through the link
# and creates the TARGET (probed 2026-06-11) instead of failing like
# POSIX O_CREAT|O_EXCL. The guard routes any non-regular-file path to the
# never-steal warn lane before the create is attempted. (On Unix, .NET's
# open uses O_CREAT|O_EXCL, which refuses symlink/FIFO/device paths with
# an exception; the guard is symmetry there.)
# * All reads of the lock file go through a FileStream opened with
# FileShare ReadWrite|Delete (never ReadAllText's Read-only share), so our
# own readers can never block another party's steal rename or release
# unlink, even transiently (probe D2).
# * The steal's content guard determines "empty" by STAT (Length -eq 0)
# WITHOUT opening the file, and opens for read only when size > 0: on
# Unix a FIFO at the lock path is neither a container nor a reparse
# point, and a read-open on a writer-less FIFO blocks in open(2) before
# any timeout logic runs. ACCEPTED RESIDUAL (ps1-on-Unix only; bash
# refuses all of these via its `[ -f ]` guard): a typo'd-path FIFO -
# and likewise a device node or socket, for which .NET has no clean
# portable type probe - stats as size 0 and takes the empty-orphan
# steal lane (replaced by the steal's rename-over), so damage is capped
# at the one misconfigured inode (in practice /dev permissions make real
# device nodes unrenamable anyway). Same accepted class as the
# empty-user-file residual. The SAME residual applies at the CLAIM path
# (CI-only configuration): a FIFO/device/socket at `${LOCK}.next` passes
# the plain-file probe, stats as size 0, and - once aged - takes the
# empty-claim CLEAR lane (CLAIM-STALE-CLEARED unlinks it). bash refuses
# it via `[ -f ]` and warns; consequence for tests: the claim wrong-type
# "refused" assertions are bash-only.
# * RENAME-OVER, the steal's install op (the claim file becomes the lock):
# - pwsh 7 / .NET Core: the 3-arg atomic overwrite overload
# [IO.File]::Move($src, $dst, $true). Probed (P1, 2026-06-12, NTFS):
# 400 replaces under a tight reader loop, ZERO absent reads, ZERO torn
# reads - the no-path-absent-window property, like bash's `mv`.
# - Windows PowerShell 5.1 / .NET Framework has no such overload (and
# File.Replace is deliberately never used: it throws on a read-only
# destination and has partial-failure states without a backup file),
# so the 5.1 steal completes as: UNLINK the ghost, then 2-arg
# fail-if-exists Move the claim in. The transient absent window
# between unlink and Move is safe UNDER THE CLAIM: a rival waiter's
# create landing in it merely wins the lock - our Move fails-if-exists
# (probed: exactly 1 of 6 concurrent Moves wins, P3c), a fairness
# loss, never a clobber. Ladder sub-lanes: ghost already gone before
# the unlink -> CLAIM-ABORT (gone), the Move is NOT attempted; unlink
# blocked by a no-delete-share handle -> the damped blocked-steal lane.
# - BOTH lanes preserve the SOURCE's mtime exactly (probed P2, both
# engines), so the installed lock's lease starts at the claim's
# pre-rename touch, as the protocol requires.
# - A DIRECTORY destination is refused by .NET's Move itself with both
# files intact (probed P5/Q3, both engines, both forms - native
# `mv -T` semantics): no extra dir guard is needed, unlike bash's
# no-`-T` fallback.
# * ACCEPTED RESIDUAL (this port, Windows): .NET's rename uses CLASSIC
# Windows semantics, not FILE_RENAME_POSIX_SEMANTICS - so the rename-over
# fails (UnauthorizedAccessException) while ANY rival handle is open on
# the destination, even one granting full ReadWrite|Delete sharing
# (probed Q4: 129/400 attempts deferred under a tight reader loop).
# Cygwin/MSYS `mv` uses POSIX semantics, so bash is immune. The failure
# leaves both files intact and routes into the damped blocked-steal lane
# (claim deleted immediately, re-poll) - a transient DEFERRAL of the
# steal by one poll, never an atomicity break or a clobber. Steals only
# happen on crashed/stale locks, so the cost is recovery latency under
# reader contention, bounded by the poll cadence.
# * The claim's pre-rename TOUCH is [IO.File]::SetLastWriteTimeUtc -
# non-creating by construction; on a missing claim it throws
# FileNotFoundException (probed Q1, both engines; PowerShell wraps it in
# MethodInvocationException, so the catch walks the inner-exception
# chain), which IS the gone signal the discovery rule keys on (bash needs
# an explicit -e check instead, `touch -c` exiting 0 on missing).
# * Release is File.Delete with a brief retry (~5x20ms) and NO rename-aside
# fallback: probe D1 shows the handle class that blocks our unlink blocks
# a rename identically for files (both need DELETE access on the source),
# so the fallback could never fire usefully. One non-handle exception: the
# Windows READ-ONLY attribute fails File.Delete but not File.Move (and
# bash `rm -f` clears it). Nothing in the protocol ever sets read-only;
# if something external does, the leftover warning fires and the stale
# steal (a rename) recovers the path.
# * The trap equivalent: bash installs EXIT/INT/TERM handlers at acquire
# start (claim-window cleanup mode). PowerShell has no traps; the
# equivalents here are (a) a try/finally INSIDE Lock-Acquire - PowerShell
# executes finally blocks on Ctrl+C/pipeline-stop and on terminating
# errors, the engine's nearest "trappable exit" - which runs the
# token-checked claim deletion (one bounded retry) + the final discovery
# read, releasing a discovery-HOLD inline per the NORMAL release rules
# (boundary re-read, bounded delete retries, honest LEFTOVER warning -
# never a false RELEASED; no 98 semantics on a mere claim; a hard kill
# is the untrappable lane, residual 5); and (b) the
# existing best-effort PowerShell.Exiting backstop for a HELD lock
# (registered by the shared take-hold helper, so steal- and
# discovery-acquired holds get it exactly like create-acquired ones).
# The cleanup path sticks to .NET primitives ([Threading.Thread]::Sleep,
# [IO.File]) because cmdlet invocation inside a stopping pipeline's
# finally can throw PipelineStoppedException. Same residual-5 class as a
# mid-create signal (see the bash header's trap-time rule): a claim
# create failing AFTER line 1 reached disk (e.g. ENOSPC mid-write)
# leaves an own-token claim the process doesn't know it wrote.
# * Future option, this side only (a recorded design option; NOT implemented):
# handle-based ops (open with delete sharing, fstat the mtime / read the
# token / delete via FILE_DISPOSITION on that one handle) could close the
# residual check-then-act windows outright here. bash has no handle
# persistence, so the protocol-level claim stays "shrunk, detected, not
# closed" - see KNOWN RESIDUAL RACES in git-commit-lock.sh.
#
# PROBE RECORDS (this port; Win11 NTFS, pwsh 7.5.5 + Windows PowerShell
# 5.1.26100, 2026-06-12 - see also the bash header's R1-R4 and the shared
# A/C/D1/F records):
# P1 3-arg File.Move overwrite: 400 atomic replaces, zero absent/torn
# reads when it succeeds (pwsh 7; the overload is absent on 5.1).
# P2 both rename lanes preserve the source's mtime EXACTLY (tick-level) -
# the lease rule rides on this.
# P3 2-arg Move is atomic fail-if-exists (1 of 6 concurrent winners; loser
# sources intact); File.Delete is silent on a missing file (so the 5.1
# ladder's gone-detection is the pre-delete existence check).
# P4/Q1 SetLastWriteTimeUtc refreshes mtime without touching content;
# on a missing file it throws FileNotFoundException and creates
# NOTHING - the non-creating touch + its gone signal.
# P5/Q3 Move onto a DIRECTORY throws (3-arg: UnauthorizedAccessException;
# 2-arg: IOException) with dir + source intact, source NOT moved into
# the dir - native `mv -T` semantics on both engines.
# P6 a no-delete-share handle on the dest blocks 3-arg Move AND
# File.Delete alike (everything intact) - the blocked-steal lane.
# Q4 3-arg Move fails on a dest held open EVEN with ReadWrite|Delete
# sharing (classic, non-POSIX rename) - the accepted deferral residual
# above.
# Q5 File.Delete of a dest whose open handle grants Delete sharing
# succeeds and frees the NAME immediately (POSIX delete on Win11), and
# the freed name is immediately re-creatable - the 5.1 ladder is not
# blocked by friendly readers.
#
# USAGE (Codex's normal path - run ONE quoted command string under the lock):
# & ~/.local/bin/git-commit-lock.ps1 run "git add -- path/a path/b; if (`$LASTEXITCODE -eq 0) { git commit -m 'msg' }"
#
# EXIT CODES of `run` (identical contract to git-commit-lock.sh):
# the command's own exit code - including a code set via `exit N` INSIDE the
# command (the command runs as a child script, so its `exit` is contained
# and propagates cleanly; it does not abort the lock release);
# 96 usage / configuration error: bad arguments, more than one command
# argument, an empty or unparseable command, or `run` outside a git repo
# with AGENT_LOCK_PATH unset. The lock was NEVER acquired and the command
# NEVER ran. An explicit --help/-h/-? is NOT an error: usage goes to
# stdout, exit 0.
# 97 timed out waiting for the lock (AGENT_LOCK_MAX_WAIT). The command
# NEVER ran.
# 98 the lock was STOLEN mid-hold (held past the stale window while a
# contender waited): at release the lock file is GONE, or carries a
# non-empty FOREIGN token - both definitive, because acquire's read-back
# verified our token at the path. The command DID run but was NOT
# serialised - verify with `git log` and redo it under the lock.
# 1 the command itself threw a terminating error; or its FINAL statement
# failed without setting a native exit code (a failing cmdlet's
# non-terminating error never sets $LASTEXITCODE - the full verdict
# table is at Invoke-WithLock; a one-line note goes to stderr); or
# (with its own distinct warning) the lock file still reads EMPTY after
# the release-time retry ladder while the file is present: ownership is
# unverifiable (that is the create->write window of a successor after a
# boundary steal, or external truncation - not proof of theft), the
# file is left in place, and success is NOT reported. A failing command
# keeps its own exit code. Same verdicts as git-commit-lock.sh for the
# same on-disk states.
#
# KNOWN LIMITATION of the failing-cmdlet mapping: only the command string's
# FINAL statement is consulted (via the staged child script's closing $?).
# A non-terminating error in the MIDDLE of the command followed by a
# succeeding final statement is invisible (exit 0) - the same blind spot as
# bash's last-command $?. Chain with `if ($?) { ... }` /
# `if ($LASTEXITCODE -eq 0) { ... }` if intermediate failures must gate
# later steps.
# Avoid exit codes 96-98 as meaningful codes of your own command: they are
# reserved by this contract and a wrapped command exiting 98 is
# indistinguishable from a stolen lock.
#
# RESIDUAL CAVEAT: a command that calls [Environment]::Exit() (a hard CLR
# process kill, unlike plain `exit`) bypasses release entirely - the lock is
# left held until the stale window reclaims it and no 96-98 mapping happens.
# Plain `exit N` inside the command is fine.
#
# Chain steps inside the command with `if ($LASTEXITCODE -eq 0) { ... }`
# rather than `&&` so the string also parses on Windows PowerShell 5.1.
#
# Or dot-source for the primitives (mirrors `source git-commit-lock.sh`):
# . ~/.local/bin/git-commit-lock.ps1
# if (-not (Lock-Acquire)) { exit 1 }
# try { git add -- path; git commit -m 'msg' } finally { Lock-Release | Out-Null }
#
# Dot-source notes:
# * Dot-sourcing injects the public functions (Lock-Acquire, Lock-Release,
# Invoke-WithLock), the script:-scoped helpers (Lock-* / Get-Lock*),
# and the script-scope $Lock* variables into your session. It does NOT
# change your $ErrorActionPreference or StrictMode: both are set inside
# the functions (function-scoped, restored automatically on return).
# * You MUST pair Lock-Acquire with Lock-Release in try/finally - there is
# no bash-style EXIT trap in PowerShell. A best-effort PowerShell.Exiting
# backstop is registered while the lock is held, but it only fires in
# hosts that raise that event (pwsh/powershell -Command and interactive
# sessions; verified NOT to fire under -File on either engine,
# 2026-06-10), so do not rely on it.
# * Lock-Acquire is NOT reentrant: a second Lock-Acquire while holding is
# refused ($false, message on stderr) rather than self-deadlocking for the
# stale window and then stealing its own lock (mirrors git-commit-lock.sh).
# * Lock-Acquire NEVER repairs a failed post-create read-back by writing to
# the path: after winning the create it re-reads line 1 and claims the
# hold only on its own token; anything else (foreign, empty, gone after
# the retry ladder) is logged loudly and treated as NOT acquired - see
# ACQUIRE VERIFICATION in git-commit-lock.sh.
# * Lock-Release returns $true on a clean release; $false otherwise, with
# $script:LockReleaseStatus set to 'stolen' (file gone, or a non-empty
# foreign token: your work was NOT exclusive - redo; `run` maps this to
# 98), 'unreadable' (the file still reads EMPTY after the retry ladder
# while present, or persistently would not open: exclusivity unproven,
# file left in place for the staleness backstop; do not report success),
# or 'leftover' (token verified - the work WAS exclusive - but the file
# could not be deleted; it is left blocking waiters until the stale
# window elapses AND the blocking handle closes - the same handle blocks
# a stealer's rename (probe D1), so until then waiters re-poll and may
# reach 97; `run` keeps the command's exit code and warns on stderr,
# mirroring git-commit-lock.sh).
#
# Hold the lock ONLY for the stage+commit (seconds). Decide what to stage,
# build any patch, resolve hook failures OUTSIDE the lock. See README.md
# ("Suggested agent instructions").
#
# CONFIG (env, mainly for tests) - identical names/semantics to git-commit-lock.sh:
# AGENT_LOCK_PATH (lock file path; default <gitdir>/commit.lock; the claim
# lives beside it at ${AGENT_LOCK_PATH}.next),
# AGENT_LOCK_STALE_SECS (default 300), AGENT_LOCK_CLAIM_STALE_SECS (claim
# ageout, default 60 - claims are normally held for milliseconds),
# AGENT_LOCK_POLL_SECS (default 2), AGENT_LOCK_MAX_WAIT (default 420; keep
# it > STALE + CLAIM_STALE - a warning is printed when it is not, gated on
# MAX_WAIT being left at its default), AGENT_LOCK_LOG.
# Invalid numeric values are reported on stderr and replaced by the default
# (never a load-time throw). STALE_SECS, CLAIM_STALE_SECS and MAX_WAIT must
# be positive integers, POLL_SECS may be fractional - same rules as
# git-commit-lock.sh.
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingEmptyCatchBlock', '',
Justification = 'Deliberate throughout: lock-path I/O must never abort the holder. Every swallow is conservative (retry, skip, or fall through to a guarded slow path) and the file-mtime stale window is the recovery backstop. See docs/git-commit-lock.md.')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '',
Justification = 'Deliberate, one variable: $global:__gclRunOk carries the staged child script''s final $? back to the runner - the global scope is the only one shared across the `& file.ps1` boundary (the caller-side $? reads True even when the script''s last cmdlet failed; probed on both engines 2026-06-11). Sentinel-initialised before each run and removed in the finally.')]
param(
[Parameter(Position = 0)]
[string]$Action,
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$Rest
)
# NOTE (dot-source hygiene): no Set-StrictMode / $ErrorActionPreference at the
# top level - dot-sourcing executes top-level statements in the CALLER's scope
# and would silently reconfigure their session. Each function below sets its
# own (function-scoped) preferences instead. Top-level code is strict-mode-safe.
# --- resolve defaults (git-dir aware, CWD-independent within the repo) --------
# Mirrors git-commit-lock.sh: lock + log live in `git rev-parse --absolute-git-dir`
# (e.g. C:/repo/.git/commit.lock). Windows git prints a forward-slash drive path
# (C:/repo/.git), exactly what MINGW git prints for git-commit-lock.sh, so both sides
# compute the SAME lock-path string and contend on the same NTFS file.
function script:Get-LockGitDir {
Set-StrictMode -Off
$ErrorActionPreference = 'Continue'
$gd = $null
try {
# Collect ALL output - do NOT pipe through `Select-Object -First 1`:
# -First stops the upstream native command early, and on pwsh 7.5 that
# reliably leaves $LASTEXITCODE unset, which read as "git failed" and
# silently fell back to CWD - putting the default lock at
# <cwd>/commit.lock instead of <gitdir>/commit.lock, so the .ps1 and
# .sh sides no longer contended on the same lock (caught by
# tests/git-commit-lock.integration.test.sh, 2026-06-10).
$out = @(& git rev-parse --absolute-git-dir 2>$null)
if ($LASTEXITCODE -eq 0 -and $out.Count -gt 0) { $gd = [string]$out[0] }
} catch { $gd = $null }
if ($gd) { return ([string]$gd).Trim() }
return $null
}
# Validated numeric config: garbage in an AGENT_LOCK_* var must never throw at
# load (this file is dot-sourced into agent sessions) - note it on stderr and
# fall back to the default instead.
function script:Get-LockNum {
param([string]$Name, [string]$Raw, [double]$Default, [switch]$IntegerOnly)
Set-StrictMode -Off
# EMPTY (or unset) means "use the default", silently - exactly like
# git-commit-lock.sh's ${VAR:-default}. Whitespace-only is NOT empty
# there (it reaches the validator and earns the stderr note), so it must
# fall through to the shape gates below, not early-return here.
if ([string]::IsNullOrEmpty($Raw)) { return $Default }
$val = 0.0
$ok = [double]::TryParse($Raw, [System.Globalization.NumberStyles]::Float,
[System.Globalization.CultureInfo]::InvariantCulture, [ref]$val)
# Integer knobs (STALE_SECS / MAX_WAIT) take plain digit strings only,
# exactly like git-commit-lock.sh's validator: a fractional stale window
# would otherwise be silently rounded here but rejected there - same
# input, different steal threshold across the two impls. Anchors are
# \A..\z, not ^..$: .NET's $ also matches BEFORE a trailing newline (and
# TryParse tolerates trailing whitespace), so "5\n" would configure this
# side while bash rejects it - same env var, different knob values.
if ($IntegerOnly -and $Raw -notmatch '\A[0-9]+\z') { $ok = $false }
# The fractional knob (POLL_SECS) takes the same raw shape as
# git-commit-lock.sh's grammar: digits with at most one dot and at least
# one digit (e.g. "2", "0.5", ".5"). TryParse(Float) alone is WIDER - it
# accepts exponents ("1e3" = 1000s between polls!), signs ("+2") and
# leading/trailing whitespace, all of which bash rejects, so the same
# env var would configure different poll intervals across the two impls.
if (-not $IntegerOnly -and $Raw -notmatch '\A(?=.*[0-9])[0-9]*\.?[0-9]*\z') { $ok = $false }
if (-not $ok -or $val -le 0) {
$want = 'positive number'; if ($IntegerOnly) { $want = 'positive integer' }
[Console]::Error.WriteLine("git-commit-lock: ignoring invalid $Name='$Raw' (want a $want); using default $Default")
return $Default
}
return $val
}
# Lazy gitdir resolution (perf): the `git rev-parse` child process exists only
# to DEFAULT the lock/log paths, so skip it entirely when BOTH are explicit
# (the common test/sub-agent-override case). When only AGENT_LOCK_PATH is
# explicit the log still defaults into the git dir, so the resolution stays.
# (Mirrors git-commit-lock.sh.) The two not-in-repo guards below stay correct
# when skipped: both are gated on AGENT_LOCK_PATH being unset.
if ($env:AGENT_LOCK_PATH -and $env:AGENT_LOCK_LOG) {
$script:LockGitDir = $null
} else {
$script:LockGitDir = script:Get-LockGitDir
}
$script:LockInRepo = [bool]$script:LockGitDir
if ($script:LockInRepo) { $script:LockBase = $script:LockGitDir } else { $script:LockBase = (Get-Location).Path }
# Not in a repo: the CLI `run` path hard-fails (exit 96) unless AGENT_LOCK_PATH
# is set; dot-sourcing keeps the CWD fallback (so sourcing never explodes) but
# says so out loud.
if (-not $script:LockInRepo -and -not $env:AGENT_LOCK_PATH -and $MyInvocation.InvocationName -eq '.') {
[Console]::Error.WriteLine("git-commit-lock: WARNING - not inside a git repository; defaulting the lock to $script:LockBase/commit.lock (CWD). Set AGENT_LOCK_PATH to control this.")
}
if ($env:AGENT_LOCK_PATH) { $script:LockPath = $env:AGENT_LOCK_PATH } else { $script:LockPath = "$script:LockBase/commit.lock" }
if ($env:AGENT_LOCK_LOG) { $script:LockLog = $env:AGENT_LOCK_LOG } else { $script:LockLog = "$script:LockBase/git-commit-lock.log" }
$script:LockStale = [int](script:Get-LockNum -Name 'AGENT_LOCK_STALE_SECS' -Raw $env:AGENT_LOCK_STALE_SECS -Default 300 -IntegerOnly)
$script:LockClaimStale = [int](script:Get-LockNum -Name 'AGENT_LOCK_CLAIM_STALE_SECS' -Raw $env:AGENT_LOCK_CLAIM_STALE_SECS -Default 60 -IntegerOnly)
$script:LockPoll = [double](script:Get-LockNum -Name 'AGENT_LOCK_POLL_SECS' -Raw $env:AGENT_LOCK_POLL_SECS -Default 2)
$script:LockMaxWait = [int](script:Get-LockNum -Name 'AGENT_LOCK_MAX_WAIT' -Raw $env:AGENT_LOCK_MAX_WAIT -Default 420 -IntegerOnly)
# Worst-case recovery stacks BOTH ageouts: a crashed holder costs a full
# STALE window, and a crashed claimant on top costs a CLAIM_STALE window
# before the steal can complete - so a waiter needs MAX_WAIT > STALE +
# CLAIM_STALE to be guaranteed a recovery chance before giving up (defaults:
# 300 + 60 < 420). Warn only in the documented footgun case - knobs raised
# while MAX_WAIT was left at its default; a caller who set MAX_WAIT chose the
# relationship deliberately (test suites do this constantly). The stacked
# relation strictly subsumes a bare STALE >= MAX_WAIT check, so no separate
# warning for that case is needed. (Mirrors git-commit-lock.sh.)
if (-not $env:AGENT_LOCK_MAX_WAIT -and $script:LockMaxWait -le ($script:LockStale + $script:LockClaimStale)) {
[Console]::Error.WriteLine("git-commit-lock: warning - AGENT_LOCK_MAX_WAIT ($($script:LockMaxWait), default) <= AGENT_LOCK_STALE_SECS ($($script:LockStale)) + AGENT_LOCK_CLAIM_STALE_SECS ($($script:LockClaimStale)): waiters may time out before a crashed holder (and a crashed claimant) can be recovered; raise AGENT_LOCK_MAX_WAIT too")
}
# Floor for a PLAUSIBLE lock mtime (epoch secs; 2000-01-01). A freshly created
# file can transiently report the Windows FILETIME zero (1601-01-01 -> a NEGATIVE
# unix epoch) to an observer (probes C/C1b), which
# would compute as a ~400-year "age" and trigger a spurious steal of a live,
# just-acquired lock. Any mtime below this floor is an unsettled/garbage reading,
# not a genuinely stale lock, so we refuse to steal on it and wait instead.
$script:LockMtimeFloor = 946684800
$script:LockHeld = $false
# The HOLD token: set by Lock-TakeHold from the WINNING attempt's token
# (per-attempt tokens - see PER-ATTEMPT TOKENS in git-commit-lock.sh's
# header); Lock-Release verifies the on-disk lock against it. Empty while not
# holding. pid alone isn't enough (pids get reused across the stale window),
# so tokens mix in Get-Random + the epoch + an in-process sequence number (so
# two attempts inside one second can never collide). The "tok." prefix is
# WIRE FORMAT (the steal's content guard keys on it - see LOCK FILE FORMAT in
# git-commit-lock.sh); the ".ps" marker just helps when reading a mixed log.
$script:LockToken = ''
$script:LockSeq = 0
# The claim path (set at acquire start: ${AGENT_LOCK_PATH}.next) and the
# token of the claim attempt currently in flight (non-empty exactly while a
# claim we created may exist on disk unresolved - the acquire's
# finally-block cleanup, the trap equivalent, keys on it).
$script:LockClaimPath = ''
$script:LockClaimToken = ''
# LEAKED-TOKEN MEMORY (see the rule in git-commit-lock.sh's header): array of
# attempt tokens whose claim file was left in place without a verifiable
# unlink. Almost always empty.
$script:LockLeaked = @()
$script:LockLeakWarned = $false
# Squatted-steal log damper (shared between the acquire loop and the steal
# install helper, like bash's globals): epoch of the last logged failed-steal
# attempt, 0 when the last attempt did not fail that way; and the per-attempt
# "may we log" verdict derived from it.
$script:LockStealFailLast = 0
$script:LockStealLogOk = $true
# [Environment]::MachineName, not $env:COMPUTERNAME: the latter is Windows-only,
# so `host=` would be blank on the POSIX CI legs.
$script:LockMe = "pid=$PID host=$([Environment]::MachineName)"
$script:LockRunRc = 0
# Set by Lock-Release when it returns $false: 'stolen', 'unreadable' or 'leftover'.
$script:LockReleaseStatus = 'ok'
# Set by Lock-Acquire when it returns $false: 'timeout' or 'reentrant'.
$script:LockAcquireFail = ''
# PSEventJob for the best-effort PowerShell.Exiting release backstop.
$script:LockExitJob = $null
function script:Lock-Now {
Set-StrictMode -Off
[DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
}
function script:Lock-Log([string]$msg) {
Set-StrictMode -Off
try {
$ts = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
# Dumb size cap (same 1MB rule as git-commit-lock.sh): if the log has
# grown past ~1MB (it gains ~2 lines per commit and nothing ever
# prunes it), start it over rather than rotating.
try {
$li = New-Object System.IO.FileInfo $script:LockLog
if ($li.Exists -and $li.Length -gt 1048576) {
[System.IO.File]::WriteAllText($script:LockLog, "$ts [pid=$PID] log exceeded 1MB; truncated`n")
}
} catch { }
[System.IO.File]::AppendAllText($script:LockLog, "$ts [pid=$PID] $msg`n")
} catch { }
}
# Loud, once-per-process config warning for a non-lock object at the lock path
# (a directory - e.g. a leftover old-protocol dir lock or a typo like
# AGENT_LOCK_PATH=$HOME - a symlink, or a regular file whose content is not
# lock-shaped). Such a path is NEVER stolen or deleted; waiters will reach 97
# until a human fixes the path or removes the object. CAVEAT (ps1-on-POSIX
# only, an unsupported CI-only config): FIFOs/devices/sockets are NOT routed
# here - .NET has no clean portable type probe for them, so they stat as
# length 0 and take the empty-orphan steal lane instead (the ACCEPTED RESIDUAL
# in PORT-SPECIFIC NOTES). bash, and this port on Windows, deliver the full
# never-steal guarantee.
$script:LockNonLockWarned = $false
function script:Lock-WarnNonLock([string]$reason) {
Set-StrictMode -Off
if ($script:LockNonLockWarned) { return }
$script:LockNonLockWarned = $true
[Console]::Error.WriteLine("git-commit-lock: WARNING - $script:LockPath exists but is not a lock file ($reason). Refusing to steal or delete it; waiters will time out (97). If AGENT_LOCK_PATH is a typo, fix it; if this is a stray file or a leftover old-protocol lock directory, remove it by hand.")
script:Lock-Log "WARNING: non-lock object at lock path ($reason) - never stolen; waiters reach 97 until it is removed by hand"
}
# The claim-path twin (PER-PATH warn-once state, mirroring bash: a shared
# flag would let a lock-path warning suppress a claim-path one, hiding the
# second misconfiguration). A non-claim object squatting ${LOCK}.next blocks
# STEALS only - normal acquisition on a free lock path is unaffected - but a
# stale lock then wedges waiters to 97.
$script:LockNonLockWarnedClaim = $false
function script:Lock-WarnNonLockClaim([string]$reason) {
Set-StrictMode -Off
if ($script:LockNonLockWarnedClaim) { return }
$script:LockNonLockWarnedClaim = $true
[Console]::Error.WriteLine("git-commit-lock: WARNING - $script:LockClaimPath exists but is not a claim file ($reason). Refusing to delete it; stale locks cannot be stolen while it squats the claim path (waiters may time out, 97). If AGENT_LOCK_PATH is a typo, fix it; otherwise remove the object by hand.")
script:Lock-Log "WARNING: non-claim object at claim path ($reason) - never deleted; steals are blocked until it is removed by hand"
}
# Link-aware existence probe: the FileSystemInfo for the path ITSELF (a
# dangling symlink included - it must read as "exists but wrong type" so the
# guard warns instead of classing it as normal contention every poll), or
# $null when the path is absent. Get-Item -Force sees the link, not the
# target; [IO.File]::Exists would report a dangling link as absent.
function script:Lock-GetItemAt([string]$Path) {
Set-StrictMode -Off
try { return (Get-Item -LiteralPath $Path -Force -ErrorAction Stop) } catch { return $null }
}
function script:Lock-GetPathItem {
Set-StrictMode -Off
return (script:Lock-GetItemAt $script:LockPath)
}
# Is this FileSystemInfo a plain regular file (not a directory, not any kind
# of symlink/reparse point)? The only shape acquire may create over and the
# steal may rename - everything else is the never-steal config-warning lane.
# CAVEAT (ps1-on-POSIX only): this probe cannot tell a FIFO/device/socket
# from a regular file (.NET surfaces no portable type bit for them), so on
# Unix those pass as "plain", stat as length 0, and end in the empty-orphan
# steal lane - the documented ACCEPTED RESIDUAL in PORT-SPECIFIC NOTES. bash
# refuses them all via its `[ -f ]` guard; on Windows the reparse/container
# checks make this guard complete.
function script:Lock-IsPlainFile($item) {
Set-StrictMode -Off
if ($null -eq $item) { return $false }
if ($item.PSIsContainer) { return $false }
return (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0)
}
# mtime (epoch secs) of the lock file itself, stamped by the creating write -
# the staleness clock, same value the .sh side reads via `stat -c %Y`. $null
# if the file vanished mid-check. If the read keeps failing while the file
# EXISTS, staleness detection is broken on this system - crashed holders can
# then never be stolen - so say so loudly, once per process (parity with
# git-commit-lock.sh's warning). The retry loop is anti-false-alarm: under
# contention the file routinely vanishes (release/steal) between probes,
# which must not be misdiagnosed as a broken clock - only persistent failure
# on a present file counts.
$script:LockMtimeWarned = $false
function script:Lock-PathMtime {
Set-StrictMode -Off
$m = $null; $present = $false
for ($i = 0; $i -lt 3; $i++) {
try {
$item = Get-Item -LiteralPath $script:LockPath -Force -ErrorAction Stop
$m = ([DateTimeOffset]$item.LastWriteTimeUtc).ToUnixTimeSeconds()
break
} catch {
$m = $null
if (Test-Path -LiteralPath $script:LockPath) { $present = $true } else { $present = $false; break }
}
}
if ($null -eq $m -and $present -and -not $script:LockMtimeWarned) {
$script:LockMtimeWarned = $true
[Console]::Error.WriteLine("git-commit-lock: WARNING - cannot read the lock file's mtime on this system. Staleness detection is BROKEN: stale locks will never be stolen, so a crashed holder wedges waiters until AGENT_LOCK_MAX_WAIT.")
script:Lock-Log 'WARNING: lock-file mtime unreadable (probes failed with the file present); staleness detection disabled'
}
return $m
}
# Read line 1 of the lock file (the token of whoever holds it now),
# distinguishing three outcomes so Lock-Release can be honest about what it saw:
# Status='ok' Token = line 1, trailing whitespace stripped (non-empty)
# Status='gone' the lock file no longer exists
# Status='unreadable' the file exists but no token came back after escalating
# retries - it persistently would not open (a Windows
# sharing violation) or it still reads EMPTY. An empty
# read is the rival create->write gap (probe F) or
# external truncation - NOT proof of theft, and not
# proof of ownership either. Mirrors git-commit-lock.sh's
# empty-after-retries lane: BOTH impls route this state
# to the conservative unverifiable verdict, never to
# "stolen".
# Retries with escalating backoff: under heavy contention a read can
# transiently hit a sharing violation or the empty window, and crying
# "stolen" on that would be false. A REAL steal renames the file away, so a
# successful read then returns a DIFFERENT token (still a mismatch) - retrying
# never hides a genuine theft. The FileStream opens with ReadWrite|Delete
# sharing so this read can never block a rival's steal/release (probe D2).
#
# SHARED RETRY SCHEDULE (keep in lock-step with git-commit-lock.sh's
# _lock_read_tok/_lock_cur_token): up to 8 read attempts with inter-attempt
# sleeps of 20/40/80/160/320/320/320 ms - ~1.26s total budget, enough to ride
# out a sub-second transient (e.g. an AV scanner's no-delete-share open). No
# sleep follows the FINAL attempt (it would only delay the verdict). The full
# ladder runs ONLY where a verdict hangs on the read - release verification,
# the acquire read-back, the claim recheck / token-checked deletion, and the
# discovery read - never inside the acquire poll loop (the steal content
# guard and the per-poll leaked-memory read are short reads), so a healthy
# lock costs one attempt and the poll cadence is unaffected. The sleep is
# [Threading.Thread]::Sleep, not Start-Sleep: this read also runs inside the
# acquire's finally-block cleanup (the trap equivalent), where cmdlet
# invocation can throw PipelineStoppedException mid-Ctrl+C.
function script:Lock-ReadTok {
param([string]$Path, [int]$MaxTries = 8)
Set-StrictMode -Off
$delay = 20
for ($i = 0; $i -lt $MaxTries; $i++) {
try {
$line = $null
$fs = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open,
[System.IO.FileAccess]::Read,
([System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete))
try {
$sr = New-Object System.IO.StreamReader($fs)
$line = $sr.ReadLine()
} finally { $fs.Dispose() }
if ($null -ne $line) { $line = $line.TrimEnd() } # CRLF tolerance
if ($line) { return @{ Status = 'ok'; Token = $line } }
# Empty read with the file present: the create->write window of a
# rival (probe F) - retry before classifying as unverifiable.
} catch [System.IO.FileNotFoundException] {
return @{ Status = 'gone'; Token = '' }
} catch [System.IO.DirectoryNotFoundException] {
return @{ Status = 'gone'; Token = '' }
} catch {
# Transient open failure (e.g. a sharing violation): retry, unless
# the file is genuinely gone.
if (-not (Test-Path -LiteralPath $Path)) { return @{ Status = 'gone'; Token = '' } }
}
if ($i -lt ($MaxTries - 1)) { [System.Threading.Thread]::Sleep($delay) }
if ($delay -lt 320) { $delay = $delay * 2 }
}
if (Test-Path -LiteralPath $Path) { return @{ Status = 'unreadable'; Token = '' } }
return @{ Status = 'gone'; Token = '' }
}
function script:Lock-ReadCurToken {
param([int]$MaxTries = 8)
Set-StrictMode -Off
return (script:Lock-ReadTok -Path $script:LockPath -MaxTries $MaxTries)
}
# Atomic create-or-fail for the lock or claim FILE, with the token+owner
# content written, flushed and closed THROUGH the creation handle: the write
# is bound to the file object we created and cannot land on a successor's
# file, whatever happens to the path meanwhile. CreateNew + the content write
# stamp the mtime (the staleness clock); no post-create stamp is needed - the
# floor guard is the backstop for unsettled readings. The handle shares
# ReadWrite|Delete so a waiter's probes never collide with the creation.
# Returns $true iff we created the file. ANY exception means "not created":
# IOException = a rival's live lock/claim (normal contention); an existing
# directory throws UnauthorizedAccessException; on Unix a FIFO/device path
# fails the O_CREAT|O_EXCL open with its own exception. All of them must
# degrade to the wait loop - which diagnoses the non-file cases - never throw
# out of acquire. A created-but-write-failed file (e.g. ENOSPC) returns
# $false too; the empty or torn orphan it leaves ages into its steal lane.
function script:Lock-TryCreateFile {
param([string]$Path, [string]$Token)
Set-StrictMode -Off
$ErrorActionPreference = 'Stop'
$fs = $null
try {
$fs = [System.IO.File]::Open($Path, [System.IO.FileMode]::CreateNew,
[System.IO.FileAccess]::Write,
([System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete))
# BOM-free UTF-8, LF line ends: the shared wire format (line 1 token,
# line 2 owner), readable by the .sh side's plain `read`.
$bytes = (New-Object System.Text.UTF8Encoding $false).GetBytes("$Token`n$script:LockMe`n")
$fs.Write($bytes, 0, $bytes.Length)
$fs.Flush()
return $true
} catch {
return $false
} finally {
if ($null -ne $fs) { try { $fs.Dispose() } catch { } }
}
}
# --- steal-protocol helpers (claim-serialized stealing; mirrors the bash
# helpers in git-commit-lock.sh - the design rationale lives in that header).
# Fresh token per create/claim ATTEMPT (per-attempt tokens - see the bash
# header): pid + Get-Random + epoch + an in-process sequence number, so two
# attempts inside one second can never collide.
function script:Lock-NewToken {
Set-StrictMode -Off
$script:LockSeq = $script:LockSeq + 1
return "tok.ps.$PID.$(Get-Random).$(script:Lock-Now).$($script:LockSeq)"
}
# Best-effort single mtime probe (epoch secs) of an arbitrary path; $null if
# unreadable/absent (mirrors bash's _lock_stat_mtime: one probe, no retries -
# the lock path's retrying variant is Lock-PathMtime below).
function script:Lock-StatMtime([string]$Path) {
Set-StrictMode -Off
try {
$item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
return ([DateTimeOffset]$item.LastWriteTimeUtc).ToUnixTimeSeconds()
} catch { return $null }
}
# Claim the hold: adopt the winning ATTEMPT token as the hold token. ONE
# helper for all three acquisition paths - create read-back, steal
# rename-over, and discovery-HOLD - so every hold runs the same
# HELD/backstop machinery (mirrors bash's _lock_take_hold).
function script:Lock-TakeHold([string]$Token) {
Set-StrictMode -Off
$script:LockToken = $Token
$script:LockClaimToken = ''
$script:LockHeld = $true
script:Lock-RegisterExitBackstop
script:Lock-Log "ACQUIRED ($script:LockMe tok=$script:LockToken)"
}
# The OWNERSHIP-DISCOVERY read (see the rule in git-commit-lock.sh's header):
# the unconditional final act of every post-claim-create exit that did not
# end in a successful rename. One read of the lock path's line 1 (full ladder
# - a verdict hangs on it); our attempt token there means a rival's rename
# installed OUR claim as the lock => we hold it. Returns $true iff the hold
# was taken.
function script:Lock-Discover([string]$Token) {
Set-StrictMode -Off
$rb = script:Lock-ReadTok -Path $script:LockPath -MaxTries 8
if ($rb.Status -eq 'ok' -and $rb.Token -eq $Token) {
script:Lock-Log "DISCOVERY-HOLD: our claim (tok=$Token) was installed at the lock path by a rival's rename - taking the hold"
script:Lock-TakeHold $Token
return $true
}
return $false
}
# --- leaked-token memory (see the rule in git-commit-lock.sh's header) ------
function script:Lock-LeakedAdd([string]$Token, [string]$Lane) {
Set-StrictMode -Off
$script:LockLeaked = @($script:LockLeaked) + $Token
script:Lock-Log "LEAKED-CLAIM (${Lane}): claim tok=$Token left in place without a verifiable unlink - added to the leaked-token memory; polls will watch the lock path for it"
if (-not $script:LockLeakWarned) {
$script:LockLeakWarned = $true
[Console]::Error.WriteLine("git-commit-lock: warning - a claim file of ours could not be verified/deleted ($Lane); its token is remembered and ownership stays discoverable (see the lock log)")
}
}
function script:Lock-LeakedMember([string]$Token) {
Set-StrictMode -Off
return ([bool](@($script:LockLeaked) -contains $Token))
}
function script:Lock-LeakedDrop([string]$Token) {
Set-StrictMode -Off
$script:LockLeaked = @(@($script:LockLeaked) | Where-Object { $_ -ne $Token })
}
# Is the leaked token verifiably RESOLVED at the lock path? Used after the
# claim-side resolution (a verified unlink, or a gone/foreign observation)
# to decide whether the entry may DROP. Three-way (mirrors bash's
# _lock_leaked_lock_resolved, riding Lock-ReadTok's status):
# * 'ok' with a DIFFERENT token, or 'gone' -> the leaked token sits at
# NEITHER path and can never reappear: resolved ($true, caller drops);
# * 'ok' with OUR token -> installed by a rival's rename: NOT resolved
# (pending; the owner's next acquire can adopt it);
# * 'unreadable' (present but no token after the read) -> INCONCLUSIVE:
# the read proves nothing about whose token is installed, so the entry
# MUST stay pending (dropping here could orphan an installed own-token
# lock with nothing left watching for it).
function script:Lock-LeakedLockResolved([string]$Token) {
Set-StrictMode -Off
$lk = script:Lock-ReadTok -Path $script:LockPath -MaxTries 1
if ($lk.Status -eq 'ok' -and $lk.Token) { return ($lk.Token -ne $Token) }
return ($lk.Status -eq 'gone')
}
# Arc-end best-effort resolution pass (run at release, at the 97 exit, and in
# the acquire cleanup's no-hold path): for each pending entry, one
# token-checked look at the CLAIM file - the blocking handle may have closed
# by now. A verified unlink, or a gone/foreign observation, resolves the
# entry - each followed by one lock-path line-1 read before the drop
# (gone-from-.next may mean installed-at-lock; an entry whose token sits at
# the LOCK path stays pending: the owner's next acquire can adopt it; a lock
# present but unreadable at that read is INCONCLUSIVE and also keeps the
# entry - see Lock-LeakedLockResolved). Any failure leaves the entry pending
# - no waiting, no retry loops.
function script:Lock-LeakedResolvePass {
Set-StrictMode -Off
if (@($script:LockLeaked).Count -eq 0) { return }
foreach ($t in @($script:LockLeaked)) {
$cr = script:Lock-ReadTok -Path $script:LockClaimPath -MaxTries 1
if ($cr.Status -eq 'ok' -and $cr.Token -eq $t) {
# Still ours at the claim path: try the unlink (token-checked,
# single best-effort attempt).
$gone = $false
try { [System.IO.File]::Delete($script:LockClaimPath); $gone = $true } catch { $gone = $false }
if ($gone -and $null -eq (script:Lock-GetItemAt $script:LockClaimPath)) {
if (script:Lock-LeakedLockResolved $t) {
script:Lock-LeakedDrop $t
script:Lock-Log "leaked-token memory: resolved tok=$t (claim unlinked at arc end)"
}
}
} elseif (($cr.Status -eq 'ok' -and $cr.Token) -or ($cr.Status -eq 'gone' -and $null -eq (script:Lock-GetItemAt $script:LockClaimPath))) {
# Foreign-tokened, or verifiably gone: the leak is resolved UNLESS
# the token was installed at the lock path meanwhile, or the lock
# read is inconclusive (present but unreadable).
if (script:Lock-LeakedLockResolved $t) {
script:Lock-LeakedDrop $t
script:Lock-Log "leaked-token memory: resolved tok=$t (claim gone/foreign at arc end)"
}
}
# present-but-empty/unreadable claim, blocked unlink, token-at-lock,
# or an inconclusive lock read: leave the entry pending (residual-5
# class once the process exits).
}
}
# Classify the claim file against OUR attempt token: returns @{ State; Tok }
# with State one of ours | gone | foreign | unreadable. "foreign" includes a
# present-but-EMPTY claim: our claim's content write was verified through the
# creating handle, so an empty file is not ours - it is a rival's mid-create
# window or external truncation; either way it is left alone (it ages out).
# "unreadable" means present, non-empty, but the full read ladder came back
# blank (a sharing violation): we can NOT verify the claim is not ours, so
# callers must treat it as a possible leak.
function script:Lock-ClaimState([string]$Token) {
Set-StrictMode -Off
$r = script:Lock-ReadTok -Path $script:LockClaimPath -MaxTries 8
if ($r.Status -eq 'ok' -and $r.Token) {
if ($r.Token -eq $Token) { return @{ State = 'ours'; Tok = $r.Token } }
return @{ State = 'foreign'; Tok = $r.Token }
}
$item = script:Lock-GetItemAt $script:LockClaimPath
if ($null -eq $item) { return @{ State = 'gone'; Tok = '' } }
$len = $null
try { $len = (New-Object System.IO.FileInfo $script:LockClaimPath).Length } catch { $len = $null }
if ($null -ne $len -and $len -eq 0) { return @{ State = 'foreign'; Tok = '' } }
return @{ State = 'unreadable'; Tok = '' }
}
# TOKEN-CHECKED CLAIM DELETION (see the rule in git-commit-lock.sh's header):
# read first, unlink only if line 1 is OUR token; never blind-unlink the
# claim path. Returns one of: deleted | gone | foreign | leaked-unreadable |
# leaked-blocked. The two leaked-* outcomes append the token to the
# leaked-token memory. A delete that finds the file already gone (File.Delete
# is silent on missing) or vanishing mid-try reports 'deleted' - the claim
# left the path either way, and the discovery read every caller runs next
# decides whether it left INTO the lock path.
function script:Lock-ClaimDelete([string]$Token, [int]$Retries = 0) {
Set-StrictMode -Off
$cs = script:Lock-ClaimState $Token
switch ($cs.State) {
'gone' { return 'gone' }
'foreign' { return 'foreign' }
'unreadable' {
script:Lock-LeakedAdd $Token 'deletion-read-unreadable'
return 'leaked-unreadable'
}
}
# Ours: unlink, with the caller's bounded retry budget on a blocked unlink
# (a no-delete-share handle can refuse the delete while the file stays).
$try = 0
while ($true) {
$deleted = $false
try { [System.IO.File]::Delete($script:LockClaimPath); $deleted = $true } catch { $deleted = $false }
if ($deleted) { return 'deleted' }
if ($null -eq (script:Lock-GetItemAt $script:LockClaimPath)) { return 'deleted' } # vanished mid-try
if ($try -ge $Retries) { break }
$try = $try + 1
[System.Threading.Thread]::Sleep(50)
}
script:Lock-LeakedAdd $Token 'deletion-unlink-blocked-while-present'
return 'leaked-blocked'
}
# Re-judge the LOCK's staleness fresh (the step-2 / step-3.3 re-verify):
# type, mtime + floor, age, content shape. Returns @{ State; Line2 } with
# State one of:
# stale confirmed stale (Line2 populated for ghost attribution)
# gone path absent
# fresh not confirmable as stale (young mtime, sub-floor/unsettled,
# unreadable mtime or content - never steal what we can't prove)
# wrongtype not a regular file, or content not lock-shaped
# "Empty" is judged by STAT without opening (the ps1-on-POSIX FIFO rule, same
# as the poll-loop content guard).
function script:Lock-VerifyStale {
Set-StrictMode -Off
$res = @{ State = ''; Line2 = '' }
$item = script:Lock-GetItemAt $script:LockPath
if ($null -eq $item) { $res.State = 'gone'; return $res }
if (-not (script:Lock-IsPlainFile $item)) { $res.State = 'wrongtype'; return $res }
$mt = script:Lock-PathMtime
if ($null -eq $mt) {
if ($null -ne (script:Lock-GetItemAt $script:LockPath)) { $res.State = 'fresh' } else { $res.State = 'gone' }
return $res
}
if ($mt -le $script:LockMtimeFloor) { $res.State = 'fresh'; return $res } # sub-floor: unsettled
$age = (script:Lock-Now) - $mt
if ($age -lt $script:LockStale) { $res.State = 'fresh'; return $res }
# Content shape (stat first - never read-open a size-0 path; one open for
# line 1 + line 2, the ghost attribution for the log).
$len = $null
try { $len = (New-Object System.IO.FileInfo $script:LockPath).Length } catch { $len = $null }
if ($null -eq $len) {
if ($null -ne (script:Lock-GetItemAt $script:LockPath)) { $res.State = 'fresh' } else { $res.State = 'gone' }
return $res
}
if ($len -eq 0) { $res.State = 'stale'; return $res } # the empty crash-orphan lane
$line1 = $null; $line2 = $null
try {
$fs = [System.IO.File]::Open($script:LockPath, [System.IO.FileMode]::Open,
[System.IO.FileAccess]::Read,
([System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete))
try {
$sr = New-Object System.IO.StreamReader($fs)
$line1 = $sr.ReadLine()
$line2 = $sr.ReadLine()
} finally { $fs.Dispose() }
} catch [System.IO.FileNotFoundException] {
$res.State = 'gone'; return $res
} catch [System.IO.DirectoryNotFoundException] {
$res.State = 'gone'; return $res
} catch {
$res.State = 'fresh'; return $res # unreadable content: not provable
}
if ($null -ne $line1) { $line1 = $line1.TrimEnd() }
if ($null -ne $line2) { $line2 = $line2.TrimEnd() }
if ($line1) {
if ($line1.StartsWith('tok.')) { $res.State = 'stale' } else { $res.State = 'wrongtype' }
} else {
$res.State = 'wrongtype' # non-empty but blank line 1
}
$res.Line2 = $line2
return $res
}
# Atomic rename-over of the claim onto the lock path. Engine split (probed,
# see PORT-SPECIFIC NOTES): pwsh 7 / .NET Core has the 3-arg overwrite
# overload [IO.File]::Move($src,$dst,$true) - one atomic replace, no
# path-absent window; Windows PowerShell 5.1 / .NET Framework does not
# (File.Replace is deliberately never used: it can throw on read-only files
# and documents partial-failure states without a backup file), so the 5.1
# lane is unlink-the-ghost + fail-if-exists Move, whose transient absent
# window is safe UNDER THE CLAIM: a rival's create landing in it merely wins
# the lock (our Move fails-if-exists - a fairness loss, never a clobber).
# .NET's Move refuses a DIRECTORY destination on both engines with both
# files intact (probed P5/Q3 - native `mv -T` semantics; no extra guard).
# Returns one of:
# ok renamed; our lock is installed
# src-gone the claim vanished (canonical discovery case)
# dest-gone (5.1 only) the lock vanished before the ghost unlink - the
# CLAIM-ABORT (gone) lane; the Move is NOT attempted
# lost (5.1 only) a rival's create won the unlink->Move window
# wrong-type a non-file appeared at the lock path
# blocked rename/unlink refused with the lock file still present
$script:LockMove3 = $null # $null = unprobed; $true/$false after the probe
function script:Lock-RenameOver {
Set-StrictMode -Off
if ($null -eq $script:LockMove3) {
$m = $null
try { $m = [System.IO.File].GetMethod('Move', [type[]]@([string],[string],[bool])) } catch { $m = $null }
$script:LockMove3 = [bool]$m
}
if ($script:LockMove3) {
try {
[System.IO.File]::Move($script:LockClaimPath, $script:LockPath, $true)
return 'ok'
} catch { }
if ($null -eq (script:Lock-GetItemAt $script:LockClaimPath)) { return 'src-gone' }
$item = script:Lock-GetItemAt $script:LockPath
if ($null -ne $item -and -not (script:Lock-IsPlainFile $item)) { return 'wrong-type' }
return 'blocked'
}
# 5.1 ladder: unlink the ghost, then fail-if-exists Move the claim in.
if ($null -eq (script:Lock-GetItemAt $script:LockPath)) { return 'dest-gone' }
$deleted = $false
try { [System.IO.File]::Delete($script:LockPath); $deleted = $true } catch { $deleted = $false }
if (-not $deleted) {
if ($null -ne (script:Lock-GetItemAt $script:LockPath)) {
$item = script:Lock-GetItemAt $script:LockPath
if ($null -ne $item -and -not (script:Lock-IsPlainFile $item)) { return 'wrong-type' }
return 'blocked'
}
# The ghost vanished while the delete failed: already gone - proceed
# to the Move exactly as if our unlink had won.
}
try {
[System.IO.File]::Move($script:LockClaimPath, $script:LockPath)
return 'ok'
} catch { }
if ($null -eq (script:Lock-GetItemAt $script:LockClaimPath)) { return 'src-gone' }
$item = script:Lock-GetItemAt $script:LockPath
if ($null -ne $item) {
if (script:Lock-IsPlainFile $item) { return 'lost' }
return 'wrong-type'
}
return 'blocked'
}
# The ordered install sequence (protocol steps 2-3.4), entered with OUR claim
# freshly created (token $Token; $script:LockClaimToken set by the caller).
# Returns $true iff a hold was taken (rename-over read-back, or a
# discovery-HOLD); $false means the attempt resolved without a hold and the
# caller falls through to the timeout check + poll sleep. Every exit that
# does not end in a successful rename runs its token-checked claim handling
# and then the FINAL DISCOVERY READ as its last act (the ownership-discovery
# rule - position-blind, unconditional; mirrors bash's _lock_steal_install).
function script:Lock-StealInstall([string]$Token) {
Set-StrictMode -Off
# Step 2: re-verify the lock still stale under the claim.
$lv = script:Lock-VerifyStale
if ($lv.State -ne 'stale') {
$reason = 'fresh'
if ($lv.State -eq 'gone') { $reason = 'gone' } # never rename onto the absent path
elseif ($lv.State -eq 'wrongtype') { $reason = 'wrong-type' }
[void](script:Lock-ClaimDelete $Token 0)