Skip to content

Commit 8bb6075

Browse files
Heal protected worktree index.lock contention
Co-authored-by: Andrew <andrewxhill@gmail.com>
1 parent 4d9f7c3 commit 8bb6075

3 files changed

Lines changed: 156 additions & 1 deletion

File tree

internal/app/app_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,64 @@ func TestNextPushReportsNextPublishedProtectedAdvance(t *testing.T) {
266266
}
267267
}
268268

269+
func TestRunOnceHealsStaleProtectedWorktreeIndexLockDuringIntegration(t *testing.T) {
270+
repoRoot, _ := createTestRepoWithRemote(t)
271+
initRepoForWorker(t, repoRoot)
272+
273+
featurePath := filepath.Join(t.TempDir(), "feature-stale-lock")
274+
runTestCommand(t, repoRoot, "git", "worktree", "add", "-b", "feature/stale-lock", featurePath)
275+
writeFileAndCommit(t, featurePath, "stale-lock.txt", "stale lock\n", "stale lock feature")
276+
featureHead := trimNewline(runTestCommand(t, featurePath, "git", "rev-parse", "HEAD"))
277+
submitBranch(t, featurePath)
278+
279+
lockPath := trimNewline(runTestCommand(t, repoRoot, "git", "rev-parse", "--path-format=absolute", "--git-path", "index.lock"))
280+
if err := os.WriteFile(lockPath, []byte("stale"), 0o644); err != nil {
281+
t.Fatalf("WriteFile(index.lock): %v", err)
282+
}
283+
staleTime := time.Now().Add(-2 * staleProtectedIndexLockAfter)
284+
if err := os.Chtimes(lockPath, staleTime, staleTime); err != nil {
285+
t.Fatalf("Chtimes(index.lock): %v", err)
286+
}
287+
288+
runOnce(t, repoRoot)
289+
290+
if _, err := os.Stat(lockPath); !errors.Is(err, os.ErrNotExist) {
291+
t.Fatalf("expected stale index.lock removed, stat err=%v", err)
292+
}
293+
localHead := trimNewline(runTestCommand(t, repoRoot, "git", "rev-parse", "HEAD"))
294+
if localHead != featureHead {
295+
t.Fatalf("expected protected head %q, got %q", featureHead, localHead)
296+
}
297+
}
298+
299+
func TestRunOnceRetriesTransientProtectedWorktreeIndexLockDuringIntegration(t *testing.T) {
300+
repoRoot, _ := createTestRepoWithRemote(t)
301+
initRepoForWorker(t, repoRoot)
302+
303+
featurePath := filepath.Join(t.TempDir(), "feature-transient-lock")
304+
runTestCommand(t, repoRoot, "git", "worktree", "add", "-b", "feature/transient-lock", featurePath)
305+
writeFileAndCommit(t, featurePath, "transient-lock.txt", "transient lock\n", "transient lock feature")
306+
featureHead := trimNewline(runTestCommand(t, featurePath, "git", "rev-parse", "HEAD"))
307+
submitBranch(t, featurePath)
308+
309+
lockPath := trimNewline(runTestCommand(t, repoRoot, "git", "rev-parse", "--path-format=absolute", "--git-path", "index.lock"))
310+
if err := os.WriteFile(lockPath, []byte("transient"), 0o644); err != nil {
311+
t.Fatalf("WriteFile(index.lock): %v", err)
312+
}
313+
314+
go func() {
315+
time.Sleep(300 * time.Millisecond)
316+
_ = os.Remove(lockPath)
317+
}()
318+
319+
runOnce(t, repoRoot)
320+
321+
localHead := trimNewline(runTestCommand(t, repoRoot, "git", "rev-parse", "HEAD"))
322+
if localHead != featureHead {
323+
t.Fatalf("expected protected head %q, got %q", featureHead, localHead)
324+
}
325+
}
326+
269327
func TestGlobalJSONForwardsToRunOnceAndPublish(t *testing.T) {
270328
repoRoot, _ := createTestRepoWithRemote(t)
271329
initRepoForWorker(t, repoRoot)

internal/app/integration_workflow.go

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"path/filepath"
8+
"time"
89

910
"github.com/recallnet/mainline/internal/domain"
1011
"github.com/recallnet/mainline/internal/git"
@@ -175,7 +176,7 @@ func processIntegrationSubmission(ctx context.Context, store state.Store, repoRe
175176
if err := applyAppTestFault("integration.fast_forward"); err != nil {
176177
return failIntegrationSubmission(ctx, store, repoRecord.ID, submission, err)
177178
}
178-
if err := mainEngine.FastForwardCurrentBranch(cfg.Repo.MainWorktree, targetRef); err != nil {
179+
if err := fastForwardProtectedBranchWithIndexLockRecovery(mainEngine, cfg.Repo.MainWorktree, targetRef); err != nil {
179180
return failIntegrationSubmissionWithSync(ctx, store, repoRecord.ID, submission, syncResult, err)
180181
}
181182

@@ -234,6 +235,44 @@ func processIntegrationSubmission(ctx context.Context, store state.Store, repoRe
234235
return fmt.Sprintf("Integrated submission %d from %s onto %s", submission.ID, submissionDisplayRef(submission), cfg.Repo.ProtectedBranch), nil
235236
}
236237

238+
var integrationIndexLockRetryBackoffs = []time.Duration{
239+
250 * time.Millisecond,
240+
time.Second,
241+
2 * time.Second,
242+
}
243+
244+
const staleProtectedIndexLockAfter = 10 * time.Second
245+
246+
func fastForwardProtectedBranchWithIndexLockRecovery(mainEngine git.Engine, worktreePath string, targetRef string) error {
247+
var lastErr error
248+
for attempt := 0; attempt <= len(integrationIndexLockRetryBackoffs); attempt++ {
249+
err := mainEngine.FastForwardCurrentBranch(worktreePath, targetRef)
250+
if err == nil {
251+
return nil
252+
}
253+
if !git.IsIndexLockHeldError(err) {
254+
return err
255+
}
256+
lastErr = err
257+
258+
lockState, inspectErr := mainEngine.InspectIndexLock(worktreePath, staleProtectedIndexLockAfter)
259+
if inspectErr != nil {
260+
return fmt.Errorf("protected worktree index lock contention: %w", inspectErr)
261+
}
262+
if lockState.Exists && lockState.IsStale {
263+
if removeErr := mainEngine.RemoveIndexLock(worktreePath); removeErr != nil {
264+
return fmt.Errorf("protected worktree stale index lock recovery failed: %w", removeErr)
265+
}
266+
continue
267+
}
268+
if attempt == len(integrationIndexLockRetryBackoffs) {
269+
break
270+
}
271+
time.Sleep(integrationIndexLockRetryBackoffs[attempt])
272+
}
273+
return fmt.Errorf("protected worktree index lock contention exceeded retry budget: %w", lastErr)
274+
}
275+
237276
type protectedSyncResult struct {
238277
Synced bool
239278
Upstream string

internal/git/git.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ var ErrRebaseEmpty = errors.New("git rebase stopped on an empty commit")
2525
var ErrFastForwardRejected = errors.New("fast-forward update was rejected")
2626
var ErrPushRejected = errors.New("git push was rejected")
2727
var ErrPushInterrupted = errors.New("git push was interrupted")
28+
var ErrIndexLockHeld = errors.New("git index.lock is held")
2829

2930
// Engine holds repository-local Git execution context.
3031
type Engine struct {
@@ -86,6 +87,14 @@ type CommitInfo struct {
8687
CommittedAt time.Time `json:"committed_at"`
8788
}
8889

90+
type IndexLockState struct {
91+
Path string `json:"path"`
92+
Exists bool `json:"exists"`
93+
Age time.Duration `json:"age,omitempty"`
94+
IsStale bool `json:"is_stale,omitempty"`
95+
UpdatedAt time.Time `json:"updated_at,omitempty"`
96+
}
97+
8998
// HealthReport captures repository health relevant to Milestone 1.
9099
type HealthReport struct {
91100
RepositoryRoot string `json:"repository_root"`
@@ -530,12 +539,56 @@ func (e Engine) FastForwardCurrentBranch(worktreePath string, targetRef string)
530539
if err == nil {
531540
return nil
532541
}
542+
if isIndexLockContentionOutput(output) {
543+
return fmt.Errorf("%w: %s", ErrIndexLockHeld, strings.TrimSpace(output))
544+
}
533545
if strings.Contains(output, "Not possible to fast-forward") || strings.Contains(output, "fatal: Not possible to fast-forward") {
534546
return fmt.Errorf("%w: %s", ErrFastForwardRejected, strings.TrimSpace(output))
535547
}
536548
return err
537549
}
538550

551+
func IsIndexLockHeldError(err error) bool {
552+
return errors.Is(err, ErrIndexLockHeld)
553+
}
554+
555+
func (e Engine) InspectIndexLock(worktreePath string, staleAfter time.Duration) (IndexLockState, error) {
556+
lockPath, err := e.gitPath(worktreePath, "index.lock")
557+
if err != nil {
558+
return IndexLockState{}, err
559+
}
560+
info, err := os.Stat(lockPath)
561+
if err != nil {
562+
if errors.Is(err, os.ErrNotExist) {
563+
return IndexLockState{Path: lockPath}, nil
564+
}
565+
return IndexLockState{}, err
566+
}
567+
updatedAt := info.ModTime().UTC()
568+
age := time.Since(updatedAt)
569+
state := IndexLockState{
570+
Path: lockPath,
571+
Exists: true,
572+
Age: age,
573+
UpdatedAt: updatedAt,
574+
}
575+
if staleAfter > 0 && age >= staleAfter {
576+
state.IsStale = true
577+
}
578+
return state, nil
579+
}
580+
581+
func (e Engine) RemoveIndexLock(worktreePath string) error {
582+
lockPath, err := e.gitPath(worktreePath, "index.lock")
583+
if err != nil {
584+
return err
585+
}
586+
if err := os.Remove(lockPath); err != nil && !errors.Is(err, os.ErrNotExist) {
587+
return err
588+
}
589+
return nil
590+
}
591+
539592
// PushBranch pushes a local branch ref to the configured remote branch.
540593
func (e Engine) PushBranch(worktreePath string, remote string, branch string, noVerify bool) error {
541594
handle, err := e.StartPushBranch(worktreePath, remote, branch, noVerify)
@@ -836,6 +889,11 @@ func (e Engine) runGit(worktreePath string, args ...string) (string, error) {
836889
return string(output), nil
837890
}
838891

892+
func isIndexLockContentionOutput(output string) bool {
893+
text := strings.TrimSpace(output)
894+
return strings.Contains(text, "index.lock") && strings.Contains(text, "File exists")
895+
}
896+
839897
func (e Engine) gitPath(worktreePath string, pathspec string) (string, error) {
840898
output, err := e.runGit(worktreePath, "rev-parse", "--git-path", pathspec)
841899
if err != nil {

0 commit comments

Comments
 (0)