Class: Space::Src::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-onlyrefuses 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/space_src/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/space_src/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/space_src/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/space_src/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/space_src/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/space_src/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/space_src/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/space_src/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/space_src/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 |