git 5.1.0 → 5.3.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 (118) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -0
  3. data/CONTRIBUTING.md +202 -169
  4. data/LICENSE +1 -1
  5. data/README.md +192 -151
  6. data/UPGRADING.md +287 -1
  7. data/git.gemspec +35 -3
  8. data/lib/git/author.rb +11 -0
  9. data/lib/git/author_info.rb +66 -0
  10. data/lib/git/branch.rb +210 -15
  11. data/lib/git/branch_info.rb +1 -1
  12. data/lib/git/branches.rb +35 -7
  13. data/lib/git/command_line/base.rb +1 -2
  14. data/lib/git/commands/base.rb +1 -1
  15. data/lib/git/commands/cat_file/raw.rb +62 -7
  16. data/lib/git/object.rb +13 -7
  17. data/lib/git/parsers/stash.rb +50 -17
  18. data/lib/git/parsers/tag.rb +54 -8
  19. data/lib/git/remote.rb +37 -7
  20. data/lib/git/remote_info.rb +67 -10
  21. data/lib/git/repository/branching.rb +190 -6
  22. data/lib/git/repository/merging.rb +96 -2
  23. data/lib/git/repository/remote_operations.rb +57 -0
  24. data/lib/git/repository/shared_private.rb +67 -0
  25. data/lib/git/stash_info.rb +32 -34
  26. data/lib/git/tag_info.rb +21 -29
  27. data/lib/git/version.rb +1 -1
  28. data/lib/git.rb +1 -0
  29. metadata +6 -94
  30. data/.claude/commands/address-copilot-reviews.md +0 -14
  31. data/.claude/settings.json +0 -16
  32. data/.claude/skills +0 -1
  33. data/.commitlintrc.yml +0 -38
  34. data/.dockerignore +0 -27
  35. data/.github/copilot-instructions.md +0 -30
  36. data/.github/hooks/bin-setup-on-worktree.json +0 -11
  37. data/.github/hooks/run-bin-setup-once.sh +0 -20
  38. data/.github/issue_template.md +0 -15
  39. data/.github/prompts/iteratively-address-copilot-reviews.prompt.md +0 -188
  40. data/.github/pull_request_template.md +0 -21
  41. data/.github/skills/breaking-change-analysis/SKILL.md +0 -99
  42. data/.github/skills/ci-cd-troubleshooting/SKILL.md +0 -264
  43. data/.github/skills/command-implementation/REFERENCE.md +0 -994
  44. data/.github/skills/command-implementation/SKILL.md +0 -230
  45. data/.github/skills/command-test-conventions/SKILL.md +0 -664
  46. data/.github/skills/command-yard-documentation/SKILL.md +0 -434
  47. data/.github/skills/dependency-management/SKILL.md +0 -72
  48. data/.github/skills/development-workflow/SKILL.md +0 -512
  49. data/.github/skills/facade-implementation/REFERENCE.md +0 -837
  50. data/.github/skills/facade-implementation/SKILL.md +0 -269
  51. data/.github/skills/facade-test-conventions/SKILL.md +0 -391
  52. data/.github/skills/facade-yard-documentation/SKILL.md +0 -435
  53. data/.github/skills/make-skill-template/SKILL.md +0 -226
  54. data/.github/skills/pr-readiness-review/SKILL.md +0 -205
  55. data/.github/skills/project-context/SKILL.md +0 -306
  56. data/.github/skills/pull-request-review/SKILL.md +0 -168
  57. data/.github/skills/rebase/SKILL.md +0 -148
  58. data/.github/skills/refactor-command-to-commandlineresult/SKILL.md +0 -131
  59. data/.github/skills/release-management/SKILL.md +0 -125
  60. data/.github/skills/resolve-feedback/SKILL.md +0 -288
  61. data/.github/skills/review-arguments-dsl/CHECKLIST.md +0 -788
  62. data/.github/skills/review-arguments-dsl/SKILL.md +0 -214
  63. data/.github/skills/review-cross-command-consistency/SKILL.md +0 -139
  64. data/.github/skills/reviewing-skills/SKILL.md +0 -214
  65. data/.github/skills/rspec-unit-testing-standards/SKILL.md +0 -685
  66. data/.github/skills/tdd-refactor-step/SKILL.md +0 -236
  67. data/.github/skills/test-debugging/SKILL.md +0 -161
  68. data/.github/skills/yard-documentation/SKILL.md +0 -981
  69. data/.github/skills/yard-documentation/element-rules.md +0 -162
  70. data/.github/skills-deprecated/README.md +0 -21
  71. data/.github/skills-deprecated/extract-command-from-lib/SKILL.md +0 -487
  72. data/.github/skills-deprecated/extract-facade-from-base-lib/KEYWORD_ARG_REMEDIATION.md +0 -22
  73. data/.github/skills-deprecated/extract-facade-from-base-lib/SKILL.md +0 -600
  74. data/.github/skills-deprecated/review-backward-compatibility/SKILL.md +0 -275
  75. data/.github/workflows/continuous_integration.yml +0 -358
  76. data/.github/workflows/enforce_conventional_commits.yml +0 -35
  77. data/.github/workflows/experimental_continuous_integration.yml +0 -59
  78. data/.github/workflows/release.yml +0 -52
  79. data/.github/workflows/warm_bundler_caches.yml +0 -82
  80. data/.gitignore +0 -30
  81. data/.husky/commit-msg +0 -1
  82. data/.husky/pre-commit +0 -13
  83. data/.release-please-config.json +0 -36
  84. data/.release-please-manifest.json +0 -3
  85. data/.rspec +0 -2
  86. data/.rubocop.yml +0 -44
  87. data/.rubocop_todo.yml +0 -30
  88. data/.yard-lint.yml +0 -75
  89. data/CLAUDE.md +0 -11
  90. data/Gemfile +0 -22
  91. data/Rakefile +0 -41
  92. data/docker/test/Dockerfile +0 -32
  93. data/docker/test/docker-compose.yml +0 -0
  94. data/package.json +0 -10
  95. data/redesign/1_architecture_existing.md +0 -102
  96. data/redesign/2_architecture_redesign.md +0 -449
  97. data/redesign/3_architecture_implementation.md +0 -1623
  98. data/redesign/Phase 4 - Step A.md +0 -366
  99. data/redesign/Phase 4 - Step B.md +0 -921
  100. data/redesign/Phase 4 - Step C.md +0 -833
  101. data/redesign/beta_release.md +0 -107
  102. data/redesign/branch_parse_refactor_plan.md +0 -163
  103. data/redesign/c1a-public-api-scope.tsv +0 -256
  104. data/redesign/c1c2_audit.md +0 -566
  105. data/redesign/c1c2_bucket6_lib_orphans.md +0 -626
  106. data/redesign/config_design.rb +0 -501
  107. data/redesign/index.md +0 -34
  108. data/redesign/info_object_migration_plan.md +0 -126
  109. data/redesign/integration_test_analysis.md +0 -521
  110. data/redesign/phase-4-step-b-test-audit.tsv +0 -485
  111. data/redesign/remote_refactor_plan.md +0 -164
  112. data/redesign/reverse_dependencies.sql +0 -44
  113. data/tasks/gem_tasks.rake +0 -14
  114. data/tasks/npm_tasks.rake +0 -7
  115. data/tasks/rspec.rake +0 -111
  116. data/tasks/rubocop.rake +0 -5
  117. data/tasks/test_gem.rake +0 -12
  118. data/tasks/yard.rake +0 -57
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+
5
+ require 'git/author_info'
3
6
  require 'git/stash_info'
4
7
 
5
8
  module Git
@@ -45,10 +48,10 @@ module Git
45
48
  # %gs = reflog subject (the stash message)
46
49
  # %an = author name
47
50
  # %ae = author email
48
- # %aI = author date (ISO 8601 format)
51
+ # %aI = author date (ISO 8601 format, parsed into a Time)
49
52
  # %cn = committer name
50
53
  # %ce = committer email
51
- # %cI = committer date (ISO 8601 format)
54
+ # %cI = committer date (ISO 8601 format, parsed into a Time)
52
55
  STASH_FORMAT = [
53
56
  '%H', # 0: full SHA
54
57
  '%h', # 1: short SHA
@@ -163,7 +166,7 @@ module Git
163
166
  # @return [Hash] attributes for StashInfo.new
164
167
  #
165
168
  def stash_info_attrs(parts, index)
166
- core_attrs(parts, index).merge(author_attrs(parts)).merge(committer_attrs(parts))
169
+ core_attrs(parts, index).merge(author: author_info(parts), committer: committer_info(parts))
167
170
  end
168
171
 
169
172
  # Build core StashInfo attributes from parsed fields
@@ -182,30 +185,60 @@ module Git
182
185
  }
183
186
  end
184
187
 
185
- # Build author-related StashInfo attributes from parsed fields
188
+ # Build the author identity from the parsed fields
186
189
  #
187
190
  # @param parts [Array<String>] the parsed format fields
188
191
  #
189
- # @return [Hash<Symbol, String>] author attributes for StashInfo.new
192
+ # @return [Git::AuthorInfo] the stash author; its `date` is a `Time`
190
193
  #
191
- def author_attrs(parts)
192
- {
193
- author_name: parts[Fields::AUTHOR_NAME], author_email: parts[Fields::AUTHOR_EMAIL],
194
- author_date: parts[Fields::AUTHOR_DATE]
195
- }
194
+ def author_info(parts)
195
+ build_author_info(parts[Fields::AUTHOR_NAME], parts[Fields::AUTHOR_EMAIL], parts[Fields::AUTHOR_DATE])
196
196
  end
197
197
 
198
- # Build committer-related StashInfo attributes from parsed fields
198
+ # Build the committer identity from the parsed fields
199
199
  #
200
200
  # @param parts [Array<String>] the parsed format fields
201
201
  #
202
- # @return [Hash<Symbol, String>] committer attributes for StashInfo.new
202
+ # @return [Git::AuthorInfo] the stash committer; its `date` is a `Time`
203
203
  #
204
- def committer_attrs(parts)
205
- {
206
- committer_name: parts[Fields::COMMITTER_NAME], committer_email: parts[Fields::COMMITTER_EMAIL],
207
- committer_date: parts[Fields::COMMITTER_DATE]
208
- }
204
+ def committer_info(parts)
205
+ build_author_info(
206
+ parts[Fields::COMMITTER_NAME], parts[Fields::COMMITTER_EMAIL], parts[Fields::COMMITTER_DATE]
207
+ )
208
+ end
209
+
210
+ # Build a Git::AuthorInfo from identity fields
211
+ #
212
+ # The date is parsed with `Time.iso8601`, so the UTC offset git emits for
213
+ # `%aI` and `%cI` is preserved in the resulting `Time`.
214
+ #
215
+ # @param name [String] the `%an` or `%cn` field
216
+ #
217
+ # @param email [String] the `%ae` or `%ce` field
218
+ #
219
+ # @param date [String] the `%aI` or `%cI` field in ISO 8601 format
220
+ #
221
+ # @return [Git::AuthorInfo] the identity with `date` as a `Time`
222
+ #
223
+ # @raise [Git::UnexpectedResultError] if the date is not a valid ISO 8601 date
224
+ #
225
+ def build_author_info(name, email, date)
226
+ Git::AuthorInfo.new(name: name, email: email, date: parse_date(date))
227
+ end
228
+
229
+ # Parse a `%aI` or `%cI` field into a Time
230
+ #
231
+ # @param date [String] the date field in ISO 8601 format
232
+ #
233
+ # @return [Time] the parsed time, preserving the UTC offset
234
+ #
235
+ # @raise [Git::UnexpectedResultError] if the field is not a valid ISO 8601 date
236
+ #
237
+ def parse_date(date)
238
+ Time.iso8601(date)
239
+ rescue ArgumentError => e
240
+ raise Git::UnexpectedResultError,
241
+ "Unexpected date #{date.inspect} in output from `git stash list`: #{e.message}"
209
242
  end
210
243
 
211
244
  # Extract the stash index from a reflog selector
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+
5
+ require 'git/author_info'
3
6
  require 'git/tag_info'
4
7
  require 'git/tag_delete_result'
5
8
  require 'git/tag_delete_failure'
@@ -114,7 +117,7 @@ module Git
114
117
  # where <FS> is the unit separator character ("\x1f").
115
118
  #
116
119
  # For lightweight tags, Git emits empty strings for the tagger fields and message;
117
- # these are converted to nil by {#parse_optional_field} and {#parse_message}.
120
+ # these are converted to nil by {#parse_tagger} and {#parse_message}.
118
121
  #
119
122
  # @param record [String] a single tag record from git tag --format output
120
123
  #
@@ -183,19 +186,62 @@ module Git
183
186
  def build_tag_info_object(parts, oid, target_oid)
184
187
  Git::TagInfo.new(
185
188
  name: parts[0], oid: oid, target_oid: target_oid, objecttype: parts[3],
186
- tagger_name: parse_optional_field(parts[4]), tagger_email: parse_optional_field(parts[5]),
187
- tagger_date: parse_optional_field(parts[6]), message: parse_message(parts[3], parts[7])
189
+ tagger: parse_tagger(parts[4], parts[5], parts[6]), message: parse_message(parts[3], parts[7])
188
190
  )
189
191
  end
190
192
 
191
- # Parse an optional field, returning nil if empty
193
+ # Build the tagger identity from the tagger name, email, and date fields
194
+ #
195
+ # Git emits empty strings for all three fields when there is no tag object
196
+ # (lightweight tags) or the tag object has no tagger header, in which case
197
+ # the tagger is nil. Otherwise the angle brackets git wraps around
198
+ # `%(taggeremail)` are stripped and the strict ISO 8601
199
+ # `%(taggerdate:iso8601-strict)` value is parsed into a `Time` that
200
+ # preserves the UTC offset. A partially populated identity (for example an
201
+ # empty name with an email and date) is kept as emitted rather than dropped,
202
+ # and an empty date becomes `nil`.
203
+ #
204
+ # @example An annotated tag's tagger
205
+ # parse_tagger('John Doe', '<john@example.com>', '2024-01-15T10:30:00-08:00')
206
+ # #=> #<data Git::AuthorInfo name="John Doe", email="john@example.com", ...>
207
+ #
208
+ # @example A lightweight tag has no tagger
209
+ # parse_tagger('', '', '') #=> nil
210
+ #
211
+ # @param name [String] the `%(taggername)` field
212
+ #
213
+ # @param email [String] the `%(taggeremail)` field, including angle brackets
214
+ #
215
+ # @param date [String] the `%(taggerdate:iso8601-strict)` field
216
+ #
217
+ # @return [Git::AuthorInfo, nil] the tagger, or nil when all three fields are empty
218
+ #
219
+ # @raise [Git::UnexpectedResultError] if a non-empty date is not a valid ISO 8601
220
+ # date
221
+ #
222
+ def parse_tagger(name, email, date)
223
+ return nil if [name, email, date].all?(&:empty?)
224
+
225
+ Git::AuthorInfo.new(
226
+ name: name,
227
+ email: email.delete_prefix('<').delete_suffix('>'),
228
+ date: date.empty? ? nil : parse_date(date)
229
+ )
230
+ end
231
+
232
+ # Parse a `%(taggerdate:iso8601-strict)` field into a Time
233
+ #
234
+ # @param date [String] the date field in strict ISO 8601 format
192
235
  #
193
- # @param value [String] the field value
236
+ # @return [Time] the parsed time, preserving the UTC offset
194
237
  #
195
- # @return [String, nil] the value or nil if empty
238
+ # @raise [Git::UnexpectedResultError] if the field is not a valid ISO 8601 date
196
239
  #
197
- def parse_optional_field(value)
198
- value.empty? ? nil : value
240
+ def parse_date(date)
241
+ Time.iso8601(date)
242
+ rescue ArgumentError => e
243
+ raise Git::UnexpectedResultError,
244
+ "Unexpected tagger date #{date.inspect} in output from `git tag --list`: #{e.message}"
199
245
  end
200
246
 
201
247
  # Parse message field, returning nil for lightweight tags or empty messages
data/lib/git/remote.rb CHANGED
@@ -7,13 +7,26 @@ module Git
7
7
  # A remote in a Git repository
8
8
  #
9
9
  # Remote objects provide access to remote metadata and operations like fetch,
10
- # merge, and remove. They should be obtained via `Git::Repository#remote`,
11
- # not constructed directly.
10
+ # merge, and remove. This class and `Git::Repository#remote`, which returns
11
+ # it, are both deprecated: read remote configuration through
12
+ # {Git::Repository::RemoteOperations#remote_list} and call the
13
+ # repository-level operations with the remote name instead.
12
14
  #
13
- # @example Getting a remote
15
+ # @example Reading a remote and fetching from it without Git::Remote
14
16
  # git = Git.open('.')
15
- # remote = git.remote('origin')
16
- # remote.fetch
17
+ # origin = git.remote_list.find { |r| r.name == 'origin' } #=> Git::RemoteInfo
18
+ # origin.url.first
19
+ # git.fetch(origin.name)
20
+ #
21
+ # @deprecated Use {Git::Repository::RemoteOperations#remote_list} and the
22
+ # repository-level remote operations instead
23
+ #
24
+ # {Git::Repository::RemoteOperations#remote_list} returns immutable
25
+ # {Git::RemoteInfo} value objects. Operations that lived on this class are
26
+ # called on the repository with the remote name instead (for example
27
+ # {Git::Repository::RemoteOperations#fetch} and
28
+ # {Git::Repository::RemoteOperations#remote_remove}). Constructing a
29
+ # `Git::Remote` emits a deprecation warning.
17
30
  #
18
31
  # @api public
19
32
  #
@@ -42,13 +55,20 @@ module Git
42
55
  #
43
56
  # @param name [String] the remote name (e.g. `'origin'`)
44
57
  #
45
- # @note Use `Git::Repository#remote` instead of constructing directly
58
+ # @note Do not construct directly. `Git::Repository#remote` is deprecated as
59
+ # well; use {Git::Repository::RemoteOperations#remote_list} and the
60
+ # repository-level remote operations instead.
46
61
  #
47
62
  # @api private
48
63
  #
49
64
  def initialize(base, name)
65
+ Git::Deprecation.warn(
66
+ 'Git::Remote is deprecated and will be removed in v6.0.0. ' \
67
+ 'Use Git::Repository#remote_list and the repository-level remote operations instead.'
68
+ )
50
69
  @base = base
51
- config = remote_repository.config_remote(name)
70
+ # config_remote is deprecated too; silence it so one Git::Remote.new emits one warning
71
+ config = Git::Deprecation.silence { remote_repository.config_remote(name) }
52
72
  @name = name
53
73
  @url = config['url']
54
74
  @fetch_opts = config['fetch']
@@ -119,6 +139,16 @@ module Git
119
139
  #
120
140
  # @return [Git::Branch] a branch object representing `<remote>/<branch>`
121
141
  #
142
+ # @deprecated Use
143
+ # `Git::Repository#branch_list("#{name}/#{branch || current_branch}").first`
144
+ # instead
145
+ #
146
+ # With no argument this method falls back to the current branch, so the
147
+ # replacement has to supply `Git::Repository#current_branch` itself. The
148
+ # replacement returns a {Git::BranchInfo} value object rather than a
149
+ # {Git::Branch}, and returns `nil` when the remote-tracking branch does
150
+ # not exist.
151
+ #
122
152
  def branch(branch = nil)
123
153
  branch ||= remote_repository.current_branch
124
154
  Git::Branch.new(@base, "#{@name}/#{branch}")
@@ -6,7 +6,11 @@ module Git
6
6
  # Each instance holds the parsed configuration for a single remote as read
7
7
  # from the repository's git config. Multi-value fields (`:url`, `:push_url`,
8
8
  # `:fetch`, `:push`) are always `Array<String>` (never `nil`; may be empty).
9
- # All other fields are nilable except `:name`.
9
+ # Those arrays are frozen copies of the values given, so the set of URLs and
10
+ # refspecs cannot change after construction; use `with` to derive a modified
11
+ # copy. The immutability is shallow, as with any `Data` member: the strings
12
+ # inside those arrays and the scalar members are the objects the caller
13
+ # passed in, not copies. All other fields are nilable except `:name`.
10
14
  #
11
15
  # @example Minimal remote (fetch-only, one URL)
12
16
  # info = Git::RemoteInfo.new(
@@ -88,13 +92,13 @@ module Git
88
92
  #
89
93
  # @param name [String] the name of the remote (required)
90
94
  #
91
- # @param url [Array<String>] fetch URLs (default `[]`)
95
+ # @param url [Array<String>] fetch URLs (default `[]`); stored as a frozen copy
92
96
  #
93
- # @param push_url [Array<String>] push URLs (default `[]`)
97
+ # @param push_url [Array<String>] push URLs (default `[]`); stored as a frozen copy
94
98
  #
95
- # @param fetch [Array<String>] fetch refspecs (default `[]`)
99
+ # @param fetch [Array<String>] fetch refspecs (default `[]`); stored as a frozen copy
96
100
  #
97
- # @param push [Array<String>] push refspecs (default `[]`)
101
+ # @param push [Array<String>] push refspecs (default `[]`); stored as a frozen copy
98
102
  #
99
103
  # @param mirror [Boolean, nil] mirror flag (default `nil`)
100
104
  #
@@ -118,7 +122,7 @@ module Git
118
122
  #
119
123
  # @return [Git::RemoteInfo]
120
124
  #
121
- def initialize(
125
+ def initialize( # rubocop:disable Metrics/ParameterLists
122
126
  name:,
123
127
  url: [],
124
128
  push_url: [],
@@ -136,11 +140,64 @@ module Git
136
140
  vcs: nil
137
141
  )
138
142
  super(
139
- name:, url: Array(url), push_url: Array(push_url), fetch: Array(fetch),
140
- push: Array(push), mirror:, skip_default_update:, tag_opt:, prune:,
141
- prune_tags:, receivepack:, uploadpack:, promisor:, partial_clone_filter:,
142
- vcs:
143
+ name:, url: Array(url).dup.freeze, push_url: Array(push_url).dup.freeze,
144
+ fetch: Array(fetch).dup.freeze, push: Array(push).dup.freeze, mirror:,
145
+ skip_default_update:, tag_opt:, prune:, prune_tags:, receivepack:, uploadpack:,
146
+ promisor:, partial_clone_filter:, vcs:
143
147
  )
144
148
  end
149
+
150
+ # Return a copy of this RemoteInfo with the given fields replaced
151
+ #
152
+ # Routes through {#initialize} so the multi-value fields of the copy are
153
+ # frozen copies, the same as on construction. `Data#with` bypasses
154
+ # `initialize` on Ruby 3.2, which would leave those arrays mutable.
155
+ #
156
+ # @example Replace the fetch URL
157
+ # info.with(url: ['https://example.com/other.git']).url
158
+ # # => ["https://example.com/other.git"]
159
+ #
160
+ # @param fields [Hash{Symbol => Object}] the fields to replace, keyed by
161
+ # member name
162
+ #
163
+ # @option fields [String] :name the name of the remote
164
+ #
165
+ # @option fields [Array<String>] :url fetch URLs
166
+ #
167
+ # @option fields [Array<String>] :push_url push URLs
168
+ #
169
+ # @option fields [Array<String>] :fetch fetch refspecs
170
+ #
171
+ # @option fields [Array<String>] :push push refspecs
172
+ #
173
+ # @option fields [Boolean, nil] :mirror mirror flag
174
+ #
175
+ # @option fields [Boolean, nil] :skip_default_update skip-default-update flag
176
+ #
177
+ # @option fields [String, nil] :tag_opt tag-fetching option
178
+ #
179
+ # @option fields [Boolean, nil] :prune prune flag
180
+ #
181
+ # @option fields [Boolean, nil] :prune_tags prune-tags flag
182
+ #
183
+ # @option fields [String, nil] :receivepack receive-pack path
184
+ #
185
+ # @option fields [String, nil] :uploadpack upload-pack path
186
+ #
187
+ # @option fields [Boolean, nil] :promisor promisor flag
188
+ #
189
+ # @option fields [String, nil] :partial_clone_filter partial-clone filter
190
+ #
191
+ # @option fields [String, nil] :vcs VCS type
192
+ #
193
+ # @return [Git::RemoteInfo] a new instance; `self` when no fields are given
194
+ #
195
+ # @raise [ArgumentError] if a key is not a member of this Data class
196
+ #
197
+ def with(**fields)
198
+ return self if fields.empty?
199
+
200
+ self.class.new(**to_h, **fields)
201
+ end
145
202
  end
146
203
  end
@@ -41,7 +41,7 @@ module Git
41
41
 
42
42
  # Option keys accepted by {#checkout}
43
43
  #
44
- CHECKOUT_ALLOWED_OPTS = %i[force f new_branch b start_point].freeze
44
+ CHECKOUT_ALLOWED_OPTS = %i[force f new_branch b start_point orphan].freeze
45
45
  private_constant :CHECKOUT_ALLOWED_OPTS
46
46
 
47
47
  # Option keys accepted by {#checkout_index}
@@ -130,6 +130,9 @@ module Git
130
130
  # @example Create a new branch with a name different from the start point
131
131
  # repo.checkout('main', new_branch: 'new-feature')
132
132
  #
133
+ # @example Create and check out an unborn branch with no history
134
+ # repo.checkout('gh-pages', orphan: true)
135
+ #
133
136
  # @example Force checkout discarding local changes
134
137
  # repo.checkout('main', force: true)
135
138
  #
@@ -151,13 +154,25 @@ module Git
151
154
  #
152
155
  # @option opts [Boolean, nil] :f (nil) alias for `:force`
153
156
  #
157
+ # @option opts [Boolean, String, nil] :orphan (nil) when `true`, creates a
158
+ # new unborn branch named `branch` whose first commit has no parents
159
+ #
160
+ # When a `String`, creates an unborn branch with that name, using
161
+ # `branch` as the start point for the working tree and index.
162
+ #
163
+ # `false` and `nil` are both treated as unset. A blank branch name is
164
+ # rejected rather than ignored.
165
+ #
154
166
  # @option opts [String, nil] :start_point (nil) the commit or branch to
155
- # start the new branch from; used together with `new_branch: true`
167
+ # start the new branch from; used together with `new_branch: true` or
168
+ # `orphan: true`
156
169
  #
157
170
  # @return [String] git's stdout from the checkout
158
171
  #
159
172
  # @raise [ArgumentError] if unsupported options are provided
160
173
  #
174
+ # @raise [ArgumentError] if `:orphan` is given a blank or missing branch name
175
+ #
161
176
  # @raise [Git::FailedError] if git exits with a non-zero exit status
162
177
  #
163
178
  def checkout(branch = nil, opts = {})
@@ -172,6 +187,73 @@ module Git
172
187
  Git::Commands::Checkout::Branch.new(@execution_context).call(target, **translated_opts).stdout
173
188
  end
174
189
 
190
+ # Run a block with the given branch checked out, then restore the original branch
191
+ #
192
+ # Records the current branch (or the current commit when HEAD is detached),
193
+ # checks out `branch`, and yields to the block. If the block returns a truthy
194
+ # value, all pending changes are committed with `message` (see
195
+ # {#commit_all}); if it returns a falsy value, the index and working tree are
196
+ # hard-reset instead (see {#reset}). The original branch or commit is then
197
+ # checked out again. The hard reset discards changes to tracked files only;
198
+ # untracked files created by the block are left in place.
199
+ #
200
+ # Unlike `Git::Branch#in_branch`, this method does not create `branch`. The
201
+ # branch must be an existing local branch. Unlike {#checkout}, a commit SHA,
202
+ # tag, or remote-tracking branch is rejected before any checkout happens:
203
+ # those detach HEAD, and a commit made there would be left dangling once the
204
+ # original branch is restored. HEAD must
205
+ # be on a branch with at least one commit, or detached: an unborn branch (no
206
+ # commits yet) cannot be checked out again by name, so it is rejected before
207
+ # any checkout happens.
208
+ #
209
+ # **Note:** the restore checkout is not wrapped in `ensure`. If the block,
210
+ # the commit, or the reset raises an exception, the repository is left
211
+ # checked out on `branch` rather than restored to the original branch.
212
+ #
213
+ # @example Commit a new file on a feature branch
214
+ # repo.in_branch('feature', 'Add README') do
215
+ # File.write('README.md', '# Hello')
216
+ # repo.add('README.md')
217
+ # true # commit and return to the original branch
218
+ # end
219
+ #
220
+ # @example Discard experimental changes to a tracked file
221
+ # repo.in_branch('scratch') do
222
+ # File.write('README.md', '# Try something')
223
+ # false # hard-reset and return to the original branch
224
+ # end
225
+ #
226
+ # @param branch [String] the name of an existing local branch to check out
227
+ #
228
+ # @param message [String] the commit message used when the block returns a
229
+ # truthy value
230
+ #
231
+ # @return [String] git's stdout from the final checkout back to the original
232
+ # branch or commit
233
+ #
234
+ # @raise [ArgumentError] if `branch` is not an existing local branch
235
+ #
236
+ # @raise [Git::Error] if HEAD is on an unborn branch
237
+ #
238
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
239
+ #
240
+ # @yield executes the block with `branch` checked out
241
+ #
242
+ # @yieldreturn [Object] a truthy value to commit all changes, a falsy value to
243
+ # hard-reset
244
+ #
245
+ def in_branch(branch, message = 'in branch work')
246
+ SharedPrivate.assert_local_branch!(self, branch)
247
+ restore_point = SharedPrivate.head_restore_point(self)
248
+ checkout(branch)
249
+ if yield
250
+ commit_all(message)
251
+ else
252
+ reset(nil, hard: true)
253
+ end
254
+ checkout(restore_point)
255
+ end
256
+
175
257
  # Populate the working tree from the index
176
258
  #
177
259
  # @example Check out all files from the index
@@ -615,7 +697,31 @@ module Git
615
697
  #
616
698
  # @raise [Git::FailedError] if git exits with a non-zero exit status
617
699
  #
700
+ # @deprecated Use `branch_list(name).first` and the name-based branch
701
+ # operations instead
702
+ #
703
+ # {#branch_list} returns immutable {Git::BranchInfo} value objects
704
+ # rather than {Git::Branch}. It takes `git branch --list` patterns, so
705
+ # pass the short name of a local branch or `"#{remote}/#{name}"` for a
706
+ # remote-tracking branch; the `remotes/` and `refs/` prefixes this
707
+ # method accepts match nothing. A `"#{remote}/#{name}"` pattern also
708
+ # matches a local branch of that name, so take `find(&:remote?)` rather
709
+ # than `first` for a remote-tracking branch. With no argument this
710
+ # method wraps {#current_branch}, which is `'HEAD'` when HEAD is
711
+ # detached; {#branch_list} has no entry for a detached or unborn HEAD,
712
+ # so use {#current_branch_state} in those states. Call the
713
+ # corresponding {Git::Repository} method (e.g. {#checkout},
714
+ # {#branch_new}, {#branch_delete}) for operations on a branch.
715
+ #
716
+ # @see #branch_list
717
+ #
618
718
  def branch(branch_name = current_branch)
719
+ Git::Deprecation.warn(
720
+ 'Git::Repository#branch is deprecated and will be removed in v6.0.0. ' \
721
+ 'Use Git::Repository#branch_list(name).first for a local branch, ' \
722
+ 'Git::Repository#branch_list("remote/name").find(&:remote?) for a remote-tracking branch, ' \
723
+ 'and the name-based branch operations instead.'
724
+ )
619
725
  Git::Branch.new(self, branch_name)
620
726
  end
621
727
 
@@ -642,7 +748,21 @@ module Git
642
748
  #
643
749
  # @raise [Git::FailedError] if git exits with a non-zero exit status
644
750
  #
751
+ # @deprecated Use {#branch_list} instead
752
+ #
753
+ # {#branch_list} returns `Array<Git::BranchInfo>` (immutable value
754
+ # objects) rather than a {Git::Branches} collection. Filter it with
755
+ # `select(&:remote?)` or `reject(&:remote?)` in place of
756
+ # `branches.remote` and `branches.local`, and look a branch up by name
757
+ # with `branch_list(name).first` in place of `branches[name]`.
758
+ #
759
+ # @see #branch_list
760
+ #
645
761
  def branches
762
+ Git::Deprecation.warn(
763
+ 'Git::Repository#branches is deprecated and will be removed in v6.0.0. ' \
764
+ 'Use Git::Repository#branch_list instead.'
765
+ )
646
766
  Git::Branches.new(self)
647
767
  end
648
768
 
@@ -662,6 +782,11 @@ module Git
662
782
  # @param execution_context [Git::ExecutionContext::Repository] the
663
783
  # execution context for git commands
664
784
  #
785
+ # The full `refs/heads/<name>` ref is verified rather than the bare name.
786
+ # A bare name follows the gitrevisions search order, in which
787
+ # `refs/tags/<name>` is tried before `refs/heads/<name>`, so an unborn
788
+ # branch that shares its name with a tag would be reported as `:active`.
789
+ #
665
790
  # @param branch_name [String] the branch name to verify
666
791
  #
667
792
  # @return [:active, :unborn] the branch ref state
@@ -672,7 +797,7 @@ module Git
672
797
  # @api private
673
798
  #
674
799
  def get_branch_state(execution_context, branch_name)
675
- Git::Commands::RevParse.new(execution_context).call(branch_name, verify: true, quiet: true)
800
+ Git::Commands::RevParse.new(execution_context).call("refs/heads/#{branch_name}", verify: true, quiet: true)
676
801
  :active
677
802
  rescue Git::FailedError => e
678
803
  raise unless e.result.status.exitstatus == 1 && e.result.stderr.empty?
@@ -680,19 +805,24 @@ module Git
680
805
  :unborn
681
806
  end
682
807
 
683
- # Translates legacy checkout options to the new command interface
808
+ # Translates {#checkout} options to the new command interface
684
809
  #
685
810
  # Legacy callers passed combinations like:
686
811
  # checkout('branch', new_branch: true, start_point: 'main')
687
812
  # which should map to:
688
813
  # checkout('main', b: 'branch')
689
814
  #
815
+ # `orphan: true` follows the same shape, naming the unborn branch:
816
+ # checkout('branch', orphan: true, start_point: 'main')
817
+ # maps to:
818
+ # checkout('main', orphan: 'branch')
819
+ #
690
820
  # @param branch [String, nil] the branch argument passed to {#checkout}
691
821
  #
692
822
  # @param checkout_options [Hash] the raw options passed to {#checkout}
693
823
  #
694
- # @return [Array] a two-element tuple `[target, options]` containing the
695
- # translated checkout arguments
824
+ # @return [Array((String, nil), Hash)] a two-element tuple
825
+ # `[target, options]` containing the translated checkout arguments
696
826
  #
697
827
  # `target` (`String` or `nil`) is the branch or commit to check out.
698
828
  # `options` is a `Hash` of keyword arguments for
@@ -701,15 +831,69 @@ module Git
701
831
  # @api private
702
832
  #
703
833
  def translate_checkout_opts(branch, checkout_options)
834
+ checkout_options = normalize_orphan_option(checkout_options)
835
+
704
836
  if checkout_options[:new_branch] == true || checkout_options[:b] == true
705
837
  [checkout_options[:start_point], checkout_options.except(:new_branch, :b, :start_point).merge(b: branch)]
706
838
  elsif checkout_options[:new_branch].is_a?(String)
707
839
  [branch, checkout_options.except(:new_branch).merge(b: checkout_options[:new_branch])]
840
+ elsif checkout_options[:orphan] == true
841
+ translate_orphan_opts(branch, checkout_options)
708
842
  else
709
843
  [branch, checkout_options]
710
844
  end
711
845
  end
712
846
 
847
+ # Normalizes the `:orphan` option, rejecting names that git would never see
848
+ #
849
+ # `:orphan` is a value option on the underlying command, so a literal
850
+ # `false` would be emitted as `--orphan false` and create a branch named
851
+ # "false". Flag options such as `:force` already ignore `false`; this
852
+ # gives `:orphan` the same behavior.
853
+ #
854
+ # A blank name is rejected rather than dropped: the argument DSL omits
855
+ # empty values, so `orphan: ''` would otherwise degrade silently into a
856
+ # plain checkout.
857
+ #
858
+ # @param checkout_options [Hash] the raw options passed to {#checkout}
859
+ #
860
+ # @return [Hash] the options with a `false` `:orphan` key removed
861
+ #
862
+ # @raise [ArgumentError] if `:orphan` is given a blank branch name
863
+ #
864
+ # @api private
865
+ #
866
+ def normalize_orphan_option(checkout_options)
867
+ orphan = checkout_options[:orphan]
868
+ return checkout_options.except(:orphan) if orphan == false
869
+ raise ArgumentError, 'orphan requires a non-empty branch name' if orphan.is_a?(String) && orphan.strip.empty?
870
+
871
+ checkout_options
872
+ end
873
+
874
+ # Translates `orphan: true` into the command's `:orphan` value option
875
+ #
876
+ # `orphan: true` names the unborn branch from the positional argument and
877
+ # takes its start point from `:start_point`, mirroring `new_branch: true`.
878
+ #
879
+ # @param branch [String, nil] the branch argument passed to {#checkout}
880
+ #
881
+ # @param checkout_options [Hash] the raw options passed to {#checkout}
882
+ #
883
+ # @return [Array((String, nil), Hash)] a two-element tuple
884
+ # `[target, options]` containing the translated checkout arguments
885
+ #
886
+ # @raise [ArgumentError] if `branch` is blank (`nil`, empty, or whitespace
887
+ # only), since the unborn branch would otherwise have no name
888
+ #
889
+ # @api private
890
+ #
891
+ def translate_orphan_opts(branch, checkout_options)
892
+ raise ArgumentError, 'orphan: true requires a branch name' if branch.to_s.strip.empty?
893
+
894
+ [checkout_options[:start_point], checkout_options.except(:start_point).merge(orphan: branch)]
895
+ end
896
+
713
897
  # Normalizes path specifications for Git commands
714
898
  #
715
899
  # @param pathspecs [String, Pathname, Array<String, Pathname>, nil]