Module: Ace::Git::Atoms::StaleLockCleaner

Defined in:
lib/ace/git/atoms/stale_lock_cleaner.rb

Overview

Pure functions for detecting and cleaning stale git index lock files

Git index.lock files can become stale when:

  • Previous git operations were interrupted (Ctrl+C, crashes, timeouts)

  • Agents are blocked mid-operation leaving orphan lock files

A stale lock is one that hasn’t been modified recently (>10 seconds by default), indicating the owning process is no longer active.

Lock File Format: Git lock files contain the PID and hostname of the process that created them. We use PID-based detection (checking if owning process is still running) as the primary method, with age-based detection as a fallback for edge cases (remote mounts, containers where PID check may not work).

Class Method Summary collapse

Class Method Details

.clean(repo_path, threshold_seconds = 10) ⇒ Hash

Clean a lock file if it is orphaned (dead PID) or stale (old age)

Uses PID-based detection first (instant), then falls back to age-based detection for edge cases (remote mounts, containers).

Examples:

Cleaned orphaned lock (dead PID)

clean("/path/to/repo", 60)
# => { success: true, cleaned: true, message: "Removed orphaned lock..." }

Cleaned stale lock (old age)

clean("/path/to/repo", 60)
# => { success: true, cleaned: true, message: "Removed stale lock..." }

No lock to clean

clean("/path/to/repo", 60)
# => { success: true, cleaned: false, message: "No lock found" }

Lock is active (PID running, fresh)

clean("/path/to/repo", 60)
# => { success: true, cleaned: false, message: "Lock is active..." }

Parameters:

  • repo_path (String)

    Path to the git repository

  • threshold_seconds (Integer) (defaults to: 10)

    Age threshold for stale detection

Returns:

  • (Hash)

    Result with :success, :cleaned, :message



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
# File 'lib/ace/git/atoms/stale_lock_cleaner.rb', line 158

def clean(repo_path, threshold_seconds = 10)
  lock_path = find_lock_file(repo_path)

  if lock_path.nil?
    return {success: true, cleaned: false, status: :missing, pid: nil, age_seconds: nil,
            message: "No lock file found"}
  end

  # Security check: ensure lock file is a regular file, not a symlink
  # This prevents accidental deletion of symlink targets, which could be
  # exploited to cause data loss or security issues.
  if File.symlink?(lock_path)
    return {success: false, cleaned: false, status: :symlink, pid: nil, age_seconds: nil,
            message: "Lock file is a symlink, refusing to delete: #{lock_path}"}
  end

  # Safety check: ensure it's a regular file (not directory or device)
  unless File.file?(lock_path)
    return {success: false, cleaned: false, status: :invalid, pid: nil, age_seconds: nil,
            message: "Lock path is not a regular file: #{lock_path}"}
  end

  pid = lock_pid(lock_path)
  age_seconds = begin
    Time.now - File.mtime(lock_path)
  rescue
    nil
  end

  pid_active = pid ? process_active?(pid) : false

  # Check PID-based activity first
  if pid_active
    return {
      success: true,
      cleaned: false,
      status: :active,
      pid: pid,
      age_seconds: age_seconds,
      message: "Lock file is active (PID running, < #{threshold_seconds}s old)"
    }
  end

  # Orphaned PID should be removed immediately
  if pid && !pid_active
    File.delete(lock_path)
    return {
      success: true,
      cleaned: true,
      status: :orphaned,
      pid: pid,
      age_seconds: age_seconds,
      message: "Removed orphaned lock file (dead PID): #{lock_path}"
    }
  end

  # Fallback to age-based stale detection
  if stale?(lock_path, threshold_seconds)
    File.delete(lock_path)
    return {
      success: true,
      cleaned: true,
      status: :stale,
      pid: pid,
      age_seconds: age_seconds,
      message: "Removed stale lock file: #{lock_path}"
    }
  end

  {
    success: true,
    cleaned: false,
    status: :unknown,
    pid: pid,
    age_seconds: age_seconds,
    message: "Lock file present but status unclear (< #{threshold_seconds}s old)"
  }
rescue Errno::ENOENT
  # Handle TOCTOU race: lock file was deleted between check and delete
  {success: true, cleaned: false, status: :missing, pid: nil, age_seconds: nil,
   message: "Lock file already removed"}
rescue => e
  {success: false, cleaned: false, status: :error, pid: nil, age_seconds: nil,
   message: "Failed to clean lock: #{e.message}"}
end

.find_lock_file(repo_path) ⇒ String?

Find index.lock file for a repository

Examples:

In main repo

find_lock_file("/path/to/repo")
# => "/path/to/repo/.git/index.lock"

In worktree

find_lock_file("/path/to/worktree")
# => "/path/to/worktree/.git/index.lock"

Parameters:

  • repo_path (String)

    Path to the git repository

Returns:

  • (String, nil)

    Path to the index.lock file or nil if not found



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
# File 'lib/ace/git/atoms/stale_lock_cleaner.rb', line 103

def find_lock_file(repo_path)
  return nil if repo_path.nil? || repo_path.empty?

  # First try to find .git directory
  git_dir = File.join(repo_path, ".git")

  # If .git is a file (worktree), read the gitdir path
  if File.file?(git_dir)
    git_file_path = git_dir
    git_dir_content = File.read(git_file_path)
    # Worktree .git files contain: "gitdir: /path/to/main/.git/worktrees/..."
    # Use greedy match to capture full path including spaces, trim trailing whitespace
    if git_dir_content =~ /^gitdir:\s*(.+)\s*$/
      raw_git_dir = Regexp.last_match(1).strip
      # Handle relative paths by expanding from .git file location
      git_dir = File.expand_path(raw_git_dir, File.dirname(git_file_path))
    else
      return nil
    end
  end

  # Check if git directory exists
  return nil unless File.directory?(git_dir)

  # Return path to index.lock
  lock_path = File.join(git_dir, "index.lock")
  File.exist?(lock_path) ? lock_path : nil
rescue
  nil
end

.lock_pid(lock_path) ⇒ Integer?

Extract PID from a git lock file

Git lock files contain the PID on the first line, optionally followed by hostname.

Parameters:

  • lock_path (String)

    Path to the lock file

Returns:

  • (Integer, nil)

    PID if present and positive, otherwise nil



27
28
29
30
31
32
33
34
35
# File 'lib/ace/git/atoms/stale_lock_cleaner.rb', line 27

def lock_pid(lock_path)
  content = File.read(lock_path)
  pid = content.to_s.split.first.to_i
  (pid > 0) ? pid : nil
rescue Errno::ENOENT
  nil
rescue
  nil
end

.orphaned?(lock_path) ⇒ Boolean

Check if lock file is orphaned (owning process no longer exists)

Git lock files contain the PID of the process that created them. If that process no longer exists, the lock is orphaned and safe to delete.

Examples:

Orphaned lock (process crashed)

File.write(lock_path, "99999")  # Non-existent PID
orphaned?(lock_path)  # => true

Active lock (process running)

File.write(lock_path, Process.pid.to_s)
orphaned?(lock_path)  # => false

Parameters:

  • lock_path (String)

    Path to the lock file

Returns:

  • (Boolean)

    true if the lock is orphaned (PID doesn’t exist)



84
85
86
87
88
89
# File 'lib/ace/git/atoms/stale_lock_cleaner.rb', line 84

def orphaned?(lock_path)
  pid = lock_pid(lock_path)
  return false unless pid

  !process_active?(pid)
end

.process_active?(pid) ⇒ Boolean

Check if a process exists (signal 0 = check only)

Parameters:

  • pid (Integer)

    Process ID

Returns:

  • (Boolean)

    true if process exists, false if not



40
41
42
43
44
45
46
47
# File 'lib/ace/git/atoms/stale_lock_cleaner.rb', line 40

def process_active?(pid)
  Process.kill(0, pid)
  true
rescue Errno::ESRCH
  false
rescue Errno::EPERM
  true
end

.stale?(lock_path, threshold_seconds = 10) ⇒ Boolean

Check if a lock file is stale (older than threshold)

Examples:

Fresh lock (active process)

File.utime(Time.now - 30, Time.now - 30, lock_path)
stale?(lock_path, 60)  # => false

Stale lock (orphaned)

File.utime(Time.now - 120, Time.now - 120, lock_path)
stale?(lock_path, 60)  # => true

Parameters:

  • lock_path (String)

    Path to the lock file

  • threshold_seconds (Integer) (defaults to: 10)

    Age threshold in seconds (default: 10)

Returns:

  • (Boolean)

    true if the lock file is stale



62
63
64
65
66
67
# File 'lib/ace/git/atoms/stale_lock_cleaner.rb', line 62

def stale?(lock_path, threshold_seconds = 10)
  age_seconds = Time.now - File.mtime(lock_path)
  age_seconds > threshold_seconds
rescue Errno::ENOENT
  false
end