Skip to content

Commit fa798a7

Browse files
jcouballCopilot
andcommitted
feat: add Git::Repository::Branching#current_branch_state facade
Define HeadState as Data.define(:state, :name) in Git::Repository::Branching and add a #current_branch_state facade method that returns it. The three possible HEAD states are: - :active -- branch ref with at least one commit - :unborn -- branch ref that exists but has no commits yet - :detached -- HEAD points directly to a commit SHA A private helper, Private.get_branch_state, mirrors the Git::Lib implementation but takes execution_context as an explicit argument rather than relying on self. Git::Base#current_branch_state delegates to the facade. Also updates UPGRADING.md with a migration row and a callout about the return- type change (mutable Struct to immutable Data object), and marks the row in redesign/c1c2_audit.md §7.3 as promoted (§7.5 promote count decremented from 2 to 1). Task commits included: - feat(branching): add Git::Repository::Branching#current_branch_state facade - docs: update UPGRADING.md and c1c2_audit.md for current_branch_state Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 52d657d commit fa798a7

7 files changed

Lines changed: 266 additions & 2 deletions

File tree

UPGRADING.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,15 @@ notes for the final migration target.
7979
| `g.lib.change_head_branch(name)` | `g.change_head_branch(name)` |
8080
| `g.lib.branch_current` | `g.current_branch` |
8181
| `g.lib.ls_remote(location, opts)` | `g.ls_remote(location, opts)` |
82+
| `g.lib.current_branch_state` | `g.current_branch_state` |
83+
84+
> **Note — `current_branch_state` return type change:** `g.lib.current_branch_state`
85+
> returned a `Git::Lib::HeadState` (a mutable `Struct`). `g.current_branch_state`
86+
> returns a `Git::Repository::Branching::HeadState` (an immutable `Data` object).
87+
> Both expose `.state` (`:active`, `:unborn`, or `:detached`) and `.name`. If your
88+
> code relies on the struct being mutable or uses positional construction
89+
> (`Git::Lib::HeadState.new(:active, 'main')`), update to keyword construction:
90+
> `Git::Repository::Branching::HeadState.new(state: :active, name: 'main')`.
8291
8392
#### Methods with no replacement
8493

lib/git/base.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -977,6 +977,17 @@ def current_branch
977977
end
978978
alias branch_current current_branch
979979

980+
# Returns the current HEAD state as a structured value object
981+
#
982+
# @return [Git::Repository::Branching::HeadState] the current HEAD state;
983+
# see {Git::Repository::Branching#current_branch_state} for details
984+
#
985+
# @raise [Git::FailedError] if git exits with a non-zero exit status
986+
#
987+
def current_branch_state
988+
facade_repository.current_branch_state
989+
end
990+
980991
# @return [Git::Branch] an object for branch_name
981992
def branch(branch_name = current_branch)
982993
facade_repository.branch(branch_name)

lib/git/repository/branching.rb

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
require 'git/commands/checkout/branch'
1212
require 'git/commands/checkout/files'
1313
require 'git/commands/checkout_index'
14+
require 'git/commands/rev_parse'
1415
require 'git/commands/update_ref/update'
1516
require 'git/parsers/branch'
1617
require 'git/repository/shared_private'
@@ -25,6 +26,16 @@ class Repository
2526
# @api public
2627
#
2728
module Branching # rubocop:disable Metrics/ModuleLength
29+
# Represents the state of HEAD in a repository
30+
#
31+
# @!attribute [r] state
32+
# @return [Symbol] one of `:active`, `:unborn`, or `:detached`
33+
#
34+
# @!attribute [r] name
35+
# @return [String] the branch name, or `'HEAD'` when detached
36+
#
37+
HeadState = Data.define(:state, :name)
38+
2839
# Option keys accepted by {#checkout}
2940
#
3041
CHECKOUT_ALLOWED_OPTS = %i[force f new_branch b start_point].freeze
@@ -54,6 +65,39 @@ def current_branch
5465
name.empty? ? 'HEAD' : name
5566
end
5667

68+
# Returns the current HEAD state as a structured value object
69+
#
70+
# HEAD can be in one of three states:
71+
#
72+
# - **`:active`** — HEAD points to a branch ref that has at least one commit.
73+
# - **`:unborn`** — HEAD points to a branch ref that has been created but has
74+
# no commits yet (e.g. immediately after `git init` before any commit).
75+
# - **`:detached`** — HEAD points directly to a commit SHA rather than a branch.
76+
#
77+
# @example Active branch
78+
# repo.current_branch_state
79+
# # => #<data Git::Repository::Branching::HeadState state=:active, name="main">
80+
#
81+
# @example Unborn branch (no commits yet)
82+
# repo.current_branch_state
83+
# # => #<data Git::Repository::Branching::HeadState state=:unborn, name="main">
84+
#
85+
# @example Detached HEAD
86+
# repo.current_branch_state
87+
# # => #<data Git::Repository::Branching::HeadState state=:detached, name="HEAD">
88+
#
89+
# @return [Git::Repository::Branching::HeadState] the current HEAD state
90+
#
91+
# @raise [Git::FailedError] if git exits with a non-zero exit status
92+
#
93+
def current_branch_state
94+
branch_name = Git::Commands::Branch::ShowCurrent.new(@execution_context).call.stdout.strip
95+
return HeadState.new(state: :detached, name: 'HEAD') if branch_name.empty?
96+
97+
state = Private.get_branch_state(@execution_context, branch_name)
98+
HeadState.new(state: state, name: branch_name)
99+
end
100+
57101
# Restore working tree files from a tree-ish
58102
#
59103
# @example Restore README.md to its HEAD state
@@ -537,6 +581,34 @@ def branches
537581
module Private
538582
module_function
539583

584+
# Determines whether the given branch ref points to an existing commit
585+
#
586+
# Returns `:active` when the branch ref resolves successfully. Returns
587+
# `:unborn` when the branch ref exists but has no commits yet (exit
588+
# status 1 with empty stderr from `git rev-parse --verify --quiet`).
589+
# Re-raises for any other failure.
590+
#
591+
# @param execution_context [Git::ExecutionContext::Repository] the
592+
# execution context for git commands
593+
#
594+
# @param branch_name [String] the branch name to verify
595+
#
596+
# @return [:active, :unborn] the branch ref state
597+
#
598+
# @raise [Git::FailedError] if git exits with a failure unrelated to an
599+
# unborn branch
600+
#
601+
# @api private
602+
#
603+
def get_branch_state(execution_context, branch_name)
604+
Git::Commands::RevParse.new(execution_context).call(branch_name, verify: true, quiet: true)
605+
:active
606+
rescue Git::FailedError => e
607+
raise unless e.result.status.exitstatus == 1 && e.result.stderr.empty?
608+
609+
:unborn
610+
end
611+
540612
# Translates legacy checkout options to the new command interface
541613
#
542614
# Legacy callers passed combinations like:

redesign/c1c2_audit.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,7 @@ These require a new facade method before a base.rb delegator can be added.
411411
| `parse_config(file)` | Parses a config file from path. | 🔍 human decision — expose or fold into `config()` with `:file` option? |
412412
| `stash_list` | Returns a formatted string `"stash@{0}: ...\n..."` — distinct from `stashes_all` which returns structured data. | 🔍 human decision — promote for backward compat, or deprecate in favor of `stashes_all`? |
413413
| `unmerged` | Returns paths with unresolved merge conflicts. Already partially covered by `each_conflict` (yields paths to temporary files for staged content). Pure path list is useful. | 🔍 human decision — promote `unmerged` as a simpler alternative to `each_conflict`? |
414-
| `current_branch_state` | Returns a `HeadState` struct with `:state` (`:active`/`:unborn`/`:detached`) and `:name`. Richer than `current_branch`. | ⬜ promoteadd to `Git::Repository::Branching`; trivial effort (command class already wired in lib.rb) |
414+
| `current_branch_state` | Returns a `HeadState` value object with `:state` (`:active`/`:unborn`/`:detached`) and `:name`. Richer than `current_branch`. Legacy `Git::Lib` implementation used a mutable `Struct`; promoted facade uses an immutable `Data` object. | ✅ promoted`HeadState` Data object defined in `Git::Repository::Branching`; facade in `Git::Repository::Branching` + `Git::Base` delegator added (PR 5g) |
415415

416416
### 7.4 Internal Plumbing — Mark as ❌ Remove
417417

@@ -439,7 +439,7 @@ upgrade notes as "unsupported; remove any `g.lib.X` calls."
439439
| Status | Count |
440440
|--------|-------|
441441
| ✅ promote (repo already had it, `Git::Base` delegator added — PR 2d; or alias added) | 24 |
442-
| ⬜ promote (new facade work required) | 2 |
442+
| ⬜ promote (new facade work required) | 1 |
443443
| ❌ remove (internal plumbing) | 12 |
444444
| 🔍 human decision | 16 |
445445
| **Total orphaned methods** | **56** |

spec/integration/git/repository/branching_spec.rb

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,4 +452,61 @@
452452
expect(result.full).to eq(described_instance.current_branch)
453453
end
454454
end
455+
456+
# ---------------------------------------------------------------------------
457+
# #current_branch_state
458+
# ---------------------------------------------------------------------------
459+
#
460+
# current_branch_state calls both ShowCurrent and RevParse, so integration
461+
# tests confirm the multi-command orchestration produces the correct HeadState
462+
# value across all three possible HEAD states.
463+
464+
describe '#current_branch_state' do
465+
context 'when HEAD is on an active branch (has commits)' do
466+
it 'returns HeadState with state :active' do
467+
result = described_instance.current_branch_state
468+
expect(result).to be_a(Git::Repository::Branching::HeadState)
469+
expect(result.state).to eq(:active)
470+
end
471+
472+
it 'returns the current branch name as the name attribute' do
473+
result = described_instance.current_branch_state
474+
expect(result.name).to eq(described_instance.current_branch)
475+
end
476+
end
477+
478+
context 'in detached HEAD state (checked out at a commit SHA directly)' do
479+
before do
480+
sha = repo.log(1).execute.first.sha
481+
repo.lib.checkout(sha)
482+
end
483+
484+
it 'returns HeadState with state :detached and name HEAD' do
485+
result = described_instance.current_branch_state
486+
expect(result.state).to eq(:detached)
487+
expect(result.name).to eq('HEAD')
488+
end
489+
end
490+
491+
context 'on an unborn branch (repository initialized with no commits)' do
492+
let(:unborn_repo_dir) { Dir.mktmpdir('unborn_repo') }
493+
let(:unborn_repo) { Git.init(unborn_repo_dir) }
494+
let(:unborn_execution_context) { Git::ExecutionContext::Repository.from_base(unborn_repo) }
495+
let(:unborn_instance) { Git::Repository.new(execution_context: unborn_execution_context) }
496+
497+
after { FileUtils.rm_rf(unborn_repo_dir) }
498+
499+
it 'returns HeadState with state :unborn' do
500+
result = unborn_instance.current_branch_state
501+
expect(result.state).to eq(:unborn)
502+
end
503+
504+
it 'returns the initial branch name (not HEAD)' do
505+
result = unborn_instance.current_branch_state
506+
expect(result.name).not_to eq('HEAD')
507+
expect(result.name).to be_a(String)
508+
expect(result.name).not_to be_empty
509+
end
510+
end
511+
end
455512
end

spec/unit/git/base_spec.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,16 @@
205205
end
206206
end
207207

208+
describe '#current_branch_state' do
209+
include_context 'with a stubbed facade_repository'
210+
211+
it 'delegates to facade_repository.current_branch_state' do
212+
head_state = instance_double(Git::Repository::Branching::HeadState)
213+
expect(facade_repository).to receive(:current_branch_state).and_return(head_state)
214+
expect(described_instance.current_branch_state).to be(head_state)
215+
end
216+
end
217+
208218
describe '#gblob' do
209219
include_context 'with a stubbed facade_repository'
210220

spec/unit/git/repository/branching_spec.rb

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,29 @@
1313
let(:execution_context) { instance_double(Git::ExecutionContext::Repository) }
1414
let(:described_instance) { Git::Repository.new(execution_context: execution_context) }
1515

16+
# ---------------------------------------------------------------------------
17+
# HeadState
18+
# ---------------------------------------------------------------------------
19+
20+
describe 'HeadState' do
21+
subject(:head_state_class) { Git::Repository::Branching::HeadState }
22+
23+
it 'is a Data class' do
24+
expect(head_state_class).to be < Data
25+
end
26+
27+
it 'constructs with keyword arguments' do
28+
instance = head_state_class.new(state: :active, name: 'main')
29+
expect(instance.state).to eq(:active)
30+
expect(instance.name).to eq('main')
31+
end
32+
33+
it 'is immutable (does not respond to state=)' do
34+
instance = head_state_class.new(state: :active, name: 'main')
35+
expect(instance).not_to respond_to(:state=)
36+
end
37+
end
38+
1639
# ---------------------------------------------------------------------------
1740
# #current_branch
1841
# ---------------------------------------------------------------------------
@@ -51,6 +74,88 @@
5174
end
5275
end
5376

77+
# ---------------------------------------------------------------------------
78+
# #current_branch_state
79+
# ---------------------------------------------------------------------------
80+
81+
describe '#current_branch_state' do
82+
subject(:result) { described_instance.current_branch_state }
83+
84+
let(:show_current_command) { instance_double(Git::Commands::Branch::ShowCurrent) }
85+
let(:rev_parse_command) { instance_double(Git::Commands::RevParse) }
86+
87+
before do
88+
allow(Git::Commands::Branch::ShowCurrent)
89+
.to receive(:new).with(execution_context).and_return(show_current_command)
90+
allow(Git::Commands::RevParse)
91+
.to receive(:new).with(execution_context).and_return(rev_parse_command)
92+
end
93+
94+
context 'when on an active branch (RevParse succeeds)' do
95+
before do
96+
allow(show_current_command).to receive(:call).and_return(command_result("main\n"))
97+
allow(rev_parse_command)
98+
.to receive(:call).with('main', verify: true, quiet: true)
99+
.and_return(command_result('abc123'))
100+
end
101+
102+
it 'returns HeadState with state :active and the branch name' do
103+
expect(result).to eq(
104+
Git::Repository::Branching::HeadState.new(state: :active, name: 'main')
105+
)
106+
end
107+
end
108+
109+
context 'when on an unborn branch (RevParse exits 1 with empty stderr)' do
110+
let(:unborn_result) { command_result('', stderr: '', exitstatus: 1) }
111+
112+
before do
113+
allow(show_current_command).to receive(:call).and_return(command_result("main\n"))
114+
allow(rev_parse_command)
115+
.to receive(:call).with('main', verify: true, quiet: true)
116+
.and_raise(Git::FailedError.new(unborn_result))
117+
end
118+
119+
it 'returns HeadState with state :unborn and the branch name' do
120+
expect(result).to eq(
121+
Git::Repository::Branching::HeadState.new(state: :unborn, name: 'main')
122+
)
123+
end
124+
end
125+
126+
context 'in detached HEAD state (ShowCurrent returns empty stdout)' do
127+
before do
128+
allow(show_current_command).to receive(:call).and_return(command_result(''))
129+
end
130+
131+
it 'returns HeadState with state :detached and name HEAD' do
132+
expect(result).to eq(
133+
Git::Repository::Branching::HeadState.new(state: :detached, name: 'HEAD')
134+
)
135+
end
136+
137+
it 'does not call RevParse' do
138+
expect(rev_parse_command).not_to receive(:call)
139+
result
140+
end
141+
end
142+
143+
context 'when RevParse exits 1 with non-empty stderr' do
144+
let(:error_result) { command_result('', stderr: 'fatal: not a git repository', exitstatus: 1) }
145+
146+
before do
147+
allow(show_current_command).to receive(:call).and_return(command_result("main\n"))
148+
allow(rev_parse_command)
149+
.to receive(:call).with('main', verify: true, quiet: true)
150+
.and_raise(Git::FailedError.new(error_result))
151+
end
152+
153+
it 're-raises the FailedError' do
154+
expect { result }.to raise_error(Git::FailedError)
155+
end
156+
end
157+
end
158+
54159
# ---------------------------------------------------------------------------
55160
# #checkout_file
56161
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)