rails-credentials-conflict 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 69ad41e2d662e231c9cc58895b567280a3f1c78943dd4dfd389f8b83db21ed73
4
+ data.tar.gz: 1ea33b52f592947663ad9c24c0cf8162c284e2caf91fcf2555c8e8d55c8b6874
5
+ SHA512:
6
+ metadata.gz: 16863610cb7967fb70d71996dbc88f347ef442464be96462e6387df629c9fe5899de3fead02debc6180abc515250c214a968ff21bcfd2c383527a4ec2c37ef9a
7
+ data.tar.gz: '0936c701ef1b31c7d3724f9a3257ec4e41fc6736ccd7547a06d6a05228531ff5190a0123443462026f866ca3aa87172e5df3fb0c2e64dbcd6438d172e2be8e28'
data/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2025-01-01
9
+
10
+ ### Added
11
+
12
+ - Initial release
13
+ - Rake tasks for resolving encrypted credentials conflicts: `resolve`, `yours`, `theirs`, `base`
14
+ - Three-way merge with conflict markers and editor-based resolution
15
+ - Support for environment-specific credentials
16
+ - YAML validation of resolved content
17
+ - Automatic staging of resolved files
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 jwo1f
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # Rails Credentials Conflict
2
+
3
+ A Rails gem that helps resolve git merge conflicts in encrypted credentials files by decrypting, merging, and re-encrypting them.
4
+
5
+ ## Problem
6
+
7
+ When working with Rails encrypted credentials in a team environment, git merge conflicts in `.yml.enc` files are common. Since these files are encrypted, git cannot automatically merge them, and the conflict markers appear in the encrypted content, making manual resolution impossible.
8
+
9
+ ## Solution
10
+
11
+ This gem provides rake tasks to:
12
+ - Decrypt both versions of conflicted credentials
13
+ - Present them in a standard git conflict format for easy resolution
14
+ - Re-encrypt the resolved content
15
+ - Or simply choose one version over the other
16
+
17
+ ## Installation
18
+
19
+ Add this line to your application's Gemfile:
20
+
21
+ ```ruby
22
+ gem 'rails-credentials-conflict'
23
+ ```
24
+
25
+ And then execute:
26
+
27
+ ```bash
28
+ bundle install
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ When you encounter a merge conflict in your credentials file, you have four options:
34
+
35
+ ### 1. Resolve conflicts manually
36
+
37
+ This command decrypts both versions, opens them in your editor with git conflict markers, and re-encrypts after you've resolved the conflicts:
38
+
39
+ ```bash
40
+ # For main credentials
41
+ rails credentials:conflict:resolve
42
+
43
+ # For environment-specific credentials
44
+ rails credentials:conflict:resolve[staging]
45
+ rails credentials:conflict:resolve[production]
46
+ ```
47
+
48
+ The command will:
49
+ 1. Detect the conflict in the encrypted file
50
+ 2. Decrypt both versions (yours and theirs)
51
+ 3. Compare them - if identical, auto-merge
52
+ 4. If different, create a temporary file with git conflict markers:
53
+ ```yaml
54
+ <<<<<<< HEAD (yours)
55
+ api_key: your-key
56
+ =======
57
+ api_key: their-key
58
+ >>>>>>> MERGE_HEAD (theirs)
59
+ ```
60
+ 5. Open the file in your `$EDITOR` (defaults to vim)
61
+ 6. After you save and close, validate YAML and re-encrypt the resolved content
62
+ 7. Stage the resolved file with git
63
+
64
+ ### 2. Keep your version
65
+
66
+ To discard their changes and keep only your version:
67
+
68
+ ```bash
69
+ # For main credentials
70
+ rails credentials:conflict:yours
71
+
72
+ # For environment-specific credentials
73
+ rails credentials:conflict:yours[staging]
74
+ ```
75
+
76
+ ### 3. Keep their version
77
+
78
+ To discard your changes and keep only their version:
79
+
80
+ ```bash
81
+ # For main credentials
82
+ rails credentials:conflict:theirs
83
+
84
+ # For environment-specific credentials
85
+ rails credentials:conflict:theirs[production]
86
+ ```
87
+
88
+ ### 4. Keep base version
89
+
90
+ To keep the base version (shown in the middle section of 3-way diff):
91
+
92
+ ```bash
93
+ # For main credentials
94
+ rails credentials:conflict:base
95
+
96
+ # For environment-specific credentials
97
+ rails credentials:conflict:base[staging]
98
+ ```
99
+
100
+ ## How it works
101
+
102
+ The gem uses git's staging area to access all versions of the conflicted file:
103
+ - Stage 1 contains "base" (merge-base/common ancestor)
104
+ - Stage 2 contains "ours" (your version)
105
+ - Stage 3 contains "theirs" (their version)
106
+
107
+ It then:
108
+ 1. Decrypts the versions using your local key
109
+ 2. Performs the requested operation (merge, yours, theirs, or base)
110
+ 3. Validates the resolved YAML
111
+ 4. Re-encrypts the result
112
+ 5. Stages the resolved file
113
+
114
+ Works with merge, rebase, and cherry-pick conflicts.
115
+
116
+ ## Requirements
117
+
118
+ - Ruby >= 3.1.0
119
+ - Rails >= 6.0
120
+ - Git repository with encrypted credentials
121
+ - The appropriate encryption key must be available via one of:
122
+ - `RAILS_MASTER_KEY` environment variable
123
+ - `config/master.key` for main credentials
124
+ - `config/credentials/<environment>.key` for environment-specific credentials
125
+
126
+ ## Development
127
+
128
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests.
129
+
130
+ To install this gem onto your local machine, run `bundle exec rake install`.
131
+
132
+ ## Contributing
133
+
134
+ Bug reports and pull requests are welcome on GitHub at https://github.com/jwo1f/rails-credentials-conflict.
135
+
136
+ ## License
137
+
138
+ The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ task default: :test
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/message_encryptor"
4
+
5
+ module Rails
6
+ module Credentials
7
+ module Conflict
8
+ # Encrypts and decrypts Rails credentials content using AES-128-GCM.
9
+ #
10
+ # The encryption key is read from either an environment variable
11
+ # (default +RAILS_MASTER_KEY+) or a key file on disk.
12
+ class EncryptionService
13
+ CIPHER = "aes-128-gcm"
14
+
15
+ def initialize(key_path, env_key: "RAILS_MASTER_KEY")
16
+ @key_path = key_path
17
+ @env_key = env_key
18
+ end
19
+
20
+ # Decrypts and verifies +encrypted_content+.
21
+ # Returns an empty string for nil or empty input.
22
+ def decrypt(encrypted_content)
23
+ return "" if encrypted_content.nil? || encrypted_content.empty?
24
+
25
+ encryptor.decrypt_and_verify(encrypted_content)
26
+ end
27
+
28
+ # Encrypts and signs +content+, returning the ciphertext.
29
+ def encrypt(content)
30
+ encryptor.encrypt_and_sign(content)
31
+ end
32
+
33
+ # Encrypts +content+ and writes it to +destination_path+ in binary mode.
34
+ def save_encrypted(content, destination_path)
35
+ File.binwrite(destination_path, encrypt(content))
36
+ end
37
+
38
+ private
39
+
40
+ def encryptor
41
+ @encryptor ||= ActiveSupport::MessageEncryptor.new(
42
+ [read_key].pack("H*"),
43
+ cipher: CIPHER
44
+ )
45
+ end
46
+
47
+ def read_key
48
+ key_from_env || key_from_file || raise(
49
+ Error, "Encryption key not found. Set #{@env_key} env variable or create #{@key_path}"
50
+ )
51
+ end
52
+
53
+ def key_from_env
54
+ value = ENV[@env_key]&.strip
55
+ value unless value.nil? || value.empty?
56
+ end
57
+
58
+ def key_from_file
59
+ return nil unless File.exist?(@key_path)
60
+
61
+ value = File.read(@key_path).strip
62
+ value.empty? ? nil : value
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Rails
6
+ module Credentials
7
+ module Conflict
8
+ # Interacts with git to read conflict stages, detect merge state,
9
+ # and stage resolved files.
10
+ #
11
+ # Uses git's staging area to access the three versions of a
12
+ # conflicted file (base, ours, theirs) and determines labels
13
+ # for the merge participants.
14
+ class GitConflictHandler
15
+ STAGE_BASE = 1
16
+ STAGE_OURS = 2
17
+ STAGE_THEIRS = 3
18
+
19
+ def initialize(credentials_path, relative_path)
20
+ @credentials_path = credentials_path.to_s
21
+ @relative_path = relative_path.to_s
22
+ end
23
+
24
+ # Verifies that the credentials file exists and is in a conflicted
25
+ # state (UU or AA in +git status --porcelain+).
26
+ # Raises +Error+ otherwise.
27
+ def validate_conflict!
28
+ raise Error, "Credentials file not found: #{@credentials_path}" unless File.exist?(@credentials_path)
29
+
30
+ git_status, status = Open3.capture2("git", "status", "--porcelain", @credentials_path)
31
+ git_status = git_status.strip
32
+
33
+ return if status.success? && (git_status.start_with?("UU") || git_status.start_with?("AA"))
34
+
35
+ raise Error, "No git conflict detected for #{@credentials_path}"
36
+ end
37
+
38
+ # Retrieves the encrypted content for the given +version+ (:base, :ours, or :theirs)
39
+ # from the git staging area. Returns an empty string on failure.
40
+ def get_version(version)
41
+ stage_number = stage_for_version(version)
42
+ content, status = Open3.capture2(
43
+ "git", "show", ":#{stage_number}:#{@relative_path}",
44
+ binmode: true
45
+ )
46
+ status.success? ? content.strip : ""
47
+ end
48
+
49
+ # Stages the resolved credentials file with +git add+.
50
+ # Raises +Error+ if the command fails.
51
+ def stage_resolved_file!
52
+ _, status = Open3.capture2("git", "add", @credentials_path)
53
+
54
+ return if status.success?
55
+
56
+ raise Error, "Failed to stage resolved file: #{@credentials_path}"
57
+ end
58
+
59
+ # Returns a Hash of labels for the three merge participants
60
+ # (+:ours+, +:base+, +:theirs+), each containing a branch name
61
+ # and short SHA.
62
+ def get_merge_labels
63
+ head_ref = detect_head_ref
64
+
65
+ {
66
+ ours: build_label(get_current_branch, get_short_sha("HEAD")),
67
+ base: build_label("ancestor", get_merge_base_sha(head_ref)),
68
+ theirs: build_label(get_incoming_branch(head_ref), get_short_sha(head_ref))
69
+ }
70
+ end
71
+
72
+ private
73
+
74
+ def stage_for_version(version)
75
+ case version
76
+ when :base then STAGE_BASE
77
+ when :ours then STAGE_OURS
78
+ when :theirs then STAGE_THEIRS
79
+ else raise Error, "Unknown version: #{version}"
80
+ end
81
+ end
82
+
83
+ def detect_head_ref
84
+ %w[MERGE_HEAD CHERRY_PICK_HEAD REBASE_HEAD].each do |ref|
85
+ _, status = Open3.capture2("git", "rev-parse", "--verify", ref)
86
+ return ref if status.success?
87
+ end
88
+
89
+ "MERGE_HEAD"
90
+ end
91
+
92
+ def build_label(branch_name, commit_sha)
93
+ "#{branch_name} (#{commit_sha})"
94
+ end
95
+
96
+ def get_current_branch
97
+ branch, status = Open3.capture2("git", "rev-parse", "--abbrev-ref", "HEAD")
98
+ branch = branch.strip
99
+ status.success? && !branch.empty? ? branch : "HEAD"
100
+ end
101
+
102
+ def get_incoming_branch(head_ref)
103
+ branch, status = Open3.capture2("git", "name-rev", "--name-only", head_ref)
104
+ branch = branch.strip
105
+ status.success? && !branch.empty? ? branch : head_ref
106
+ end
107
+
108
+ def get_short_sha(ref)
109
+ sha, status = Open3.capture2("git", "rev-parse", "--short=8", ref)
110
+ status.success? ? sha.strip : "unknown"
111
+ end
112
+
113
+ def get_merge_base_sha(head_ref)
114
+ ours_sha, s1 = Open3.capture2("git", "rev-parse", "HEAD")
115
+ theirs_sha, s2 = Open3.capture2("git", "rev-parse", head_ref)
116
+
117
+ return "unknown" unless s1.success? && s2.success?
118
+
119
+ base_sha, s3 = Open3.capture2("git", "merge-base", ours_sha.strip, theirs_sha.strip)
120
+ s3.success? ? base_sha.strip[0..7] : "unknown"
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tempfile"
5
+ require "yaml"
6
+
7
+ module Rails
8
+ module Credentials
9
+ module Conflict
10
+ # Performs three-way merge of decrypted credentials using git merge-file.
11
+ #
12
+ # Handles creating conflict markers for manual resolution,
13
+ # detecting unresolved markers, validating YAML, and opening
14
+ # an editor for interactive conflict resolution.
15
+ class MergeStrategy
16
+ CONFLICT_MARKER_START = "<<<<<<<"
17
+ CONFLICT_MARKER_END = ">>>>>>>"
18
+
19
+ def initialize(encryption_service)
20
+ @encryption_service = encryption_service
21
+ end
22
+
23
+ # Runs a three-way merge via +git merge-file+ and returns the result.
24
+ #
25
+ # Returns a Hash with +:content+ (merged text) and +:has_conflicts+
26
+ # (true when conflict markers are present).
27
+ #
28
+ # Raises +Error+ when +git merge-file+ exits with a status greater
29
+ # than 1, which indicates an actual error rather than conflicts.
30
+ def create_conflict_markers(ours_content, base_content, theirs_content, labels:)
31
+ with_temp_files(ours_content, base_content, theirs_content) do |ours_path, base_path, theirs_path|
32
+ merged_content, status = Open3.capture2(
33
+ "git", "merge-file", "-p", "--diff3",
34
+ "-L", labels[:ours],
35
+ "-L", labels[:base],
36
+ "-L", labels[:theirs],
37
+ ours_path, base_path, theirs_path,
38
+ err: File::NULL
39
+ )
40
+
41
+ exit_code = status.exitstatus
42
+
43
+ raise Error, "git merge-file failed (exit #{exit_code || "unknown"})" if exit_code.nil? || exit_code > 1
44
+
45
+ { content: merged_content, has_conflicts: exit_code == 1 }
46
+ end
47
+ end
48
+
49
+ # Returns true if the content contains unresolved git conflict markers.
50
+ def has_conflict_markers?(content)
51
+ content.include?(CONFLICT_MARKER_START) || content.include?(CONFLICT_MARKER_END)
52
+ end
53
+
54
+ # Parses content as YAML and raises +Error+ if it is invalid.
55
+ def validate_yaml!(content)
56
+ YAML.safe_load(content)
57
+ rescue Psych::SyntaxError => e
58
+ raise Error, "Resolved content is not valid YAML: #{e.message}"
59
+ end
60
+
61
+ # Writes +merged_content+ to a temp file, opens it in +$EDITOR+,
62
+ # and returns the edited content after the editor exits.
63
+ def open_editor_for_resolution(merged_content)
64
+ Tempfile.create(["credentials_conflict", ".yml"]) do |tempfile|
65
+ tempfile.write(merged_content)
66
+ tempfile.flush
67
+
68
+ editor = ENV.fetch("EDITOR", "vim")
69
+ system(editor, tempfile.path)
70
+
71
+ File.read(tempfile.path)
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def with_temp_files(*contents)
78
+ temp_files = contents.map.with_index do |content, index|
79
+ file = Tempfile.new(["temp_#{index}", ".yml"])
80
+ file.write(content)
81
+ file.flush
82
+ file
83
+ end
84
+
85
+ yield(*temp_files.map(&:path))
86
+ ensure
87
+ temp_files&.each do |file|
88
+ file.close
89
+ file.unlink
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Credentials
5
+ module Conflict
6
+ # Resolves file system paths for Rails encrypted credentials
7
+ # and their corresponding key files.
8
+ #
9
+ # Supports both the default credentials (+config/credentials.yml.enc+)
10
+ # and environment-specific ones (+config/credentials/<env>.yml.enc+).
11
+ class PathResolver
12
+ VALID_ENVIRONMENT = /\A[a-z0-9_]+\z/
13
+
14
+ attr_reader :environment
15
+
16
+ def initialize(environment = nil)
17
+ if environment && !VALID_ENVIRONMENT.match?(environment.to_s)
18
+ raise Error,
19
+ "Invalid environment name: #{environment.inspect}. " \
20
+ "Must contain only lowercase letters, digits, and underscores."
21
+ end
22
+
23
+ @environment = environment
24
+ end
25
+
26
+ # Returns the absolute Pathname to the encrypted credentials file.
27
+ def credentials_path
28
+ if environment
29
+ ::Rails.root.join("config", "credentials", "#{environment}.yml.enc")
30
+ else
31
+ ::Rails.root.join("config", "credentials.yml.enc")
32
+ end
33
+ end
34
+
35
+ # Returns the absolute Pathname to the encryption key file.
36
+ def key_path
37
+ if environment
38
+ ::Rails.root.join("config", "credentials", "#{environment}.key")
39
+ else
40
+ ::Rails.root.join("config", "master.key")
41
+ end
42
+ end
43
+
44
+ # Returns the credentials path relative to +Rails.root+.
45
+ def relative_credentials_path
46
+ credentials_path.relative_path_from(::Rails.root)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Credentials
5
+ module Conflict
6
+ # Integrates the gem with Rails by loading the rake tasks
7
+ # when the application boots.
8
+ class Railtie < Rails::Railtie
9
+ rake_tasks do
10
+ load "tasks/credentials_conflict.rake"
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Credentials
5
+ module Conflict
6
+ # Top-level orchestrator for resolving git merge conflicts in
7
+ # encrypted Rails credentials files.
8
+ #
9
+ # Coordinates between PathResolver, EncryptionService,
10
+ # GitConflictHandler, and MergeStrategy to decrypt conflicting
11
+ # versions, merge them, and re-encrypt the result.
12
+ class Resolver
13
+ attr_reader :environment
14
+
15
+ def initialize(environment = nil, output: $stdout)
16
+ @environment = environment
17
+ @output = output
18
+ @path_resolver = PathResolver.new(environment)
19
+ @encryption_service = EncryptionService.new(@path_resolver.key_path)
20
+ @git_handler = GitConflictHandler.new(
21
+ @path_resolver.credentials_path,
22
+ @path_resolver.relative_credentials_path
23
+ )
24
+ @merge_strategy = MergeStrategy.new(@encryption_service)
25
+ end
26
+
27
+ # Decrypts both sides of a conflict, performs a three-way merge,
28
+ # and re-encrypts the resolved content. Opens an editor when
29
+ # automatic merge is not possible.
30
+ def resolve
31
+ @git_handler.validate_conflict!
32
+
33
+ ours_content = decrypt_version(:ours)
34
+ theirs_content = decrypt_version(:theirs)
35
+
36
+ if ours_content == theirs_content
37
+ log "No conflicts detected. Both versions are identical."
38
+ save_and_stage(ours_content)
39
+ return
40
+ end
41
+
42
+ base_content = get_base_version
43
+ labels = @git_handler.get_merge_labels
44
+ merge_result = @merge_strategy.create_conflict_markers(
45
+ ours_content,
46
+ base_content,
47
+ theirs_content,
48
+ labels: labels
49
+ )
50
+
51
+ if merge_result[:has_conflicts]
52
+ log "Conflicts detected. Opening editor for manual resolution..."
53
+ resolved_content = @merge_strategy.open_editor_for_resolution(merge_result[:content])
54
+
55
+ if @merge_strategy.has_conflict_markers?(resolved_content)
56
+ raise Error, "Conflict markers still present. Please resolve all conflicts."
57
+ end
58
+ else
59
+ log "Changes in different sections detected. Auto-merged successfully."
60
+ resolved_content = merge_result[:content]
61
+ end
62
+
63
+ @merge_strategy.validate_yaml!(resolved_content)
64
+ save_and_stage(resolved_content)
65
+ log "Credentials successfully resolved and encrypted."
66
+ end
67
+
68
+ # Resolves the conflict by keeping the current branch's version.
69
+ def yours
70
+ resolve_with_version(:ours, "your")
71
+ end
72
+
73
+ # Resolves the conflict by keeping the incoming branch's version.
74
+ def theirs
75
+ resolve_with_version(:theirs, "their")
76
+ end
77
+
78
+ # Resolves the conflict by keeping the common ancestor's version.
79
+ def base
80
+ resolve_with_version(:base, "base")
81
+ end
82
+
83
+ private
84
+
85
+ def log(message)
86
+ @output.puts(message)
87
+ end
88
+
89
+ def resolve_with_version(version, label)
90
+ @git_handler.validate_conflict!
91
+
92
+ encrypted_content = @git_handler.get_version(version)
93
+ raise Error, "No #{label} version found." if encrypted_content.empty?
94
+
95
+ content = @encryption_service.decrypt(encrypted_content)
96
+ save_and_stage(content)
97
+ log "Kept #{label} version of credentials."
98
+ end
99
+
100
+ def decrypt_version(version)
101
+ encrypted_content = @git_handler.get_version(version)
102
+ @encryption_service.decrypt(encrypted_content)
103
+ end
104
+
105
+ def get_base_version
106
+ base_encrypted = @git_handler.get_version(:base)
107
+ return "" if base_encrypted.empty?
108
+
109
+ @encryption_service.decrypt(base_encrypted)
110
+ rescue StandardError => e
111
+ log "Warning: Could not decrypt base version (#{e.message}). Using empty base."
112
+ ""
113
+ end
114
+
115
+ def save_and_stage(content)
116
+ @encryption_service.save_encrypted(content, @path_resolver.credentials_path)
117
+ @git_handler.stage_resolved_file!
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Credentials
5
+ module Conflict
6
+ VERSION = "0.1.0"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "conflict/version"
4
+ require_relative "conflict/path_resolver"
5
+ require_relative "conflict/encryption_service"
6
+ require_relative "conflict/git_conflict_handler"
7
+ require_relative "conflict/merge_strategy"
8
+ require_relative "conflict/resolver"
9
+ require_relative "conflict/railtie" if defined?(Rails::Railtie)
10
+
11
+ module Rails
12
+ module Credentials
13
+ # Provides tooling to resolve git merge conflicts in Rails
14
+ # encrypted credentials files by decrypting, merging, and
15
+ # re-encrypting them.
16
+ module Conflict
17
+ class Error < StandardError; end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/credentials/conflict/resolver"
4
+
5
+ namespace :credentials do
6
+ namespace :conflict do
7
+ desc "Resolve credentials conflict by decrypting and merging"
8
+ task :resolve, [:environment] => :environment do |_t, args|
9
+ Rails::Credentials::Conflict::Resolver.new(args[:environment]).resolve
10
+ rescue Rails::Credentials::Conflict::Error => e
11
+ abort e.message
12
+ end
13
+
14
+ desc "Resolve credentials conflict by keeping yours (usage: rails credentials:conflict:yours[environment])"
15
+ task :yours, [:environment] => :environment do |_t, args|
16
+ Rails::Credentials::Conflict::Resolver.new(args[:environment]).yours
17
+ rescue Rails::Credentials::Conflict::Error => e
18
+ abort e.message
19
+ end
20
+
21
+ desc "Resolve credentials conflict by keeping theirs (usage: rails credentials:conflict:theirs[environment])"
22
+ task :theirs, [:environment] => :environment do |_t, args|
23
+ Rails::Credentials::Conflict::Resolver.new(args[:environment]).theirs
24
+ rescue Rails::Credentials::Conflict::Error => e
25
+ abort e.message
26
+ end
27
+
28
+ desc "Resolve credentials conflict by keeping base version (usage: rails credentials:conflict:base[environment])"
29
+ task :base, [:environment] => :environment do |_t, args|
30
+ Rails::Credentials::Conflict::Resolver.new(args[:environment]).base
31
+ rescue Rails::Credentials::Conflict::Error => e
32
+ abort e.message
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,224 @@
1
+ ---
2
+ path: ".gem_rbs_collection"
3
+ gems:
4
+ - name: actionpack
5
+ version: '7.2'
6
+ source:
7
+ type: git
8
+ name: ruby/gem_rbs_collection
9
+ revision: 5aeae33367fa324abf3a4ef912f4e27aaf17f6b9
10
+ remote: https://github.com/ruby/gem_rbs_collection.git
11
+ repo_dir: gems
12
+ - name: actionview
13
+ version: '6.0'
14
+ source:
15
+ type: git
16
+ name: ruby/gem_rbs_collection
17
+ revision: 5aeae33367fa324abf3a4ef912f4e27aaf17f6b9
18
+ remote: https://github.com/ruby/gem_rbs_collection.git
19
+ repo_dir: gems
20
+ - name: activesupport
21
+ version: '7.0'
22
+ source:
23
+ type: git
24
+ name: ruby/gem_rbs_collection
25
+ revision: 5aeae33367fa324abf3a4ef912f4e27aaf17f6b9
26
+ remote: https://github.com/ruby/gem_rbs_collection.git
27
+ repo_dir: gems
28
+ - name: base64
29
+ version: 0.3.0
30
+ source:
31
+ type: rubygems
32
+ - name: bigdecimal
33
+ version: '3.1'
34
+ source:
35
+ type: git
36
+ name: ruby/gem_rbs_collection
37
+ revision: 5aeae33367fa324abf3a4ef912f4e27aaf17f6b9
38
+ remote: https://github.com/ruby/gem_rbs_collection.git
39
+ repo_dir: gems
40
+ - name: cgi
41
+ version: '0'
42
+ source:
43
+ type: stdlib
44
+ - name: concurrent-ruby
45
+ version: '1.1'
46
+ source:
47
+ type: git
48
+ name: ruby/gem_rbs_collection
49
+ revision: 5aeae33367fa324abf3a4ef912f4e27aaf17f6b9
50
+ remote: https://github.com/ruby/gem_rbs_collection.git
51
+ repo_dir: gems
52
+ - name: connection_pool
53
+ version: '2.4'
54
+ source:
55
+ type: git
56
+ name: ruby/gem_rbs_collection
57
+ revision: 5aeae33367fa324abf3a4ef912f4e27aaf17f6b9
58
+ remote: https://github.com/ruby/gem_rbs_collection.git
59
+ repo_dir: gems
60
+ - name: date
61
+ version: '0'
62
+ source:
63
+ type: stdlib
64
+ - name: digest
65
+ version: '0'
66
+ source:
67
+ type: stdlib
68
+ - name: erb
69
+ version: '0'
70
+ source:
71
+ type: stdlib
72
+ - name: fileutils
73
+ version: '0'
74
+ source:
75
+ type: stdlib
76
+ - name: i18n
77
+ version: '1.10'
78
+ source:
79
+ type: git
80
+ name: ruby/gem_rbs_collection
81
+ revision: 5aeae33367fa324abf3a4ef912f4e27aaf17f6b9
82
+ remote: https://github.com/ruby/gem_rbs_collection.git
83
+ repo_dir: gems
84
+ - name: io-console
85
+ version: '0'
86
+ source:
87
+ type: stdlib
88
+ - name: json
89
+ version: '0'
90
+ source:
91
+ type: stdlib
92
+ - name: logger
93
+ version: '0'
94
+ source:
95
+ type: stdlib
96
+ - name: minitest
97
+ version: '5.25'
98
+ source:
99
+ type: git
100
+ name: ruby/gem_rbs_collection
101
+ revision: 5aeae33367fa324abf3a4ef912f4e27aaf17f6b9
102
+ remote: https://github.com/ruby/gem_rbs_collection.git
103
+ repo_dir: gems
104
+ - name: monitor
105
+ version: '0'
106
+ source:
107
+ type: stdlib
108
+ - name: nokogiri
109
+ version: '1.11'
110
+ source:
111
+ type: git
112
+ name: ruby/gem_rbs_collection
113
+ revision: 5aeae33367fa324abf3a4ef912f4e27aaf17f6b9
114
+ remote: https://github.com/ruby/gem_rbs_collection.git
115
+ repo_dir: gems
116
+ - name: openssl
117
+ version: '0'
118
+ source:
119
+ type: stdlib
120
+ - name: pp
121
+ version: '0'
122
+ source:
123
+ type: stdlib
124
+ - name: prettyprint
125
+ version: '0'
126
+ source:
127
+ type: stdlib
128
+ - name: rack
129
+ version: '2.2'
130
+ source:
131
+ type: git
132
+ name: ruby/gem_rbs_collection
133
+ revision: 5aeae33367fa324abf3a4ef912f4e27aaf17f6b9
134
+ remote: https://github.com/ruby/gem_rbs_collection.git
135
+ repo_dir: gems
136
+ - name: rails-dom-testing
137
+ version: '2.0'
138
+ source:
139
+ type: git
140
+ name: ruby/gem_rbs_collection
141
+ revision: 5aeae33367fa324abf3a4ef912f4e27aaf17f6b9
142
+ remote: https://github.com/ruby/gem_rbs_collection.git
143
+ repo_dir: gems
144
+ - name: rails-html-sanitizer
145
+ version: '1.6'
146
+ source:
147
+ type: git
148
+ name: ruby/gem_rbs_collection
149
+ revision: 5aeae33367fa324abf3a4ef912f4e27aaf17f6b9
150
+ remote: https://github.com/ruby/gem_rbs_collection.git
151
+ repo_dir: gems
152
+ - name: railties
153
+ version: '6.0'
154
+ source:
155
+ type: git
156
+ name: ruby/gem_rbs_collection
157
+ revision: 5aeae33367fa324abf3a4ef912f4e27aaf17f6b9
158
+ remote: https://github.com/ruby/gem_rbs_collection.git
159
+ repo_dir: gems
160
+ - name: rake
161
+ version: '13.0'
162
+ source:
163
+ type: git
164
+ name: ruby/gem_rbs_collection
165
+ revision: 5aeae33367fa324abf3a4ef912f4e27aaf17f6b9
166
+ remote: https://github.com/ruby/gem_rbs_collection.git
167
+ repo_dir: gems
168
+ - name: rdoc
169
+ version: '0'
170
+ source:
171
+ type: stdlib
172
+ - name: securerandom
173
+ version: '0'
174
+ source:
175
+ type: stdlib
176
+ - name: singleton
177
+ version: '0'
178
+ source:
179
+ type: stdlib
180
+ - name: socket
181
+ version: '0'
182
+ source:
183
+ type: stdlib
184
+ - name: stringio
185
+ version: '0'
186
+ source:
187
+ type: stdlib
188
+ - name: tempfile
189
+ version: '0'
190
+ source:
191
+ type: stdlib
192
+ - name: thor
193
+ version: '1.2'
194
+ source:
195
+ type: git
196
+ name: ruby/gem_rbs_collection
197
+ revision: 5aeae33367fa324abf3a4ef912f4e27aaf17f6b9
198
+ remote: https://github.com/ruby/gem_rbs_collection.git
199
+ repo_dir: gems
200
+ - name: time
201
+ version: '0'
202
+ source:
203
+ type: stdlib
204
+ - name: timeout
205
+ version: '0'
206
+ source:
207
+ type: stdlib
208
+ - name: tsort
209
+ version: '0'
210
+ source:
211
+ type: stdlib
212
+ - name: tzinfo
213
+ version: '2.0'
214
+ source:
215
+ type: git
216
+ name: ruby/gem_rbs_collection
217
+ revision: 5aeae33367fa324abf3a4ef912f4e27aaf17f6b9
218
+ remote: https://github.com/ruby/gem_rbs_collection.git
219
+ repo_dir: gems
220
+ - name: uri
221
+ version: '0'
222
+ source:
223
+ type: stdlib
224
+ gemfile_lock_path: Gemfile.lock
@@ -0,0 +1,19 @@
1
+ # Download sources
2
+ sources:
3
+ - type: git
4
+ name: ruby/gem_rbs_collection
5
+ remote: https://github.com/ruby/gem_rbs_collection.git
6
+ revision: main
7
+ repo_dir: gems
8
+
9
+ # You can specify local directories as sources also.
10
+ # - type: local
11
+ # path: path/to/your/local/repository
12
+
13
+ # A directory to install the downloaded RBSs
14
+ path: .gem_rbs_collection
15
+
16
+ # gems:
17
+ # # If you want to avoid installing rbs files for gems, you can specify them here.
18
+ # - name: GEM_NAME
19
+ # ignore: true
@@ -0,0 +1,31 @@
1
+ module Rails
2
+ module Credentials
3
+ module Conflict
4
+ class EncryptionService
5
+ CIPHER: String
6
+
7
+ @key_path: (String | Pathname)
8
+ @env_key: String
9
+ @encryptor: ActiveSupport::MessageEncryptor?
10
+
11
+ def initialize: ((String | Pathname) key_path, ?env_key: String) -> void
12
+
13
+ def decrypt: (String? encrypted_content) -> String
14
+
15
+ def encrypt: (String content) -> String
16
+
17
+ def save_encrypted: (String content, (String | Pathname) destination_path) -> Integer
18
+
19
+ private
20
+
21
+ def encryptor: () -> ActiveSupport::MessageEncryptor
22
+
23
+ def read_key: () -> String
24
+
25
+ def key_from_env: () -> String?
26
+
27
+ def key_from_file: () -> String?
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,44 @@
1
+ module Rails
2
+ module Credentials
3
+ module Conflict
4
+ class GitConflictHandler
5
+ type version = :base | :ours | :theirs
6
+
7
+ type merge_labels = { ours: String, base: String, theirs: String }
8
+
9
+ STAGE_BASE: Integer
10
+ STAGE_OURS: Integer
11
+ STAGE_THEIRS: Integer
12
+
13
+ @credentials_path: String
14
+ @relative_path: String
15
+
16
+ def initialize: ((String | Pathname) credentials_path, (String | Pathname) relative_path) -> void
17
+
18
+ def validate_conflict!: () -> void
19
+
20
+ def get_version: (version version) -> String
21
+
22
+ def stage_resolved_file!: () -> void
23
+
24
+ def get_merge_labels: () -> merge_labels
25
+
26
+ private
27
+
28
+ def stage_for_version: (version version) -> Integer
29
+
30
+ def detect_head_ref: () -> String
31
+
32
+ def build_label: (String branch_name, String commit_sha) -> String
33
+
34
+ def get_current_branch: () -> String
35
+
36
+ def get_incoming_branch: (String head_ref) -> String
37
+
38
+ def get_short_sha: (String ref) -> String
39
+
40
+ def get_merge_base_sha: (String head_ref) -> String
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,35 @@
1
+ module Rails
2
+ module Credentials
3
+ module Conflict
4
+ class MergeStrategy
5
+ type merge_result = { content: String, has_conflicts: bool }
6
+
7
+ type merge_labels = { ours: String, base: String, theirs: String }
8
+
9
+ CONFLICT_MARKER_START: String
10
+ CONFLICT_MARKER_END: String
11
+
12
+ @encryption_service: EncryptionService
13
+
14
+ def initialize: (EncryptionService encryption_service) -> void
15
+
16
+ def create_conflict_markers: (
17
+ String ours_content,
18
+ String base_content,
19
+ String theirs_content,
20
+ labels: merge_labels
21
+ ) -> merge_result
22
+
23
+ def has_conflict_markers?: (String content) -> bool
24
+
25
+ def validate_yaml!: (String content) -> void
26
+
27
+ def open_editor_for_resolution: (String merged_content) -> String
28
+
29
+ private
30
+
31
+ def with_temp_files: (*String contents) { (*String paths) -> untyped } -> untyped
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,19 @@
1
+ module Rails
2
+ module Credentials
3
+ module Conflict
4
+ class PathResolver
5
+ attr_reader environment: String?
6
+
7
+ @environment: String?
8
+
9
+ def initialize: (?String? environment) -> void
10
+
11
+ def credentials_path: () -> Pathname
12
+
13
+ def key_path: () -> Pathname
14
+
15
+ def relative_credentials_path: () -> Pathname
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,8 @@
1
+ module Rails
2
+ module Credentials
3
+ module Conflict
4
+ class Railtie < Rails::Railtie
5
+ end
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,38 @@
1
+ module Rails
2
+ module Credentials
3
+ module Conflict
4
+ class Resolver
5
+ attr_reader environment: String?
6
+
7
+ @environment: String?
8
+ @output: IO | StringIO
9
+ @path_resolver: PathResolver
10
+ @encryption_service: EncryptionService
11
+ @git_handler: GitConflictHandler
12
+ @merge_strategy: MergeStrategy
13
+
14
+ def initialize: (?String? environment, ?output: (IO | StringIO)) -> void
15
+
16
+ def resolve: () -> void
17
+
18
+ def yours: () -> void
19
+
20
+ def theirs: () -> void
21
+
22
+ def base: () -> void
23
+
24
+ private
25
+
26
+ def log: (String message) -> void
27
+
28
+ def resolve_with_version: (GitConflictHandler::version version, String label) -> void
29
+
30
+ def decrypt_version: (GitConflictHandler::version version) -> String
31
+
32
+ def get_base_version: () -> String
33
+
34
+ def save_and_stage: (String content) -> void
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,10 @@
1
+ module Rails
2
+ module Credentials
3
+ module Conflict
4
+ VERSION: String
5
+
6
+ class Error < StandardError
7
+ end
8
+ end
9
+ end
10
+ end
metadata ADDED
@@ -0,0 +1,95 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails-credentials-conflict
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - jwo1f
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '6.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '6.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: railties
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '6.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '6.0'
40
+ description: A gem that helps resolve git merge conflicts in Rails encrypted credentials
41
+ by decrypting, merging, and re-encrypting the files.
42
+ email:
43
+ - ''
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - CHANGELOG.md
49
+ - LICENSE.txt
50
+ - README.md
51
+ - Rakefile
52
+ - lib/rails/credentials/conflict.rb
53
+ - lib/rails/credentials/conflict/encryption_service.rb
54
+ - lib/rails/credentials/conflict/git_conflict_handler.rb
55
+ - lib/rails/credentials/conflict/merge_strategy.rb
56
+ - lib/rails/credentials/conflict/path_resolver.rb
57
+ - lib/rails/credentials/conflict/railtie.rb
58
+ - lib/rails/credentials/conflict/resolver.rb
59
+ - lib/rails/credentials/conflict/version.rb
60
+ - lib/tasks/credentials_conflict.rake
61
+ - rbs_collection.lock.yaml
62
+ - rbs_collection.yaml
63
+ - sig/rails/credentials/conflict.rbs
64
+ - sig/rails/credentials/conflict/encryption_service.rbs
65
+ - sig/rails/credentials/conflict/git_conflict_handler.rbs
66
+ - sig/rails/credentials/conflict/merge_strategy.rbs
67
+ - sig/rails/credentials/conflict/path_resolver.rbs
68
+ - sig/rails/credentials/conflict/railtie.rbs
69
+ - sig/rails/credentials/conflict/resolver.rbs
70
+ homepage: https://github.com/jwo1f/rails-credentials-conflict
71
+ licenses:
72
+ - MIT
73
+ metadata:
74
+ homepage_uri: https://github.com/jwo1f/rails-credentials-conflict
75
+ source_code_uri: https://github.com/jwo1f/rails-credentials-conflict
76
+ changelog_uri: https://github.com/jwo1f/rails-credentials-conflict/blob/main/CHANGELOG.md
77
+ rubygems_mfa_required: 'true'
78
+ rdoc_options: []
79
+ require_paths:
80
+ - lib
81
+ required_ruby_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: 3.1.0
86
+ required_rubygems_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ requirements: []
92
+ rubygems_version: 4.0.0
93
+ specification_version: 4
94
+ summary: Resolve Rails encrypted credentials conflicts during git merges
95
+ test_files: []