Class: RepoTender::SCM::Git

Inherits:
Client
  • Object
show all
Defined in:
lib/repo_tender/scm/git.rb

Overview

Git CLI implementation of SCM::Client. All subprocess work is delegated to Shell.run (which requires an ambient Async::Task).

Instance Method Summary collapse

Instance Method Details

#clone(url, dest) ⇒ Object



110
111
112
113
114
115
116
117
118
119
# File 'lib/repo_tender/scm/git.rb', line 110

def clone(url, dest)
  parent = File.dirname(dest)
  FileUtils.mkdir_p(parent)
  result = Shell.run("git", "clone", url, dest)
  if result.success?
    Dry::Monads::Success(dest)
  else
    Dry::Monads::Failure({url: url, dest: dest, stderr: result.failure[:stderr]})
  end
end

#current_branch(path) ⇒ Object



34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# File 'lib/repo_tender/scm/git.rb', line 34

def current_branch(path)
  # `git symbolic-ref --short HEAD` exits non-zero on detached HEAD.
  result = Shell.run("git", "symbolic-ref", "--short", "HEAD", chdir: path)
  if result.success?
    Dry::Monads::Success(result.success.strip)
  else
    # Detached HEAD is not a hard failure — report nil.
    head = Shell.run("git", "rev-parse", "--verify", "HEAD", chdir: path)
    if head.success?
      Dry::Monads::Success(nil)
    else
      Dry::Monads::Failure({path: path, reason: "no HEAD", stderr: result.failure[:stderr]})
    end
  end
end

#default_branch(path) ⇒ Object

Resolve the bare remote’s HEAD. First try the local ‘origin/HEAD` symbolic ref; if missing/stale, do a one-shot `git remote set-head origin -a` (network) to refresh it, then re-read. This is the gotcha path from AGENTS.md: a plain `git fetch` does NOT update `origin/HEAD`.



19
20
21
22
23
24
25
26
27
28
29
30
31
32
# File 'lib/repo_tender/scm/git.rb', line 19

def default_branch(path)
  symbolic = read_origin_head(path)
  return Dry::Monads::Success(symbolic) if symbolic

  refresh = Shell.run("git", "remote", "set-head", "origin", "-a", chdir: path)
  return refresh if refresh.failure?

  resolved = read_origin_head(path)
  if resolved
    Dry::Monads::Success(resolved)
  else
    Dry::Monads::Failure({path: path, reason: "could not resolve origin/HEAD after set-head -a"})
  end
end

#fast_forward(path, default_branch) ⇒ Object

‘merge –ff-only` refuses on divergence. We additionally check the rev-list left/right count first so we can surface a clean “diverged” failure with diagnostic info, not just a git error string.



64
65
66
67
68
69
70
71
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 'lib/repo_tender/scm/git.rb', line 64

def fast_forward(path, default_branch)
  upstream = "origin/#{default_branch}"
  counts = Shell.run("git", "rev-list", "--left-right", "--count", "HEAD...#{upstream}", chdir: path)
  return counts if counts.failure?

  left, right = counts.success.strip.split("\t").map(&:to_i)
  if left > 0
    return Dry::Monads::Failure({
      path: path,
      reason: "diverged: local is #{left} commit(s) ahead of #{upstream}; not auto-resolving",
      local_ahead: left,
      remote_ahead: right
    })
  end

  if right == 0
    return Dry::Monads::Success(0)
  end

  # Do the fetch (cheap; FETCH_HEAD mtime hint logic can skip this
  # later, but Slice 1 always fetches when asked to fast-forward).
  fetch_result = fetch(path)
  return fetch_result if fetch_result.failure?

  merge = Shell.run("git", "merge", "--ff-only", upstream, chdir: path)
  if merge.success?
    Dry::Monads::Success(right)
  else
    # On --ff-only failure, git leaves the working tree and local
    # commits intact — the test asserts this.
    Dry::Monads::Failure({
      path: path,
      reason: "fast-forward failed (likely raced divergence)",
      stderr: merge.failure[:stderr]
    })
  end
end

#fetch(path) ⇒ Object



56
57
58
# File 'lib/repo_tender/scm/git.rb', line 56

def fetch(path)
  Shell.run("git", "fetch", "--prune", "--no-tags", "origin", chdir: path)
end

#last_fetch_at(path) ⇒ Object



50
51
52
53
54
# File 'lib/repo_tender/scm/git.rb', line 50

def last_fetch_at(path)
  fetch_head = File.join(path, ".git", "FETCH_HEAD")
  return Dry::Monads::Success(nil) unless File.exist?(fetch_head)
  Dry::Monads::Success(Time.at(File.mtime(fetch_head).to_i))
end

#status(path) ⇒ Object



102
103
104
105
106
107
108
# File 'lib/repo_tender/scm/git.rb', line 102

def status(path)
  result = Shell.run("git", "status", "--porcelain=v2", "--branch", "--untracked-files=normal", chdir: path)
  return result if result.failure?

  parsed = parse_porcelain_v2(result.success)
  Dry::Monads::Success(parsed)
end

#switch(path, branch) ⇒ Object

‘git switch <branch>`. `git switch` aborts on a dirty tree by default (man git-switch: “The operation is aborted however if the operation leads to loss of local changes”), so a nonzero exit here most likely means the caller violated the engine’s dirty-tree guard. We surface that as a Failure with the captured stderr so the engine / log can diagnose it.



127
128
129
130
131
132
133
134
# File 'lib/repo_tender/scm/git.rb', line 127

def switch(path, branch)
  result = Shell.run("git", "switch", branch, chdir: path)
  if result.success?
    Dry::Monads::Success(branch)
  else
    Dry::Monads::Failure({path: path, branch: branch, reason: "git switch refused", stderr: result.failure[:stderr]})
  end
end

#sync_empty(path) ⇒ Object

Handle an unborn (empty) local clone. If the remote has no branches, the repo is already a valid empty clone — return Success(:empty) with no mutation. If the remote has gained commits, fetch and fast-forward the unborn branch into them.

‘git ls-remote –heads origin` is the authoritative empty-vs-error discriminator: exit 0 + empty stdout means the remote truly has no branches; exit 0 + output means it has commits; non-zero exit means a real network/probe error.



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

def sync_empty(path)
  ls = Shell.run("git", "ls-remote", "--heads", "origin", chdir: path)
  return ls if ls.failure?

  return Dry::Monads::Success(:empty) if ls.success.strip.empty?

  fetch_result = fetch(path)
  return fetch_result if fetch_result.failure?

  branch_result = default_branch(path)
  return branch_result if branch_result.failure?

  upstream = "origin/#{branch_result.success}"
  merge = Shell.run("git", "merge", "--ff-only", upstream, chdir: path)
  if merge.success?
    Dry::Monads::Success(:fast_forwarded)
  else
    Dry::Monads::Failure({path: path, reason: "ff merge into unborn branch failed", stderr: merge.failure[:stderr]})
  end
end