Module: Mbeditor::GitService
- Included in:
- GitBlameService, GitCombinedDiffService, GitCommitDetailService, GitCommitGraphService, GitDiffService, GitFileHistoryService, GitLineDiffService
- 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, and a leading - so a ref can never be read as a command-line option by the git it is interpolated into.
%r{\A(?!-)[\w./-]+\z}
Class Method Summary collapse
-
.ahead_behind(repo_path, upstream) ⇒ Object
Returns [ahead_count, behind_count] relative to upstream, or [0,0].
-
.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.
-
.current_branch(repo_path) ⇒ Object
Current branch name, or nil if not in a git repo.
-
.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.
-
.parse_git_log(raw_output) ⇒ Object
Parse compact
git log --pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1eoutput. -
.parse_git_log_with_parents(raw_output) ⇒ Object
Parse compact
git log --pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%aI%x1eoutput. -
.parse_name_status(output) ⇒ Object
Parse
git diff --name-statusoutput. -
.parse_numstat(output) ⇒ Object
Parse
git diff --numstatoutput, in either the newline form or the NUL-delimited-zform (which is what pairs withstatus --porcelain -z, since only it leaves non-ASCII paths unquoted). -
.parse_numstat_z(output) ⇒ Object
In
-zform a rename leaves the path field empty and follows the record with the old name then the new name, each NUL-terminated. -
.parse_porcelain_status(output) ⇒ Object
Parse
git status --porcelainoutput, in either the newline form or the NUL-delimited-zform. -
.resolve_path(repo_path, relative) ⇒ Object
Resolve a file path safely within repo_path.
-
.run_git(repo_path, *args, max_bytes: nil) ⇒ Object
Run an arbitrary git command inside
repo_path. -
.upstream_branch(repo_path) ⇒ Object
Upstream tracking branch for the current branch, e.g.
Class Method Details
.ahead_behind(repo_path, upstream) ⇒ Object
Returns [ahead_count, behind_count] relative to upstream, or [0,0].
49 50 51 52 53 54 55 56 57 58 |
# File 'app/services/mbeditor/git_service.rb', line 49 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.
105 106 107 108 109 110 |
# File 'app/services/mbeditor/git_service.rb', line 105 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).
33 34 35 36 |
# File 'app/services/mbeditor/git_service.rb', line 33 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).
72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
# File 'app/services/mbeditor/git_service.rb', line 72 def find_branch_base(repo_path, current_branch, candidates: nil) candidates ||= Mbeditor.configuration.base_branch_candidates # One listing instead of a rev-parse per candidate: with six defaults # that was up to twelve sequential spawns on a path the git panel polls. refs_out, refs_st = run_git(repo_path, "for-each-ref", "--format=%(refname:short)", "refs/heads", "refs/remotes") return [nil, nil] unless refs_st.success? existing = refs_out.split("\n").map(&:strip) candidates.each do |ref| short = ref.delete_prefix("origin/") next if short == current_branch || ref == current_branch next unless existing.include?(ref) 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.
201 202 203 |
# File 'app/services/mbeditor/git_service.rb', line 201 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.
195 196 197 |
# File 'app/services/mbeditor/git_service.rb', line 195 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 }.
144 145 146 147 148 149 150 151 152 153 154 155 |
# File 'app/services/mbeditor/git_service.rb', line 144 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, in either the newline form or the
NUL-delimited -z form (which is what pairs with status --porcelain -z,
since only it leaves non-ASCII paths unquoted).
Returns Hash of path => { added: Integer, removed: Integer }.
161 162 163 164 165 166 167 168 169 170 171 |
# File 'app/services/mbeditor/git_service.rb', line 161 def parse_numstat(output) out = (output || "") return parse_numstat_z(out) if out.include?("\0") out.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_numstat_z(output) ⇒ Object
In -z form a rename leaves the path field empty and follows the record
with the old name then the new name, each NUL-terminated.
175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 |
# File 'app/services/mbeditor/git_service.rb', line 175 def parse_numstat_z(output) records = output.split("\0") map = {} i = 0 while i < records.length added, removed, path = records[i].split("\t", 3) i += 1 if path.to_s.empty? path = records[i + 1] i += 2 end next if path.nil? || added == "-" map[path] = { added: added.to_i, removed: removed.to_i } end map end |
.parse_porcelain_status(output) ⇒ Object
Parse git status --porcelain output, in either the newline form or the
NUL-delimited -z form.
Only -z gives usable paths: without it git quotes anything containing a
space or a non-ASCII byte ("caf\303\251.rb") and writes a rename as
old -> new, so the path came back unopenable and never matched numstat.
In -z a rename/copy spans two records and the NEW name comes first.
Returns Array of { status: String, path: String }.
121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 |
# File 'app/services/mbeditor/git_service.rb', line 121 def parse_porcelain_status(output) nul = output.include?("\0") records = nul ? output.split("\0") : output.lines.map(&:chomp) result = [] i = 0 while i < records.length record = records[i] i += 1 next if record.length < 4 status = record[0..1].strip i += 1 if nul && status.start_with?("R", "C") # skip the old name path = nul ? record[3..].to_s : record[3..].to_s.strip next if path.blank? result << { status: status, path: path } end result 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.
Resolves symlinks (via SafePath, including dangling ones) so that a symlink inside the repo cannot escape it. Deliberately stricter than ApplicationController#resolve_path, which follows links out of the workspace so linked-in files open: git has nothing to say about a file outside its own repo, so there is no feature to lose here. 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.
216 217 218 219 220 221 222 223 224 225 226 227 228 |
# File 'app/services/mbeditor/git_service.rb', line 216 def resolve_path(repo_path, relative) return nil if relative.blank? root = repo_path.to_s full = File.(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, max_bytes: nil) ⇒ 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.
22 23 24 25 26 27 28 29 |
# File 'app/services/mbeditor/git_service.rb', line 22 def run_git(repo_path, *args, max_bytes: nil) 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, max_bytes: max_bytes) [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.
40 41 42 43 44 45 46 |
# File 'app/services/mbeditor/git_service.rb', line 40 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 |