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.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/git/commit.yml +22 -0
  3. data/.ace-defaults/nav/protocols/guide-sources/ace-git-commit.yml +10 -0
  4. data/.ace-defaults/nav/protocols/prompt-sources/ace-git-commit.yml +19 -0
  5. data/.ace-defaults/nav/protocols/wfi-sources/ace-git-commit.yml +19 -0
  6. data/CHANGELOG.md +404 -0
  7. data/COMPARISON.md +176 -0
  8. data/LICENSE +21 -0
  9. data/README.md +44 -0
  10. data/Rakefile +14 -0
  11. data/exe/ace-git-commit +13 -0
  12. data/handbook/guides/version-control-system-message.g.md +507 -0
  13. data/handbook/prompts/git-commit.md +22 -0
  14. data/handbook/prompts/git-commit.system.md +150 -0
  15. data/handbook/skills/as-git-commit/SKILL.md +57 -0
  16. data/handbook/workflow-instructions/git/commit.wf.md +75 -0
  17. data/lib/ace/git_commit/atoms/git_executor.rb +62 -0
  18. data/lib/ace/git_commit/atoms/gitignore_checker.rb +118 -0
  19. data/lib/ace/git_commit/cli/commands/commit.rb +147 -0
  20. data/lib/ace/git_commit/cli.rb +23 -0
  21. data/lib/ace/git_commit/models/commit_group.rb +53 -0
  22. data/lib/ace/git_commit/models/commit_options.rb +75 -0
  23. data/lib/ace/git_commit/models/split_commit_result.rb +60 -0
  24. data/lib/ace/git_commit/models/stage_result.rb +71 -0
  25. data/lib/ace/git_commit/molecules/commit_grouper.rb +123 -0
  26. data/lib/ace/git_commit/molecules/commit_summarizer.rb +43 -0
  27. data/lib/ace/git_commit/molecules/diff_analyzer.rb +111 -0
  28. data/lib/ace/git_commit/molecules/file_stager.rb +153 -0
  29. data/lib/ace/git_commit/molecules/message_generator.rb +438 -0
  30. data/lib/ace/git_commit/molecules/path_resolver.rb +365 -0
  31. data/lib/ace/git_commit/molecules/split_commit_executor.rb +272 -0
  32. data/lib/ace/git_commit/organisms/commit_orchestrator.rb +330 -0
  33. data/lib/ace/git_commit/version.rb +7 -0
  34. data/lib/ace/git_commit.rb +41 -0
  35. 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