ace-git-commit 0.23.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/commit.yml +22 -0
- data/.ace-defaults/nav/protocols/guide-sources/ace-git-commit.yml +10 -0
- data/.ace-defaults/nav/protocols/prompt-sources/ace-git-commit.yml +19 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-git-commit.yml +19 -0
- data/CHANGELOG.md +404 -0
- data/COMPARISON.md +176 -0
- data/LICENSE +21 -0
- data/README.md +44 -0
- data/Rakefile +14 -0
- data/exe/ace-git-commit +13 -0
- data/handbook/guides/version-control-system-message.g.md +507 -0
- data/handbook/prompts/git-commit.md +22 -0
- data/handbook/prompts/git-commit.system.md +150 -0
- data/handbook/skills/as-git-commit/SKILL.md +57 -0
- data/handbook/workflow-instructions/git/commit.wf.md +75 -0
- data/lib/ace/git_commit/atoms/git_executor.rb +62 -0
- data/lib/ace/git_commit/atoms/gitignore_checker.rb +118 -0
- data/lib/ace/git_commit/cli/commands/commit.rb +147 -0
- data/lib/ace/git_commit/cli.rb +23 -0
- data/lib/ace/git_commit/models/commit_group.rb +53 -0
- data/lib/ace/git_commit/models/commit_options.rb +75 -0
- data/lib/ace/git_commit/models/split_commit_result.rb +60 -0
- data/lib/ace/git_commit/models/stage_result.rb +71 -0
- data/lib/ace/git_commit/molecules/commit_grouper.rb +123 -0
- data/lib/ace/git_commit/molecules/commit_summarizer.rb +43 -0
- data/lib/ace/git_commit/molecules/diff_analyzer.rb +111 -0
- data/lib/ace/git_commit/molecules/file_stager.rb +153 -0
- data/lib/ace/git_commit/molecules/message_generator.rb +438 -0
- data/lib/ace/git_commit/molecules/path_resolver.rb +365 -0
- data/lib/ace/git_commit/molecules/split_commit_executor.rb +272 -0
- data/lib/ace/git_commit/organisms/commit_orchestrator.rb +330 -0
- data/lib/ace/git_commit/version.rb +7 -0
- data/lib/ace/git_commit.rb +41 -0
- metadata +149 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module GitCommit
|
|
5
|
+
module Models
|
|
6
|
+
# CommitOptions encapsulates all options for a commit operation
|
|
7
|
+
class CommitOptions
|
|
8
|
+
attr_accessor :intention, :message, :model, :files,
|
|
9
|
+
:only_staged, :dry_run, :debug, :force, :verbose, :quiet, :no_split
|
|
10
|
+
|
|
11
|
+
def initialize(
|
|
12
|
+
intention: nil,
|
|
13
|
+
message: nil,
|
|
14
|
+
model: nil,
|
|
15
|
+
files: [],
|
|
16
|
+
only_staged: false,
|
|
17
|
+
dry_run: false,
|
|
18
|
+
debug: false,
|
|
19
|
+
force: false,
|
|
20
|
+
verbose: true,
|
|
21
|
+
quiet: false,
|
|
22
|
+
no_split: false
|
|
23
|
+
)
|
|
24
|
+
@intention = intention
|
|
25
|
+
@message = message
|
|
26
|
+
@model = model
|
|
27
|
+
@files = files || []
|
|
28
|
+
@only_staged = only_staged
|
|
29
|
+
@dry_run = dry_run
|
|
30
|
+
@debug = debug
|
|
31
|
+
@force = force
|
|
32
|
+
@verbose = verbose
|
|
33
|
+
@quiet = quiet
|
|
34
|
+
@no_split = no_split
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Check if we should use LLM generation
|
|
38
|
+
# @return [Boolean] True if LLM should be used
|
|
39
|
+
def use_llm?
|
|
40
|
+
@message.nil? || @message.empty?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Check if specific files are targeted
|
|
44
|
+
# @return [Boolean] True if specific files provided
|
|
45
|
+
def specific_files?
|
|
46
|
+
!@files.empty?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Check if we should stage all changes
|
|
50
|
+
# @return [Boolean] True if all changes should be staged
|
|
51
|
+
def stage_all?
|
|
52
|
+
!@only_staged && !specific_files?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Convert to hash for debugging
|
|
56
|
+
# @return [Hash] Options as hash
|
|
57
|
+
def to_h
|
|
58
|
+
{
|
|
59
|
+
intention: @intention,
|
|
60
|
+
message: @message,
|
|
61
|
+
model: @model,
|
|
62
|
+
files: @files,
|
|
63
|
+
only_staged: @only_staged,
|
|
64
|
+
dry_run: @dry_run,
|
|
65
|
+
debug: @debug,
|
|
66
|
+
force: @force,
|
|
67
|
+
verbose: @verbose,
|
|
68
|
+
quiet: @quiet,
|
|
69
|
+
no_split: @no_split
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module GitCommit
|
|
5
|
+
module Models
|
|
6
|
+
# SplitCommitResult tracks results for split commit execution
|
|
7
|
+
class SplitCommitResult
|
|
8
|
+
CommitRecord = Struct.new(:group, :sha, :status, :error, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
attr_reader :records, :original_head, :rollback_error
|
|
11
|
+
|
|
12
|
+
def initialize(original_head: nil)
|
|
13
|
+
@records = []
|
|
14
|
+
@original_head = original_head
|
|
15
|
+
@rollback_error = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def add_success(group, sha)
|
|
19
|
+
@records << CommitRecord.new(group: group, sha: sha, status: :success, error: nil)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def add_dry_run(group)
|
|
23
|
+
@records << CommitRecord.new(group: group, sha: nil, status: :dry_run, error: nil)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def add_failure(group, error)
|
|
27
|
+
@records << CommitRecord.new(group: group, sha: nil, status: :failure, error: error)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def add_skipped(group, reason)
|
|
31
|
+
@records << CommitRecord.new(group: group, sha: nil, status: :skipped, error: reason)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def commit_shas
|
|
35
|
+
records.map(&:sha).compact
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def success?
|
|
39
|
+
records.all? { |record| record.status == :success || record.status == :dry_run || record.status == :skipped }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def skipped?
|
|
43
|
+
records.any? { |record| record.status == :skipped }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def dry_run?
|
|
47
|
+
records.any? { |record| record.status == :dry_run }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def failed?
|
|
51
|
+
!success?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def mark_rollback_error(error)
|
|
55
|
+
@rollback_error = error
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module GitCommit
|
|
5
|
+
module Models
|
|
6
|
+
# StageResult represents the outcome of staging a file or set of files
|
|
7
|
+
class StageResult
|
|
8
|
+
attr_reader :file_path, :success, :error_message, :file_size, :status
|
|
9
|
+
|
|
10
|
+
def initialize(file_path:, success:, error_message: nil, file_size: nil, status: nil)
|
|
11
|
+
@file_path = file_path
|
|
12
|
+
@success = success
|
|
13
|
+
@error_message = error_message
|
|
14
|
+
@file_size = file_size
|
|
15
|
+
@status = status # :modified, :new, :deleted, etc.
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Check if staging was successful
|
|
19
|
+
# @return [Boolean] True if successful
|
|
20
|
+
def success?
|
|
21
|
+
@success
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Check if this is a large file (>50MB)
|
|
25
|
+
# @return [Boolean] True if file is large
|
|
26
|
+
def large_file?
|
|
27
|
+
return false unless @file_size
|
|
28
|
+
@file_size > 50 * 1024 * 1024 # 50MB in bytes
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Get a human-readable file size
|
|
32
|
+
# @return [String] File size with unit
|
|
33
|
+
def human_file_size
|
|
34
|
+
return nil unless @file_size
|
|
35
|
+
|
|
36
|
+
if @file_size < 1024
|
|
37
|
+
"#{@file_size} B"
|
|
38
|
+
elsif @file_size < 1024 * 1024
|
|
39
|
+
"#{(@file_size / 1024.0).round(1)} KB"
|
|
40
|
+
elsif @file_size < 1024 * 1024 * 1024
|
|
41
|
+
"#{(@file_size / (1024.0 * 1024.0)).round(2)} MB"
|
|
42
|
+
else
|
|
43
|
+
"#{(@file_size / (1024.0 * 1024.0 * 1024.0)).round(2)} GB"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Get status indicator emoji
|
|
48
|
+
# @return [String] Status emoji
|
|
49
|
+
def status_indicator
|
|
50
|
+
if success?
|
|
51
|
+
"✓"
|
|
52
|
+
else
|
|
53
|
+
"✗"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Convert to hash for debugging
|
|
58
|
+
# @return [Hash] Result as hash
|
|
59
|
+
def to_h
|
|
60
|
+
{
|
|
61
|
+
file_path: @file_path,
|
|
62
|
+
success: @success,
|
|
63
|
+
error_message: @error_message,
|
|
64
|
+
file_size: @file_size,
|
|
65
|
+
status: @status
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module GitCommit
|
|
5
|
+
module Molecules
|
|
6
|
+
# CommitGrouper groups files by effective configuration
|
|
7
|
+
class CommitGrouper
|
|
8
|
+
# Reference the default scope name constant
|
|
9
|
+
DEFAULT_SCOPE_NAME = Ace::Support::Config::Models::ConfigGroup::DEFAULT_SCOPE_NAME
|
|
10
|
+
def initialize(file_config_resolver: nil)
|
|
11
|
+
@file_config_resolver = file_config_resolver || default_config_resolver
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Group files by scope name and config signature
|
|
15
|
+
# Files with the same scope name but different configs are kept separate
|
|
16
|
+
# @param files [Array<String>] File paths (relative)
|
|
17
|
+
# @param project_root [String, nil] Project root path for scope name derivation
|
|
18
|
+
# @return [Array<Models::CommitGroup>] Grouped commits
|
|
19
|
+
def group(files, project_root: nil)
|
|
20
|
+
groups = {}
|
|
21
|
+
|
|
22
|
+
Array(files).each do |file|
|
|
23
|
+
resolved = @file_config_resolver.resolve(file, namespace: "git", filename: "commit", project_root: project_root)
|
|
24
|
+
# .ace/ config files always group into "ace-config" scope
|
|
25
|
+
scope_name = if ace_config_file?(file)
|
|
26
|
+
"ace-config"
|
|
27
|
+
else
|
|
28
|
+
# Derive scope name: use path-based derivation for distributed configs,
|
|
29
|
+
# otherwise keep the resolved name (path rule name or DEFAULT_SCOPE_NAME)
|
|
30
|
+
derive_scope_name(resolved.source, resolved.name, project_root)
|
|
31
|
+
end
|
|
32
|
+
# Group by scope_name AND config signature to prevent merging scopes with different configs
|
|
33
|
+
# Exception: DEFAULT_SCOPE_NAME always groups together regardless of config (to avoid duplicates)
|
|
34
|
+
# For path rules: use rule_config for grouping (ignores cascade differences like per-package model)
|
|
35
|
+
# For distributed configs: use full config (package-specific configs matter)
|
|
36
|
+
grouping_config = resolved.rule_config || resolved.config
|
|
37
|
+
config_sig = Models::CommitGroup.signature_for(grouping_config)
|
|
38
|
+
key = (scope_name == DEFAULT_SCOPE_NAME) ? scope_name : "#{scope_name}::#{config_sig}"
|
|
39
|
+
|
|
40
|
+
group = groups[key] ||= Models::CommitGroup.new(
|
|
41
|
+
scope_name: scope_name,
|
|
42
|
+
source: resolved.source,
|
|
43
|
+
config: resolved.config,
|
|
44
|
+
files: []
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
group.add_file(file)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
groups.values.each { |group| group.files.sort! }
|
|
51
|
+
groups.values.sort_by { |group| sort_key(group) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Derive scope name from config source path
|
|
55
|
+
# For distributed configs (package/.ace/), derive from path
|
|
56
|
+
# For path rules or root config, keep the resolved name
|
|
57
|
+
# @param source_path [String, nil] Full path to config file (may be compound "path1 -> path2")
|
|
58
|
+
# @param resolved_name [String] Name from FileConfigResolver (path rule name or DEFAULT_SCOPE_NAME)
|
|
59
|
+
# @param project_root [String, nil] Project root path
|
|
60
|
+
# @return [String] Scope name
|
|
61
|
+
def derive_scope_name(source_path, resolved_name, project_root)
|
|
62
|
+
# Path rule names take precedence over distributed config derivation
|
|
63
|
+
# This ensures inherited rules like "ace-config" are preserved
|
|
64
|
+
return resolved_name if resolved_name && resolved_name != DEFAULT_SCOPE_NAME
|
|
65
|
+
|
|
66
|
+
return resolved_name unless source_path && project_root
|
|
67
|
+
|
|
68
|
+
# Filter to only .ace/ paths (ignore .ace-defaults completely for scope derivation)
|
|
69
|
+
# .ace-defaults provides default VALUES only, not SCOPE
|
|
70
|
+
ace_sources = source_path.split(" -> ").reject { |p| p.include?(".ace-defaults") }
|
|
71
|
+
return resolved_name if ace_sources.empty?
|
|
72
|
+
|
|
73
|
+
primary_source = ace_sources.first
|
|
74
|
+
|
|
75
|
+
# Remove project root prefix to get relative path
|
|
76
|
+
relative = primary_source.sub("#{project_root}/", "")
|
|
77
|
+
|
|
78
|
+
# If still absolute or unchanged, keep resolved name
|
|
79
|
+
return resolved_name if relative == primary_source || relative.start_with?("/")
|
|
80
|
+
|
|
81
|
+
# Check if this is a distributed config (package/.ace/)
|
|
82
|
+
# "ace-bundle/.ace/git/commit.yml" → ["ace-bundle", "git/commit.yml"]
|
|
83
|
+
# ".ace/git/commit.yml" → ["", "git/commit.yml"] (root config)
|
|
84
|
+
parts = relative.split("/.ace/")
|
|
85
|
+
package_name = parts.first
|
|
86
|
+
|
|
87
|
+
# If empty or starts with .ace, it's root config - keep resolved name (path rule or default)
|
|
88
|
+
return resolved_name if package_name.nil? || package_name.empty? || package_name.start_with?(".ace")
|
|
89
|
+
|
|
90
|
+
# This is a distributed config - derive scope from package path
|
|
91
|
+
package_name
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Check if a file path is inside a .ace/ directory
|
|
95
|
+
def ace_config_file?(file)
|
|
96
|
+
file.include?("/.ace/") || file.start_with?(".ace/")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def default_config_resolver
|
|
102
|
+
gem_root = Gem.loaded_specs["ace-git-commit"]&.gem_dir ||
|
|
103
|
+
File.expand_path("../../../..", __dir__)
|
|
104
|
+
|
|
105
|
+
Ace::Support::Config::Molecules::FileConfigResolver.new(
|
|
106
|
+
config_dir: ".ace",
|
|
107
|
+
defaults_dir: ".ace-defaults",
|
|
108
|
+
gem_path: gem_root
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def sort_key(group)
|
|
113
|
+
name = group.scope_name.to_s
|
|
114
|
+
if name.empty? || name == DEFAULT_SCOPE_NAME || name == "no package"
|
|
115
|
+
[1, name]
|
|
116
|
+
else
|
|
117
|
+
[0, name]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module GitCommit
|
|
5
|
+
module Molecules
|
|
6
|
+
# CommitSummarizer generates human-readable commit summaries
|
|
7
|
+
# using git's native formatting commands
|
|
8
|
+
class CommitSummarizer
|
|
9
|
+
def initialize(git_executor)
|
|
10
|
+
@git = git_executor
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Generate a formatted summary for a commit
|
|
14
|
+
# @param commit_sha [String] The commit SHA to summarize (e.g., "HEAD", "abc123")
|
|
15
|
+
# @return [String] Formatted commit summary with hash, message, and file stats
|
|
16
|
+
def summarize(commit_sha)
|
|
17
|
+
# Get commit info: hash (refs) message
|
|
18
|
+
commit_line = @git.execute("log", "--oneline", commit_sha, "-1").strip
|
|
19
|
+
|
|
20
|
+
# Get file stats
|
|
21
|
+
stats = get_commit_stats(commit_sha)
|
|
22
|
+
|
|
23
|
+
# Combine with newline
|
|
24
|
+
"#{commit_line}\n#{stats}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# Get file statistics for a commit
|
|
30
|
+
# Handles both regular commits and first commits (no parent)
|
|
31
|
+
# @param commit_sha [String] The commit SHA
|
|
32
|
+
# @return [String] File statistics from git diff --stat
|
|
33
|
+
def get_commit_stats(commit_sha)
|
|
34
|
+
# Try diff against parent first (normal case)
|
|
35
|
+
@git.execute("diff", "--stat", "#{commit_sha}~1", commit_sha, capture_stderr: true)
|
|
36
|
+
rescue GitError
|
|
37
|
+
# If no parent (first commit), use git show
|
|
38
|
+
@git.execute("show", "--stat", "--format=", commit_sha)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module GitCommit
|
|
5
|
+
module Molecules
|
|
6
|
+
# DiffAnalyzer analyzes git diffs and extracts information for commit message generation
|
|
7
|
+
class DiffAnalyzer
|
|
8
|
+
def initialize(git_executor)
|
|
9
|
+
@git = git_executor
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Get the diff for staged changes
|
|
13
|
+
# @param files [Array<String>, nil] Specific files to diff
|
|
14
|
+
# @return [String] The diff output
|
|
15
|
+
def get_staged_diff(files = nil)
|
|
16
|
+
args = ["diff", "--cached"]
|
|
17
|
+
args += ["--"] + files if files && !files.empty?
|
|
18
|
+
@git.execute(*args)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Get the diff for all changes (staged and unstaged)
|
|
22
|
+
# @param files [Array<String>, nil] Specific files to diff
|
|
23
|
+
# @return [String] The diff output
|
|
24
|
+
def get_all_diff(files = nil)
|
|
25
|
+
args = ["diff", "HEAD"]
|
|
26
|
+
args += ["--"] + files if files && !files.empty?
|
|
27
|
+
@git.execute(*args)
|
|
28
|
+
rescue GitError
|
|
29
|
+
# If HEAD doesn't exist (new repo), get all changes
|
|
30
|
+
get_unstaged_diff(files)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Get the diff for unstaged changes
|
|
34
|
+
# @param files [Array<String>, nil] Specific files to diff
|
|
35
|
+
# @return [String] The diff output
|
|
36
|
+
def get_unstaged_diff(files = nil)
|
|
37
|
+
args = ["diff"]
|
|
38
|
+
args += ["--"] + files if files && !files.empty?
|
|
39
|
+
@git.execute(*args)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Get list of changed files
|
|
43
|
+
# @param staged_only [Boolean] Only staged files
|
|
44
|
+
# @return [Array<String>] List of file paths
|
|
45
|
+
def changed_files(staged_only: false)
|
|
46
|
+
if staged_only
|
|
47
|
+
@git.execute("diff", "--cached", "--name-only").strip.split("\n")
|
|
48
|
+
else
|
|
49
|
+
# Get all changed files (staged, unstaged, and untracked)
|
|
50
|
+
staged = @git.execute("diff", "--cached", "--name-only").strip.split("\n")
|
|
51
|
+
unstaged = @git.execute("diff", "--name-only").strip.split("\n")
|
|
52
|
+
untracked = @git.execute("ls-files", "--others", "--exclude-standard").strip.split("\n")
|
|
53
|
+
(staged + unstaged + untracked).uniq
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Analyze diff to extract summary information
|
|
58
|
+
# @param diff [String] The diff to analyze
|
|
59
|
+
# @return [Hash] Summary with :files_changed, :insertions, :deletions
|
|
60
|
+
def analyze_diff(diff)
|
|
61
|
+
files = []
|
|
62
|
+
insertions = 0
|
|
63
|
+
deletions = 0
|
|
64
|
+
|
|
65
|
+
diff.lines.each do |line|
|
|
66
|
+
if line.start_with?("+++")
|
|
67
|
+
# Extract file path from +++ line
|
|
68
|
+
file = line.sub(/^\+\+\+ b\//, "").strip
|
|
69
|
+
files << file unless file == "/dev/null"
|
|
70
|
+
elsif line.start_with?("+") && !line.start_with?("+++")
|
|
71
|
+
insertions += 1
|
|
72
|
+
elsif line.start_with?("-") && !line.start_with?("---")
|
|
73
|
+
deletions += 1
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
{
|
|
78
|
+
files_changed: files.uniq,
|
|
79
|
+
insertions: insertions,
|
|
80
|
+
deletions: deletions
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Detect the scope from changed files
|
|
85
|
+
# @param files [Array<String>] List of file paths
|
|
86
|
+
# @return [String, nil] Detected scope or nil
|
|
87
|
+
def detect_scope(files)
|
|
88
|
+
return nil if files.empty?
|
|
89
|
+
|
|
90
|
+
# Check if all files are in a specific directory/component
|
|
91
|
+
if files.all? { |f| f.start_with?("ace-") }
|
|
92
|
+
# All files in a specific ace gem
|
|
93
|
+
gem = files.first.split("/").first
|
|
94
|
+
return gem
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Check common patterns
|
|
98
|
+
if files.all? { |f| f.match?(%r{(^|/)test/}) || f.match?(%r{(^|/)spec/}) }
|
|
99
|
+
return "test"
|
|
100
|
+
elsif files.all? { |f| f.end_with?(".md") }
|
|
101
|
+
return "docs"
|
|
102
|
+
elsif files.all? { |f| f.include?("config") }
|
|
103
|
+
return "config"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module GitCommit
|
|
5
|
+
module Molecules
|
|
6
|
+
# FileStager handles staging files for commit
|
|
7
|
+
class FileStager
|
|
8
|
+
attr_reader :last_error, :last_skipped_files
|
|
9
|
+
|
|
10
|
+
def initialize(git_executor, gitignore_checker: nil)
|
|
11
|
+
@git = git_executor
|
|
12
|
+
@gitignore_checker = gitignore_checker || Atoms::GitignoreChecker.new
|
|
13
|
+
@last_error = nil
|
|
14
|
+
@last_skipped_files = []
|
|
15
|
+
@had_valid_files = false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Stage specific files
|
|
19
|
+
# @param files [Array<String>] Files to stage
|
|
20
|
+
# @return [Boolean] True if successful
|
|
21
|
+
def stage_files(files)
|
|
22
|
+
return false if files.nil? || files.empty?
|
|
23
|
+
|
|
24
|
+
@last_error = nil
|
|
25
|
+
@git.execute("add", *files)
|
|
26
|
+
true
|
|
27
|
+
rescue GitError => e
|
|
28
|
+
@last_error = e.message
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Stage all changes in the repository
|
|
33
|
+
# @return [Boolean] True if successful
|
|
34
|
+
def stage_all
|
|
35
|
+
@last_error = nil
|
|
36
|
+
@git.execute("add", "-A")
|
|
37
|
+
true
|
|
38
|
+
rescue GitError => e
|
|
39
|
+
@last_error = e.message
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Unstage specific files
|
|
44
|
+
# @param files [Array<String>] Files to unstage
|
|
45
|
+
# @return [Boolean] True if successful
|
|
46
|
+
def unstage_files(files)
|
|
47
|
+
return false if files.nil? || files.empty?
|
|
48
|
+
|
|
49
|
+
@git.execute("reset", "HEAD", *files)
|
|
50
|
+
true
|
|
51
|
+
rescue GitError
|
|
52
|
+
# If HEAD doesn't exist (new repo), use rm --cached
|
|
53
|
+
@git.execute("rm", "--cached", *files)
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Get list of staged files
|
|
58
|
+
# Uses --no-renames to ensure deleted files from directory renames are included
|
|
59
|
+
# @return [Array<String>] List of staged file paths
|
|
60
|
+
def staged_files
|
|
61
|
+
@git.execute("diff", "--cached", "--name-only", "--no-renames").strip.split("\n")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Check if specific files are staged
|
|
65
|
+
# @param files [Array<String>] Files to check
|
|
66
|
+
# @return [Boolean] True if all files are staged
|
|
67
|
+
def files_staged?(files)
|
|
68
|
+
staged = staged_files
|
|
69
|
+
files.all? { |f| staged.include?(f) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Stage only files within specified paths
|
|
73
|
+
# Resets staging area first, then stages only files in paths
|
|
74
|
+
# @param paths [Array<String>] Paths to stage (files or directories)
|
|
75
|
+
# @param quiet [Boolean] Suppress output about skipped files
|
|
76
|
+
# @return [Boolean] True if successful (including when all files skipped)
|
|
77
|
+
def stage_paths(paths, quiet: false)
|
|
78
|
+
return false if paths.nil? || paths.empty?
|
|
79
|
+
|
|
80
|
+
@last_error = nil
|
|
81
|
+
@last_skipped_files = []
|
|
82
|
+
@had_valid_files = false
|
|
83
|
+
|
|
84
|
+
begin
|
|
85
|
+
# Categorize paths: valid (not gitignored), force_add (gitignored but tracked), skipped (gitignored and untracked)
|
|
86
|
+
result = @gitignore_checker.categorize_paths(paths, @git)
|
|
87
|
+
|
|
88
|
+
# Track skipped files (gitignored and not tracked)
|
|
89
|
+
if result[:skipped].any?
|
|
90
|
+
@last_skipped_files = result[:skipped]
|
|
91
|
+
unless quiet
|
|
92
|
+
warn "⚠ Skipping gitignored files (not tracked):"
|
|
93
|
+
result[:skipped].each do |info|
|
|
94
|
+
if info[:pattern]
|
|
95
|
+
warn " #{info[:path]}"
|
|
96
|
+
warn " (matches pattern: #{info[:pattern]})"
|
|
97
|
+
else
|
|
98
|
+
warn " #{info[:path]}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Combine valid paths and force_add paths
|
|
105
|
+
# Force add paths need -f flag because they're in gitignored locations but are tracked
|
|
106
|
+
normal_paths = result[:valid]
|
|
107
|
+
force_add_paths = result[:force_add].map { |f| f[:path] }
|
|
108
|
+
|
|
109
|
+
all_paths = normal_paths + force_add_paths
|
|
110
|
+
|
|
111
|
+
# If all files are skipped (gitignored and untracked), return success
|
|
112
|
+
if all_paths.empty?
|
|
113
|
+
return true
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
@had_valid_files = true
|
|
117
|
+
|
|
118
|
+
# Reset staging area to clear everything
|
|
119
|
+
@git.execute("reset", "--quiet")
|
|
120
|
+
|
|
121
|
+
# Stage normal files (retry with -f if path is ignored)
|
|
122
|
+
normal_paths.each do |path|
|
|
123
|
+
@git.execute("add", path)
|
|
124
|
+
rescue GitError => e
|
|
125
|
+
# If git says path is ignored but we expected it to work, try force add
|
|
126
|
+
if e.message.include?("ignored by one of your .gitignore files")
|
|
127
|
+
@git.execute("add", "-f", path)
|
|
128
|
+
else
|
|
129
|
+
raise
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Stage tracked files in gitignored locations with force flag
|
|
134
|
+
force_add_paths.each do |path|
|
|
135
|
+
@git.execute("add", "-f", path)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
true
|
|
139
|
+
rescue GitError => e
|
|
140
|
+
@last_error = e.message
|
|
141
|
+
false
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Check if the last stage_paths call had all files gitignored
|
|
146
|
+
# @return [Boolean] True if all files were skipped due to gitignore
|
|
147
|
+
def all_files_skipped?
|
|
148
|
+
@last_skipped_files.any? && !@had_valid_files && @last_error.nil?
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|