krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
repo-descriptions: internal/gitutil/gitutil.go · raw
1// Package gitutil wraps the system git binary. All repository access goes
2// through git subprocesses; there is no in-process git implementation.
3package gitutil
4
5import (
6 "context"
7 "fmt"
8 "io"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "strings"
13)
14
15// InitBare creates a bare repository with the shared hooks directory wired
16// via core.hooksPath.
17func InitBare(path, defaultBranch, hooksPath string) error {
18 if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
19 return err
20 }
21 cmd := exec.Command("git", "init", "--bare", "--initial-branch="+defaultBranch, path)
22 if out, err := cmd.CombinedOutput(); err != nil {
23 return fmt.Errorf("git init: %v\n%s", err, out)
24 }
25 cmd = exec.Command("git", "-C", path, "config", "core.hooksPath", hooksPath)
26 if out, err := cmd.CombinedOutput(); err != nil {
27 return fmt.Errorf("git config core.hooksPath: %v\n%s", err, out)
28 }
29 return nil
30}
31
32// Transport streams one git transport service (upload-pack, receive-pack,
33// upload-archive). extraEnv entries are appended to the process environment;
34// hooks read the GITBAY_* variables from it.
35func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string) error {
36 var args []string
37 switch service {
38 case "git-upload-pack", "git-receive-pack", "git-upload-archive":
39 args = []string{strings.TrimPrefix(service, "git-"), repoPath}
40 default:
41 return fmt.Errorf("unknown service %q", service)
42 }
43 cmd := exec.Command("git", args...)
44 cmd.Env = append(os.Environ(), extraEnv...)
45 cmd.Stdin = stdin
46 cmd.Stdout = stdout
47 cmd.Stderr = errW
48 return cmd.Run()
49}
50
51// IsAncestor reports whether old is an ancestor of new in the repository at
52// dir. It must run with the caller's environment intact so that quarantined
53// objects during pre-receive remain visible.
54func IsAncestor(dir, old, new string) (bool, error) {
55 cmd := exec.Command("git", "-C", dir, "merge-base", "--is-ancestor", old, new)
56 err := cmd.Run()
57 if err == nil {
58 return true, nil
59 }
60 if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
61 return false, nil
62 }
63 return false, err
64}
65
66// ZeroSHA reports whether s is an all-zero object id (SHA-1 or SHA-256).
67func ZeroSHA(s string) bool {
68 if len(s) != 40 && len(s) != 64 {
69 return false
70 }
71 for i := 0; i < len(s); i++ {
72 if s[i] != '0' {
73 return false
74 }
75 }
76 return true
77}
78
79// RevList returns up to limit commit SHAs reachable from ref, newest first.
80func RevList(dir, ref string, limit int) ([]string, error) {
81 cmd := exec.Command("git", "-C", dir, "rev-list", fmt.Sprintf("--max-count=%d", limit), ref)
82 out, err := cmd.Output()
83 if err != nil {
84 return nil, fmt.Errorf("rev-list %s: %w", ref, err)
85 }
86 var shas []string
87 for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
88 if l != "" {
89 shas = append(shas, l)
90 }
91 }
92 return shas, nil
93}
94
95// ReadCommit returns the raw commit object bytes.
96func ReadCommit(dir, sha string) ([]byte, error) {
97 cmd := exec.Command("git", "-C", dir, "cat-file", "commit", sha)
98 out, err := cmd.Output()
99 if err != nil {
100 return nil, fmt.Errorf("cat-file commit %s: %w", sha, err)
101 }
102 return out, nil
103}
104
105// FetchMirror pulls all branches, tags, and notes from a foreign URL into
106// the bare repository at dir, forcing updates. Progress streams to errW so
107// an interactive caller can watch. extraEnv carries credentials via
108// GIT_ASKPASS; the URL itself must never contain them.
109func FetchMirror(ctx context.Context, dir, url string, errW io.Writer, extraEnv []string) error {
110 cmd := exec.CommandContext(ctx, "git", "-C", dir, "fetch", "--progress", "--no-write-fetch-head", url,
111 "+refs/heads/*:refs/heads/*",
112 "+refs/tags/*:refs/tags/*",
113 "+refs/notes/*:refs/notes/*")
114 cmd.Env = append(os.Environ(), extraEnv...)
115 cmd.Stderr = errW
116 if err := cmd.Run(); err != nil {
117 return fmt.Errorf("fetch from %s: %w", url, err)
118 }
119 return nil
120}
121
122// RemoteDefaultBranch asks the remote which branch HEAD points at.
123func RemoteDefaultBranch(ctx context.Context, url string, extraEnv []string) (string, error) {
124 cmd := exec.CommandContext(ctx, "git", "ls-remote", "--symref", url, "HEAD")
125 cmd.Env = append(os.Environ(), extraEnv...)
126 out, err := cmd.Output()
127 if err != nil {
128 return "", fmt.Errorf("ls-remote %s: %w", url, err)
129 }
130 // "ref: refs/heads/<branch>\tHEAD"
131 for _, line := range strings.Split(string(out), "\n") {
132 if rest, ok := strings.CutPrefix(line, "ref: refs/heads/"); ok {
133 if branch, _, ok := strings.Cut(rest, "\t"); ok {
134 return branch, nil
135 }
136 }
137 }
138 return "", fmt.Errorf("remote %s did not advertise a default branch", url)
139}
140
141// SetHead points the bare repo's HEAD at a branch.
142func SetHead(dir, branch string) error {
143 cmd := exec.Command("git", "-C", dir, "symbolic-ref", "HEAD", "refs/heads/"+branch)
144 if out, err := cmd.CombinedOutput(); err != nil {
145 return fmt.Errorf("symbolic-ref: %v\n%s", err, out)
146 }
147 return nil
148}
149
150// gitDefaultDescription is the placeholder git init writes; treated as no
151// description at all.
152const gitDefaultDescription = "Unnamed repository; edit this file 'description' to name the repository."
153
154// ReadDescription returns the repo's description from the classic
155// <repo>.git/description file, empty for the git-init placeholder.
156func ReadDescription(dir string) string {
157 raw, err := os.ReadFile(filepath.Join(dir, "description"))
158 if err != nil {
159 return ""
160 }
161 desc := strings.TrimSpace(string(raw))
162 if desc == gitDefaultDescription {
163 return ""
164 }
165 return desc
166}
167
168// WriteDescription sets the description file: first line only, capped.
169func WriteDescription(dir, desc string) error {
170 desc, _, _ = strings.Cut(strings.TrimSpace(desc), "\n")
171 if len(desc) > 256 {
172 desc = desc[:256]
173 }
174 return os.WriteFile(filepath.Join(dir, "description"), []byte(desc+"\n"), 0o644)
175}