mirrorfile 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: 2b29787fe3ec7d699137a8bc8f02dbfafd0ac95693dece15bf7199c977d3aa0e
4
+ data.tar.gz: df9541b78b01255029986e18f3144294f4e60b436b8d928c7e8ff8937177cadc
5
+ SHA512:
6
+ metadata.gz: b3fd0d4747c159d0cf957012a7ba997b0dfc062278ef5d3b0b0bd823e23e96df11d78ebd863a1dbfe91beabac0a8e1a207e84cdbca662c0d2fa70a2ef5706172
7
+ data.tar.gz: b0fea1083d5ec020119d35d775d19545662223110306e0ac6ce87598c80bc190a952dcb4113ed7f056845b0074651097b17add026c7743f90cbdc23ac1b1895c
data/.yardopts ADDED
@@ -0,0 +1,7 @@
1
+ --markup markdown
2
+ --markup-provider redcarpet
3
+ --charset utf-8
4
+ --output-dir doc
5
+ --readme README.md
6
+ --title "GitMirror Documentation"
7
+ lib/**/*.rb
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
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.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2024-01-01
11
+
12
+ ### Added
13
+
14
+ - Initial release
15
+ - `mirror init` command to initialize projects
16
+ - `mirror install` command to clone repositories
17
+ - `mirror update` command to pull latest changes
18
+ - `mirror list` command to show defined mirrors
19
+ - Mirrorfile DSL with `source` and `mirror` methods
20
+ - Rails/Zeitwerk integration for autoloading mirrored code
21
+ - Automatic `.gitignore` management
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 Your Name
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,77 @@
1
+ # Mirrorfile
2
+
3
+ Clone git repositories into a local `mirrors/` folder and keep them updated. Uses a `Mirrorfile` with Bundler-like syntax.
4
+
5
+ ## Why use this
6
+
7
+ - You want to vendor gems or libraries without git submodules
8
+ - You need local copies of repos for reference or offline work
9
+ - You want Zeitwerk to autoload code from external repos in Rails
10
+
11
+ ## Why not use this
12
+
13
+ - Git submodules already work fine for you
14
+ - You need pinned versions or tags (this just pulls `HEAD`)
15
+ - You want proper dependency management (use Bundler)
16
+
17
+
18
+ ## Install
19
+ ```ruby
20
+ gem install mirrorfile
21
+ ```
22
+
23
+ ## Usage
24
+ ```bash
25
+ mirror init # creates Mirrorfile, .gitignore entry, zeitwerk initializer
26
+ mirror install # clones missing repos
27
+ mirror update # pulls existing repos
28
+ mirror list # shows defined mirrors
29
+ ```
30
+
31
+ ## Example Mirrorfile
32
+
33
+ You can change sources mid-file:
34
+
35
+ ```ruby
36
+ # frozen_string_literal: true
37
+
38
+ # Rails ecosystem
39
+ source "https://github.com"
40
+
41
+ mirror "rails/rails", as: "rails-source"
42
+ mirror "hotwired/turbo-rails"
43
+ mirror "hotwired/stimulus-rails"
44
+
45
+ # Internal gems
46
+ source "https://git.company.com"
47
+
48
+ mirror "platform/shared-models", as: "shared-models"
49
+ mirror "platform/api-client"
50
+
51
+ # One-off from different host
52
+ mirror "https://gitlab.com/someorg/special-gem"
53
+ ```
54
+
55
+ ## Contributing
56
+
57
+ Bug reports and pull requests are welcome on GitHub.
58
+
59
+ ## Development
60
+
61
+ After checking out the repo:
62
+
63
+ ```bash
64
+ bin/setup
65
+ bundle exec rake test
66
+ ```
67
+
68
+ Generate documentation:
69
+
70
+ ```bash
71
+ bundle exec yard doc
72
+ bundle exec yard server # Browse at http://localhost:8808
73
+ ```
74
+
75
+ ## License
76
+
77
+ The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+ require "yard"
6
+
7
+ Rake::TestTask.new(:test) do |t|
8
+ t.libs << "test"
9
+ t.libs << "lib"
10
+ t.test_files = FileList["test/**/*_test.rb"]
11
+ end
12
+
13
+ YARD::Rake::YardocTask.new do |t|
14
+ t.files = ["lib/**/*.rb"]
15
+ t.options = ["--markup", "markdown"]
16
+ end
17
+
18
+ task default: :test
data/exe/mirror ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/mirrorfile"
5
+
6
+ exit Mirrorfile::CLI.new.call(ARGV)
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mirrorfile
4
+ # Command-line interface for Mirrorfile.
5
+ #
6
+ # CLI parses command-line arguments and dispatches to the appropriate
7
+ # {Mirror} methods. It provides a simple, git-like interface for
8
+ # managing mirrored repositories.
9
+ #
10
+ # @example Running from command line
11
+ # $ mirror init # Initialize project
12
+ # $ mirror install # Clone repositories
13
+ # $ mirror update # Pull latest changes
14
+ # $ mirror list # Show all mirrors
15
+ #
16
+ # @example Programmatic usage
17
+ # cli = Mirrorfile::CLI.new
18
+ # cli.call(["install"])
19
+ #
20
+ # @since 0.1.0
21
+ class CLI
22
+ # Available CLI commands
23
+ # @return [Array<String>] list of valid commands
24
+ COMMANDS = %w[init install update list help].freeze
25
+
26
+ # Creates a new CLI instance.
27
+ #
28
+ # @param stdout [IO] output stream for normal messages (default: $stdout)
29
+ # @param stderr [IO] output stream for error messages (default: $stderr)
30
+ # @return [CLI] a new CLI instance
31
+ def initialize(stdout: $stdout, stderr: $stderr)
32
+ @stdout = stdout
33
+ @stderr = stderr
34
+ end
35
+
36
+ # Parses arguments and executes the appropriate command.
37
+ #
38
+ # @param args [Array<String>] command-line arguments (typically ARGV)
39
+ # @return [Integer] exit status code (0 for success, 1 for error)
40
+ #
41
+ # @example
42
+ # cli = Mirrorfile::CLI.new
43
+ # exit_code = cli.call(["install"])
44
+ #
45
+ # @example With error handling
46
+ # cli = Mirrorfile::CLI.new
47
+ # exit cli.call(ARGV)
48
+ def call(args)
49
+ command = args.first
50
+
51
+ case command
52
+ when "init" then init
53
+ when "install" then install
54
+ when "update" then update
55
+ when "list" then list
56
+ when "help" then help
57
+ when "-h", "--help" then help
58
+ when "-v", "--version" then version
59
+ else usage
60
+ end
61
+
62
+ 0
63
+ rescue MirrorfileNotFound => e
64
+ @stderr.puts "Error: #{e.message}"
65
+ 1
66
+ rescue StandardError => e
67
+ @stderr.puts "Error: #{e.message}"
68
+ @stderr.puts e.backtrace.first(5).map { " #{_1}" } if ENV["DEBUG"]
69
+ 1
70
+ end
71
+
72
+ private
73
+
74
+ # Executes the init command.
75
+ #
76
+ # @return [void]
77
+ # @api private
78
+ def init
79
+ Mirror.new.init
80
+ end
81
+
82
+ # Executes the install command.
83
+ #
84
+ # @return [void]
85
+ # @api private
86
+ def install
87
+ @stdout.puts "Installing mirrors..."
88
+ Mirror.new.install
89
+ @stdout.puts "Done."
90
+ end
91
+
92
+ # Executes the update command.
93
+ #
94
+ # @return [void]
95
+ # @api private
96
+ def update
97
+ @stdout.puts "Updating mirrors..."
98
+ Mirror.new.update
99
+ @stdout.puts "Done."
100
+ end
101
+
102
+ # Executes the list command.
103
+ #
104
+ # @return [void]
105
+ # @api private
106
+ def list
107
+ entries = Mirror.new.list
108
+
109
+ entries.empty? ? @stdout.puts("No mirrors defined.") : entries.each { @stdout.puts _1 }
110
+ end
111
+
112
+ # Displays help information.
113
+ #
114
+ # @return [void]
115
+ # @api private
116
+ def help
117
+ @stdout.puts <<~HELP
118
+ Mirrorfile - Manage local mirrors of git repositories
119
+
120
+ Usage: mirror <command>
121
+
122
+ Commands:
123
+ init Initialize project with Mirrorfile, .gitignore entry,
124
+ and Zeitwerk initializer for Rails projects
125
+ install Clone repositories that don't exist locally
126
+ update Pull latest changes for existing repositories
127
+ list Show all defined mirrors
128
+ help Show this help message
129
+
130
+ Options:
131
+ -h, --help Show this help message
132
+ -v, --version Show version number
133
+
134
+ Examples:
135
+ $ mirror init
136
+ $ mirror install
137
+ $ mirror update
138
+
139
+ Mirrorfile syntax:
140
+ source "https://github.com"
141
+
142
+ mirror "rails/rails", as: "rails-source"
143
+ mirror "hotwired/turbo-rails"
144
+
145
+ source "https://gitlab.com"
146
+
147
+ mirror "org/project"
148
+
149
+ For more information, see: https://github.com/n-at-han-k/mirrorfile
150
+ HELP
151
+ end
152
+
153
+ # Displays version information.
154
+ #
155
+ # @return [void]
156
+ # @api private
157
+ def version
158
+ @stdout.puts "mirrorfile #{Mirrorfile::VERSION}"
159
+ end
160
+
161
+ # Displays usage information for unknown commands.
162
+ #
163
+ # @return [void]
164
+ # @api private
165
+ def usage
166
+ @stderr.puts <<~USAGE
167
+ Usage: mirror <command>
168
+
169
+ Commands:
170
+ init Create Mirrorfile, .gitignore entry, and Zeitwerk initializer
171
+ install Clone repositories that don't exist locally
172
+ update Pull latest changes for existing repositories
173
+ list Show all defined mirrors
174
+ help Show detailed help
175
+
176
+ Run 'mirror help' for more information.
177
+ USAGE
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mirrorfile
4
+ # Represents a single repository entry to be mirrored.
5
+ #
6
+ # Entry is an immutable data object that holds the URL and local name
7
+ # for a mirrored repository. It provides methods for cloning and updating
8
+ # the repository.
9
+ #
10
+ # @example Creating an entry
11
+ # entry = Mirrorfile::Entry.new(
12
+ # url: "https://github.com/rails/rails",
13
+ # name: "rails-source"
14
+ # )
15
+ # entry.install(Pathname.new("mirrors"))
16
+ #
17
+ # @!attribute [r] url
18
+ # @return [String] the full git URL of the repository
19
+ #
20
+ # @!attribute [r] name
21
+ # @return [String] the local directory name for the clone
22
+ #
23
+ # @since 0.1.0
24
+ Entry = Data.define(:url, :name) do
25
+ # Returns the local path where this repository will be cloned.
26
+ #
27
+ # @param base_dir [Pathname] the base directory containing all mirrors
28
+ # @return [Pathname] the full path to this repository's local directory
29
+ #
30
+ # @example
31
+ # entry = Entry.new(url: "https://github.com/rails/rails", name: "rails")
32
+ # entry.local_path(Pathname.new("/project/mirrors"))
33
+ # #=> #<Pathname:/project/mirrors/rails>
34
+ def local_path(base_dir)
35
+ base_dir.join(name)
36
+ end
37
+
38
+ # Clones the repository if it doesn't already exist locally.
39
+ #
40
+ # This method is idempotent - calling it multiple times will only
41
+ # clone the repository once. If the local directory already exists,
42
+ # no action is taken.
43
+ #
44
+ # @param base_dir [Pathname] the base directory to clone into
45
+ # @return [Boolean, nil] true if clone succeeded, false if failed,
46
+ # nil if already exists
47
+ #
48
+ # @example
49
+ # entry.install(Pathname.new("mirrors"))
50
+ #
51
+ # @see #update
52
+ def install(base_dir)
53
+ dir = local_path(base_dir)
54
+ system("git", "clone", url, dir.to_s) unless dir.exist?
55
+ end
56
+
57
+ # Updates an existing repository by pulling the latest changes.
58
+ #
59
+ # Uses fast-forward only merges to avoid creating merge commits.
60
+ # If the local directory doesn't exist, no action is taken.
61
+ #
62
+ # @param base_dir [Pathname] the base directory containing the clone
63
+ # @return [Boolean, nil] true if pull succeeded, false if failed,
64
+ # nil if directory doesn't exist
65
+ #
66
+ # @example
67
+ # entry.update(Pathname.new("mirrors"))
68
+ #
69
+ # @see #install
70
+ def update(base_dir)
71
+ dir = local_path(base_dir)
72
+ system("git", "-C", dir.to_s, "pull", "--ff-only") if dir.exist?
73
+ end
74
+
75
+ # Returns a human-readable representation of the entry.
76
+ #
77
+ # @return [String] formatted string showing url and name
78
+ def to_s
79
+ "#{name} (#{url})"
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mirrorfile
4
+ # Orchestrates mirror operations: init, install, and update.
5
+ #
6
+ # Mirror is the main interface for performing operations on mirrored
7
+ # repositories. It handles loading the Mirrorfile, creating necessary
8
+ # directories and files, and delegating to individual entries.
9
+ #
10
+ # @example Initializing a new project
11
+ # mirror = Mirrorfile::Mirror.new
12
+ # mirror.init
13
+ #
14
+ # @example Installing and updating mirrors
15
+ # mirror = Mirrorfile::Mirror.new
16
+ # mirror.install # Clone missing repositories
17
+ # mirror.update # Pull latest changes
18
+ #
19
+ # @since 0.1.0
20
+ class Mirror
21
+ # @return [Pathname] the project root directory
22
+ attr_reader :root
23
+
24
+ # @return [Pathname] the mirrors directory path
25
+ attr_reader :mirrors_dir
26
+
27
+ # @return [Pathname] the .gitignore file path
28
+ attr_reader :gitignore_path
29
+
30
+ # @return [Pathname] the Mirrorfile path
31
+ attr_reader :mirrorfile_path
32
+
33
+ # @return [Pathname] the Rails initializer path
34
+ attr_reader :initializer_path
35
+
36
+ # Creates a new Mirror instance.
37
+ #
38
+ # @param root [Pathname, String] the project root directory
39
+ # (defaults to current working directory)
40
+ # @return [Mirror] a new Mirror instance
41
+ #
42
+ # @example With default root
43
+ # mirror = Mirrorfile::Mirror.new
44
+ #
45
+ # @example With custom root
46
+ # mirror = Mirrorfile::Mirror.new(root: "/path/to/project")
47
+ def initialize(root: Dir.pwd)
48
+ @root = Pathname.new(root)
49
+ @mirrors_dir = @root.join("mirrors")
50
+ @gitignore_path = @root.join(".gitignore")
51
+ @mirrorfile_path = @root.join("Mirrorfile")
52
+ @initializer_path = @root.join("config/initializers/mirrors.rb")
53
+ @mirrorfile = load_mirrorfile if @mirrorfile_path.exist?
54
+ end
55
+
56
+ # Clones all repositories that don't exist locally.
57
+ #
58
+ # Creates the mirrors directory if it doesn't exist, then iterates
59
+ # through all entries in the Mirrorfile and clones any that are missing.
60
+ #
61
+ # @return [void]
62
+ # @raise [MirrorfileNotFound] if Mirrorfile doesn't exist
63
+ #
64
+ # @example
65
+ # mirror = Mirrorfile::Mirror.new
66
+ # mirror.install
67
+ #
68
+ # @see Entry#install
69
+ def install
70
+ ensure_mirrorfile!
71
+ mirrors_dir.mkpath
72
+ @mirrorfile.entries.each { _1.install(mirrors_dir) }
73
+ end
74
+
75
+ # Updates all existing local repositories.
76
+ #
77
+ # Iterates through all entries in the Mirrorfile and pulls the latest
78
+ # changes for any that exist locally. Repositories that haven't been
79
+ # cloned are skipped.
80
+ #
81
+ # @return [void]
82
+ # @raise [MirrorfileNotFound] if Mirrorfile doesn't exist
83
+ #
84
+ # @example
85
+ # mirror = Mirrorfile::Mirror.new
86
+ # mirror.update
87
+ #
88
+ # @see Entry#update
89
+ def update
90
+ ensure_mirrorfile!
91
+ @mirrorfile.entries.each { _1.update(mirrors_dir) }
92
+ end
93
+
94
+ # Initializes a new project with mirror support.
95
+ #
96
+ # This method creates all necessary files and directories for using
97
+ # Mirrorfile in a project:
98
+ #
99
+ # - Creates a Mirrorfile with example syntax
100
+ # - Adds /mirrors to .gitignore (creates file if needed)
101
+ # - Creates a Rails initializer for Zeitwerk autoloading
102
+ #
103
+ # Existing files are not overwritten.
104
+ #
105
+ # @return [void]
106
+ #
107
+ # @example
108
+ # mirror = Mirrorfile::Mirror.new
109
+ # mirror.init
110
+ # # => Creates Mirrorfile, updates .gitignore, creates initializer
111
+ #
112
+ # @see #create_mirrorfile
113
+ # @see #setup_gitignore
114
+ # @see #setup_zeitwerk
115
+ def init
116
+ create_mirrorfile
117
+ setup_gitignore
118
+ setup_zeitwerk
119
+ puts "Initialized mirrors in #{root}"
120
+ end
121
+
122
+ # Lists all entries in the Mirrorfile.
123
+ #
124
+ # @return [Array<Entry>] array of all mirror entries
125
+ # @raise [MirrorfileNotFound] if Mirrorfile doesn't exist
126
+ #
127
+ # @example
128
+ # mirror.list.each { |entry| puts entry }
129
+ def list
130
+ ensure_mirrorfile!
131
+ @mirrorfile.entries.to_a
132
+ end
133
+
134
+ private
135
+
136
+ # Loads and parses the Mirrorfile.
137
+ #
138
+ # @return [Mirrorfile] the parsed Mirrorfile
139
+ # @api private
140
+ def load_mirrorfile
141
+ Mirrorfile.load(mirrorfile_path)
142
+ end
143
+
144
+ # Raises an error if Mirrorfile doesn't exist.
145
+ #
146
+ # @raise [MirrorfileNotFound] if Mirrorfile doesn't exist
147
+ # @return [void]
148
+ # @api private
149
+ def ensure_mirrorfile!
150
+ raise MirrorfileNotFound, "Run 'mirror init' first" unless @mirrorfile
151
+ end
152
+
153
+ # Creates a new Mirrorfile with example syntax.
154
+ #
155
+ # Does nothing if Mirrorfile already exists.
156
+ #
157
+ # @return [Integer, nil] bytes written or nil if file exists
158
+ # @api private
159
+ def create_mirrorfile
160
+ mirrorfile_path.exist? || mirrorfile_path.write(<<~RUBY)
161
+ # frozen_string_literal: true
162
+
163
+ # Mirror repositories
164
+ #
165
+ # Set a source for subsequent mirror declarations:
166
+ #
167
+ # source "https://github.com"
168
+ #
169
+ # Then declare mirrors with optional custom names:
170
+ #
171
+ # mirror "user/repo"
172
+ # mirror "user/other-repo", as: "custom-name"
173
+ #
174
+ # You can change sources mid-file:
175
+ #
176
+ # source "https://gitlab.com"
177
+ #
178
+ # mirror "org/project"
179
+ #
180
+ # Or use full URLs without a source:
181
+ #
182
+ # mirror "https://bitbucket.org/team/repo"
183
+
184
+ source "https://github.com"
185
+
186
+ # mirror "rails/rails", as: "rails-source"
187
+ RUBY
188
+ end
189
+
190
+ # Adds /mirrors to .gitignore.
191
+ #
192
+ # Creates .gitignore if it doesn't exist. Appends the ignore
193
+ # pattern only if not already present.
194
+ #
195
+ # @return [Integer, nil] bytes written or nil if already ignored
196
+ # @api private
197
+ def setup_gitignore
198
+ gitignore_path.exist? || gitignore_path.write("")
199
+
200
+ lines = gitignore_path.readlines.map(&:chomp)
201
+ lines.include?("/mirrors") || gitignore_path.write([*lines, "/mirrors"].join("\n") + "\n")
202
+ end
203
+
204
+ # Creates a Rails initializer for Zeitwerk autoloading.
205
+ #
206
+ # The initializer configures Zeitwerk to autoload code from
207
+ # lib/ directories within mirrored repositories.
208
+ #
209
+ # Does nothing if initializer already exists.
210
+ #
211
+ # @return [Integer, nil] bytes written or nil if file exists
212
+ # @api private
213
+ def setup_zeitwerk
214
+ initializer_path.dirname.mkpath
215
+ initializer_path.exist? || initializer_path.write(<<~RUBY)
216
+ # frozen_string_literal: true
217
+
218
+ # Autoload mirrored repositories
219
+ #
220
+ # This initializer configures Zeitwerk to autoload code from
221
+ # lib/ directories within mirrored repositories.
222
+ #
223
+ # @see https://github.com/fxn/zeitwerk
224
+
225
+ Rails.autoloaders.main.tap do |loader|
226
+ mirrors = Rails.root.join("mirrors")
227
+
228
+ mirrors.glob("*/lib").each do |lib_path|
229
+ loader.push_dir(lib_path)
230
+ end if mirrors.exist?
231
+ end
232
+ RUBY
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mirrorfile
4
+ # Provides the DSL for parsing and evaluating Mirrorfile contents.
5
+ #
6
+ # Mirrorfile implements a domain-specific language similar to Bundler's
7
+ # Gemfile. It allows users to specify repository sources and mirror
8
+ # definitions in a readable, declarative format.
9
+ #
10
+ # @example Mirrorfile DSL
11
+ # source "https://github.com"
12
+ #
13
+ # mirror "rails/rails", as: "rails-source"
14
+ # mirror "hotwired/turbo-rails"
15
+ #
16
+ # source "https://gitlab.com"
17
+ #
18
+ # mirror "org/project"
19
+ #
20
+ # @example Programmatic usage
21
+ # mirrorfile = Mirrorfile::Mirrorfile.new
22
+ # mirrorfile.source("https://github.com")
23
+ # mirrorfile.mirror("rails/rails", as: "rails")
24
+ # mirrorfile.entries.each { |e| puts e.url }
25
+ #
26
+ # @since 0.1.0
27
+ class Mirrorfile
28
+ # Creates a new Mirrorfile instance.
29
+ #
30
+ # @return [Mirrorfile] a new instance with no entries or source
31
+ def initialize
32
+ @entries = []
33
+ @source = nil
34
+ end
35
+
36
+ # Sets the base URL for subsequent mirror declarations.
37
+ #
38
+ # The source URL is prepended to repository paths in following
39
+ # {#mirror} calls until a new source is declared. This allows
40
+ # grouping repositories by host without repeating the full URL.
41
+ #
42
+ # @param url [String] the base URL for repositories (e.g., "https://github.com")
43
+ # @return [String] the normalized source URL (trailing slash removed)
44
+ #
45
+ # @example Setting a GitHub source
46
+ # source "https://github.com"
47
+ # mirror "rails/rails" # clones from https://github.com/rails/rails
48
+ #
49
+ # @example Multiple sources
50
+ # source "https://github.com"
51
+ # mirror "user/repo1"
52
+ #
53
+ # source "https://gitlab.com"
54
+ # mirror "user/repo2" # clones from https://gitlab.com/user/repo2
55
+ def source(url)
56
+ @source = url.chomp("/")
57
+ end
58
+
59
+ # Declares a repository to be mirrored.
60
+ #
61
+ # If a {#source} has been set, the path is appended to it to form
62
+ # the full URL. Otherwise, the path is treated as a complete URL.
63
+ #
64
+ # @param path [String] the repository path or full URL
65
+ # @param as [String] the local directory name (defaults to repo name)
66
+ # @return [Entry] the newly created entry
67
+ #
68
+ # @example With source set
69
+ # source "https://github.com"
70
+ # mirror "rails/rails" # uses source + path
71
+ # mirror "hotwired/turbo", as: "turbo" # custom local name
72
+ #
73
+ # @example Without source (full URL)
74
+ # mirror "https://github.com/rails/rails"
75
+ # mirror "git@github.com:rails/rails.git", as: "rails"
76
+ #
77
+ # @see #source
78
+ def mirror(path, as: File.basename(path, ".git"))
79
+ url = @source ? "#{@source}/#{path}" : path
80
+ Entry.new(url:, name: as).tap { @entries << _1 }
81
+ end
82
+
83
+ # Returns a lazy enumerator of all declared entries.
84
+ #
85
+ # Using a lazy enumerator allows for efficient iteration over
86
+ # entries without loading them all into memory at once, and
87
+ # enables chaining with other enumerable methods.
88
+ #
89
+ # @return [Enumerator::Lazy<Entry>] lazy enumerator of Entry objects
90
+ #
91
+ # @example Iterating over entries
92
+ # mirrorfile.entries.each { |entry| entry.install(base_dir) }
93
+ #
94
+ # @example Filtering entries
95
+ # mirrorfile.entries
96
+ # .select { |e| e.name.start_with?("rails") }
97
+ # .each { |e| e.update(base_dir) }
98
+ def entries
99
+ @entries.lazy
100
+ end
101
+
102
+ # Returns the number of declared entries.
103
+ #
104
+ # @return [Integer] the count of mirror entries
105
+ def size
106
+ @entries.size
107
+ end
108
+
109
+ # Loads and evaluates a Mirrorfile from disk.
110
+ #
111
+ # @param path [Pathname, String] path to the Mirrorfile
112
+ # @return [Mirrorfile] a new instance with entries from the file
113
+ # @raise [MirrorfileNotFound] if the file doesn't exist
114
+ # @raise [SyntaxError] if the file contains invalid Ruby
115
+ #
116
+ # @example
117
+ # mirrorfile = Mirrorfile::Mirrorfile.load("Mirrorfile")
118
+ # mirrorfile.entries.each { |e| puts e.url }
119
+ def self.load(path)
120
+ path = Pathname.new(path)
121
+ raise MirrorfileNotFound, "Mirrorfile not found at #{path}" unless path.exist?
122
+
123
+ new.tap { _1.instance_eval(path.read, path.to_s) }
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mirrorfile
4
+ # Current version of the Mirrorfile gem
5
+ # @return [String] the semantic version string
6
+ VERSION = "0.1.0"
7
+ end
data/lib/mirrorfile.rb ADDED
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ require_relative "mirrorfile/version"
6
+ require_relative "mirrorfile/entry"
7
+ require_relative "mirrorfile/mirrorfile"
8
+ require_relative "mirrorfile/mirror"
9
+ require_relative "mirrorfile/cli"
10
+
11
+ # Mirrorfile is a gem for managing local mirrors of git repositories.
12
+ #
13
+ # It provides a DSL similar to Bundler's Gemfile for specifying repositories
14
+ # to clone and keep updated locally. This is useful for vendoring dependencies,
15
+ # referencing source code, or maintaining offline copies of repositories.
16
+ #
17
+ # @example Basic usage with a Mirrorfile
18
+ # # Mirrorfile
19
+ # source "https://github.com"
20
+ #
21
+ # mirror "rails/rails", as: "rails-source"
22
+ # mirror "hotwired/turbo-rails"
23
+ #
24
+ # @example Command line usage
25
+ # $ bin/mirror init # Initialize project with Mirrorfile
26
+ # $ bin/mirror install # Clone all repositories
27
+ # $ bin/mirror update # Pull latest changes
28
+ #
29
+ # @author Your Name
30
+ # @since 0.1.0
31
+ module Mirrorfile
32
+ class Error < StandardError; end
33
+
34
+ # Error raised when Mirrorfile is not found
35
+ class MirrorfileNotFound < Error; end
36
+
37
+ # Error raised when a git operation fails
38
+ class GitOperationError < Error; end
39
+
40
+ class << self
41
+ # Returns the root path for mirror operations
42
+ #
43
+ # @return [Pathname] the current working directory as a Pathname
44
+ def root
45
+ Pathname.new(Dir.pwd)
46
+ end
47
+
48
+ # Returns the path to the mirrors directory
49
+ #
50
+ # @return [Pathname] path to the mirrors directory
51
+ def mirrors_dir
52
+ root.join("mirrors")
53
+ end
54
+
55
+ # Returns the path to the Mirrorfile
56
+ #
57
+ # @return [Pathname] path to the Mirrorfile
58
+ def mirrorfile_path
59
+ root.join("Mirrorfile")
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/mirrorfile/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "mirrorfile"
7
+ spec.version = Mirrorfile::VERSION
8
+ spec.authors = ["Nathan Kidd"]
9
+ spec.email = ["nathankidd@hey.com"]
10
+
11
+ spec.summary = "Manage local mirrors of git repositories"
12
+
13
+ spec.description = <<~DESC
14
+ Mirrorfile provides a DSL similar to Bundler's Gemfile for managing local
15
+ mirrors of git repositories. Clone and keep repositories updated with
16
+ simple commands. Includes Rails/Zeitwerk integration for autoloading
17
+ mirrored code.
18
+ DESC
19
+
20
+ spec.homepage = "https://github.com/n-at-han-k/mirrorfile"
21
+ spec.license = "MIT"
22
+ spec.required_ruby_version = ">= 3.2.0"
23
+
24
+ spec.metadata["homepage_uri"] = spec.homepage
25
+ spec.metadata["source_code_uri"] = spec.homepage
26
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
27
+ spec.metadata["documentation_uri"] = spec.homepage
28
+ spec.metadata["rubygems_mfa_required"] = "true"
29
+
30
+ spec.files = Dir.chdir(__dir__) do
31
+ `git ls-files -z`.split("\x0").reject do |f|
32
+ (File.expand_path(f) == __FILE__) ||
33
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
34
+ end
35
+ end
36
+
37
+ spec.bindir = "exe"
38
+ spec.executables = ["mirror"]
39
+ spec.require_paths = ["lib"]
40
+
41
+ spec.add_development_dependency "minitest", "~> 5.0"
42
+ spec.add_development_dependency "rake", "~> 13.0"
43
+ spec.add_development_dependency "rubocop", "~> 1.21"
44
+ spec.add_development_dependency "yard", "~> 0.9"
45
+ end
metadata ADDED
@@ -0,0 +1,119 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mirrorfile
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nathan Kidd
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: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '5.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rubocop
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.21'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.21'
54
+ - !ruby/object:Gem::Dependency
55
+ name: yard
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.9'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.9'
68
+ description: |
69
+ Mirrorfile provides a DSL similar to Bundler's Gemfile for managing local
70
+ mirrors of git repositories. Clone and keep repositories updated with
71
+ simple commands. Includes Rails/Zeitwerk integration for autoloading
72
+ mirrored code.
73
+ email:
74
+ - nathankidd@hey.com
75
+ executables:
76
+ - mirror
77
+ extensions: []
78
+ extra_rdoc_files: []
79
+ files:
80
+ - ".yardopts"
81
+ - CHANGELOG.md
82
+ - LICENSE.txt
83
+ - README.md
84
+ - Rakefile
85
+ - exe/mirror
86
+ - lib/mirrorfile.rb
87
+ - lib/mirrorfile/cli.rb
88
+ - lib/mirrorfile/entry.rb
89
+ - lib/mirrorfile/mirror.rb
90
+ - lib/mirrorfile/mirrorfile.rb
91
+ - lib/mirrorfile/version.rb
92
+ - mirrorfile.gemspec
93
+ homepage: https://github.com/n-at-han-k/mirrorfile
94
+ licenses:
95
+ - MIT
96
+ metadata:
97
+ homepage_uri: https://github.com/n-at-han-k/mirrorfile
98
+ source_code_uri: https://github.com/n-at-han-k/mirrorfile
99
+ changelog_uri: https://github.com/n-at-han-k/mirrorfile/blob/main/CHANGELOG.md
100
+ documentation_uri: https://github.com/n-at-han-k/mirrorfile
101
+ rubygems_mfa_required: 'true'
102
+ rdoc_options: []
103
+ require_paths:
104
+ - lib
105
+ required_ruby_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: 3.2.0
110
+ required_rubygems_version: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: '0'
115
+ requirements: []
116
+ rubygems_version: 3.6.7
117
+ specification_version: 4
118
+ summary: Manage local mirrors of git repositories
119
+ test_files: []