ace-git-secrets 0.13.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 (49) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/git-secrets/config.yml +63 -0
  3. data/.ace-defaults/git-secrets/gitleaks.toml +14 -0
  4. data/.ace-defaults/nav/protocols/guide-sources/ace-git-secrets.yml +10 -0
  5. data/.ace-defaults/nav/protocols/wfi-sources/ace-git-secrets.yml +19 -0
  6. data/CHANGELOG.md +298 -0
  7. data/LICENSE +21 -0
  8. data/README.md +40 -0
  9. data/Rakefile +16 -0
  10. data/docs/demo/ace-git-secrets-getting-started.gif +0 -0
  11. data/docs/demo/ace-git-secrets-getting-started.tape.yml +38 -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 +109 -0
  15. data/docs/handbook.md +43 -0
  16. data/docs/usage.md +301 -0
  17. data/exe/ace-git-secrets +19 -0
  18. data/handbook/agents/security-audit.ag.md +237 -0
  19. data/handbook/guides/security/ruby.md +27 -0
  20. data/handbook/guides/security/rust.md +51 -0
  21. data/handbook/guides/security/typescript.md +33 -0
  22. data/handbook/guides/security.g.md +155 -0
  23. data/handbook/skills/as-git-security-audit/SKILL.md +29 -0
  24. data/handbook/skills/as-git-token-remediation/SKILL.md +21 -0
  25. data/handbook/workflow-instructions/git/security-audit.wf.md +247 -0
  26. data/handbook/workflow-instructions/git/token-remediation.wf.md +294 -0
  27. data/lib/ace/git/secrets/atoms/gitleaks_runner.rb +244 -0
  28. data/lib/ace/git/secrets/atoms/service_api_client.rb +188 -0
  29. data/lib/ace/git/secrets/cli/commands/check_release.rb +41 -0
  30. data/lib/ace/git/secrets/cli/commands/revoke.rb +44 -0
  31. data/lib/ace/git/secrets/cli/commands/rewrite.rb +46 -0
  32. data/lib/ace/git/secrets/cli/commands/scan.rb +51 -0
  33. data/lib/ace/git/secrets/cli.rb +75 -0
  34. data/lib/ace/git/secrets/commands/check_release_command.rb +48 -0
  35. data/lib/ace/git/secrets/commands/revoke_command.rb +199 -0
  36. data/lib/ace/git/secrets/commands/rewrite_command.rb +147 -0
  37. data/lib/ace/git/secrets/commands/scan_command.rb +113 -0
  38. data/lib/ace/git/secrets/models/detected_token.rb +129 -0
  39. data/lib/ace/git/secrets/models/revocation_result.rb +119 -0
  40. data/lib/ace/git/secrets/models/scan_report.rb +402 -0
  41. data/lib/ace/git/secrets/molecules/git_rewriter.rb +199 -0
  42. data/lib/ace/git/secrets/molecules/history_scanner.rb +155 -0
  43. data/lib/ace/git/secrets/molecules/token_revoker.rb +100 -0
  44. data/lib/ace/git/secrets/organisms/history_cleaner.rb +201 -0
  45. data/lib/ace/git/secrets/organisms/release_gate.rb +133 -0
  46. data/lib/ace/git/secrets/organisms/security_auditor.rb +220 -0
  47. data/lib/ace/git/secrets/version.rb +9 -0
  48. data/lib/ace/git/secrets.rb +168 -0
  49. metadata +227 -0
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tempfile"
5
+
6
+ module Ace
7
+ module Git
8
+ module Secrets
9
+ module Molecules
10
+ # Wrapper for git-filter-repo to remove tokens from Git history
11
+ # Handles tool availability, filter building, and execution
12
+ class GitRewriter
13
+ FILTER_REPO_INSTALL_INSTRUCTIONS = <<~MSG
14
+ git-filter-repo is required for history rewriting.
15
+ Install with: brew install git-filter-repo
16
+ See: https://github.com/newren/git-filter-repo
17
+ MSG
18
+
19
+ attr_reader :repository_path
20
+
21
+ # @param repository_path [String] Path to git repository
22
+ # @raise [ArgumentError] If repository_path is not a valid git repository
23
+ def initialize(repository_path: ".")
24
+ @repository_path = File.expand_path(repository_path)
25
+ validate_repository_path!
26
+ end
27
+
28
+ # Check if git-filter-repo is available
29
+ # @return [Boolean]
30
+ def available?
31
+ system("which git-filter-repo > /dev/null 2>&1")
32
+ end
33
+
34
+ # Check if working directory is clean
35
+ # @return [Boolean]
36
+ def clean_working_directory?
37
+ output, status = Open3.capture2(
38
+ "git", "-C", repository_path, "status", "--porcelain",
39
+ err: File::NULL
40
+ )
41
+ status.success? && output.strip.empty?
42
+ end
43
+
44
+ # Rewrite history to remove specific tokens
45
+ # @param tokens [Array<DetectedToken>] Tokens to remove
46
+ # @param dry_run [Boolean] Whether to preview changes only
47
+ # @return [Hash] Result with :success, :message, :changes keys
48
+ def rewrite(tokens, dry_run: false)
49
+ unless available?
50
+ return {
51
+ success: false,
52
+ message: FILTER_REPO_INSTALL_INSTRUCTIONS,
53
+ changes: []
54
+ }
55
+ end
56
+
57
+ unless clean_working_directory?
58
+ return {
59
+ success: false,
60
+ message: "Working directory has uncommitted changes. Commit or stash before rewriting history.",
61
+ changes: []
62
+ }
63
+ end
64
+
65
+ if tokens.empty?
66
+ return {
67
+ success: true,
68
+ message: "No tokens to remove",
69
+ changes: []
70
+ }
71
+ end
72
+
73
+ # Build replacement expressions
74
+ replacements = build_replacements(tokens)
75
+
76
+ if dry_run
77
+ {
78
+ success: true,
79
+ dry_run: true,
80
+ message: "Dry run: Would remove #{tokens.size} token(s) from history",
81
+ changes: replacements.map { |r| {original: r[:pattern], replacement: r[:replacement]} }
82
+ }
83
+ else
84
+ execute_filter_repo(replacements)
85
+ end
86
+ end
87
+
88
+ # Create a backup of the repository
89
+ # @param backup_path [String] Path for backup
90
+ # @return [Boolean] Success status
91
+ def create_backup(backup_path)
92
+ _, status = Open3.capture2(
93
+ "git", "clone", "--mirror", repository_path, backup_path,
94
+ err: File::NULL
95
+ )
96
+ status.success?
97
+ rescue
98
+ false
99
+ end
100
+
101
+ private
102
+
103
+ # Validate that repository_path is a valid git repository
104
+ # @raise [ArgumentError] If path doesn't exist or isn't a git repo
105
+ def validate_repository_path!
106
+ unless Dir.exist?(repository_path)
107
+ raise ArgumentError, "Repository path does not exist: #{repository_path}"
108
+ end
109
+
110
+ git_dir = File.join(repository_path, ".git")
111
+ unless Dir.exist?(git_dir) || File.exist?(git_dir)
112
+ raise ArgumentError, "Not a git repository: #{repository_path}"
113
+ end
114
+ end
115
+
116
+ # Build replacement expressions for git-filter-repo
117
+ # @param tokens [Array<DetectedToken>]
118
+ # @return [Array<Hash>]
119
+ def build_replacements(tokens)
120
+ tokens.map do |token|
121
+ # Create a masked replacement value
122
+ masked = "[REDACTED:#{token.token_type}]"
123
+
124
+ {
125
+ pattern: token.raw_value,
126
+ replacement: masked,
127
+ token_type: token.token_type
128
+ }
129
+ end.uniq { |r| r[:pattern] }
130
+ end
131
+
132
+ # Execute git-filter-repo with replacements
133
+ # @param replacements [Array<Hash>]
134
+ # @return [Hash]
135
+ def execute_filter_repo(replacements)
136
+ # Create replacement file for git-filter-repo with secure permissions
137
+ # Mode 0600 ensures only the owner can read/write the file containing tokens
138
+ #
139
+ # Security: Use memory-backed tmpfs if available (Linux /dev/shm).
140
+ # Tempfiles in memory are harder to recover than disk-based temps.
141
+ # macOS doesn't have /dev/shm but has encrypted swap; this is defense in depth.
142
+ temp_dir = Dir.exist?("/dev/shm") ? "/dev/shm" : Dir.tmpdir
143
+ replacement_file = Tempfile.new(
144
+ ["git-filter-repo-replacements", ".txt"],
145
+ temp_dir,
146
+ mode: File::RDWR | File::CREAT | File::EXCL,
147
+ perm: 0o600
148
+ )
149
+
150
+ begin
151
+ # Write replacements in git-filter-repo format
152
+ # Format: literal:ORIGINAL==>literal:REPLACEMENT
153
+ replacements.each do |r|
154
+ replacement_file.puts("literal:#{r[:pattern]}==>literal:#{r[:replacement]}")
155
+ end
156
+ replacement_file.close
157
+
158
+ cmd = [
159
+ "git-filter-repo",
160
+ "--replace-text", replacement_file.path,
161
+ "--force"
162
+ ]
163
+
164
+ Dir.chdir(repository_path) do
165
+ stdout, stderr, status = Open3.capture3(*cmd)
166
+
167
+ if status.success?
168
+ {
169
+ success: true,
170
+ message: "Successfully removed #{replacements.size} token(s) from history",
171
+ changes: replacements.map { |r| {token_type: r[:token_type], status: "removed"} },
172
+ stdout: stdout,
173
+ stderr: stderr
174
+ }
175
+ else
176
+ {
177
+ success: false,
178
+ message: "git-filter-repo failed: #{stderr}",
179
+ changes: [],
180
+ stdout: stdout,
181
+ stderr: stderr
182
+ }
183
+ end
184
+ end
185
+ ensure
186
+ replacement_file.unlink
187
+ end
188
+ rescue => e
189
+ {
190
+ success: false,
191
+ message: "Error executing git-filter-repo: #{e.message}",
192
+ changes: []
193
+ }
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Ace
6
+ module Git
7
+ module Secrets
8
+ module Molecules
9
+ # Scans Git history for authentication tokens using gitleaks
10
+ #
11
+ # This is a thin wrapper around GitleaksRunner that adds:
12
+ # - File exclusion filtering
13
+ # - Confidence level filtering
14
+ # - ScanReport generation
15
+ #
16
+ # Gitleaks is required - install with: brew install gitleaks
17
+ class HistoryScanner
18
+ attr_reader :repository_path, :gitleaks_runner, :exclusions
19
+
20
+ # @param repository_path [String] Path to git repository
21
+ # @param gitleaks_config [String, nil] Path to gitleaks config
22
+ # @param exclusions [Array<String>, nil] Glob patterns for files to exclude
23
+ def initialize(repository_path: ".", gitleaks_config: nil, exclusions: nil)
24
+ @repository_path = File.expand_path(repository_path)
25
+ @gitleaks_runner = Atoms::GitleaksRunner.new(config_path: gitleaks_config)
26
+ @exclusions = exclusions || Ace::Git::Secrets.exclusions
27
+ end
28
+
29
+ # Scan repository history for tokens
30
+ # @param since [String, nil] Start commit or date
31
+ # @param min_confidence [String] Minimum confidence level
32
+ # @return [Models::ScanReport]
33
+ def scan(since: nil, min_confidence: "low")
34
+ # Ensure gitleaks is available
35
+ Atoms::GitleaksRunner.ensure_available!
36
+
37
+ result = gitleaks_runner.scan_history(
38
+ path: repository_path,
39
+ since: since
40
+ )
41
+
42
+ detected_tokens = result[:findings].map do |f|
43
+ # Apply exclusions
44
+ next if excluded_file?(f[:file_path])
45
+
46
+ Models::DetectedToken.new(
47
+ token_type: f[:token_type],
48
+ pattern_name: f[:pattern_name],
49
+ confidence: f[:confidence],
50
+ commit_hash: f[:commit_hash],
51
+ file_path: f[:file_path],
52
+ line_number: f[:line_number],
53
+ raw_value: f[:matched_value],
54
+ detected_by: "gitleaks"
55
+ )
56
+ end.compact
57
+
58
+ # Filter by confidence
59
+ detected_tokens = filter_by_confidence(detected_tokens, min_confidence)
60
+
61
+ Models::ScanReport.new(
62
+ tokens: detected_tokens,
63
+ repository_path: repository_path,
64
+ scanned_at: Time.now,
65
+ scan_options: {since: since, min_confidence: min_confidence},
66
+ commits_scanned: count_commits(since: since),
67
+ detection_method: "gitleaks"
68
+ )
69
+ end
70
+
71
+ # Scan only current files (no history)
72
+ # @param min_confidence [String] Minimum confidence level
73
+ # @return [Models::ScanReport]
74
+ def scan_files(min_confidence: "low")
75
+ # Ensure gitleaks is available
76
+ Atoms::GitleaksRunner.ensure_available!
77
+
78
+ result = gitleaks_runner.scan_files(path: repository_path)
79
+
80
+ detected_tokens = result[:findings].map do |f|
81
+ # Apply exclusions
82
+ next if excluded_file?(f[:file_path])
83
+
84
+ Models::DetectedToken.new(
85
+ token_type: f[:token_type],
86
+ pattern_name: f[:pattern_name],
87
+ confidence: f[:confidence],
88
+ commit_hash: "HEAD",
89
+ file_path: f[:file_path],
90
+ line_number: f[:line_number],
91
+ raw_value: f[:matched_value],
92
+ detected_by: "gitleaks"
93
+ )
94
+ end.compact
95
+
96
+ detected_tokens = filter_by_confidence(detected_tokens, min_confidence)
97
+
98
+ Models::ScanReport.new(
99
+ tokens: detected_tokens,
100
+ repository_path: repository_path,
101
+ scanned_at: Time.now,
102
+ scan_options: {files_only: true, min_confidence: min_confidence},
103
+ commits_scanned: 0,
104
+ detection_method: "gitleaks"
105
+ )
106
+ end
107
+
108
+ private
109
+
110
+ # Check if file path matches any exclusion pattern
111
+ # @param path [String] File path to check
112
+ # @return [Boolean] true if file should be excluded
113
+ def excluded_file?(path)
114
+ exclusions.any? do |pattern|
115
+ File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB)
116
+ end
117
+ end
118
+
119
+ # Count commits in repository
120
+ # @param since [String, nil] Start commit or date
121
+ # @return [Integer]
122
+ def count_commits(since: nil)
123
+ cmd = ["git", "-C", repository_path, "rev-list", "--count", "HEAD"]
124
+ cmd.insert(-2, "--since=#{since}") if since
125
+
126
+ output, status = Open3.capture2(*cmd, err: File::NULL)
127
+ status.success? ? output.strip.to_i : 0
128
+ rescue
129
+ 0
130
+ end
131
+
132
+ # Filter tokens by minimum confidence
133
+ # @param tokens [Array<DetectedToken>]
134
+ # @param min_confidence [String]
135
+ # @return [Array<DetectedToken>]
136
+ def filter_by_confidence(tokens, min_confidence)
137
+ confidence_order = {"high" => 3, "medium" => 2, "low" => 1}
138
+
139
+ unless confidence_order.key?(min_confidence)
140
+ warn "Warning: Invalid confidence level '#{min_confidence}'. " \
141
+ "Valid values: high, medium, low. Defaulting to 'low'."
142
+ end
143
+
144
+ min_level = confidence_order[min_confidence] || 1
145
+
146
+ tokens.select do |token|
147
+ level = confidence_order[token.confidence] || 1
148
+ level >= min_level
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Secrets
6
+ module Molecules
7
+ # Orchestrates token revocation across multiple services
8
+ # Routes tokens to appropriate service handlers
9
+ class TokenRevoker
10
+ attr_reader :api_client
11
+
12
+ # @param api_client [Atoms::ServiceApiClient, nil] API client for revocation
13
+ def initialize(api_client: nil)
14
+ @api_client = api_client || Atoms::ServiceApiClient.new
15
+ end
16
+
17
+ # Revoke multiple tokens
18
+ # @param tokens [Array<DetectedToken>] Tokens to revoke
19
+ # @param services [Array<String>, nil] Filter to specific services
20
+ # @return [Array<Models::RevocationResult>]
21
+ def revoke_all(tokens, services: nil)
22
+ tokens.map do |token|
23
+ next unless token.revocable?
24
+ next if services && !services.include?(token.revocation_service)
25
+
26
+ revoke_token(token)
27
+ end.compact
28
+ end
29
+
30
+ # Revoke a single token
31
+ # @param token [DetectedToken] Token to revoke
32
+ # @return [Models::RevocationResult]
33
+ def revoke_token(token)
34
+ service = token.revocation_service
35
+
36
+ unless service
37
+ return Models::RevocationResult.unsupported(token: token)
38
+ end
39
+
40
+ case service
41
+ when "github"
42
+ revoke_github(token)
43
+ when "anthropic", "openai", "aws"
44
+ # These services don't have public revocation APIs
45
+ manual_revocation_result(token, service)
46
+ else
47
+ Models::RevocationResult.unsupported(token: token, service: service)
48
+ end
49
+ end
50
+
51
+ # Get revocation instructions for a token
52
+ # @param token [DetectedToken] Token to get instructions for
53
+ # @return [Hash] Instructions hash
54
+ def revocation_instructions(token)
55
+ service = token.revocation_service
56
+ api_client.build_revocation_request(service, token.raw_value)
57
+ end
58
+
59
+ private
60
+
61
+ # Revoke GitHub token via API
62
+ # @param token [DetectedToken]
63
+ # @return [Models::RevocationResult]
64
+ def revoke_github(token)
65
+ result = api_client.revoke_github_token(token.raw_value)
66
+
67
+ if result[:success]
68
+ Models::RevocationResult.success(
69
+ token: token,
70
+ service: "github",
71
+ message: result[:message]
72
+ )
73
+ else
74
+ Models::RevocationResult.failure(
75
+ token: token,
76
+ service: "github",
77
+ message: result[:message]
78
+ )
79
+ end
80
+ end
81
+
82
+ # Create result for services requiring manual revocation
83
+ # @param token [DetectedToken]
84
+ # @param service [String]
85
+ # @return [Models::RevocationResult]
86
+ def manual_revocation_result(token, service)
87
+ instructions = api_client.build_revocation_request(service, token.raw_value)
88
+
89
+ Models::RevocationResult.new(
90
+ token: token,
91
+ service: service,
92
+ status: "skipped",
93
+ message: "Manual revocation required. #{instructions[:notes]} Visit: #{instructions[:url]}"
94
+ )
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/b36ts"
4
+
5
+ module Ace
6
+ module Git
7
+ module Secrets
8
+ module Organisms
9
+ # Orchestrates the history cleaning workflow
10
+ # Combines scanning, confirmation, backup, and rewriting
11
+ #
12
+ # Requires gitleaks to be installed: brew install gitleaks
13
+ class HistoryCleaner
14
+ CONFIRMATION_TEXT = "REWRITE HISTORY"
15
+
16
+ attr_reader :rewriter, :scanner, :repository_path
17
+
18
+ # @param repository_path [String] Path to git repository
19
+ # @param gitleaks_config [String, nil] Path to gitleaks config file
20
+ # @param exclusions [Array<String>, nil] Glob patterns for files to exclude
21
+ def initialize(repository_path: ".", gitleaks_config: nil, exclusions: nil)
22
+ @repository_path = File.expand_path(repository_path)
23
+ @rewriter = Molecules::GitRewriter.new(repository_path: @repository_path)
24
+ @scanner = Molecules::HistoryScanner.new(
25
+ repository_path: @repository_path,
26
+ gitleaks_config: gitleaks_config,
27
+ exclusions: exclusions
28
+ )
29
+ end
30
+
31
+ # Clean tokens from history
32
+ # @param tokens [Array<DetectedToken>, nil] Tokens to remove (scans if nil)
33
+ # @param dry_run [Boolean] Preview only
34
+ # @param force [Boolean] Skip confirmation
35
+ # @param create_backup [Boolean] Create backup before rewriting
36
+ # @param backup_path [String, nil] Custom backup path
37
+ # @return [Hash] Result with :success, :message, :report keys
38
+ def clean(tokens: nil, dry_run: false, force: false, create_backup: true, backup_path: nil)
39
+ # Scan if tokens not provided (needed for both dry-run and actual run)
40
+ if tokens.nil?
41
+ report = scanner.scan
42
+ tokens = report.tokens
43
+ end
44
+
45
+ if tokens.empty?
46
+ return {
47
+ success: true,
48
+ message: "No tokens found to remove. Repository is clean.",
49
+ tokens_removed: 0
50
+ }
51
+ end
52
+
53
+ # Dry run - just show what would be removed (no rewriter needed)
54
+ if dry_run
55
+ return dry_run_result(tokens)
56
+ end
57
+
58
+ # Check prerequisites (only needed for actual rewrite)
59
+ unless rewriter.available?
60
+ return {
61
+ success: false,
62
+ message: Molecules::GitRewriter::FILTER_REPO_INSTALL_INSTRUCTIONS
63
+ }
64
+ end
65
+
66
+ unless rewriter.clean_working_directory?
67
+ return {
68
+ success: false,
69
+ message: "Working directory has uncommitted changes. Commit or stash first."
70
+ }
71
+ end
72
+
73
+ # Require confirmation unless forced
74
+ unless force
75
+ return {
76
+ success: false,
77
+ requires_confirmation: true,
78
+ message: confirmation_warning(tokens),
79
+ confirmation_text: CONFIRMATION_TEXT
80
+ }
81
+ end
82
+
83
+ # Create backup if requested
84
+ if create_backup
85
+ backup_result = create_repository_backup(backup_path)
86
+ unless backup_result[:success]
87
+ return {
88
+ success: false,
89
+ message: "Failed to create backup: #{backup_result[:message]}"
90
+ }
91
+ end
92
+ puts "Backup created at: #{backup_result[:path]}"
93
+ end
94
+
95
+ # Execute rewrite
96
+ result = rewriter.rewrite(tokens)
97
+
98
+ if result[:success]
99
+ {
100
+ success: true,
101
+ message: result[:message],
102
+ tokens_removed: tokens.size,
103
+ next_steps: post_rewrite_instructions
104
+ }
105
+ else
106
+ {
107
+ success: false,
108
+ message: result[:message]
109
+ }
110
+ end
111
+ end
112
+
113
+ # Validate confirmation text
114
+ # @param input [String] User input
115
+ # @return [Boolean]
116
+ def valid_confirmation?(input)
117
+ input.strip == CONFIRMATION_TEXT
118
+ end
119
+
120
+ private
121
+
122
+ # Generate dry run result
123
+ def dry_run_result(tokens)
124
+ {
125
+ success: true,
126
+ dry_run: true,
127
+ message: "Dry run: Would remove #{tokens.size} token(s) from history",
128
+ tokens: tokens.map do |t|
129
+ {
130
+ type: t.token_type,
131
+ masked_value: t.masked_value,
132
+ file: t.file_path,
133
+ commit: t.short_commit
134
+ }
135
+ end
136
+ }
137
+ end
138
+
139
+ # Generate confirmation warning
140
+ def confirmation_warning(tokens)
141
+ <<~WARNING
142
+ WARNING: This operation will rewrite Git history.
143
+
144
+ This action:
145
+ - Is IRREVERSIBLE (without backup)
146
+ - Will change commit SHAs
147
+ - Requires all collaborators to re-clone after completion
148
+ - Should only be done after revoking the tokens
149
+
150
+ Tokens to be removed: #{tokens.size}
151
+
152
+ To proceed, type exactly: #{CONFIRMATION_TEXT}
153
+ WARNING
154
+ end
155
+
156
+ # Create repository backup
157
+ def create_repository_backup(custom_path)
158
+ backup_path = custom_path || generate_backup_path
159
+
160
+ if rewriter.create_backup(backup_path)
161
+ {success: true, path: backup_path}
162
+ else
163
+ {success: false, message: "Could not create mirror clone"}
164
+ end
165
+ end
166
+
167
+ # Generate default backup path
168
+ def generate_backup_path
169
+ session_id = Ace::B36ts.encode(Time.now)
170
+ repo_name = File.basename(repository_path)
171
+ File.join(File.dirname(repository_path), "#{repo_name}-backup-#{session_id}.git")
172
+ end
173
+
174
+ # Instructions after successful rewrite
175
+ def post_rewrite_instructions
176
+ <<~INSTRUCTIONS
177
+
178
+ History has been rewritten successfully.
179
+
180
+ IMPORTANT: Complete these steps:
181
+
182
+ 1. Verify the changes:
183
+ git log --oneline -20
184
+
185
+ 2. Force push to remote:
186
+ git push --force-with-lease origin <branch>
187
+
188
+ 3. Notify all collaborators to:
189
+ - Delete their local clones
190
+ - Re-clone the repository
191
+
192
+ 4. If you created a backup, you can safely delete it after
193
+ confirming everything works correctly.
194
+
195
+ INSTRUCTIONS
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end