| 1 | module git |
| 2 | |
| 3 | import os |
| 4 | import time |
| 5 | |
| 6 | pub const clone_size_limit_exit_code = 125 |
| 7 | pub const clone_size_limit_marker = '[gitly:clone-size-limit]' |
| 8 | |
| 9 | pub struct Git {} |
| 10 | |
| 11 | pub fn Git.exec(args []string) os.Result { |
| 12 | mut git_args := ['git'] |
| 13 | git_args << args |
| 14 | return os.exec(git_args) |
| 15 | } |
| 16 | |
| 17 | pub fn Git.exec_in_dir(dir string, args []string) os.Result { |
| 18 | mut git_args := ['-C', dir] |
| 19 | git_args << args |
| 20 | return Git.exec(git_args) |
| 21 | } |
| 22 | |
| 23 | pub fn Git.exec_in_dir_command(dir string, command string) os.Result { |
| 24 | return Git.exec_in_dir(dir, split_command(command)) |
| 25 | } |
| 26 | |
| 27 | pub fn Git.exec_shell(command string) os.Result { |
| 28 | return os.exec(['/bin/sh', '-c', command]) |
| 29 | } |
| 30 | |
| 31 | // Git.exec_in_dir_with_env runs `git -C <dir> <args...>` without going through a |
| 32 | // shell, with `extra_env` added on top of the inherited environment. Use this |
| 33 | // instead of building a shell string when any argument or environment value is |
| 34 | // user-controlled (file paths, branch names, commit messages, author names), |
| 35 | // since arguments are passed directly to git and never re-parsed by /bin/sh. |
| 36 | pub fn Git.exec_in_dir_with_env(dir string, args []string, extra_env map[string]string) os.Result { |
| 37 | mut full_args := ['-C', dir] |
| 38 | full_args << args |
| 39 | mut p := os.new_process('git') |
| 40 | p.set_args(full_args) |
| 41 | mut merged := os.environ() |
| 42 | for k, v in extra_env { |
| 43 | merged[k] = v |
| 44 | } |
| 45 | p.set_environment(merged) |
| 46 | p.set_redirect_stdio() |
| 47 | p.run() |
| 48 | output := p.stdout_slurp() + p.stderr_slurp() |
| 49 | p.wait() |
| 50 | code := p.code |
| 51 | p.close() |
| 52 | return os.Result{ |
| 53 | exit_code: code |
| 54 | output: output |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | pub fn Git.clone(url string, path string) os.Result { |
| 59 | println('new clone url="${url}" path="${path}"') |
| 60 | return os.exec(['git', 'clone', '--bare', url, path]) |
| 61 | } |
| 62 | |
| 63 | // Git.clone_with_progress runs `git clone --bare --progress` and streams |
| 64 | // every byte of git's progress output (which goes to stderr) into |
| 65 | // `progress_path` while the clone is running, so a separate process can |
| 66 | // poll the file and show live progress to the user. |
| 67 | pub fn Git.clone_with_progress(url string, path string, progress_path string) os.Result { |
| 68 | return Git.clone_with_progress_limit(url, path, progress_path, 0) |
| 69 | } |
| 70 | |
| 71 | pub fn Git.clone_with_progress_limit(url string, path string, progress_path string, max_bytes u64) os.Result { |
| 72 | println('new clone (progress) url="${url}" path="${path}" progress="${progress_path}"') |
| 73 | os.rm(progress_path) or {} |
| 74 | mut p := os.new_process('git') |
| 75 | p.set_args(['clone', '--bare', '--progress', url, path]) |
| 76 | p.set_redirect_stdio() |
| 77 | p.run() |
| 78 | mut log := os.open_append(progress_path) or { |
| 79 | eprintln('clone_with_progress: cannot open progress file "${progress_path}": ${err}') |
| 80 | // fall back to non-streaming behaviour |
| 81 | p.wait() |
| 82 | out := p.stdout_slurp() + p.stderr_slurp() |
| 83 | code := p.code |
| 84 | p.close() |
| 85 | return os.Result{ |
| 86 | exit_code: code |
| 87 | output: out |
| 88 | } |
| 89 | } |
| 90 | mut collected := '' |
| 91 | mut stopped_for_size := false |
| 92 | for p.is_alive() { |
| 93 | chunk := p.stderr_read() |
| 94 | if chunk.len > 0 { |
| 95 | log.write_string(chunk) or {} |
| 96 | log.flush() |
| 97 | collected += chunk |
| 98 | if max_bytes > 0 && !stopped_for_size |
| 99 | && clone_progress_received_bytes(collected) >= max_bytes { |
| 100 | stopped_for_size = true |
| 101 | marker := '\n${clone_size_limit_marker}\n' |
| 102 | log.write_string(marker) or {} |
| 103 | log.flush() |
| 104 | collected += marker |
| 105 | p.signal_term() |
| 106 | time.sleep(500 * time.millisecond) |
| 107 | if p.is_alive() { |
| 108 | p.signal_kill() |
| 109 | } |
| 110 | } |
| 111 | } |
| 112 | // drain stdout so the pipe buffer never blocks the child |
| 113 | _ := p.stdout_read() |
| 114 | time.sleep(100 * time.millisecond) |
| 115 | } |
| 116 | final := p.stderr_slurp() |
| 117 | if final.len > 0 { |
| 118 | log.write_string(final) or {} |
| 119 | log.flush() |
| 120 | collected += final |
| 121 | } |
| 122 | log.close() |
| 123 | p.wait() |
| 124 | exit_code := if stopped_for_size { clone_size_limit_exit_code } else { p.code } |
| 125 | p.close() |
| 126 | return os.Result{ |
| 127 | exit_code: exit_code |
| 128 | output: collected |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | pub fn clone_progress_received_bytes(progress string) u64 { |
| 133 | mut max_bytes := u64(0) |
| 134 | for line in progress.replace('\r', '\n').split('\n') { |
| 135 | if !line.contains('Receiving objects:') { |
| 136 | continue |
| 137 | } |
| 138 | bytes := parse_clone_progress_size(line) or { continue } |
| 139 | if bytes > max_bytes { |
| 140 | max_bytes = bytes |
| 141 | } |
| 142 | } |
| 143 | return max_bytes |
| 144 | } |
| 145 | |
| 146 | fn parse_clone_progress_size(line string) ?u64 { |
| 147 | comma := line.index('),') or { return none } |
| 148 | mut size_part := line[comma + 2..].trim_space() |
| 149 | pipe := size_part.index('|') or { size_part.len } |
| 150 | size_part = size_part[..pipe].trim_space() |
| 151 | parts := size_part.fields() |
| 152 | if parts.len < 2 { |
| 153 | return none |
| 154 | } |
| 155 | value := parts[0].f64() |
| 156 | multiplier := match parts[1] { |
| 157 | 'B', 'byte', 'bytes' { 1.0 } |
| 158 | 'KiB' { 1024.0 } |
| 159 | 'MiB' { 1024.0 * 1024.0 } |
| 160 | 'GiB' { 1024.0 * 1024.0 * 1024.0 } |
| 161 | else { return none } |
| 162 | } |
| 163 | |
| 164 | return u64(value * multiplier) |
| 165 | } |
| 166 | |
| 167 | pub fn Git.fetch_ref(repo_dir string, remote string, refspec string) os.Result { |
| 168 | return Git.exec_in_dir(repo_dir, ['fetch', remote, refspec]) |
| 169 | } |
| 170 | |
| 171 | pub fn Git.show_file_blob(repo_dir string, branch string, file_path string) !string { |
| 172 | result := Git.exec_in_dir(repo_dir, ['--no-pager', 'show', '${branch}:${file_path}']) |
| 173 | if result.exit_code != 0 { |
| 174 | return error(result.output) |
| 175 | } |
| 176 | return result.output |
| 177 | } |
| 178 | |
| 179 | fn split_command(command string) []string { |
| 180 | mut args := []string{} |
| 181 | mut current := []u8{} |
| 182 | mut quote := u8(0) |
| 183 | mut escaped := false |
| 184 | |
| 185 | for ch in command.bytes() { |
| 186 | if escaped { |
| 187 | current << ch |
| 188 | escaped = false |
| 189 | continue |
| 190 | } |
| 191 | if ch == `\\` { |
| 192 | escaped = true |
| 193 | continue |
| 194 | } |
| 195 | if quote != 0 { |
| 196 | if ch == quote { |
| 197 | quote = 0 |
| 198 | } else { |
| 199 | current << ch |
| 200 | } |
| 201 | continue |
| 202 | } |
| 203 | if ch == `"` || ch == `'` { |
| 204 | quote = ch |
| 205 | continue |
| 206 | } |
| 207 | if ch.is_space() { |
| 208 | if current.len > 0 { |
| 209 | args << current.bytestr() |
| 210 | current.clear() |
| 211 | } |
| 212 | continue |
| 213 | } |
| 214 | current << ch |
| 215 | } |
| 216 | if current.len > 0 { |
| 217 | args << current.bytestr() |
| 218 | } |
| 219 | return args |
| 220 | } |
| 221 | |