Module: Canopus::Workspace::GitStaging

Defined in:
lib/canopus/workspace/git_staging.rb

Defined Under Namespace

Classes: SCMContent, SCMDiffSnapshot

Constant Summary collapse

SCM_SECTIONS =
{staged: "Staged", unstaged: "Changes", untracked: "Untracked"}.freeze
SCM_DIFF_MAX_BYTES =
10 << 20
SCM_DIFF_MAX_LINES =
200_000
SCM_DIFF_BUDGET =
Porrima::Budget.new(max_bytes: 1 << 20, max_lines: 2_000)
SCM_COMMIT_SUBJECT_GUIDE =
50
SCM_COMMIT_BODY_GUIDE =
72

Instance Method Summary collapse

Instance Method Details

#commit_git(message = nil, amend: nil) ⇒ Object

Raises:

  • (ArgumentError)


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
79
80
81
82
83
84
# File 'lib/canopus/workspace/git_staging.rb', line 49

def commit_git(message = nil, amend: nil)
  message = message.nil? ? @scm_commit_message.to_s : String(message)
  amend = !!@scm_commit_amend if amend.nil?
  raise ArgumentError, "amend must be true or false" unless [true, false].include?(amend)
  raise Error, "Enter a commit message" if message.strip.empty?
  raise Error, "Git commit already in progress" if @git_commit_job&.alive?

  state = git_state
  raise Error, "Not a Git repository" unless state
  snapshot = state.snapshot
  snapshot ||= state.capture unless @window
  raise Error, "Git status is still loading; try again" unless snapshot
  raise Error, "Cannot commit from a detached HEAD" unless snapshot.branch
  staged = snapshot.entries.any? { |_, code| !code.start_with?(" ", "?") }
  raise Error, "Stage changes before committing" unless amend || staged

   = message.dup.freeze
  set_scm_commit_busy(true)
  @git_commit_job = Thread.new do
    oid = state.synchronize(snapshot: snapshot, index_lock: true) do |repository|
      current = repository.commit
      raise Error, "Cannot amend an unborn branch" if amend && !current

      author = amend ? current.signature(role: :author) : repository.signature(role: :author)
      committer = repository.signature(role: :committer)
      repository.commit!(message: , author: author, committer: committer, amend: amend)
    end
    latest = state.capture rescue nil
    worker = Thread.current
    post { finish_git_commit(worker, state, latest, , oid, nil) } unless @closed
  rescue StandardError => error
    latest = state.capture rescue nil
    worker = Thread.current
    post { finish_git_commit(worker, state, latest, , nil, error) } unless @closed
  end
end

#refresh_scm ⇒ Object



86
87
88
89
90
91
92
# File 'lib/canopus/workspace/git_staging.rb', line 86

def refresh_scm
  entries = git_status
  @scm_tree&.replace(scm_nodes(entries))
  @panels.badge("scm", entries.empty? ? nil : entries.length) if @panels&.key?("scm")
  @window&.request_frame
  nil
end

#scm_diff_decorations(buffer, rows) ⇒ Object



184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/canopus/workspace/git_staging.rb', line 184

def scm_diff_decorations(buffer, rows)
  snapshot = buffer.instance_variable_get(:@scm_diff_snapshot)
  return [] unless snapshot&.diff && !snapshot.conflicted

  action = snapshot.kind == :staged ? :unstage : :stage
  verb = action == :stage ? "Stage" : "Unstage"
  line_items = snapshot.line_rows.filter_map do |display_row, index|
    next unless rows.cover?(display_row)

    click = ->(current, row) { change_scm_line(action, index, current.buffer, row) }
    Decoration::Item.new(:gutter, nil, display_row, "#{verb} line",
      {color: :accent, gutter_offset: 2, gutter_width: 3, hit_width: 10}.freeze,
      1, :scm_diff, click)
  end
  hunk_items = snapshot.hunk_rows.filter_map do |display_row, index|
    next unless rows.cover?(display_row) && !snapshot.line_rows.key?(display_row)

    click = ->(current, row) { change_scm_hunk(action, index, current.buffer, row) }
    Decoration::Item.new(:gutter, nil, display_row, "#{verb} hunk",
      {color: :muted, gutter_offset: 2, gutter_width: 3, hit_width: 10}.freeze,
      2, :scm_diff, click)
  end
  line_items + hunk_items
end

#scm_panel ⇒ Object



28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# File 'lib/canopus/workspace/git_staging.rb', line 28

def scm_panel
  @scm_commit_message ||= +""
  @scm_commit_input ||= Zaniah::UI::TextArea.new(@scm_commit_message, rows: 5,
    label: "Commit message", placeholder: "Subject\n\nBody").on_change do |value|
    @scm_commit_message = value
  end
  @scm_commit_amend ||= false
  @scm_commit_amend_control ||= Zaniah::UI::Checkbox.new("Amend", value: @scm_commit_amend).on_change do |value, *, **|
    @scm_commit_amend = value
  end
  @scm_commit_button ||= Zaniah::UI::Button.new("Commit").on_click { submit_scm_commit }
  guide = Zaniah::UI::Label.new(
    "Subject guide: #{SCM_COMMIT_SUBJECT_GUIDE} columns · Body guide: #{SCM_COMMIT_BODY_GUIDE} columns",
    tone: :muted, size: :xs, wrap: :word
  )
  controls = Zaniah::Div.new.flex_row.items_center.gap(8)
    .children([@scm_commit_amend_control, @scm_commit_button])
  Zaniah::Div.new.flex_col.gap(8).p(4)
    .children([@scm_commit_input, guide, controls, scm_tree.flex_1])
end

#scm_tree ⇒ Object



17
18
19
20
21
22
23
24
25
26
# File 'lib/canopus/workspace/git_staging.rb', line 17

def scm_tree
  return @scm_tree if @scm_tree

  @scm_tree = Zaniah::UI::TreeView.new(scm_nodes, height: 600).on_select do |change, event, _context|
    select_scm_change(change, event)
  end
  SCM_SECTIONS.each_key { |kind| @scm_tree.expand([:scm_section, kind]) }
  refresh_scm
  @scm_tree
end

#show_scm_diff(change = @scm_selection) ⇒ Object

Raises:



166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/canopus/workspace/git_staging.rb', line 166

def show_scm_diff(change = @scm_selection)
  raise Error, "Select a changed file" unless change.is_a?(Hash) && SCM_SECTIONS.key?(change[:kind])

  path, kind = change.values_at(:path, :kind)
  state = git_state
  raise Error, "Not a Git repository" unless state
  capture = state.synchronize { |repository| capture_scm_diff(repository, path, kind) }
  snapshot, text = materialize_scm_diff(capture)
  buffer = Buffer.new(text, read_only: true)
  buffer.instance_variable_set(:@scm_diff_snapshot, snapshot)
  @decorations.invalidate(:scm_diff, buffer: buffer)
  @buffers[buffer.object_id] = buffer
  document = @active_pane.open(buffer)
  document.language = Language::Definition.new("diff", "diff", [], "", /\A\z/, /\A\z/, [])
  invalidate_hidden_selection_ranges
  document
end

#stage_git_file(path = nil) ⇒ Object



94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/canopus/workspace/git_staging.rb', line 94

def stage_git_file(path = nil)
  path ||= selected_scm_path(:unstaged, :untracked)
  state = git_state
  raise Error, "Not a Git repository" unless state

  state.synchronize do |repository|
    index = repository.index
    absolute = repository.worktree_path(path)
    if File.exist?(absolute) || File.symlink?(absolute)
      stat = File.lstat(absolute)
      if stat.directory?
        entry = index[path] || repository.tree[path]
        raise Error, "Git submodule is not initialized: #{path}" unless entry&.mode == 0o160000
        nested = submodule_repository(absolute)
        raise Error, "Git submodule is not initialized: #{path}" unless nested
        oid = nested.head
        raise Error, "Git submodule has no HEAD: #{path}" unless oid
        index.stage(path, oid, 0o160000, stat: stat)
      else
        content = repository.worktree_content(path)
        mode = scm_file_mode(repository, stat, index[path] || repository.tree[path])
        index.stage(path, repository.write_blob(content), mode, stat: stat)
      end
    else
      index.remove(path)
    end
    index.write
  end
  refresh_after_index_write
  path
rescue Thuban::RefLockError
  refresh_after_index_write
  raise
end

#stage_git_hunk(index = nil, buffer: editor.buffer, row: nil) ⇒ Object



129
130
131
# File 'lib/canopus/workspace/git_staging.rb', line 129

def stage_git_hunk(index = nil, buffer: editor.buffer, row: nil)
  change_scm_hunk(:stage, index, buffer, row)
end

#stage_git_line(index = nil, buffer: editor.buffer, row: nil) ⇒ Object



137
138
139
# File 'lib/canopus/workspace/git_staging.rb', line 137

def stage_git_line(index = nil, buffer: editor.buffer, row: nil)
  change_scm_line(:stage, index, buffer, row)
end

#unstage_git_file(path = nil) ⇒ Object



145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/canopus/workspace/git_staging.rb', line 145

def unstage_git_file(path = nil)
  path ||= selected_scm_path(:staged)
  state = git_state
  raise Error, "Not a Git repository" unless state

  state.synchronize do |repository|
    index = repository.index
    if (entry = repository.tree[path])
      index.stage(path, entry.oid, entry.mode)
    else
      index.remove(path)
    end
    index.write
  end
  refresh_after_index_write
  path
rescue Thuban::RefLockError
  refresh_after_index_write
  raise
end

#unstage_git_hunk(index = nil, buffer: editor.buffer, row: nil) ⇒ Object



133
134
135
# File 'lib/canopus/workspace/git_staging.rb', line 133

def unstage_git_hunk(index = nil, buffer: editor.buffer, row: nil)
  change_scm_hunk(:unstage, index, buffer, row)
end

#unstage_git_line(index = nil, buffer: editor.buffer, row: nil) ⇒ Object



141
142
143
# File 'lib/canopus/workspace/git_staging.rb', line 141

def unstage_git_line(index = nil, buffer: editor.buffer, row: nil)
  change_scm_line(:unstage, index, buffer, row)
end