Module: Mbeditor::GitService

Included in:
GitBlameService, GitCommitGraphService, GitDiffService, GitFileHistoryService
Defined in:
app/services/mbeditor/git_service.rb

Overview

Shared helpers for running git CLI commands read-only inside a repo. All public methods accept repo_path as their first argument so services stay stateless and composable.

Constant Summary collapse

SAFE_GIT_REF =

Safe pattern for git ref names (branch, remote/branch, tag). Excludes @ to prevent reflog syntax like @-1 or @u.

%r{\A[\w./-]+\z}

Class Method Summary collapse

Class Method Details

.ahead_behind(repo_path, upstream) ⇒ Object

Returns [ahead_count, behind_count] relative to upstream, or [0,0].



47
48
49
50
51
52
53
54
55
56
# File 'app/services/mbeditor/git_service.rb', line 47

def ahead_behind(repo_path, upstream)
  return [0, 0] if upstream.blank?
  return [0, 0] unless upstream.match?(SAFE_GIT_REF)

  out, status = run_git(repo_path, "rev-list", "--left-right", "--count", "HEAD...#{upstream}")
  return [0, 0] unless status.success?

  parts = out.strip.split("\t", 2)
  [parts[0].to_i, parts[1].to_i]
end

.current_branch(repo_path) ⇒ Object

Current branch name, or nil if not in a git repo. Uses rev-parse for compatibility with Git < 2.22 (which lacks –show-current).



31
32
33
34
# File 'app/services/mbeditor/git_service.rb', line 31

def current_branch(repo_path)
  out, status = run_git(repo_path, "rev-parse", "--abbrev-ref", "HEAD")
  status.success? ? out.strip : nil
end

.find_branch_base(repo_path, current_branch, candidates: nil) ⇒ Object

Returns [merge_base_sha, ref_name] of the first candidate base branch found, or [nil, nil] if none can be determined. Candidates are tried in preference order; skips the current branch and refs whose merge-base equals HEAD.



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# File 'app/services/mbeditor/git_service.rb', line 61

def find_branch_base(repo_path, current_branch, candidates: nil)
  candidates ||= Mbeditor.configuration.base_branch_candidates
  head_sha_out, = run_git(repo_path, "rev-parse", "HEAD")
  head_sha = head_sha_out.strip

  candidates.each do |ref|
    short = ref.delete_prefix("origin/")
    next if short == current_branch || ref == current_branch

    _o, st = run_git(repo_path, "rev-parse", "--verify", "--quiet", ref)
    next unless st.success?

    base_out, base_st = run_git(repo_path, "merge-base", "HEAD", ref)
    next unless base_st.success?

    sha = base_out.strip
    next unless sha.match?(/\A[0-9a-f]{40}\z/)
    next if sha == head_sha

    return [sha, ref]
  end

  [nil, nil]
rescue StandardError
  [nil, nil]
end

.parse_git_log(raw_output) ⇒ Object

Parse compact ‘git log –pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e` output. Returns Array of hashes with string keys.



135
136
137
# File 'app/services/mbeditor/git_service.rb', line 135

def self.parse_git_log(raw_output)
  parse_log_entries(raw_output, with_parents: false)
end

.parse_git_log_with_parents(raw_output) ⇒ Object

Parse compact ‘git log –pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%aI%x1e` output. Returns Array of hashes with string keys.



129
130
131
# File 'app/services/mbeditor/git_service.rb', line 129

def self.parse_git_log_with_parents(raw_output)
  parse_log_entries(raw_output, with_parents: true)
end

.parse_name_status(output) ⇒ Object

Parse ‘git diff –name-status` output. Returns Array of { status: String, path: String }.



103
104
105
106
107
108
109
110
111
112
113
114
# File 'app/services/mbeditor/git_service.rb', line 103

def parse_name_status(output)
  output.lines.filter_map do |line|
    parts = line.strip.split("\t")
    next if parts.empty?

    status = parts[0].to_s.strip
    path = parts.last.to_s.strip
    next if path.blank?

    { status: status, path: path }
  end
end

.parse_numstat(output) ⇒ Object

Parse ‘git diff –numstat` output. Returns Hash of path => { added: Integer, removed: Integer }.



118
119
120
121
122
123
124
125
# File 'app/services/mbeditor/git_service.rb', line 118

def parse_numstat(output)
  (output || "").lines.each_with_object({}) do |line, map|
    parts = line.strip.split("\t", 3)
    next if parts.length < 3 || parts[0] == "-"

    map[parts[2].strip] = { added: parts[0].to_i, removed: parts[1].to_i }
  end
end

.parse_porcelain_status(output) ⇒ Object

Parse ‘git status –porcelain` output. Returns Array of { status: String, path: String }.



90
91
92
93
94
95
96
97
98
99
# File 'app/services/mbeditor/git_service.rb', line 90

def parse_porcelain_status(output)
  output.lines.filter_map do |line|
    next if line.length < 4

    path = line[3..].to_s.strip
    next if path.blank?

    { status: line[0..1].strip, path: path }
  end
end

.resolve_path(repo_path, relative) ⇒ Object

Resolve a file path safely within repo_path. Returns full path string or nil if the path escapes the root.



141
142
143
144
145
146
# File 'app/services/mbeditor/git_service.rb', line 141

def resolve_path(repo_path, relative)
  return nil if relative.blank?

  full = File.expand_path(relative.to_s, repo_path.to_s)
  full.start_with?(repo_path.to_s + "/") || full == repo_path.to_s ? full : nil
end

.run_git(repo_path, *args) ⇒ Object

Run an arbitrary git command inside repo_path. Returns [stdout, Process::Status]. stderr is discarded to prevent git diagnostic messages from leaking into the Rails server log. Honors config.git_timeout (seconds) when set.



20
21
22
23
24
25
26
27
# File 'app/services/mbeditor/git_service.rb', line 20

def run_git(repo_path, *args)
  timeout_secs = Mbeditor.configuration.git_timeout&.to_i
  timeout = timeout_secs && timeout_secs > 0 ? timeout_secs : nil
  result = ProcessRunner.call(["git", "-C", repo_path, *args], timeout: timeout)
  [result[:stdout], result[:exit_status]]
rescue ProcessRunner::TimeoutError
  raise Timeout::Error, "git timed out after #{timeout_secs}s"
end

.upstream_branch(repo_path) ⇒ Object

Upstream tracking branch for the current branch, e.g. “origin/main”. Returns nil if the branch name contains characters outside SAFE_GIT_REF.



38
39
40
41
42
43
44
# File 'app/services/mbeditor/git_service.rb', line 38

def upstream_branch(repo_path)
  out, status = run_git(repo_path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
  return nil unless status.success?

  ref = out.strip
  ref.match?(SAFE_GIT_REF) ? ref : nil
end