module git import os import time pub const clone_size_limit_exit_code = 125 pub const clone_size_limit_marker = '[gitly:clone-size-limit]' pub struct Git {} pub fn Git.exec(args []string) os.Result { mut git_args := ['git'] git_args << args return os.exec(git_args) } pub fn Git.exec_in_dir(dir string, args []string) os.Result { mut git_args := ['-C', dir] git_args << args return Git.exec(git_args) } pub fn Git.exec_in_dir_command(dir string, command string) os.Result { return Git.exec_in_dir(dir, split_command(command)) } pub fn Git.exec_shell(command string) os.Result { return os.exec(['/bin/sh', '-c', command]) } // Git.exec_in_dir_with_env runs `git -C ` without going through a // shell, with `extra_env` added on top of the inherited environment. Use this // instead of building a shell string when any argument or environment value is // user-controlled (file paths, branch names, commit messages, author names), // since arguments are passed directly to git and never re-parsed by /bin/sh. pub fn Git.exec_in_dir_with_env(dir string, args []string, extra_env map[string]string) os.Result { mut full_args := ['-C', dir] full_args << args mut p := os.new_process('git') p.set_args(full_args) mut merged := os.environ() for k, v in extra_env { merged[k] = v } p.set_environment(merged) p.set_redirect_stdio() p.run() output := p.stdout_slurp() + p.stderr_slurp() p.wait() code := p.code p.close() return os.Result{ exit_code: code output: output } } pub fn Git.clone(url string, path string) os.Result { println('new clone url="${url}" path="${path}"') return os.exec(['git', 'clone', '--bare', url, path]) } // Git.clone_with_progress runs `git clone --bare --progress` and streams // every byte of git's progress output (which goes to stderr) into // `progress_path` while the clone is running, so a separate process can // poll the file and show live progress to the user. pub fn Git.clone_with_progress(url string, path string, progress_path string) os.Result { return Git.clone_with_progress_limit(url, path, progress_path, 0) } pub fn Git.clone_with_progress_limit(url string, path string, progress_path string, max_bytes u64) os.Result { println('new clone (progress) url="${url}" path="${path}" progress="${progress_path}"') os.rm(progress_path) or {} mut p := os.new_process('git') p.set_args(['clone', '--bare', '--progress', url, path]) p.set_redirect_stdio() p.run() mut log := os.open_append(progress_path) or { eprintln('clone_with_progress: cannot open progress file "${progress_path}": ${err}') // fall back to non-streaming behaviour p.wait() out := p.stdout_slurp() + p.stderr_slurp() code := p.code p.close() return os.Result{ exit_code: code output: out } } mut collected := '' mut stopped_for_size := false for p.is_alive() { chunk := p.stderr_read() if chunk.len > 0 { log.write_string(chunk) or {} log.flush() collected += chunk if max_bytes > 0 && !stopped_for_size && clone_progress_received_bytes(collected) >= max_bytes { stopped_for_size = true marker := '\n${clone_size_limit_marker}\n' log.write_string(marker) or {} log.flush() collected += marker p.signal_term() time.sleep(500 * time.millisecond) if p.is_alive() { p.signal_kill() } } } // drain stdout so the pipe buffer never blocks the child _ := p.stdout_read() time.sleep(100 * time.millisecond) } final := p.stderr_slurp() if final.len > 0 { log.write_string(final) or {} log.flush() collected += final } log.close() p.wait() exit_code := if stopped_for_size { clone_size_limit_exit_code } else { p.code } p.close() return os.Result{ exit_code: exit_code output: collected } } pub fn clone_progress_received_bytes(progress string) u64 { mut max_bytes := u64(0) for line in progress.replace('\r', '\n').split('\n') { if !line.contains('Receiving objects:') { continue } bytes := parse_clone_progress_size(line) or { continue } if bytes > max_bytes { max_bytes = bytes } } return max_bytes } fn parse_clone_progress_size(line string) ?u64 { comma := line.index('),') or { return none } mut size_part := line[comma + 2..].trim_space() pipe := size_part.index('|') or { size_part.len } size_part = size_part[..pipe].trim_space() parts := size_part.fields() if parts.len < 2 { return none } value := parts[0].f64() multiplier := match parts[1] { 'B', 'byte', 'bytes' { 1.0 } 'KiB' { 1024.0 } 'MiB' { 1024.0 * 1024.0 } 'GiB' { 1024.0 * 1024.0 * 1024.0 } else { return none } } return u64(value * multiplier) } pub fn Git.fetch_ref(repo_dir string, remote string, refspec string) os.Result { return Git.exec_in_dir(repo_dir, ['fetch', remote, refspec]) } pub fn Git.show_file_blob(repo_dir string, branch string, file_path string) !string { result := Git.exec_in_dir(repo_dir, ['--no-pager', 'show', '${branch}:${file_path}']) if result.exit_code != 0 { return error(result.output) } return result.output } fn split_command(command string) []string { mut args := []string{} mut current := []u8{} mut quote := u8(0) mut escaped := false for ch in command.bytes() { if escaped { current << ch escaped = false continue } if ch == `\\` { escaped = true continue } if quote != 0 { if ch == quote { quote = 0 } else { current << ch } continue } if ch == `"` || ch == `'` { quote = ch continue } if ch.is_space() { if current.len > 0 { args << current.bytestr() current.clear() } continue } current << ch } if current.len > 0 { args << current.bytestr() } return args }