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
data/UPGRADING.md CHANGED
@@ -4,6 +4,7 @@ This document covers breaking changes and migration steps when upgrading the
4
4
  `git` gem to a new major version. Each section describes what changed and how
5
5
  to update your code when upgrading from the preceding major version.
6
6
 
7
+ - [Upgrading to v6.0.0](#upgrading-to-v600)
7
8
  - [Upgrading to v5.x](#upgrading-to-v5x)
8
9
  - [Overview](#overview)
9
10
  - [Breaking changes](#breaking-changes)
@@ -17,6 +18,31 @@ to update your code when upgrading from the preceding major version.
17
18
  - [Facade method renames](#facade-method-renames)
18
19
  - [v4.x-style configuration methods](#v4x-style-configuration-methods)
19
20
  - [`Git` module mixin deprecations](#git-module-mixin-deprecations)
21
+ - [`Git::Author` deprecated](#gitauthor-deprecated)
22
+ - [`Git::Branch#stashes` deprecated](#gitbranchstashes-deprecated)
23
+ - [`Git::Repository#remotes` deprecated](#gitrepositoryremotes-deprecated)
24
+ - [`Git::Remote` deprecated](#gitremote-deprecated)
25
+ - [`Git::Commands::CatFile::Raw` `allow_unknown_type` option deprecated](#gitcommandscatfileraw-allow_unknown_type-option-deprecated)
26
+ - [`Git::Branch` and `Git::Branches` deprecated](#gitbranch-and-gitbranches-deprecated)
27
+
28
+ ## Upgrading to v6.0.0
29
+
30
+ v6.0.0 is not yet released. This section will be completed when it ships.
31
+
32
+ v6.0.0 removes the APIs deprecated during v5.x under the project's
33
+ [deprecation policy](README.md#deprecation-policy).
34
+ [Issue 1717](https://github.com/ruby-git/ruby-git/issues/1717) tracks its scope.
35
+
36
+ To prepare:
37
+
38
+ 1. Upgrade to the latest v5.x release.
39
+ 2. Set `GIT_DEPRECATION_BEHAVIOR=raise` (or `Git::Deprecation.behavior = :raise`) in
40
+ your test suite and, if possible, staging.
41
+ 3. Fix each deprecation using the entries under
42
+ [Deprecated methods](#deprecated-methods) until the suite is clean.
43
+ 4. Upgrade to v6.0.0.
44
+
45
+ ---
20
46
 
21
47
  ## Upgrading to v5.x
22
48
 
@@ -240,7 +266,7 @@ shim cannot forward them). Update call sites directly:
240
266
 
241
267
  | v4.x call | Notes |
242
268
  |-----------|-------|
243
- | `g.lib.list_files(ref_dir)` | Walked `.git/refs/` directly. Use `g.branches`, `g.tags`, or `g.remotes` instead. |
269
+ | `g.lib.list_files(ref_dir)` | Walked `.git/refs/` directly. Use `g.branch_list`, `g.tags`, or `g.remote_list` instead. |
244
270
 
245
271
  ##### Internal plumbing methods (no replacement)
246
272
 
@@ -362,4 +388,264 @@ as bare methods is deprecated:
362
388
  | `include Git; global_config(name, value)` | `Git.config_set(name, value, global: true)` |
363
389
  | `include Git; global_config` | `Git.config_list(global: true)` |
364
390
 
391
+ #### `Git::Author` deprecated
392
+
393
+ Starting in v5.3.0, methods that return author, committer, or tagger data —
394
+ `Git::Object::Commit#author`, `Git::Object::Commit#committer`,
395
+ `Git::Object::Tag#tagger`, and `Git::TagInfo#tagger` — return an immutable
396
+ `Git::AuthorInfo` value object instead of the mutable `Git::Author`.
397
+
398
+ `Git::AuthorInfo` exposes the same `name`, `email`, and `date` readers, so code
399
+ that only reads these attributes needs no changes. Code that mutated a
400
+ `Git::Author` (via `name=`, `email=`, or `date=`) must be updated:
401
+ `Git::AuthorInfo` is frozen, and `#with` returns a modified copy rather than
402
+ updating in place (e.g. `info = info.with(name: 'New Name')`).
403
+
404
+ Constructing `Git::Author` directly emits a deprecation warning naming
405
+ `Git::AuthorInfo` as the replacement. The class is removed in v6.0.0.
406
+
407
+ | Deprecated usage | Replacement |
408
+ |-----------------|-------------|
409
+ | `Git::Author.new('Name <email> 1627849923 +0200')` | `Git::AuthorInfo.parse('Name <email> 1627849923 +0200')` |
410
+ | `author.name = 'New Name'` | `author = author.with(name: 'New Name')` (returns a new object) |
411
+
412
+ #### `Git::Branch#stashes` deprecated
413
+
414
+ `Git::Branch#stashes` ignores the branch it is called on and returns every stash
415
+ in the repository, so `g.branch('feature').stashes` and `g.branch('main').stashes`
416
+ return the same entries. Call `Git::Repository#stashes_all` instead; it is the
417
+ query `Git::Branch#stashes` was already running.
418
+
419
+ > **Return type change:** `Git::Branch#stashes` returns a `Git::Stashes`
420
+ > collection of `Git::Stash` objects, newest first. `g.stashes_all` returns an
421
+ > array of `[index, message]` pairs, oldest first. Code that read `stash.message`
422
+ > from each entry should read the second element of each pair instead. Code that
423
+ > iterated or indexed the collection must reverse the order first, because
424
+ > `Git::Stashes` yields and indexes newest first while `g.stashes_all` is oldest
425
+ > first.
426
+
427
+ `Git::Stashes` also exposes `save`, `apply`, and `clear`. Those map to the
428
+ repository's `stash_save`, `stash_apply`, and `stash_clear`, which are not
429
+ deprecated. `Git::Stashes#apply(i)` already passed `i` to git as `stash@{i}`
430
+ (`0` = newest), and `g.stash_apply(i)` does the same, so that index needs no
431
+ conversion.
432
+
433
+ | Deprecated call (works in v5.x, removed in v6.0.0) | Replacement |
434
+ |-----------------------------------------------------|-------------|
435
+ | `g.branch(name).stashes` | `g.stashes_all` — returns `[[index, message], ...]` |
436
+ | `g.branch(name).stashes.each { \|s\| puts s.message }` | `g.stashes_all.reverse_each { \|_index, message\| puts message }` |
437
+ | `g.branch(name).stashes.all` | `g.stashes_all` |
438
+ | `g.branch(name).stashes.size` | `g.stashes_all.size` |
439
+ | `g.branch(name).stashes[i].message` (`0` = newest, `i` coerced with `to_i`) | `g.stashes_all.reverse[i.to_i][1]` |
440
+ | `g.branch(name).stashes.save(message)` | `g.stash_save(message)` |
441
+ | `g.branch(name).stashes.apply` | `g.stash_apply` |
442
+ | `g.branch(name).stashes.apply(i)` (`0` = newest) | `g.stash_apply(i)` |
443
+ | `g.branch(name).stashes.clear` | `g.stash_clear` |
444
+ #### `Git::Repository#remotes` deprecated
445
+
446
+ `Git::Repository#remotes` is deprecated in favor of `Git::Repository#remote_list`
447
+ and is removed in v6.0.0. Its return value is unchanged. Calling `remotes` emits
448
+ one deprecation warning for itself plus one `Git::Remote` constructor warning for
449
+ each remote it returns (see the `Git::Remote` deprecation below), so a repository
450
+ with N remotes produces N + 1 warnings per call.
451
+
452
+ > **Return type change:** `remotes` returns `Array<Git::Remote>` — mutable
453
+ > objects with `name`, `url`, and `fetch_opts` accessors and `fetch`, `merge`,
454
+ > `branch`, and `remove` operations. `remote_list` returns
455
+ > `Array<Git::RemoteInfo>` — immutable value objects read from the repository's
456
+ > git config, with fields such as `name`, `url`, `push_url`, `fetch`, and `push`.
457
+ > Because a remote may carry more than one URL or refspec, `url`, `push_url`,
458
+ > `fetch`, and `push` are always frozen `Array<String>`. When a remote has more
459
+ > than one URL, git fetches from the first; the legacy `Git::Remote#url` returned
460
+ > the last one configured, so use `r.url.last` to reproduce that exact value.
461
+ > Likewise, `Git::Remote#fetch_opts` returned only the last configured fetch
462
+ > refspec, while `fetch` holds all of them. Operations that lived on
463
+ > `Git::Remote` are called on the repository with the remote name instead.
464
+ >
465
+ > **Order change:** `remotes` lists remotes in the order `git remote` prints
466
+ > them, while `remote_list` keeps the order in which remotes first appear in the
467
+ > config. When the legacy order matters, iterate `g.remote_names` (the same
468
+ > `git remote` order) or sort `g.remote_list` explicitly.
469
+
470
+ | Deprecated call (works in v5.x, removed in v6.0.0) | Replacement |
471
+ |-----------------------------------------------------|-------------|
472
+ | `g.remotes` | `g.remote_list` — returns `Array<Git::RemoteInfo>` |
473
+ | `g.remotes.map(&:name)` | `g.remote_list.map(&:name)` or `g.remote_names` |
474
+ | `g.remotes.map(&:to_s)` | `g.remote_list.map(&:name)` — `Git::RemoteInfo#to_s` is not the name |
475
+ | `g.remotes.map(&:url)` | `g.remote_list.map { \|r\| r.url.first }` — `url` is an `Array<String>` |
476
+ | `g.remotes.map(&:fetch_opts)` | `g.remote_list.map { \|r\| r.fetch.last }` — `fetch` holds every refspec |
477
+ | `g.remotes.each(&:fetch)` | `g.remote_names.each { \|name\| g.fetch(name) }` — same order as `remotes` |
478
+ | `remote.fetch` | `g.fetch(remote.name)` |
479
+ | `remote.fetch(opts)` | `g.fetch(remote.name, opts)` — same options hash |
480
+ | `remote.merge` | `g.merge("#{remote.name}/#{g.current_branch}")` |
481
+ | `remote.merge(branch)` | `g.merge("#{remote.name}/#{branch}")` |
482
+ | `remote.branch` | `g.branch_list("#{remote.name}/#{g.current_branch}").first` — returns a `Git::BranchInfo` |
483
+ | `remote.branch(name)` | `g.branch_list("#{remote.name}/#{name}").first` — returns a `Git::BranchInfo` |
484
+ | `remote.remove` | `g.remote_remove(remote.name)` |
485
+
486
+ #### `Git::Remote` deprecated
487
+
488
+ `Git::Remote`, `Git::Repository#remote`, and `Git::Repository#config_remote` are
489
+ deprecated and are removed in v6.0.0. Read a remote's configuration through
490
+ `Git::Repository#remote_list`, which returns one `Git::RemoteInfo` value object per
491
+ remote, and call the repository-level operations (`fetch`, `merge`, `branch_list`,
492
+ `remote_remove`) with the remote name. Return values are unchanged. Constructing a
493
+ `Git::Remote` directly emits one deprecation warning, and so does calling
494
+ `g.config_remote`. Calling `g.remote` emits two: one for `Git::Repository#remote`
495
+ and one for the `Git::Remote` it constructs. Likewise `g.remotes` emits one warning
496
+ for itself plus one per `Git::Remote` it returns (N + 1 for N remotes). The extra
497
+ warnings from `g.remote` and `g.remotes` are expected, not a bug.
498
+
499
+ > **Return type changes:** `Git::RemoteInfo#url` and `Git::RemoteInfo#fetch` are
500
+ > frozen `Array<String>` because a remote may carry more than one URL or fetch
501
+ > refspec. The legacy `Git::Remote#url` and `Git::Remote#fetch_opts` returned only
502
+ > the last configured value, so `r.url.last` and `r.fetch.last` reproduce them
503
+ > exactly; `r.url.first` is the URL git actually fetches from.
504
+ > `config_remote` returned a flat `Hash{String => String}` in which a repeated
505
+ > `url` or `fetch` key overwrote the earlier value, so it could not report every
506
+ > configured URL or refspec; `remote_list` keeps all of them. In the other
507
+ > direction, `Git::RemoteInfo` models only the remote variables git defines and
508
+ > drops any other `remote.<name>.*` key, while `config_remote` returned every key.
509
+ > Code that reads custom keys should filter `g.config_list` instead (see the
510
+ > table); that yields the same `Hash{String => String}` as `config_remote`.
511
+ > `Git::Remote#branch` returned a `Git::Branch`. Its replacement,
512
+ > `g.branch_list("#{name}/#{branch}").first`, returns a `Git::BranchInfo` value
513
+ > object, or `nil` when the remote-tracking branch does not exist.
514
+
515
+ In the table, `name` is the remote name (`g.remote` defaults it to `'origin'`).
516
+
517
+ | Deprecated call (works in v5.x, removed in v6.0.0) | Replacement |
518
+ |-----------------------------------------------------|-------------|
519
+ | `g.remote` | `g.remote_list.find { \|r\| r.name == 'origin' }` — returns a `Git::RemoteInfo`; the deprecated call emits two warnings |
520
+ | `g.remote(name)` | `g.remote_list.find { \|r\| r.name == name }` — returns a `Git::RemoteInfo`; the deprecated call emits two warnings |
521
+ | `g.config_remote(name)` for `url`, `fetch`, and the other modeled fields | `g.remote_list.find { \|r\| r.name == name }` — a `Git::RemoteInfo`, not a `Hash` |
522
+ | `g.config_remote(name)` for every key, including custom ones | `g.config_list.select { \|e\| e.key.start_with?("remote.#{name}.") }.to_h { \|e\| [e.key.delete_prefix("remote.#{name}."), e.value] }` — the same `Hash{String => String}` |
523
+ | `remote.name`, `remote.to_s` | `g.remote_list.find { \|r\| r.name == name }.name` or `g.remote_names` |
524
+ | `remote.url` | `g.remote_list.find { \|r\| r.name == name }.url` — `Array<String>`; `.first` for the single-URL case |
525
+ | `remote.fetch_opts` | `g.remote_list.find { \|r\| r.name == name }.fetch` — `Array<String>` of refspecs |
526
+ | `remote.fetch` | `g.fetch(name)` |
527
+ | `remote.fetch(opts)` | `g.fetch(name, opts)` — same option keys |
528
+ | `remote.merge` | `g.merge("#{name}/#{g.current_branch}")` |
529
+ | `remote.merge(branch)` | `g.merge("#{name}/#{branch}")` |
530
+ | `remote.branch` | `g.branch_list("#{name}/#{g.current_branch}").first` — returns a `Git::BranchInfo` |
531
+ | `remote.branch(branch)` | `g.branch_list("#{name}/#{branch}").first` — returns a `Git::BranchInfo` |
532
+ | `remote.remove` | `g.remote_remove(name)` |
533
+
534
+ #### `Git::Commands::CatFile::Raw` `allow_unknown_type` option deprecated
535
+
536
+ The `allow_unknown_type:` option of `Git::Commands::CatFile::Raw` is deprecated
537
+ and is removed in v6.0.0. Passing it emits a deprecation warning; the
538
+ `--allow-unknown-type` flag still reaches git unchanged until the option is
539
+ removed.
540
+
541
+ There is no replacement. Git 2.50 removed the unknown-type feature, so on git
542
+ 2.50 and later `--allow-unknown-type` is an accepted no-op and the option has no
543
+ effect. On git 2.28 through 2.49 the flag still lets `t: true` and `s: true`
544
+ report the type and size of an object whose type git does not recognize, but
545
+ that behavior is dropped together with the option. The class is internal
546
+ (`@api private`) and no `Git::Repository` method passes the option, so only code
547
+ that constructs the command class directly is affected.
548
+
549
+ | Deprecated call (works in v5.x, removed in v6.0.0) | Replacement |
550
+ |-----------------------------------------------------|-------------|
551
+ | `Git::Commands::CatFile::Raw.new(ctx).call(sha, t: true, allow_unknown_type: true)` | `Git::Commands::CatFile::Raw.new(ctx).call(sha, t: true)` |
552
+ | `Git::Commands::CatFile::Raw.new(ctx).call(sha, s: true, allow_unknown_type: true)` | `Git::Commands::CatFile::Raw.new(ctx).call(sha, s: true)` |
553
+
554
+ #### `Git::Branch` and `Git::Branches` deprecated
555
+
556
+ `Git::Branch`, `Git::Branches`, `Git::Repository#branch`, and
557
+ `Git::Repository#branches` are deprecated and are removed in v6.0.0. Read branch
558
+ data through `Git::Repository#branch_list`, which returns one `Git::BranchInfo`
559
+ value object per local and remote-tracking branch, and call the repository-level
560
+ operations (`checkout`, `branch_new`, `branch_delete`, `merge`, `merge_into`,
561
+ `in_branch`, and so on) with the branch name. Calling `g.branch` or `g.branches`,
562
+ constructing a `Git::Branches`, and calling any operation on a `Git::Branch` each
563
+ emit a deprecation warning; their return values are unchanged. The `full`,
564
+ `name`, `remote`, `to_s`, and `to_a` readers on `Git::Branch` do not warn.
565
+
566
+ > **Return shape change:** `Git::Branch` exposes `full` (`main` or
567
+ > `remotes/origin/main`), `name`, and `remote` (a `Git::Remote`, or `nil`).
568
+ > `Git::BranchInfo` exposes `refname` (always the full ref: `refs/heads/main` or
569
+ > `refs/remotes/origin/main`), `short_name` (`main` for both), `remote_name` (a
570
+ > `String`, or `nil`), `remote?`, `current?`, `target_oid`, `upstream`,
571
+ > `worktree_path`, and `symref`. `Git::BranchInfo#to_s` is the full ref, not the
572
+ > `remotes/origin/main` form `Git::Branch#to_s` returned. `branch_list` takes
573
+ > `git branch --list` patterns: `'main'` matches the local branch and
574
+ > `'origin/main'` matches the remote-tracking branch. The `remotes/origin/main`
575
+ > and `refs/...` forms that `g.branches[...]` accepted match nothing.
576
+ >
577
+ > **`checkout` no longer creates the branch:** `g.branch('x').checkout` created
578
+ > `x` when it did not exist, ignoring any error from that attempt, and then
579
+ > checked it out. `g.checkout('x')` does not create a missing local branch,
580
+ > with one exception that is git's own: when exactly one remote has a branch
581
+ > named `x`, git creates a local tracking branch from it (its default guess
582
+ > behavior). Otherwise the checkout fails. To reproduce create-or-checkout,
583
+ > call `g.branch_new('x') unless g.local_branch?('x')` and then
584
+ > `g.checkout('x')`. Use `g.checkout('x', new_branch: true)` only when `x` is
585
+ > known not to exist; like `g.branch_new('x')`, it fails when `x` already
586
+ > exists. Likewise `g.branch('x').create` ignored every error, while
587
+ > `g.branch_new('x')` raises `Git::FailedError` when `x` already exists.
588
+ >
589
+ > **`in_branch` and `merge_into` differences:**
590
+ >
591
+ > 1. **Branch creation.** `g.branch('x').in_branch { ... }` created `x` if it did
592
+ > not exist. `g.in_branch('x') { ... }` raises `ArgumentError` unless `x` is an
593
+ > existing local branch, so call `g.branch_new('x')` first. A commit SHA, tag,
594
+ > or remote-tracking name is also rejected before any checkout.
595
+ > 2. **Detached HEAD.** `Git::Branch#in_branch` recorded the literal `HEAD` and
596
+ > could not restore a detached HEAD to its original commit. `g.in_branch` and
597
+ > `g.merge_into` record the SHA and restore it.
598
+ > 3. **Unborn HEAD.** Both new methods raise `Git::Error` before checking anything
599
+ > out when HEAD is on a branch with no commits. The old methods failed later,
600
+ > mid-flow.
601
+ > 4. **Merge overload.** `g.branch('main').merge('feature')` returned stdout from
602
+ > the final restore checkout and ran a hard reset after the merge.
603
+ > `g.merge_into('main', 'feature')` returns the merge's stdout and does no
604
+ > reset. It also rejects the `:no_commit` option; callers who need
605
+ > `--no-commit` use `checkout` and `merge` directly.
606
+ > 5. **Remote-tracking receivers.** Called on a remote-tracking `Git::Branch`,
607
+ > `in_branch` and `merge(branch)` checked out the remote-tracking ref,
608
+ > detaching HEAD, and any commit made there was left dangling. `g.in_branch`
609
+ > and `g.merge_into` take an existing local branch only. Create one from the
610
+ > remote-tracking ref first, with
611
+ > `g.branch_new(name, "remotes/#{remote}/#{name}")`, and pass that branch.
612
+
613
+ In the table, `name` is the branch name (`g.branch` defaults it to the current
614
+ branch), `remote` is the remote name of a remote-tracking branch, `b` is a
615
+ `Git::Branch`, and `info` is the `Git::BranchInfo` that replaces it. Where a
616
+ row says to pass `info.refname` for a remote-tracking branch, `b.full` (the
617
+ `remotes/<remote>/<name>` form) works too; the shorter `"#{remote}/#{name}"`
618
+ can resolve a local branch of that name and is only used where git expects it
619
+ (`branch_delete` with `remotes: true`).
620
+
621
+ | Deprecated call (works in v5.x, removed in v6.0.0) | Replacement |
622
+ |-----------------------------------------------------|-------------|
623
+ | `g.branch(name)` | `g.branch_list(name).first` for a local branch, or `g.branch_list("#{remote}/#{name}").find(&:remote?)` for a remote-tracking one — a `Git::BranchInfo`, or `nil` when the branch does not exist; the `remotes/` and `refs/` forms match nothing |
624
+ | `g.branch` | `g.branch_list(g.current_branch).first` — `nil` when HEAD is detached or unborn; use `g.current_branch_state` there |
625
+ | `g.branches` | `g.branch_list` — returns `Array<Git::BranchInfo>` |
626
+ | `g.branches[name]` | `g.branch_list(name).first`, or `g.branch_list("#{remote}/#{name}").find(&:remote?)` for a remote-tracking branch |
627
+ | `g.branches.local` | `g.branch_list.reject(&:remote?)` |
628
+ | `g.branches.remote` | `g.branch_list.select(&:remote?)` |
629
+ | `g.branches.size` | `g.branch_list.size` |
630
+ | `g.branches.each { \|b\| ... }` | `g.branch_list.each { \|info\| ... }` |
631
+ | `g.branches.to_s` | `g.branch_list.map { \|i\| "#{i.current? ? '* ' : ' '}#{i.refname}\n" }.join` — full refs, not `remotes/...` |
632
+ | `b.full`, `b.to_s` | `info.refname` — `refs/remotes/origin/main` rather than `remotes/origin/main` |
633
+ | `b.to_a` | `[info.refname]` |
634
+ | `b.name` | `info.short_name` |
635
+ | `b.remote` | `info.remote_name` — a `String`, or `nil` for a local branch |
636
+ | `b.gcommit` | `g.gcommit(name)` — pass `info.refname` for a remote-tracking branch |
637
+ | `b.checkout` | `g.checkout(name)` — does not create the branch (see above); pass `info.refname` for a remote-tracking branch |
638
+ | `b.create` | `g.branch_new(name)` — raises when the branch already exists |
639
+ | `b.delete` (local) | `g.branch_delete(name)` |
640
+ | `b.delete` (remote-tracking) | `g.branch_delete("#{remote}/#{name}", remotes: true)` |
641
+ | `b.current` | `g.current_branch == name` |
642
+ | `b.contains?(commit)` | `!g.branch_contains(commit, name).empty?` |
643
+ | `b.merge` | `g.merge(name)` |
644
+ | `b.merge(branch, message)` | `g.merge_into(name, branch, message)` — local `b` only; see the differences above |
645
+ | `b.update_ref(commit)` (local) | `g.update_ref(name, commit)` |
646
+ | `b.update_ref(commit)` (remote-tracking) | `g.update_ref("remotes/#{remote}/#{name}", commit)` |
647
+ | `b.archive(file, opts)` | `g.archive(name, file, opts)` — pass `info.refname` for a remote-tracking branch |
648
+ | `b.in_branch(message) { ... }` | `g.in_branch(name, message) { ... }` — local `b` only; see the differences above |
649
+ | `b.stashes` | `g.stashes_all` — see [`Git::Branch#stashes` deprecated](#gitbranchstashes-deprecated) |
650
+
365
651
  ---
data/git.gemspec CHANGED
@@ -43,7 +43,7 @@ Gem::Specification.new do |spec|
43
43
 
44
44
  spec.add_dependency 'activesupport', '>= 5.0'
45
45
  spec.add_dependency 'addressable', '~> 2.8'
46
- spec.add_dependency 'process_executer', '~> 4.0'
46
+ spec.add_dependency 'process_executer', '~> 4.1'
47
47
  spec.add_dependency 'rchardet', '~> 1.9'
48
48
 
49
49
  # Not every development dependency is installed on every runtime. Each predicate
@@ -101,8 +101,40 @@ Gem::Specification.new do |spec|
101
101
  spec.add_development_dependency 'yard-lint', '~> 1.8' if install_yard_lint
102
102
 
103
103
  # Specify which files should be added to the gem when it is released.
104
- # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
104
+ #
105
+ # This is an allowlist rather than a denylist. A denylist admitted every new
106
+ # development-only path by default, so the gem shipped `.github/`, `tasks/`, the
107
+ # husky hooks, and -- the reason this became an allowlist -- the `.claude/skills`
108
+ # symlink. Extracting a symlink needs a privilege that Windows grants only under
109
+ # Developer Mode or an elevated shell, so installing the gem there either failed
110
+ # outright or, on RubyGems new enough to fall back to a copy, silently duplicated
111
+ # the whole skills tree into the installed gem.
112
+ #
113
+ # spec/unit/gemspec_spec.rb guards both directions: nothing in the list may be a
114
+ # symlink, and every tracked file under lib/ must be present, so the allowlist
115
+ # cannot quietly drop runtime code.
116
+ #
117
+ # doc_files must stay in sync with the extra files named in .yardopts -- those are
118
+ # what rubydoc.info renders for the published documentation, so a file listed there
119
+ # but absent from the gem becomes a broken link.
120
+ doc_files = %w[
121
+ AI_POLICY.md
122
+ CHANGELOG.md
123
+ CODE_OF_CONDUCT.md
124
+ CONTRIBUTING.md
125
+ GOVERNANCE.md
126
+ LICENSE
127
+ MAINTAINERS.md
128
+ README.md
129
+ UPGRADING.md
130
+ ]
131
+
132
+ # .yardopts drives the rubydoc.info build; the gemspec is included by convention.
133
+ build_files = %w[.yardopts git.gemspec]
134
+
105
135
  spec.files = Dir.chdir(File.expand_path(__dir__)) do
106
- `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(tests|spec|features|bin)/}) }
136
+ `git ls-files -z`.split("\x0").select do |f|
137
+ f.start_with?('lib/') || doc_files.include?(f) || build_files.include?(f)
138
+ end
107
139
  end
108
140
  end
data/lib/git/author.rb CHANGED
@@ -3,6 +3,9 @@
3
3
  module Git
4
4
  # An author in a Git commit
5
5
  #
6
+ # @deprecated Use {Git::AuthorInfo} instead; this mutable class will be
7
+ # removed in v6.0.0
8
+ #
6
9
  # @api public
7
10
  #
8
11
  class Author
@@ -24,7 +27,15 @@ module Git
24
27
  #
25
28
  # @return [void]
26
29
  #
30
+ # @deprecated Use {Git::AuthorInfo.parse} instead
31
+ #
27
32
  def initialize(author_string)
33
+ if defined?(Git::Deprecation)
34
+ Git::Deprecation.warn(
35
+ 'Git::Author is deprecated and will be removed in v6.0.0. Use Git::AuthorInfo instead.'
36
+ )
37
+ end
38
+
28
39
  return unless (m = /(.*?) <(.*?)> (\d+) (.*)/.match(author_string))
29
40
 
30
41
  @name = m[1]
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ # Immutable value object representing an author or committer identity
5
+ #
6
+ # This is a lightweight, immutable data structure holding the identity data
7
+ # git records for commit authors, committers, and taggers. It replaces the
8
+ # mutable {Git::Author}, which is deprecated.
9
+ #
10
+ # @example Construct from individual values
11
+ # info = Git::AuthorInfo.new(
12
+ # name: 'John Doe',
13
+ # email: 'john.doe@example.com',
14
+ # date: Time.at(1627849923)
15
+ # )
16
+ # info.name #=> 'John Doe'
17
+ #
18
+ # @example Parse from a raw git author string
19
+ # info = Git::AuthorInfo.parse('John Doe <john.doe@example.com> 1627849923 +0200')
20
+ # info.email #=> 'john.doe@example.com'
21
+ # info.date.to_i #=> 1627849923
22
+ #
23
+ # @see Git::Object::Commit#author
24
+ #
25
+ # @see Git::Object::Commit#committer
26
+ #
27
+ # @api public
28
+ #
29
+ # @!attribute [r] name
30
+ # @return [String, nil] the person's name, or `nil` if not available
31
+ #
32
+ # @!attribute [r] email
33
+ # @return [String, nil] the person's email address, or `nil` if not available
34
+ #
35
+ # @!attribute [r] date
36
+ # @return [Time, nil] the timestamp of the change, or `nil` if not available
37
+ #
38
+ AuthorInfo = Data.define(:name, :email, :date) do
39
+ # Parses a raw git identity string into a Git::AuthorInfo
40
+ #
41
+ # The expected format is `"Name <email> timestamp offset"` as emitted by
42
+ # `git cat-file` for the `author`, `committer`, and `tagger` headers. The
43
+ # timestamp is interpreted as seconds since the Unix epoch; the timezone
44
+ # offset is not preserved in the resulting `date`.
45
+ #
46
+ # @example Parse a well-formed identity string
47
+ # Git::AuthorInfo.parse('John Doe <john.doe@example.com> 1627849923 +0200')
48
+ # #=> #<data Git::AuthorInfo name="John Doe", email="john.doe@example.com", ...>
49
+ #
50
+ # @example A string that does not match the expected format
51
+ # Git::AuthorInfo.parse('garbage')
52
+ # #=> #<data Git::AuthorInfo name=nil, email=nil, date=nil>
53
+ #
54
+ # @param author_string [String] the raw identity string to parse
55
+ #
56
+ # @return [Git::AuthorInfo] the parsed identity; all attributes are `nil`
57
+ # when the string does not match the expected format
58
+ #
59
+ def self.parse(author_string)
60
+ match = /(.*?) <(.*?)> (\d+) (.*)/.match(author_string)
61
+ return new(name: nil, email: nil, date: nil) unless match
62
+
63
+ new(name: match[1], email: match[2], date: Time.at(match[3].to_i))
64
+ end
65
+ end
66
+ end