package gitutil import ( "bytes" "fmt" "os" "os/exec" "strings" "gitbay.org/gitbay/internal/toolpath" ) // FetchInto copies srcRef from srcDir into dstDir as dstRef, forcing the // update. Objects are copied, not shared — the destination owns everything // afterward, which is what keeps MRs alive when their fork is deleted. func FetchInto(dstDir, srcDir, srcRef, dstRef string) error { cmd := exec.Command(toolpath.Look("git"), "-C", dstDir, "fetch", "--quiet", "--no-write-fetch-head", srcDir, "+"+srcRef+":"+dstRef) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("fetch %s from %s: %v\n%s", srcRef, srcDir, err, out) } return nil } // UpdateRefCAS points ref at newSHA only if it currently points at oldSHA // (empty oldSHA = must not exist). This is the compare-and-swap that makes // merges safe against concurrent pushes. func UpdateRefCAS(dir, ref, newSHA, oldSHA string) error { args := []string{"-C", dir, "update-ref", ref, newSHA} if oldSHA != "" { args = append(args, oldSHA) } cmd := exec.Command(toolpath.Look("git"), args...) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("update-ref %s: %v\n%s", ref, err, out) } return nil } func DeleteRef(dir, ref string) error { cmd := exec.Command(toolpath.Look("git"), "-C", dir, "update-ref", "-d", ref) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("delete-ref %s: %v\n%s", ref, err, out) } return nil } // RefExists reports whether ref is present, whatever it points at. Unlike // ResolveRef it does not need the object to exist, so a ref left dangling // by an interrupted prune still reads as present and gets deleted. func RefExists(dir, ref string) bool { return exec.Command(toolpath.Look("git"), "-C", dir, "show-ref", "--verify", "--quiet", ref).Run() == nil } // PruneNow repacks the repository and drops every unreachable object at // once, instead of after git's two-week grace. For when a ref was deleted // so that what it pointed at stops being fetchable by sha. Without the // grace, a push whose objects have left quarantine but whose ref is not // yet written can lose them; the window is milliseconds, and the one // caller is an explicit admin command, not a timer. func PruneNow(dir string) error { cmd := exec.Command(toolpath.Look("git"), "-C", dir, "gc", "--quiet", "--prune=now") if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("gc --prune=now: %v\n%s", err, out) } return nil } // FsckConnectivity checks that every object reachable from the // repository's refs is present, without reading blob contents. A backup // verify runs it on each archived repository (#259). dir is the git // directory itself; it is passed as --git-dir so that a directory that is // not a repository fails instead of git checking one enclosing it. func FsckConnectivity(dir string) error { cmd := exec.Command(toolpath.Look("git"), "--git-dir="+dir, "fsck", "--connectivity-only", "--no-progress", "--no-dangling") if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("fsck --connectivity-only: %v\n%s", err, out) } return nil } // RevListRange returns commits in old..new, newest first. func RevListRange(dir, old, new string) ([]string, error) { cmd := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list", "--end-of-options", new, "^"+old) out, err := cmd.Output() if err != nil { return nil, fmt.Errorf("rev-list %s..%s: %w", old, new, err) } var shas []string for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") { if l != "" { shas = append(shas, l) } } return shas, nil } // MergeTree performs a real merge of ours and theirs, returning the merged // tree id. conflict=true means the merge cannot be done automatically. func MergeTree(dir, ours, theirs string) (tree string, conflict bool, err error) { cmd := exec.Command(toolpath.Look("git"), "-C", dir, "merge-tree", "--write-tree", "--end-of-options", ours, theirs) out, runErr := cmd.Output() tree = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0]) if runErr != nil { if ee, ok := runErr.(*exec.ExitError); ok && ee.ExitCode() == 1 { return "", true, nil // conflicted merge } return "", false, fmt.Errorf("merge-tree: %w", runErr) } return tree, false, nil } // CommitTree creates a merge commit with the given parents, authored and // committed by the merging user. There is no server signing key by design. func CommitTree(dir, tree string, parents []string, name, email, message string) (string, error) { args := []string{"-C", dir, "commit-tree", tree, "-m", message} for _, p := range parents { args = append(args, "-p", p) } cmd := exec.Command(toolpath.Look("git"), args...) cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME="+name, "GIT_AUTHOR_EMAIL="+email, "GIT_COMMITTER_NAME="+name, "GIT_COMMITTER_EMAIL="+email, ) out, err := cmd.Output() if err != nil { return "", fmt.Errorf("commit-tree: %w", err) } return strings.TrimSpace(string(out)), nil } // Diff returns the patch for old..new (three-dot semantics are the caller's // job: pass the merge base as old), cut at limit bytes on a line boundary; // truncated says whether it was cut, so the caller can say so instead of // rendering a hunk that ends mid-line (#117). func Diff(dir, old, new string, limit int64) (patch string, truncated bool, err error) { cmd := exec.Command(toolpath.Look("git"), "-C", dir, "diff", "--stat", "--patch", "--end-of-options", old, new) out, err := cmd.Output() if err != nil { return "", false, fmt.Errorf("diff: %w", err) } out, truncated = cutAtLine(out, limit) return string(out), truncated, nil } // cutAtLine keeps at most limit bytes, ending on the last newline before // the limit. func cutAtLine(out []byte, limit int64) ([]byte, bool) { if int64(len(out)) <= limit { return out, false } cut := out[:limit] if i := bytes.LastIndexByte(cut, '\n'); i >= 0 { cut = cut[:i+1] } return cut, true } // PatchID identifies the change between old and new independently of the // commits carrying it: git patch-id --stable over the whole-range diff. // Two revisions with the same PatchID propose the same change, whatever // was rebased underneath. "" when the range has no diff. func PatchID(dir, old, new string) (string, error) { diff := exec.Command(toolpath.Look("git"), "-C", dir, "diff", "--end-of-options", old, new) pid := exec.Command(toolpath.Look("git"), "-C", dir, "patch-id", "--stable") pipe, err := diff.StdoutPipe() if err != nil { return "", err } pid.Stdin = pipe if err := diff.Start(); err != nil { return "", fmt.Errorf("diff: %w", err) } out, err := pid.Output() if werr := diff.Wait(); werr != nil { return "", fmt.Errorf("diff: %w", werr) } if err != nil { return "", fmt.Errorf("patch-id: %w", err) } fields := strings.Fields(string(out)) if len(fields) == 0 { return "", nil } return fields[0], nil } // MergeBase returns the best common ancestor, or an error if none exists. func MergeBase(dir, a, b string) (string, error) { cmd := exec.Command(toolpath.Look("git"), "-C", dir, "merge-base", "--end-of-options", a, b) out, err := cmd.Output() if err != nil { return "", fmt.Errorf("no common history between %s and %s", a, b) } return strings.TrimSpace(string(out)), nil } // CommitFileChange writes content at path on branch as a new commit and // advances the branch with compare-and-swap. Used by web edits; hooks do not // run, so callers enforce policy themselves. func CommitFileChange(dir, branch, path string, content []byte, name, email, message string) (string, error) { branchRef := "refs/heads/" + branch parent, err := ResolveRef(dir, branchRef) if err != nil { // An unborn branch is a root commit only in a repository with no // refs at all. Anywhere else an unresolvable branch is a typo, and // starting an orphan branch for it would be worse than refusing. if !isEmptyRepo(dir) { return "", fmt.Errorf("branch %s: %w", branch, err) } parent = "" } sha, err := CommitWithFile(dir, parent, path, "100644", content, name, email, message) if err != nil { return "", err } if err := UpdateRefCAS(dir, branchRef, sha, parent); err != nil { return "", fmt.Errorf("branch moved during edit; reload and retry: %w", err) } return sha, nil } // CommitWithFile writes a commit on parent ("" for a root commit) whose // tree is parent's with content at path, as a file of mode (100644 or // 100755), authored and committed as name . No ref moves: the // caller updates one, and enforces policy, since no hook runs. func CommitWithFile(dir, parent, path, mode string, content []byte, name, email, message string) (string, error) { // Hash the new blob. hb := exec.Command(toolpath.Look("git"), "-C", dir, "hash-object", "-w", "--stdin") hb.Stdin = strings.NewReader(string(content)) out, err := hb.Output() if err != nil { return "", fmt.Errorf("hash-object: %w", err) } blob := strings.TrimSpace(string(out)) // Stage the parent tree in a temporary index, splice the blob in, and // write the new tree. idx, err := os.CreateTemp("", "gitbay-index-*") if err != nil { return "", err } idx.Close() defer os.Remove(idx.Name()) env := append(os.Environ(), "GIT_INDEX_FILE="+idx.Name()) tree0 := parent + "^{tree}" if parent == "" { tree0 = "--empty" } rt := exec.Command(toolpath.Look("git"), "-C", dir, "read-tree", tree0) rt.Env = env if out, err := rt.CombinedOutput(); err != nil { return "", fmt.Errorf("read-tree: %v\n%s", err, out) } ui := exec.Command(toolpath.Look("git"), "-C", dir, "update-index", "--add", "--cacheinfo", mode+","+blob+","+path) ui.Env = env if out, err := ui.CombinedOutput(); err != nil { return "", fmt.Errorf("update-index: %v\n%s", err, out) } wt := exec.Command(toolpath.Look("git"), "-C", dir, "write-tree") wt.Env = env out, err = wt.Output() if err != nil { return "", fmt.Errorf("write-tree: %w", err) } tree := strings.TrimSpace(string(out)) var parents []string if parent != "" { parents = []string{parent} } return CommitTree(dir, tree, parents, name, email, message) } // isEmptyRepo reports whether dir has no refs at all — a repository // created but never pushed to. func isEmptyRepo(dir string) bool { out, err := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list", "-n1", "--all").Output() return err == nil && strings.TrimSpace(string(out)) == "" } // CommitParents returns the parent SHAs of a commit. func CommitParents(dir, sha string) ([]string, error) { out, err := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list", "--parents", "-n1", "--end-of-options", sha).Output() if err != nil { return nil, fmt.Errorf("rev-list --parents %s: %w", sha, err) } fields := strings.Fields(string(out)) if len(fields) < 1 { return nil, fmt.Errorf("no output for %s", sha) } return fields[1:], nil } // AuthorIdent returns a commit's author name, email, and ISO date. func AuthorIdent(dir, sha string) (name, email, date string, err error) { out, err := exec.Command(toolpath.Look("git"), "-C", dir, "log", "-1", "--format=%an%x1f%ae%x1f%aI", "--end-of-options", sha).Output() if err != nil { return "", "", "", fmt.Errorf("log %s: %w", sha, err) } parts := strings.SplitN(strings.TrimSpace(string(out)), "\x1f", 3) if len(parts) != 3 { return "", "", "", fmt.Errorf("bad ident for %s", sha) } return parts[0], parts[1], parts[2], nil } // CommitMessage returns a commit's full message. func CommitMessage(dir, sha string) (string, error) { out, err := exec.Command(toolpath.Look("git"), "-C", dir, "log", "-1", "--format=%B", "--end-of-options", sha).Output() if err != nil { return "", fmt.Errorf("log %s: %w", sha, err) } return strings.TrimRight(string(out), "\n"), nil } // MergeTreeOnto replays commit's changes (relative to base) onto onto, // returning the resulting tree. conflict=true when it cannot apply cleanly. func MergeTreeOnto(dir, base, onto, commit string) (tree string, conflict bool, err error) { cmd := exec.Command(toolpath.Look("git"), "-C", dir, "merge-tree", "--write-tree", "--merge-base="+base, "--end-of-options", onto, commit) out, runErr := cmd.Output() tree = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0]) if runErr != nil { if ee, ok := runErr.(*exec.ExitError); ok && ee.ExitCode() == 1 { return "", true, nil } return "", false, fmt.Errorf("merge-tree: %w", runErr) } return tree, false, nil } // CommitTreeIdent creates a commit with distinct author and committer // identities. Empty authorDate means now. func CommitTreeIdent(dir, tree string, parents []string, authorName, authorEmail, authorDate, committerName, committerEmail, message string) (string, error) { args := []string{"-C", dir, "commit-tree", tree, "-m", message} for _, p := range parents { args = append(args, "-p", p) } cmd := exec.Command(toolpath.Look("git"), args...) env := append(os.Environ(), "GIT_AUTHOR_NAME="+authorName, "GIT_AUTHOR_EMAIL="+authorEmail, "GIT_COMMITTER_NAME="+committerName, "GIT_COMMITTER_EMAIL="+committerEmail, ) if authorDate != "" { env = append(env, "GIT_AUTHOR_DATE="+authorDate) } cmd.Env = env out, err := cmd.Output() if err != nil { return "", fmt.Errorf("commit-tree: %w", err) } return strings.TrimSpace(string(out)), nil } // ResolveTree returns the tree id of a commit. func ResolveTree(dir, sha string) (string, error) { out, err := exec.Command(toolpath.Look("git"), "-C", dir, "rev-parse", "--verify", "--end-of-options", sha+"^{tree}").Output() if err != nil { return "", fmt.Errorf("rev-parse %s^{tree}: %w", sha, err) } return strings.TrimSpace(string(out)), nil } // DiffFiles lists the paths changed between old and new. func DiffFiles(dir, old, new string) ([]string, error) { out, err := exec.Command(toolpath.Look("git"), "-C", dir, "diff", "--name-only", "--end-of-options", old, new).Output() if err != nil { return nil, fmt.Errorf("diff --name-only: %w", err) } var files []string for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") { if l != "" { files = append(files, l) } } return files, nil } // RangeDiff compares two revisions of the same work: what the commits // between oldBase and oldHead became between newBase and newHead. This is // what answers "what changed since I reviewed this", which a plain diff // of the two heads cannot — that shows the whole branch again, rebases // and all. // // Each side carries its own base, because the target moves: comparing // both revisions against today's base would attribute every commit that // landed on the target in between to the author of this merge request. // // --creation-factor is raised from git's default of 60. That default is // tuned for comparing two independently developed patch series, where // refusing to pair is the safe answer. Here the two sides are known to be // revisions of one branch, and the commonest revision of all — a commit // that adds a file, with one line inside it changed — is not paired at // 60: git reports the commit as deleted and a different one added, which // tells a reviewer nothing. It pairs at 80, and two genuinely unrelated // commits are still left unpaired there; both measured. func RangeDiff(dir, oldBase, oldHead, newBase, newHead string, limit int64) (patch string, truncated bool, err error) { cmd := exec.Command(toolpath.Look("git"), "-C", dir, "range-diff", "--creation-factor=80", "--end-of-options", oldBase+".."+oldHead, newBase+".."+newHead) out, err := cmd.Output() if err != nil { return "", false, fmt.Errorf("range-diff: %w", err) } out, truncated = cutAtLine(out, limit) return string(out), truncated, nil }