Module: Mbeditor::GitService

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

.base_branch?(current_branch, candidates: nil) ⇒ Boolean

True when the branch is itself one of the configured base branches, in which case "changes in branch" means "commits not yet pushed" and comparing against its own upstream is correct rather than degenerate.

Returns:

  • (Boolean)


97
98
99
100
101
102
# File 'app/services/mbeditor/git_service.rb', line 97

def base_branch?(current_branch, candidates: nil)
  return false if current_branch.to_s.empty? || current_branch == "HEAD"

  candidates ||= Mbeditor.configuration.base_branch_candidates
  candidates.any? { |ref| ref == current_branch || ref.delete_prefix("origin/") == current_branch }
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 that exists, or [nil, nil] when none does.

Candidates are tried in preference order and the FIRST one that resolves wins, even when the merge-base turns out to be HEAD itself. That case means the branch is fully contained in its base — an empty "changes in branch" diff is then the truthful answer, and walking on to a lower-preference candidate would report a larger, wrong diff instead.

A candidate naming the current branch is skipped: develop is not its own base. Callers handle that case by comparing against the upstream instead (see GitCombinedDiffService#branch_diff).



70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'app/services/mbeditor/git_service.rb', line 70

def find_branch_base(repo_path, current_branch, candidates: nil)
  candidates ||= Mbeditor.configuration.base_branch_candidates

  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/)

    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.



151
152
153
# File 'app/services/mbeditor/git_service.rb', line 151

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.



145
146
147
# File 'app/services/mbeditor/git_service.rb', line 145

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 }.



119
120
121
122
123
124
125
126
127
128
129
130
# File 'app/services/mbeditor/git_service.rb', line 119

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 }.



134
135
136
137
138
139
140
141
# File 'app/services/mbeditor/git_service.rb', line 134

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 }.



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

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.

Follows symlinks (via SafePath, including dangling ones) so that a symlink inside the repo cannot escape it, mirroring ApplicationController#resolve_path. When repo_path is not a real directory (e.g. unit tests with synthetic roots) the symlink check is skipped, since there is nothing on disk to resolve and repo_path is server-controlled.



163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'app/services/mbeditor/git_service.rb', line 163

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

  root = repo_path.to_s
  full = File.expand_path(relative.to_s, root)
  return nil unless full.start_with?("#{root}/") || full == root
  return full unless File.directory?(root)
  return nil unless SafePath.within?(root, full)

  full
rescue Errno::EACCES
  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