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.
- checksums.yaml +7 -0
- data/.ace-defaults/git/config.yml +83 -0
- data/.ace-defaults/nav/protocols/guide-sources/ace-git.yml +10 -0
- data/.ace-defaults/nav/protocols/tmpl-sources/ace-git.yml +19 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-git.yml +19 -0
- data/CHANGELOG.md +762 -0
- data/LICENSE +21 -0
- data/README.md +48 -0
- data/Rakefile +14 -0
- data/docs/demo/ace-git-getting-started.gif +0 -0
- data/docs/demo/ace-git-getting-started.tape.yml +18 -0
- data/docs/demo/fixtures/README.md +3 -0
- data/docs/demo/fixtures/sample.txt +1 -0
- data/docs/getting-started.md +87 -0
- data/docs/handbook.md +50 -0
- data/docs/usage.md +259 -0
- data/exe/ace-git +37 -0
- data/handbook/guides/version-control/ruby.md +41 -0
- data/handbook/guides/version-control/rust.md +49 -0
- data/handbook/guides/version-control/typescript.md +47 -0
- data/handbook/guides/version-control-system-git.g.md +829 -0
- data/handbook/skills/as-git-rebase/SKILL.md +43 -0
- data/handbook/skills/as-git-reorganize-commits/SKILL.md +41 -0
- data/handbook/skills/as-github-pr-create/SKILL.md +60 -0
- data/handbook/skills/as-github-pr-update/SKILL.md +41 -0
- data/handbook/skills/as-github-release-publish/SKILL.md +58 -0
- data/handbook/templates/commit/squash.template.md +59 -0
- data/handbook/templates/pr/bugfix.template.md +103 -0
- data/handbook/templates/pr/default.template.md +40 -0
- data/handbook/templates/pr/feature.template.md +41 -0
- data/handbook/workflow-instructions/git/rebase.wf.md +402 -0
- data/handbook/workflow-instructions/git/reorganize-commits.wf.md +158 -0
- data/handbook/workflow-instructions/github/pr/create.wf.md +282 -0
- data/handbook/workflow-instructions/github/pr/update.wf.md +199 -0
- data/handbook/workflow-instructions/github/release-publish.wf.md +162 -0
- data/lib/ace/git/atoms/command_executor.rb +253 -0
- data/lib/ace/git/atoms/date_resolver.rb +129 -0
- data/lib/ace/git/atoms/diff_numstat_parser.rb +82 -0
- data/lib/ace/git/atoms/diff_parser.rb +110 -0
- data/lib/ace/git/atoms/file_grouper.rb +152 -0
- data/lib/ace/git/atoms/git_scope_filter.rb +86 -0
- data/lib/ace/git/atoms/git_status_fetcher.rb +29 -0
- data/lib/ace/git/atoms/grouped_stats_formatter.rb +233 -0
- data/lib/ace/git/atoms/lock_error_detector.rb +79 -0
- data/lib/ace/git/atoms/pattern_filter.rb +156 -0
- data/lib/ace/git/atoms/pr_identifier_parser.rb +88 -0
- data/lib/ace/git/atoms/repository_checker.rb +97 -0
- data/lib/ace/git/atoms/repository_state_detector.rb +92 -0
- data/lib/ace/git/atoms/stale_lock_cleaner.rb +247 -0
- data/lib/ace/git/atoms/status_formatter.rb +180 -0
- data/lib/ace/git/atoms/task_pattern_extractor.rb +57 -0
- data/lib/ace/git/atoms/time_formatter.rb +84 -0
- data/lib/ace/git/cli/commands/branch.rb +62 -0
- data/lib/ace/git/cli/commands/diff.rb +252 -0
- data/lib/ace/git/cli/commands/pr.rb +119 -0
- data/lib/ace/git/cli/commands/status.rb +84 -0
- data/lib/ace/git/cli.rb +87 -0
- data/lib/ace/git/models/diff_config.rb +185 -0
- data/lib/ace/git/models/diff_result.rb +94 -0
- data/lib/ace/git/models/repo_status.rb +202 -0
- data/lib/ace/git/molecules/branch_reader.rb +92 -0
- data/lib/ace/git/molecules/config_loader.rb +108 -0
- data/lib/ace/git/molecules/diff_filter.rb +102 -0
- data/lib/ace/git/molecules/diff_generator.rb +160 -0
- data/lib/ace/git/molecules/git_status_fetcher.rb +32 -0
- data/lib/ace/git/molecules/pr_metadata_fetcher.rb +286 -0
- data/lib/ace/git/molecules/recent_commits_fetcher.rb +53 -0
- data/lib/ace/git/organisms/diff_orchestrator.rb +178 -0
- data/lib/ace/git/organisms/repo_status_loader.rb +264 -0
- data/lib/ace/git/version.rb +7 -0
- data/lib/ace/git.rb +230 -0
- 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
|