Class: RepoTender::SCM::Git
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
- #clone(url, dest) ⇒ Object
- #current_branch(path) ⇒ Object
-
#default_branch(path) ⇒ Object
Resolve the bare remote’s HEAD.
-
#fast_forward(path, default_branch) ⇒ Object
‘merge –ff-only` refuses on divergence.
- #fetch(path) ⇒ Object
- #last_fetch_at(path) ⇒ Object
- #status(path) ⇒ Object
-
#switch(path, branch) ⇒ Object
‘git switch <branch>`.
-
#sync_empty(path) ⇒ Object
Handle an unborn (empty) local clone.
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 |