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
-
.clean(repo_path, threshold_seconds = 10) ⇒ Hash
Clean a lock file if it is orphaned (dead PID) or stale (old age).
-
.find_lock_file(repo_path) ⇒ String?
Find index.lock file for a repository.
-
.lock_pid(lock_path) ⇒ Integer?
Extract PID from a git lock file.
-
.orphaned?(lock_path) ⇒ Boolean
Check if lock file is orphaned (owning process no longer exists).
-
.process_active?(pid) ⇒ Boolean
Check if a process exists (signal 0 = check only).
-
.stale?(lock_path, threshold_seconds = 10) ⇒ Boolean
Check if a lock file is stale (older than threshold).
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).
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.}"} end |
.find_lock_file(repo_path) ⇒ String?
Find index.lock file for a repository
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.(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.
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.
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)
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)
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 |