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.
%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. -
.parse_porcelain_status(output) ⇒ Object
Parse
git status --porcelainoutput. -
.resolve_path(repo_path, relative) ⇒ Object
Resolve a file path safely within repo_path.
-
.run_git(repo_path, *args) ⇒ 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].
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.
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.(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 |