Class: Diagrams::GitgraphDiagram

Inherits:
Base
  • Object
show all
Defined in:
lib/diagrams/gitgraph_diagram.rb

Overview

Represents a Gitgraph diagram, tracking commits, branches, and their relationships.

Instance Attribute Summary collapse

Attributes inherited from Base

#checksum, #version

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Base

#diff, from_hash, from_json, #to_h, #to_json

Constructor Details

#initialize(version: 1) ⇒ GitgraphDiagram

Initializes a new GitgraphDiagram. Starts with a ‘master’ branch by default.

Parameters:

  • version (String, Integer, nil) (defaults to: 1)

    User-defined version identifier.



14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# File 'lib/diagrams/gitgraph_diagram.rb', line 14

def initialize(version: 1)
  super
  @commits = {} # Hash { commit_id => GitCommit }
  @branches = {} # Hash { branch_name => GitBranch }
  @commit_order = [] # Array<String> - IDs of commits in order of creation/operation
  @current_branch_name = 'master'

  # Initialize main branch conceptually. Its start/head commit will be set by the first commit.
  # We need a placeholder start_commit_id; using a special value or handling nil in GitBranch might be better.
  # For now, let's use a placeholder that signifies it's the root.
  # A better approach might be to create the branch *during* the first commit. Let's refine this.
  # --> Refinement: Don't create the branch object here. Create it during the first 'commit' or 'branch' operation.
  #                 Initialize @current_branch_name = 'master' conceptually.

  update_checksum! # Initial checksum for an empty graph
end

Instance Attribute Details

#branchesObject (readonly)

Returns the value of attribute branches.



8
9
10
# File 'lib/diagrams/gitgraph_diagram.rb', line 8

def branches
  @branches
end

#commit_orderObject (readonly)

Returns the value of attribute commit_order.



8
9
10
# File 'lib/diagrams/gitgraph_diagram.rb', line 8

def commit_order
  @commit_order
end

#commitsObject (readonly)

Returns the value of attribute commits.



8
9
10
# File 'lib/diagrams/gitgraph_diagram.rb', line 8

def commits
  @commits
end

#current_branch_nameObject (readonly)

Returns the value of attribute current_branch_name.



8
9
10
# File 'lib/diagrams/gitgraph_diagram.rb', line 8

def current_branch_name
  @current_branch_name
end

Class Method Details

.from_h(data_hash, version:, checksum:) ⇒ GitgraphDiagram

Class method to create a GitgraphDiagram from a hash.

Parameters:

  • data_hash (Hash)

    Hash containing diagram data.

  • version (String, Integer, nil)

    Diagram version.

  • checksum (String, nil)

    Expected checksum (optional).

Returns:



285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/diagrams/gitgraph_diagram.rb', line 285

def self.from_h(data_hash, version:, checksum:)
  diagram = new(version:)

  # Restore commits
  commits_data = data_hash[:commits] || data_hash['commits'] || []
  commits_data.each do |commit_h|
    # Convert type back to symbol if it's a string
    commit_data = commit_h.transform_keys(&:to_sym)
    commit_data[:type] = commit_data[:type].to_sym if commit_data[:type].is_a?(String)
    commit = Elements::GitCommit.new(commit_data)
    diagram.commits[commit.id] = commit
  end

  # Restore branches
  branches_data = data_hash[:branches] || data_hash['branches'] || []
  branches_data.each do |branch_h|
    branch = Elements::GitBranch.new(branch_h.transform_keys(&:to_sym))
    diagram.branches[branch.name] = branch
  end

  # Restore commit order
  diagram.instance_variable_set(:@commit_order, data_hash[:commit_order] || data_hash['commit_order'] || [])

  # Restore current branch name
  diagram.instance_variable_set(:@current_branch_name,
                                data_hash[:current_branch_name] || data_hash['current_branch_name'] || 'master')

  # Recalculate checksum after loading all data
  diagram.send(:update_checksum!) # Use send to call protected method from class scope

  # Optional: Verify checksum if provided
  if checksum && diagram.checksum != checksum
    warn "Checksum mismatch for loaded GitgraphDiagram (version: #{version}). Expected #{checksum}, got #{diagram.checksum}."
  end

  diagram
end

Instance Method Details

#branch(name:, start_commit_id: nil) ⇒ Elements::GitBranch

Creates a new branch pointing to a specific commit (or the current head) and switches the current context to the new branch.

Parameters:

  • name (String)

    The name for the new branch.

  • start_commit_id (String, nil) (defaults to: nil)

    Optional ID of the commit where the branch should start. Defaults to the head commit of the current branch.

Returns:

Raises:

  • (ArgumentError)

    if the branch name already exists or if trying to branch before any commits exist.

  • (ArgumentError)

    if a specified ‘start_commit_id` does not exist.



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/diagrams/gitgraph_diagram.rb', line 89

def branch(name:, start_commit_id: nil)
  raise ArgumentError, "Branch name '#{name}' already exists" if @branches.key?(name)

  effective_start_commit_id = start_commit_id || current_head_commit_id

  # Ensure there's a commit to branch from
  raise ArgumentError, 'Cannot create a branch before the first commit' unless effective_start_commit_id

  unless @commits.key?(effective_start_commit_id)
    raise ArgumentError,
          "Start commit ID '#{effective_start_commit_id}' does not exist"
  end

  new_branch = Elements::GitBranch.new(
    name:,
    # The new branch initially points to the commit it was created from
    start_commit_id: effective_start_commit_id,
    head_commit_id: effective_start_commit_id
  )

  @branches[name] = new_branch
  @current_branch_name = name # Switch to the new branch

  update_checksum!
  new_branch
end

#checkout(name:) ⇒ String

Switches the current context to an existing branch.

Parameters:

  • name (String)

    The name of the branch to switch to.

Returns:

  • (String)

    The name of the branch checked out.

Raises:

  • (ArgumentError)

    if the branch name does not exist.



121
122
123
124
125
126
127
128
# File 'lib/diagrams/gitgraph_diagram.rb', line 121

def checkout(name:)
  raise ArgumentError, "Branch '#{name}' does not exist. Cannot checkout." unless @branches.key?(name)

  @current_branch_name = name
  # NOTE: Checkout does not change the diagram structure itself (commits/branches),
  # so we do NOT update the checksum here.
  name
end

#cherry_pick(commit_id:, parent_override_id: nil) ⇒ Elements::GitCommit

Cherry-picks an existing commit onto the current branch. Creates a new commit on the current branch that mirrors the specified commit.

Basic implementation ignores parent_override_id for now

Parameters:

  • commit_id (String)

    The ID of the commit to cherry-pick.

  • parent_override_id (String, nil) (defaults to: nil)

    Optional: If cherry-picking a merge commit, specifies which parent lineage to follow. (Note: Basic implementation might ignore this for simplicity initially).

Returns:

Raises:

  • (ArgumentError)

    if the commit_id does not exist, is already on the current branch, or if the current branch has no commits.



202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
# File 'lib/diagrams/gitgraph_diagram.rb', line 202

def cherry_pick(commit_id:, parent_override_id: nil)
  unless @commits.key?(commit_id)
    raise ArgumentError,
          "Commit with ID '#{commit_id}' does not exist. Cannot cherry-pick."
  end

  source_commit = @commits[commit_id]
  current_branch_head_id = current_head_commit_id

  unless current_branch_head_id
    raise ArgumentError,
          "Current branch '#{@current_branch_name}' has no commits. Cannot cherry-pick onto it."
  end
  if source_commit.branch_name == @current_branch_name
    raise ArgumentError,
          "Commit '#{commit_id}' is already on the current branch '#{@current_branch_name}'. Cannot cherry-pick."
  end

  # More robust check: walk history? For now, simple branch name check.

  # TODO: Handle cherry-picking merge commits and parent_override_id if needed later.
  if source_commit.parent_ids.length > 1 && !parent_override_id
    warn "Cherry-picking a merge commit (#{commit_id}) without specifying a parent override is ambiguous. Picking first parent lineage by default."
    # Or raise ArgumentError: "Cherry-picking a merge commit requires specifying parent_override_id."
  end

  parent_ids = [current_branch_head_id] # Cherry-pick commit's parent is the current head
  new_commit_id = generate_commit_id(parent_ids, "Cherry-pick: #{source_commit.message || source_commit.id}")
  if @commits.key?(new_commit_id)
    raise ArgumentError,
          "Generated commit ID '#{new_commit_id}' conflicts with existing commit."
  end

  cherry_pick_commit = Elements::GitCommit.new(
    id: new_commit_id,
    parent_ids:,
    branch_name: @current_branch_name,
    message: source_commit.message || "Cherry-pick of #{source_commit.id}", # Copy message or use default
    tag: nil, # Cherry-picks usually don't copy tags directly
    type: :CHERRY_PICK,
    cherry_pick_source_id: commit_id # Link back to the original commit
  )

  @commits[new_commit_id] = cherry_pick_commit
  @commit_order << new_commit_id

  # Update the head of the current branch
  current_branch = @branches[@current_branch_name]
  current_branch.attributes[:head_commit_id] = new_commit_id

  update_checksum!
  cherry_pick_commit
end

#commit(id: nil, message: nil, tag: nil, type: :NORMAL) ⇒ Elements::GitCommit

Adds a commit to the current branch. Handles the creation of the initial ‘master’ branch on the first commit.

Parameters:

  • id (String, nil) (defaults to: nil)

    Optional custom ID for the commit. Auto-generated if nil.

  • message (String, nil) (defaults to: nil)

    Optional commit message.

  • tag (String, nil) (defaults to: nil)

    Optional tag for the commit.

  • type (Symbol) (defaults to: :NORMAL)

    Type of the commit (:NORMAL, :REVERSE, :HIGHLIGHT).

Returns:

Raises:

  • (ArgumentError)

    if a commit with the given ID already exists.



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/diagrams/gitgraph_diagram.rb', line 42

def commit(id: nil, message: nil, tag: nil, type: :NORMAL)
  parent_id = current_head_commit_id
  parent_ids = parent_id ? [parent_id] : []

  commit_id = id || generate_commit_id(parent_ids, message)
  raise ArgumentError, "Commit with ID '#{commit_id}' already exists" if @commits.key?(commit_id)

  # Handle first commit: create the master branch
  if @commits.empty? && @current_branch_name == 'master' && !@branches.key?('master')
    # The first commit *is* the starting point of the master branch
    master_branch = Elements::GitBranch.new(name: 'master', start_commit_id: commit_id, head_commit_id: commit_id)
    @branches['master'] = master_branch
  elsif !@branches.key?(@current_branch_name)
    # This case shouldn't typically happen if branch/checkout is used correctly,
    # but defensively handle committing to a non-existent branch (other than initial master).
    raise ArgumentError, "Cannot commit: Branch '#{@current_branch_name}' does not exist."
  end

  new_commit = Elements::GitCommit.new(
    id: commit_id,
    parent_ids:,
    branch_name: @current_branch_name,
    message:,
    tag:,
    type:
  )

  @commits[commit_id] = new_commit
  @commit_order << commit_id

  # Update the head of the current branch
  current_branch = @branches[@current_branch_name]
  current_branch.attributes[:head_commit_id] = commit_id # Update using Dry::Struct's way if needed, direct assign might work

  update_checksum!
  new_commit
end

#identifiable_elementsHash{Symbol => Array<Elements::GitCommit | Elements::GitBranch>}

Returns a hash mapping element types to their collections for diffing.

Returns:



273
274
275
276
277
278
# File 'lib/diagrams/gitgraph_diagram.rb', line 273

def identifiable_elements
  {
    commits: @commits.values,
    branches: @branches.values
  }
end

#merge(from_branch_name:, id: nil, tag: nil, type: :MERGE) ⇒ Elements::GitCommit

Merges the head of a specified branch into the current branch. Creates a merge commit on the current branch.

Parameters:

  • from_branch_name (String)

    The name of the branch to merge from.

  • id (String, nil) (defaults to: nil)

    Optional custom ID for the merge commit. Auto-generated if nil.

  • tag (String, nil) (defaults to: nil)

    Optional tag for the merge commit.

  • type (Symbol) (defaults to: :MERGE)

    Type of the merge commit (defaults to :MERGE, can be overridden e.g., :REVERSE).

Returns:

Raises:

  • (ArgumentError)

    if ‘from_branch_name` does not exist, is the same as the current branch, or if either branch has no commits.

  • (ArgumentError)

    if a commit with the given ID already exists.



141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
# File 'lib/diagrams/gitgraph_diagram.rb', line 141

def merge(from_branch_name:, id: nil, tag: nil, type: :MERGE)
  if from_branch_name == @current_branch_name
    raise ArgumentError,
          "Cannot merge branch '#{from_branch_name}' into itself"
  end
  unless @branches.key?(from_branch_name)
    raise ArgumentError,
          "Branch '#{from_branch_name}' does not exist. Cannot merge."
  end
  unless @branches.key?(@current_branch_name)
    raise ArgumentError, "Current branch '#{@current_branch_name}' does not exist. Cannot merge."
  end

  target_branch = @branches[@current_branch_name]
  source_branch = @branches[from_branch_name]

  target_head_id = target_branch.head_commit_id
  source_head_id = source_branch.head_commit_id

  unless target_head_id
    raise ArgumentError,
          "Current branch '#{@current_branch_name}' has no commits to merge into."
  end
  raise ArgumentError, "Source branch '#{from_branch_name}' has no commits to merge from." unless source_head_id

  # Merge commit parents are the heads of the two branches being merged
  parent_ids = [target_head_id, source_head_id].sort # Sort for consistent checksumming/comparison

  merge_commit_id = id || generate_commit_id(parent_ids,
                                             "Merge branch '#{from_branch_name}' into #{@current_branch_name}")
  raise ArgumentError, "Commit with ID '#{merge_commit_id}' already exists" if @commits.key?(merge_commit_id)

  merge_commit = Elements::GitCommit.new(
    id: merge_commit_id,
    parent_ids:,
    branch_name: @current_branch_name, # Merge commit belongs to the target branch
    message: "Merge branch '#{from_branch_name}' into #{@current_branch_name}", # Default message
    tag:,
    type: # Use provided type, default :MERGE
  )

  @commits[merge_commit_id] = merge_commit
  @commit_order << merge_commit_id

  # Update the head of the current (target) branch
  target_branch.attributes[:head_commit_id] = merge_commit_id

  update_checksum!
  merge_commit
end

#to_h_contentHash

Returns the specific content of the gitgraph diagram as a hash.

Returns:

  • (Hash)


260
261
262
263
264
265
266
267
268
269
# File 'lib/diagrams/gitgraph_diagram.rb', line 260

def to_h_content
  {
    commits: @commits.values.map(&:to_h),
    branches: @branches.values.map(&:to_h),
    commit_order: @commit_order,
    current_branch_name: @current_branch_name # Useful for resuming state? Maybe not needed in content hash.
    # Consider if current_branch_name should be part of the checksummable content.
    # For now, let's include it for potential deserialization needs.
  }
end