1+ // @index Repository locking and clone-or-pull operations for webhook sync.
12package webhook
23
34import (
@@ -19,30 +20,39 @@ import (
1920 "github.com/go-git/go-git/v5/plumbing/transport"
2021)
2122
23+ // @intent signal that a per-repository lock could not be acquired within the configured wait window.
2224var ErrRepoLockTimeout = errors .New ("repo lock timeout" )
2325
26+ // @intent treat repository lock files older than this threshold as abandoned by a previous process.
2427const repoLockStaleAfter = 30 * time .Minute
2528
29+ // @intent keep repository-scoped git operations serialized across concurrent webhook deliveries.
2630type RepoLocker struct {
2731 timeout time.Duration
2832 mu sync.Mutex
2933 locks map [string ]chan struct {}
3034}
3135
36+ // @intent persist enough lock provenance to detect and clean up stale repository lock files safely.
3237type repoLockMetadata struct {
3338 Repo string `json:"repo"`
3439 PID int `json:"pid"`
3540 Hostname string `json:"hostname"`
3641 CreatedAt time.Time `json:"created_at"`
3742}
3843
44+ // NewRepoLocker creates a per-repository lock coordinator with a bounded wait time.
45+ // @intent serialize concurrent webhook sync for the same repo so git operations do not corrupt the working tree.
3946func NewRepoLocker (timeout time.Duration ) * RepoLocker {
4047 if timeout <= 0 {
4148 timeout = 30 * time .Second
4249 }
4350 return & RepoLocker {timeout : timeout , locks : make (map [string ]chan struct {})}
4451}
4552
53+ // WithLock runs a sync operation while holding both in-process and filesystem repo locks.
54+ // @intent coordinate webhook workers across goroutines and processes before touching a repository checkout.
55+ // @sideEffect creates and removes filesystem lock files under the repo root.
4656func (l * RepoLocker ) WithLock (ctx context.Context , lockRoot , repoFullName string , fn func (context.Context ) error ) error {
4757 if ctx == nil {
4858 ctx = context .Background ()
@@ -65,6 +75,7 @@ func (l *RepoLocker) WithLock(ctx context.Context, lockRoot, repoFullName string
6575 return fn (ctx )
6676}
6777
78+ // @intent gate same-process sync attempts for a repository before filesystem locking is attempted.
6879func (l * RepoLocker ) acquireLocal (ctx context.Context , repoFullName string ) (chan struct {}, func (chan struct {}), error ) {
6980 l .mu .Lock ()
7081 lock := l .locks [repoFullName ]
@@ -83,6 +94,8 @@ func (l *RepoLocker) acquireLocal(ctx context.Context, repoFullName string) (cha
8394 }
8495}
8596
97+ // @intent coordinate repository sync across processes by creating an exclusive lock file under the repo root.
98+ // @sideEffect creates and removes repository lock files on disk.
8699func acquireFilesystemLock (ctx context.Context , lockRoot , repoFullName string ) (* os.File , func (* os.File ), error ) {
87100 lockFile := filepath .Join (lockRoot , ".locks" , lockFileName (repoFullName ))
88101 if err := os .MkdirAll (filepath .Dir (lockFile ), 0755 ); err != nil {
@@ -123,6 +136,8 @@ func acquireFilesystemLock(ctx context.Context, lockRoot, repoFullName string) (
123136 }
124137}
125138
139+ // @intent write lock ownership metadata so stale lock cleanup can be diagnosed from the filesystem.
140+ // @sideEffect writes JSON metadata and fsyncs the lock file.
126141func writeRepoLockMetadata (file * os.File , repoFullName string ) error {
127142 hostname , err := os .Hostname ()
128143 if err != nil {
@@ -140,6 +155,8 @@ func writeRepoLockMetadata(file *os.File, repoFullName string) error {
140155 return file .Sync ()
141156}
142157
158+ // @intent discard abandoned repository lock files after the stale timeout elapses.
159+ // @sideEffect may remove an on-disk lock file.
143160func removeStaleFilesystemLock (lockFile string ) (bool , time.Duration ) {
144161 info , err := os .Stat (lockFile )
145162 if err != nil {
@@ -155,19 +172,27 @@ func removeStaleFilesystemLock(lockFile string) (bool, time.Duration) {
155172 return true , age
156173}
157174
175+ // @intent convert repository names into stable lock-safe filenames.
158176func lockFileName (repoFullName string ) string {
159177 replacer := strings .NewReplacer ("/" , "-" , "\\ " , "-" , ":" , "-" )
160178 return replacer .Replace (repoFullName ) + ".lock"
161179}
162180
181+ // RepoDir maps a namespace to the checkout directory used for repository sync.
182+ // @intent keep workspace naming stable across clone, pull, and downstream build steps.
163183func RepoDir (repoRoot , namespace string ) string {
164184 return filepath .Join (repoRoot , namespace )
165185}
166186
187+ // CloneOrPull syncs the repository namespace to the default remote branch.
188+ // @intent give webhook handlers a branch-agnostic entry point for standard repo refresh.
167189func CloneOrPull (ctx context.Context , repoURL , repoRoot , namespace string , auth transport.AuthMethod ) error {
168190 return CloneOrPullBranch (ctx , repoURL , repoRoot , namespace , "" , auth )
169191}
170192
193+ // CloneOrPullBranch ensures the namespace checkout exists and matches the requested branch head.
194+ // @intent reuse the same repo sync path for first clone and subsequent updates.
195+ // @sideEffect creates or hard-resets the namespace checkout on disk.
171196func CloneOrPullBranch (ctx context.Context , repoURL , repoRoot , namespace , branch string , auth transport.AuthMethod ) error {
172197 dest := RepoDir (repoRoot , namespace )
173198
@@ -187,6 +212,8 @@ func CloneOrPullBranch(ctx context.Context, repoURL, repoRoot, namespace, branch
187212 return syncRepoBranch (ctx , repo , branch , auth )
188213}
189214
215+ // CloneOrPullBranchLocked wraps branch sync with repository locking.
216+ // @intent prevent overlapping webhook deliveries from cloning or resetting the same checkout simultaneously.
190217func CloneOrPullBranchLocked (ctx context.Context , locker * RepoLocker , repoURL , repoRoot , repoFullName , namespace , branch string , auth transport.AuthMethod ) error {
191218 if locker == nil {
192219 locker = NewRepoLocker (30 * time .Second )
@@ -196,6 +223,7 @@ func CloneOrPullBranchLocked(ctx context.Context, locker *RepoLocker, repoURL, r
196223 })
197224}
198225
226+ // @intent log clone URLs without leaking embedded credentials.
199227func sanitizeURL (raw string ) string {
200228 u , err := url .Parse (raw )
201229 if err != nil {
@@ -207,6 +235,8 @@ func sanitizeURL(raw string) string {
207235 return u .String ()
208236}
209237
238+ // @intent perform the first namespace clone via a temp directory so partially cloned repos are never promoted.
239+ // @sideEffect creates temporary directories and renames the completed clone into place.
210240func cloneRepo (ctx context.Context , repoURL , repoRoot , dest , namespace , branch string , auth transport.AuthMethod ) error {
211241 slog .Info ("cloning repository" , "url" , sanitizeURL (repoURL ), "dest" , dest )
212242
@@ -251,6 +281,8 @@ func cloneRepo(ctx context.Context, repoURL, repoRoot, dest, namespace, branch s
251281 return nil
252282}
253283
284+ // @intent hard-reset an existing checkout to the latest remote branch head for deterministic rebuilds.
285+ // @sideEffect fetches from origin and rewrites the local worktree state.
254286func syncRepoBranch (ctx context.Context , repo * git.Repository , branch string , auth transport.AuthMethod ) error {
255287 slog .Info ("syncing repository to remote branch" , "branch" , branch )
256288
@@ -294,6 +326,7 @@ func syncRepoBranch(ctx context.Context, repo *git.Repository, branch string, au
294326 return nil
295327}
296328
329+ // @intent build fetch options that keep sync traffic branch-scoped and shallow when possible.
297330func fetchOptions (remoteName , branch string , auth transport.AuthMethod ) * git.FetchOptions {
298331 opts := & git.FetchOptions {RemoteName : remoteName , Depth : 1 }
299332 if branch != "" {
0 commit comments