ace-git 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/git/config.yml +83 -0
  3. data/.ace-defaults/nav/protocols/guide-sources/ace-git.yml +10 -0
  4. data/.ace-defaults/nav/protocols/tmpl-sources/ace-git.yml +19 -0
  5. data/.ace-defaults/nav/protocols/wfi-sources/ace-git.yml +19 -0
  6. data/CHANGELOG.md +762 -0
  7. data/LICENSE +21 -0
  8. data/README.md +48 -0
  9. data/Rakefile +14 -0
  10. data/docs/demo/ace-git-getting-started.gif +0 -0
  11. data/docs/demo/ace-git-getting-started.tape.yml +18 -0
  12. data/docs/demo/fixtures/README.md +3 -0
  13. data/docs/demo/fixtures/sample.txt +1 -0
  14. data/docs/getting-started.md +87 -0
  15. data/docs/handbook.md +50 -0
  16. data/docs/usage.md +259 -0
  17. data/exe/ace-git +37 -0
  18. data/handbook/guides/version-control/ruby.md +41 -0
  19. data/handbook/guides/version-control/rust.md +49 -0
  20. data/handbook/guides/version-control/typescript.md +47 -0
  21. data/handbook/guides/version-control-system-git.g.md +829 -0
  22. data/handbook/skills/as-git-rebase/SKILL.md +43 -0
  23. data/handbook/skills/as-git-reorganize-commits/SKILL.md +41 -0
  24. data/handbook/skills/as-github-pr-create/SKILL.md +60 -0
  25. data/handbook/skills/as-github-pr-update/SKILL.md +41 -0
  26. data/handbook/skills/as-github-release-publish/SKILL.md +58 -0
  27. data/handbook/templates/commit/squash.template.md +59 -0
  28. data/handbook/templates/pr/bugfix.template.md +103 -0
  29. data/handbook/templates/pr/default.template.md +40 -0
  30. data/handbook/templates/pr/feature.template.md +41 -0
  31. data/handbook/workflow-instructions/git/rebase.wf.md +402 -0
  32. data/handbook/workflow-instructions/git/reorganize-commits.wf.md +158 -0
  33. data/handbook/workflow-instructions/github/pr/create.wf.md +282 -0
  34. data/handbook/workflow-instructions/github/pr/update.wf.md +199 -0
  35. data/handbook/workflow-instructions/github/release-publish.wf.md +162 -0
  36. data/lib/ace/git/atoms/command_executor.rb +253 -0
  37. data/lib/ace/git/atoms/date_resolver.rb +129 -0
  38. data/lib/ace/git/atoms/diff_numstat_parser.rb +82 -0
  39. data/lib/ace/git/atoms/diff_parser.rb +110 -0
  40. data/lib/ace/git/atoms/file_grouper.rb +152 -0
  41. data/lib/ace/git/atoms/git_scope_filter.rb +86 -0
  42. data/lib/ace/git/atoms/git_status_fetcher.rb +29 -0
  43. data/lib/ace/git/atoms/grouped_stats_formatter.rb +233 -0
  44. data/lib/ace/git/atoms/lock_error_detector.rb +79 -0
  45. data/lib/ace/git/atoms/pattern_filter.rb +156 -0
  46. data/lib/ace/git/atoms/pr_identifier_parser.rb +88 -0
  47. data/lib/ace/git/atoms/repository_checker.rb +97 -0
  48. data/lib/ace/git/atoms/repository_state_detector.rb +92 -0
  49. data/lib/ace/git/atoms/stale_lock_cleaner.rb +247 -0
  50. data/lib/ace/git/atoms/status_formatter.rb +180 -0
  51. data/lib/ace/git/atoms/task_pattern_extractor.rb +57 -0
  52. data/lib/ace/git/atoms/time_formatter.rb +84 -0
  53. data/lib/ace/git/cli/commands/branch.rb +62 -0
  54. data/lib/ace/git/cli/commands/diff.rb +252 -0
  55. data/lib/ace/git/cli/commands/pr.rb +119 -0
  56. data/lib/ace/git/cli/commands/status.rb +84 -0
  57. data/lib/ace/git/cli.rb +87 -0
  58. data/lib/ace/git/models/diff_config.rb +185 -0
  59. data/lib/ace/git/models/diff_result.rb +94 -0
  60. data/lib/ace/git/models/repo_status.rb +202 -0
  61. data/lib/ace/git/molecules/branch_reader.rb +92 -0
  62. data/lib/ace/git/molecules/config_loader.rb +108 -0
  63. data/lib/ace/git/molecules/diff_filter.rb +102 -0
  64. data/lib/ace/git/molecules/diff_generator.rb +160 -0
  65. data/lib/ace/git/molecules/git_status_fetcher.rb +32 -0
  66. data/lib/ace/git/molecules/pr_metadata_fetcher.rb +286 -0
  67. data/lib/ace/git/molecules/recent_commits_fetcher.rb +53 -0
  68. data/lib/ace/git/organisms/diff_orchestrator.rb +178 -0
  69. data/lib/ace/git/organisms/repo_status_loader.rb +264 -0
  70. data/lib/ace/git/version.rb +7 -0
  71. data/lib/ace/git.rb +230 -0
  72. metadata +201 -0
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Atoms
6
+ # Pure functions for pattern matching and filtering
7
+ # Migrated from ace-git-diff
8
+ module PatternFilter
9
+ # Maximum cache size to prevent unbounded memory growth in long-running processes
10
+ MAX_CACHE_SIZE = 100
11
+
12
+ # Cache for compiled regex patterns (performance optimization)
13
+ # Key: sorted pattern array joined by "|", Value: Array of compiled Regexp
14
+ # Uses FIFO eviction: oldest inserted entry removed when cache exceeds MAX_CACHE_SIZE
15
+ # Note: Ruby hashes preserve insertion order, so shift removes the oldest entry
16
+ @pattern_cache = {}
17
+ @cache_mutex = Mutex.new
18
+
19
+ class << self
20
+ # Convert glob patterns to regex patterns
21
+ # Uses caching for performance when same patterns are used repeatedly
22
+ # @param glob_patterns [Array<String>] Glob patterns like "test/**/*"
23
+ # @return [Array<Regexp>] Regex patterns for matching
24
+ def glob_to_regex(glob_patterns)
25
+ return [] if glob_patterns.nil? || glob_patterns.empty?
26
+
27
+ # Generate cache key from sorted patterns
28
+ cache_key = glob_patterns.sort.join("|")
29
+
30
+ # Check cache first (thread-safe read)
31
+ PatternFilter.instance_variable_get(:@cache_mutex).synchronize do
32
+ cache = PatternFilter.instance_variable_get(:@pattern_cache)
33
+ return cache[cache_key] if cache.key?(cache_key)
34
+
35
+ # Evict oldest entry if cache is full (FIFO eviction)
36
+ cache.shift if cache.size >= MAX_CACHE_SIZE
37
+
38
+ # Compile patterns and cache
39
+ patterns = glob_patterns.map { |pattern| compile_glob_pattern(pattern) }
40
+ cache[cache_key] = patterns
41
+ patterns
42
+ end
43
+ end
44
+
45
+ # Clear the pattern cache (mainly for testing)
46
+ def clear_cache!
47
+ PatternFilter.instance_variable_get(:@cache_mutex).synchronize do
48
+ PatternFilter.instance_variable_get(:@pattern_cache).clear
49
+ end
50
+ end
51
+
52
+ # Check if a file path should be excluded based on patterns
53
+ # @param file_path [String] Path to check
54
+ # @param patterns [Array<Regexp>] Regex patterns to match against
55
+ # @return [Boolean] True if path matches any exclude pattern
56
+ def should_exclude?(file_path, patterns)
57
+ return false if file_path.nil? || file_path.empty?
58
+ return false if patterns.nil? || patterns.empty?
59
+
60
+ patterns.any? { |pattern| file_path.match?(pattern) }
61
+ end
62
+
63
+ # Check if a line is a file header in git diff format
64
+ # @param line [String] Line to check
65
+ # @return [Boolean] True if line is a file header
66
+ def file_header?(line)
67
+ return false if line.nil? || line.empty?
68
+
69
+ line.start_with?("diff --git", "+++", "---") ||
70
+ line.match?(/^index [a-f0-9]+\.\.[a-f0-9]+/)
71
+ end
72
+
73
+ # Extract file path from diff header line
74
+ # @param line [String] Diff header line
75
+ # @return [String] Extracted file path or empty string
76
+ def extract_file_path(line)
77
+ return "" if line.nil? || line.empty?
78
+
79
+ case line
80
+ when /^diff --git a\/(.+) b\/(.+)$/
81
+ Regexp.last_match(2) # Use the 'b/' path (new file path)
82
+ when /^\+\+\+ b\/(.+)$/
83
+ Regexp.last_match(1)
84
+ when /^--- a\/(.+)$/
85
+ Regexp.last_match(1)
86
+ else
87
+ ""
88
+ end
89
+ end
90
+
91
+ # Filter paths from diff output based on exclude patterns
92
+ # @param diff [String] The diff content
93
+ # @param exclude_patterns [Array<Regexp>] Patterns to exclude
94
+ # @return [String] Filtered diff content
95
+ def filter_diff_by_patterns(diff, exclude_patterns)
96
+ return "" if diff.nil? || diff.empty?
97
+ return diff if exclude_patterns.nil? || exclude_patterns.empty?
98
+
99
+ lines = diff.split("\n")
100
+ filtered_lines = []
101
+ skip_until_next_file = false
102
+
103
+ lines.each do |line|
104
+ # Check if this is a file header
105
+ if file_header?(line)
106
+ file_path = extract_file_path(line)
107
+ if should_exclude?(file_path, exclude_patterns)
108
+ skip_until_next_file = true
109
+ else
110
+ skip_until_next_file = false
111
+ filtered_lines << line
112
+ end
113
+ elsif !skip_until_next_file
114
+ filtered_lines << line
115
+ end
116
+ end
117
+
118
+ filtered_lines.join("\n")
119
+ end
120
+
121
+ # Match a path against include patterns (glob)
122
+ # @param file_path [String] Path to check
123
+ # @param include_patterns [Array<String>] Glob patterns to include
124
+ # @return [Boolean] True if path matches any include pattern
125
+ def matches_include?(file_path, include_patterns)
126
+ return true if include_patterns.nil? || include_patterns.empty?
127
+ return false if file_path.nil? || file_path.empty?
128
+
129
+ regex_patterns = glob_to_regex(include_patterns)
130
+ regex_patterns.any? { |pattern| file_path.match?(pattern) }
131
+ end
132
+
133
+ private
134
+
135
+ # Compile a single glob pattern to regex
136
+ # @param pattern [String] Glob pattern
137
+ # @return [Regexp] Compiled regex
138
+ def compile_glob_pattern(pattern)
139
+ # Escape special regex characters except glob wildcards
140
+ regex_str = Regexp.escape(pattern)
141
+
142
+ # Convert escaped glob patterns to regex
143
+ regex_str = regex_str
144
+ .gsub('\\*\\*/', ".*") # **/ → .* (zero or more segments)
145
+ .gsub('\\*\\*', ".*") # ** → .* (zero or more segments)
146
+ .gsub('\\*', "[^/]*") # * → [^/]* (within segment)
147
+ .gsub('\\?', ".") # ? → . (single char)
148
+
149
+ # Anchor to start of path
150
+ Regexp.new("^#{regex_str}")
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Atoms
6
+ # Parse PR identifiers into structured format
7
+ #
8
+ # Supports three formats:
9
+ # - Simple number: "123"
10
+ # - Qualified reference: "owner/repo#456"
11
+ # - GitHub URL: "https://github.com/owner/repo/pull/789"
12
+ #
13
+ # Consolidated from ace-bundle PrIdentifierParser
14
+ module PrIdentifierParser
15
+ # Parsed PR identifier result
16
+ ParseResult = Data.define(:number, :repo, :gh_format)
17
+
18
+ # Parse a PR identifier string
19
+ #
20
+ # Returns nil for nil/empty input (no PR specified), raises ArgumentError for
21
+ # invalid formats. This design allows callers to distinguish between "no PR"
22
+ # (nil input -> nil result) and "invalid PR" (bad format -> exception).
23
+ #
24
+ # @param input [String, Integer] PR identifier
25
+ # @return [ParseResult, nil] Parsed identifier with number, repo, and gh_format,
26
+ # or nil if input is nil/empty
27
+ # @raise [ArgumentError] if identifier format is invalid (non-empty but malformed)
28
+ #
29
+ # @example Simple number
30
+ # parse(123)
31
+ # # => ParseResult(number: "123", repo: nil, gh_format: "123")
32
+ #
33
+ # @example Qualified reference
34
+ # parse("owner/repo#456")
35
+ # # => ParseResult(number: "456", repo: "owner/repo", gh_format: "owner/repo#456")
36
+ #
37
+ # @example GitHub URL
38
+ # parse("https://github.com/owner/repo/pull/789")
39
+ # # => ParseResult(number: "789", repo: "owner/repo", gh_format: "owner/repo#789")
40
+ #
41
+ # @example Nil/empty input
42
+ # parse(nil) # => nil
43
+ # parse("") # => nil
44
+ # parse(" ") # => nil
45
+ # Maximum length for PR identifier to prevent ReDoS attacks
46
+ # GitHub URLs are typically under 200 chars, this provides generous margin
47
+ MAX_IDENTIFIER_LENGTH = 256
48
+
49
+ def self.parse(input)
50
+ return nil if input.nil?
51
+
52
+ input_str = input.to_s.strip
53
+ return nil if input_str.empty?
54
+
55
+ # Validate length to prevent ReDoS attacks on regex patterns
56
+ if input_str.length > MAX_IDENTIFIER_LENGTH
57
+ raise ArgumentError, "PR identifier too long (max #{MAX_IDENTIFIER_LENGTH} characters)"
58
+ end
59
+
60
+ case input_str
61
+ when /^(\d+)$/
62
+ # Simple PR number: "123"
63
+ number = ::Regexp.last_match(1)
64
+ # Reject zero: GitHub PR numbers are positive integers starting from 1
65
+ raise ArgumentError, "Invalid PR identifier format: #{input_str}" if number.to_i.zero?
66
+ # Normalize to canonical form (strip leading zeros) for consistent gh_format
67
+ canonical_number = number.to_i.to_s
68
+ ParseResult.new(number: canonical_number, repo: nil, gh_format: canonical_number)
69
+
70
+ when /^(?<repo>[a-zA-Z0-9_\-.]+\/[a-zA-Z0-9_\-.]+)#(?<number>\d+)$/
71
+ # Qualified reference: "owner/repo#456"
72
+ # GitHub owner/repo names: alphanumeric, hyphens, underscores, dots only
73
+ match = ::Regexp.last_match
74
+ ParseResult.new(number: match[:number], repo: match[:repo], gh_format: "#{match[:repo]}##{match[:number]}")
75
+
76
+ when %r{github\.com/(?<repo>[^/]+/[^/]+)/pull/(?<number>\d+)}
77
+ # GitHub URL: "https://github.com/owner/repo/pull/789"
78
+ match = ::Regexp.last_match
79
+ ParseResult.new(number: match[:number], repo: match[:repo], gh_format: "#{match[:repo]}##{match[:number]}")
80
+
81
+ else
82
+ raise ArgumentError, "Invalid PR identifier format: #{input_str}"
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Atoms
6
+ # Check repository status: detached HEAD, bare repo, nested worktree
7
+ # Pure function that uses CommandExecutor for git commands
8
+ module RepositoryChecker
9
+ class << self
10
+ # Check if in a git repository
11
+ # @param executor [Module] Command executor
12
+ # @return [Boolean] True if in git repo
13
+ def in_git_repo?(executor: CommandExecutor)
14
+ executor.in_git_repo?
15
+ end
16
+
17
+ # Check if HEAD is detached
18
+ # @param executor [Module] Command executor
19
+ # @return [Boolean] True if HEAD is detached
20
+ def detached_head?(executor: CommandExecutor)
21
+ return false unless in_git_repo?(executor: executor)
22
+
23
+ result = executor.execute("git", "symbolic-ref", "-q", "HEAD")
24
+ # If symbolic-ref fails, HEAD is detached
25
+ !result[:success]
26
+ end
27
+
28
+ # Check if repository is bare
29
+ # @param executor [Module] Command executor
30
+ # @return [Boolean] True if bare repository
31
+ def bare_repository?(executor: CommandExecutor)
32
+ return false unless in_git_repo?(executor: executor)
33
+
34
+ result = executor.execute("git", "rev-parse", "--is-bare-repository")
35
+ result[:success] && result[:output].strip == "true"
36
+ end
37
+
38
+ # Check if current directory is in a git worktree (not main repo)
39
+ # @param executor [Module] Command executor
40
+ # @return [Boolean] True if in worktree
41
+ def in_worktree?(executor: CommandExecutor)
42
+ return false unless in_git_repo?(executor: executor)
43
+
44
+ # Get git directory and common directory
45
+ git_dir = executor.execute("git", "rev-parse", "--git-dir")
46
+ return false unless git_dir[:success]
47
+
48
+ git_path = git_dir[:output].strip
49
+
50
+ # In worktrees, git-dir contains "worktrees/"
51
+ git_path.include?("/worktrees/")
52
+ end
53
+
54
+ # Get repository type description
55
+ # @param executor [Module] Command executor
56
+ # @return [Symbol] :normal, :detached, :bare, :worktree, :not_git
57
+ def repository_type(executor: CommandExecutor)
58
+ return :not_git unless in_git_repo?(executor: executor)
59
+ return :bare if bare_repository?(executor: executor)
60
+ return :worktree if in_worktree?(executor: executor)
61
+ return :detached if detached_head?(executor: executor)
62
+
63
+ :normal
64
+ end
65
+
66
+ # Get human-readable repository status
67
+ # @param executor [Module] Command executor
68
+ # @return [String] Status description
69
+ def status_description(executor: CommandExecutor)
70
+ case repository_type(executor: executor)
71
+ when :normal
72
+ "normal repository"
73
+ when :detached
74
+ "detached HEAD state"
75
+ when :bare
76
+ "bare repository"
77
+ when :worktree
78
+ "git worktree"
79
+ else
80
+ "not a git repository"
81
+ end
82
+ end
83
+
84
+ # Check if repository is in a usable state for typical git operations
85
+ # @param executor [Module] Command executor
86
+ # @return [Boolean] True if usable
87
+ def usable?(executor: CommandExecutor)
88
+ return false unless in_git_repo?(executor: executor)
89
+ return false if bare_repository?(executor: executor)
90
+
91
+ true
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Atoms
6
+ # Detect repository state: clean, dirty, rebasing, merging
7
+ # Pure function that uses CommandExecutor for git commands
8
+ module RepositoryStateDetector
9
+ class << self
10
+ # Detect current repository state
11
+ # @param executor [Module] Command executor (default: CommandExecutor)
12
+ # @return [Symbol] One of :clean, :dirty, :rebasing, :merging, :unknown
13
+ def detect(executor: CommandExecutor)
14
+ return :unknown unless executor.in_git_repo?
15
+
16
+ # Check for rebase in progress
17
+ return :rebasing if rebasing?(executor: executor)
18
+
19
+ # Check for merge in progress
20
+ return :merging if merging?(executor: executor)
21
+
22
+ # Check for uncommitted changes
23
+ return :dirty if dirty?(executor: executor)
24
+
25
+ :clean
26
+ end
27
+
28
+ # Check if repository is in a clean state
29
+ # @param executor [Module] Command executor
30
+ # @return [Boolean] True if clean
31
+ def clean?(executor: CommandExecutor)
32
+ detect(executor: executor) == :clean
33
+ end
34
+
35
+ # Check if repository has uncommitted changes
36
+ # @param executor [Module] Command executor
37
+ # @return [Boolean] True if dirty
38
+ def dirty?(executor: CommandExecutor)
39
+ # Check git status for changes
40
+ result = executor.execute("git", "status", "--porcelain")
41
+ return false unless result[:success]
42
+
43
+ !result[:output].strip.empty?
44
+ end
45
+
46
+ # Check if repository is in rebase state
47
+ # @param executor [Module] Command executor
48
+ # @return [Boolean] True if rebasing
49
+ def rebasing?(executor: CommandExecutor)
50
+ # Check for rebase-apply or rebase-merge directories
51
+ git_dir = executor.execute("git", "rev-parse", "--git-dir")
52
+ return false unless git_dir[:success]
53
+
54
+ git_path = git_dir[:output].strip
55
+ File.exist?(File.join(git_path, "rebase-apply")) ||
56
+ File.exist?(File.join(git_path, "rebase-merge"))
57
+ end
58
+
59
+ # Check if repository is in merge state
60
+ # @param executor [Module] Command executor
61
+ # @return [Boolean] True if merging
62
+ def merging?(executor: CommandExecutor)
63
+ # Check for MERGE_HEAD file
64
+ git_dir = executor.execute("git", "rev-parse", "--git-dir")
65
+ return false unless git_dir[:success]
66
+
67
+ git_path = git_dir[:output].strip
68
+ File.exist?(File.join(git_path, "MERGE_HEAD"))
69
+ end
70
+
71
+ # Get human-readable state description
72
+ # @param executor [Module] Command executor
73
+ # @return [String] State description
74
+ def state_description(executor: CommandExecutor)
75
+ case detect(executor: executor)
76
+ when :clean
77
+ "clean (no uncommitted changes)"
78
+ when :dirty
79
+ "dirty (uncommitted changes)"
80
+ when :rebasing
81
+ "rebasing in progress"
82
+ when :merging
83
+ "merge in progress"
84
+ else
85
+ "unknown state"
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Atoms
6
+ # Pure functions for detecting and cleaning stale git index lock files
7
+ #
8
+ # Git index.lock files can become stale when:
9
+ # - Previous git operations were interrupted (Ctrl+C, crashes, timeouts)
10
+ # - Agents are blocked mid-operation leaving orphan lock files
11
+ #
12
+ # A stale lock is one that hasn't been modified recently (>10 seconds by default),
13
+ # indicating the owning process is no longer active.
14
+ #
15
+ # Lock File Format:
16
+ # Git lock files contain the PID and hostname of the process that created them.
17
+ # We use PID-based detection (checking if owning process is still running) as
18
+ # the primary method, with age-based detection as a fallback for edge cases
19
+ # (remote mounts, containers where PID check may not work).
20
+ module StaleLockCleaner
21
+ class << self
22
+ # Extract PID from a git lock file
23
+ #
24
+ # Git lock files contain the PID on the first line, optionally followed by hostname.
25
+ # @param lock_path [String] Path to the lock file
26
+ # @return [Integer, nil] PID if present and positive, otherwise nil
27
+ def lock_pid(lock_path)
28
+ content = File.read(lock_path)
29
+ pid = content.to_s.split.first.to_i
30
+ (pid > 0) ? pid : nil
31
+ rescue Errno::ENOENT
32
+ nil
33
+ rescue
34
+ nil
35
+ end
36
+
37
+ # Check if a process exists (signal 0 = check only)
38
+ # @param pid [Integer] Process ID
39
+ # @return [Boolean] true if process exists, false if not
40
+ def process_active?(pid)
41
+ Process.kill(0, pid)
42
+ true
43
+ rescue Errno::ESRCH
44
+ false
45
+ rescue Errno::EPERM
46
+ true
47
+ end
48
+
49
+ # Check if a lock file is stale (older than threshold)
50
+ #
51
+ # @param lock_path [String] Path to the lock file
52
+ # @param threshold_seconds [Integer] Age threshold in seconds (default: 10)
53
+ # @return [Boolean] true if the lock file is stale
54
+ #
55
+ # @example Fresh lock (active process)
56
+ # File.utime(Time.now - 30, Time.now - 30, lock_path)
57
+ # stale?(lock_path, 60) # => false
58
+ #
59
+ # @example Stale lock (orphaned)
60
+ # File.utime(Time.now - 120, Time.now - 120, lock_path)
61
+ # stale?(lock_path, 60) # => true
62
+ def stale?(lock_path, threshold_seconds = 10)
63
+ age_seconds = Time.now - File.mtime(lock_path)
64
+ age_seconds > threshold_seconds
65
+ rescue Errno::ENOENT
66
+ false
67
+ end
68
+
69
+ # Check if lock file is orphaned (owning process no longer exists)
70
+ #
71
+ # Git lock files contain the PID of the process that created them.
72
+ # If that process no longer exists, the lock is orphaned and safe to delete.
73
+ #
74
+ # @param lock_path [String] Path to the lock file
75
+ # @return [Boolean] true if the lock is orphaned (PID doesn't exist)
76
+ #
77
+ # @example Orphaned lock (process crashed)
78
+ # File.write(lock_path, "99999") # Non-existent PID
79
+ # orphaned?(lock_path) # => true
80
+ #
81
+ # @example Active lock (process running)
82
+ # File.write(lock_path, Process.pid.to_s)
83
+ # orphaned?(lock_path) # => false
84
+ def orphaned?(lock_path)
85
+ pid = lock_pid(lock_path)
86
+ return false unless pid
87
+
88
+ !process_active?(pid)
89
+ end
90
+
91
+ # Find index.lock file for a repository
92
+ #
93
+ # @param repo_path [String] Path to the git repository
94
+ # @return [String, nil] Path to the index.lock file or nil if not found
95
+ #
96
+ # @example In main repo
97
+ # find_lock_file("/path/to/repo")
98
+ # # => "/path/to/repo/.git/index.lock"
99
+ #
100
+ # @example In worktree
101
+ # find_lock_file("/path/to/worktree")
102
+ # # => "/path/to/worktree/.git/index.lock"
103
+ def find_lock_file(repo_path)
104
+ return nil if repo_path.nil? || repo_path.empty?
105
+
106
+ # First try to find .git directory
107
+ git_dir = File.join(repo_path, ".git")
108
+
109
+ # If .git is a file (worktree), read the gitdir path
110
+ if File.file?(git_dir)
111
+ git_file_path = git_dir
112
+ git_dir_content = File.read(git_file_path)
113
+ # Worktree .git files contain: "gitdir: /path/to/main/.git/worktrees/..."
114
+ # Use greedy match to capture full path including spaces, trim trailing whitespace
115
+ if git_dir_content =~ /^gitdir:\s*(.+)\s*$/
116
+ raw_git_dir = Regexp.last_match(1).strip
117
+ # Handle relative paths by expanding from .git file location
118
+ git_dir = File.expand_path(raw_git_dir, File.dirname(git_file_path))
119
+ else
120
+ return nil
121
+ end
122
+ end
123
+
124
+ # Check if git directory exists
125
+ return nil unless File.directory?(git_dir)
126
+
127
+ # Return path to index.lock
128
+ lock_path = File.join(git_dir, "index.lock")
129
+ File.exist?(lock_path) ? lock_path : nil
130
+ rescue
131
+ nil
132
+ end
133
+
134
+ # Clean a lock file if it is orphaned (dead PID) or stale (old age)
135
+ #
136
+ # Uses PID-based detection first (instant), then falls back to age-based
137
+ # detection for edge cases (remote mounts, containers).
138
+ #
139
+ # @param repo_path [String] Path to the git repository
140
+ # @param threshold_seconds [Integer] Age threshold for stale detection
141
+ # @return [Hash] Result with :success, :cleaned, :message
142
+ #
143
+ # @example Cleaned orphaned lock (dead PID)
144
+ # clean("/path/to/repo", 60)
145
+ # # => { success: true, cleaned: true, message: "Removed orphaned lock..." }
146
+ #
147
+ # @example Cleaned stale lock (old age)
148
+ # clean("/path/to/repo", 60)
149
+ # # => { success: true, cleaned: true, message: "Removed stale lock..." }
150
+ #
151
+ # @example No lock to clean
152
+ # clean("/path/to/repo", 60)
153
+ # # => { success: true, cleaned: false, message: "No lock found" }
154
+ #
155
+ # @example Lock is active (PID running, fresh)
156
+ # clean("/path/to/repo", 60)
157
+ # # => { success: true, cleaned: false, message: "Lock is active..." }
158
+ def clean(repo_path, threshold_seconds = 10)
159
+ lock_path = find_lock_file(repo_path)
160
+
161
+ if lock_path.nil?
162
+ return {success: true, cleaned: false, status: :missing, pid: nil, age_seconds: nil,
163
+ message: "No lock file found"}
164
+ end
165
+
166
+ # Security check: ensure lock file is a regular file, not a symlink
167
+ # This prevents accidental deletion of symlink targets, which could be
168
+ # exploited to cause data loss or security issues.
169
+ if File.symlink?(lock_path)
170
+ return {success: false, cleaned: false, status: :symlink, pid: nil, age_seconds: nil,
171
+ message: "Lock file is a symlink, refusing to delete: #{lock_path}"}
172
+ end
173
+
174
+ # Safety check: ensure it's a regular file (not directory or device)
175
+ unless File.file?(lock_path)
176
+ return {success: false, cleaned: false, status: :invalid, pid: nil, age_seconds: nil,
177
+ message: "Lock path is not a regular file: #{lock_path}"}
178
+ end
179
+
180
+ pid = lock_pid(lock_path)
181
+ age_seconds = begin
182
+ Time.now - File.mtime(lock_path)
183
+ rescue
184
+ nil
185
+ end
186
+
187
+ pid_active = pid ? process_active?(pid) : false
188
+
189
+ # Check PID-based activity first
190
+ if pid_active
191
+ return {
192
+ success: true,
193
+ cleaned: false,
194
+ status: :active,
195
+ pid: pid,
196
+ age_seconds: age_seconds,
197
+ message: "Lock file is active (PID running, < #{threshold_seconds}s old)"
198
+ }
199
+ end
200
+
201
+ # Orphaned PID should be removed immediately
202
+ if pid && !pid_active
203
+ File.delete(lock_path)
204
+ return {
205
+ success: true,
206
+ cleaned: true,
207
+ status: :orphaned,
208
+ pid: pid,
209
+ age_seconds: age_seconds,
210
+ message: "Removed orphaned lock file (dead PID): #{lock_path}"
211
+ }
212
+ end
213
+
214
+ # Fallback to age-based stale detection
215
+ if stale?(lock_path, threshold_seconds)
216
+ File.delete(lock_path)
217
+ return {
218
+ success: true,
219
+ cleaned: true,
220
+ status: :stale,
221
+ pid: pid,
222
+ age_seconds: age_seconds,
223
+ message: "Removed stale lock file: #{lock_path}"
224
+ }
225
+ end
226
+
227
+ {
228
+ success: true,
229
+ cleaned: false,
230
+ status: :unknown,
231
+ pid: pid,
232
+ age_seconds: age_seconds,
233
+ message: "Lock file present but status unclear (< #{threshold_seconds}s old)"
234
+ }
235
+ rescue Errno::ENOENT
236
+ # Handle TOCTOU race: lock file was deleted between check and delete
237
+ {success: true, cleaned: false, status: :missing, pid: nil, age_seconds: nil,
238
+ message: "Lock file already removed"}
239
+ rescue => e
240
+ {success: false, cleaned: false, status: :error, pid: nil, age_seconds: nil,
241
+ message: "Failed to clean lock: #{e.message}"}
242
+ end
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end