├── LICENSE.txt ├── README.md ├── go.mod └── gogit.go /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ben Hoyt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | gogit 3 | ===== 4 | 5 | gogit is just enough of a git client (implemented in Go) to initialise a repo, commit, and push to GitHub. It's 400 lines of Go code, and I've experimented writing it in a "scripting style" -- that is, using `panic` for error handling (exceptions with a stack trace). 6 | 7 | This repo was created and pushed to GitHub using gogit. [Read the full story here.](http://benhoyt.com/writings/gogit/) 8 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module gogit 2 | 3 | go 1.19 4 | -------------------------------------------------------------------------------- /gogit.go: -------------------------------------------------------------------------------- 1 | // Implement just enough git (in Go) to commit and push to GitHub. 2 | // 3 | // Read the story here: https://benhoyt.com/writings/gogit/ 4 | // 5 | // Released under a permissive MIT license (see LICENSE.txt). 6 | 7 | package main 8 | 9 | import ( 10 | "bytes" 11 | "compress/zlib" 12 | "crypto/sha1" 13 | "encoding/base64" 14 | "encoding/binary" 15 | "encoding/hex" 16 | "flag" 17 | "fmt" 18 | "io" 19 | "net/http" 20 | "os" 21 | "path/filepath" 22 | "sort" 23 | "strconv" 24 | "strings" 25 | "time" 26 | ) 27 | 28 | func main() { 29 | if len(os.Args) < 2 { 30 | fmt.Fprintln(os.Stderr, "usage: gogit init|commit|push [...]") 31 | os.Exit(2) 32 | } 33 | switch os.Args[1] { 34 | case "init": 35 | initRepo() 36 | 37 | case "commit": 38 | // "gogit commit" is different from "git commit": for simplicity, gogit 39 | // doesn't maintain the git index (staging area), so you have to 40 | // specify the list of paths you want committed each time. 41 | var message string 42 | flagSet := flag.NewFlagSet("gogit commit -m 'message' paths...", flag.ExitOnError) 43 | flagSet.StringVar(&message, "m", "", "commit message (required)") 44 | flagSet.Parse(os.Args[2:]) 45 | if message == "" || len(flagSet.Args()) == 0 { 46 | flagSet.Usage() 47 | os.Exit(1) 48 | } 49 | authorName := os.Getenv("GIT_AUTHOR_NAME") 50 | authorEmail := os.Getenv("GIT_AUTHOR_EMAIL") 51 | if authorName == "" || authorEmail == "" { 52 | fmt.Fprintln(os.Stderr, "GIT_AUTHOR_NAME and GIT_AUTHOR_EMAIL must be set") 53 | os.Exit(1) 54 | } 55 | author := fmt.Sprintf("%s <%s>", authorName, authorEmail) 56 | hash := commit(message, author, flagSet.Args()) 57 | fmt.Printf("committed %s to master\n", hash[:7]) 58 | 59 | case "push": 60 | if len(os.Args) != 3 { 61 | fmt.Fprintln(os.Stderr, "usage: gogit push git-url") 62 | os.Exit(1) 63 | } 64 | gitURL := os.Args[2] 65 | username := os.Getenv("GIT_USERNAME") 66 | password := os.Getenv("GIT_PASSWORD") 67 | if username == "" || password == "" { 68 | fmt.Fprintln(os.Stderr, "GIT_USERNAME and GIT_PASSWORD must be set") 69 | os.Exit(1) 70 | } 71 | remote, local, num := push(gitURL, username, password) 72 | if num == 0 { 73 | fmt.Printf("local and remote at %s, nothing to update\n", local[:7]) 74 | } else { 75 | fmt.Printf("updating remote master from %s to %s (%d objects)\n", remote[:7], local[:7], num) 76 | } 77 | 78 | default: 79 | fmt.Fprintln(os.Stderr, "usage: gogit init|commit|push [...]") 80 | os.Exit(2) 81 | } 82 | } 83 | 84 | func check0(err error) { 85 | if err != nil { 86 | panic(err) 87 | } 88 | } 89 | 90 | func check[T any](value T, err error) T { 91 | if err != nil { 92 | panic(err) 93 | } 94 | return value 95 | } 96 | 97 | func assert(cond bool, format string, args ...any) { 98 | if !cond { 99 | panic(fmt.Sprintf(format, args...)) 100 | } 101 | } 102 | 103 | // Create the directories and files required to initialise a git repo. 104 | func initRepo() { 105 | for _, name := range []string{".git/objects", ".git/refs/heads"} { 106 | check0(os.MkdirAll(name, 0o775)) 107 | } 108 | check0(os.WriteFile(".git/HEAD", []byte("ref: refs/heads/master"), 0o664)) 109 | } 110 | 111 | // Commit tree of given paths to master, returning the commit hash. 112 | func commit(message, author string, paths []string) string { 113 | tree := writeTree(paths) 114 | var buf bytes.Buffer 115 | fmt.Fprintln(&buf, "tree", hex.EncodeToString(tree)) 116 | parent := getLocalHash() 117 | if parent != "" { 118 | fmt.Fprintln(&buf, "parent", parent) 119 | } 120 | now := time.Now() 121 | offset := now.Format("-0700") 122 | fmt.Fprintln(&buf, "author", author, now.Unix(), offset) 123 | fmt.Fprintln(&buf, "committer", author, now.Unix(), offset) 124 | fmt.Fprintln(&buf) 125 | fmt.Fprintln(&buf, message) 126 | data := buf.Bytes() 127 | hash := hashObject("commit", data) 128 | check0(os.WriteFile(".git/refs/heads/master", []byte(hex.EncodeToString(hash)+"\n"), 0o664)) 129 | return hex.EncodeToString(hash) 130 | } 131 | 132 | // Write a "tree" object with the given paths (sub-trees are not supported). 133 | func writeTree(paths []string) []byte { 134 | sort.Strings(paths) // tree object needs paths sorted 135 | var buf bytes.Buffer 136 | for _, path := range paths { 137 | st := check(os.Stat(path)) 138 | assert(!st.IsDir(), "sub-trees not supported") 139 | data := check(os.ReadFile(path)) 140 | hash := hashObject("blob", data) 141 | fmt.Fprintf(&buf, "%o %s\x00%s", st.Mode().Perm()|0o100000, path, hash) 142 | } 143 | return hashObject("tree", buf.Bytes()) 144 | } 145 | 146 | // Hash and write the given data as a git object of the given type. 147 | func hashObject(objType string, data []byte) []byte { 148 | sha := sha1.New() 149 | header := fmt.Sprintf("%s %d\x00", objType, len(data)) 150 | io.WriteString(sha, header) // these writes can't fail 151 | sha.Write(data) 152 | hash := sha.Sum(nil) 153 | hashStr := hex.EncodeToString(hash) 154 | path := filepath.Join(".git/objects", hashStr[:2], hashStr[2:]) 155 | if _, err := os.Stat(path); err == nil { 156 | return hash // file already exists 157 | } 158 | check0(os.MkdirAll(filepath.Dir(path), 0o775)) 159 | f := check(os.Create(path)) 160 | compressed := compress(append([]byte(header), data...)) 161 | check(f.Write(compressed)) 162 | check0(f.Close()) 163 | return hash 164 | } 165 | 166 | // Return current commit hash of the local master branch. 167 | func getLocalHash() string { 168 | hash, err := os.ReadFile(".git/refs/heads/master") 169 | if err != nil { 170 | return "" 171 | } 172 | return string(bytes.TrimSpace(hash)) 173 | } 174 | 175 | // Read git object with given hash (or hash prefix). 176 | func readObject(hashPrefix string) (objType string, data []byte) { 177 | path := findObject(hashPrefix) 178 | f := check(os.Open(path)) 179 | defer f.Close() 180 | decompressor := check(zlib.NewReader(f)) 181 | var buf bytes.Buffer 182 | check(io.Copy(&buf, decompressor)) 183 | check0(decompressor.Close()) 184 | fullData := buf.Bytes() 185 | header, data, ok := bytes.Cut(fullData, []byte{0}) 186 | assert(ok, "invalid object data: no NUL byte") 187 | objType, sizeStr, ok := strings.Cut(string(header), " ") 188 | assert(ok, "invalid object header") 189 | size := check(strconv.Atoi(sizeStr)) 190 | assert(size == len(data), "invalid object: expected size %d, got %d", size, len(data)) 191 | return objType, data 192 | } 193 | 194 | // Find object with given hash prefix and return path to object. 195 | func findObject(hashPrefix string) string { 196 | objDir := filepath.Join(".git/objects", hashPrefix[:2]) 197 | rest := hashPrefix[2:] 198 | entries, _ := os.ReadDir(objDir) 199 | var matches []string 200 | for _, entry := range entries { 201 | if strings.HasPrefix(entry.Name(), rest) { 202 | matches = append(matches, entry.Name()) 203 | } 204 | } 205 | assert(len(matches) > 0, "object %q not found", hashPrefix) 206 | assert(len(matches) == 1, "multiple objects with prefix %q", hashPrefix) 207 | return filepath.Join(objDir, matches[0]) 208 | } 209 | 210 | // Push master branch (and missing objects) to remote. 211 | func push(gitURL, username, password string) (remoteHash, localHash string, num int) { 212 | client := &http.Client{Timeout: 10 * time.Second} 213 | remoteHash = getRemoteHash(client, gitURL, username, password) 214 | localHash = getLocalHash() 215 | missing := findMissingObjects(localHash, remoteHash) 216 | if len(missing) == 0 { 217 | return remoteHash, localHash, 0 218 | } 219 | if remoteHash == "" { 220 | remoteHash = strings.Repeat("0", 40) 221 | } 222 | line := fmt.Sprintf("%s %s refs/heads/master\x00 report-status\n", remoteHash, localHash) 223 | packData := createPack(missing) 224 | sendData := append([]byte(fmt.Sprintf("%04x%s0000", len(line)+4, line)), packData...) 225 | request := check(http.NewRequest("POST", gitURL+"/git-receive-pack", bytes.NewReader(sendData))) 226 | addBasicAuth(request, username, password) 227 | response := check(client.Do(request)) 228 | defer response.Body.Close() 229 | data := check(io.ReadAll(response.Body)) 230 | assert(response.StatusCode == 200, "expected status 200, got %d", response.StatusCode) 231 | lines := extractLines(data) 232 | assert(lines[0] == "unpack ok\n", `expected line 1 to be "unpack ok\n", got %q`, lines[0]) 233 | assert(lines[1] == "ok refs/heads/master\n", `expected line 2 to be "ok refs/heads/master\n", got %q`, lines[1]) 234 | return remoteHash, localHash, len(missing) 235 | } 236 | 237 | // Get current hash of master branch on remote. 238 | func getRemoteHash(client *http.Client, gitURL, username, password string) string { 239 | request := check(http.NewRequest("GET", gitURL+"/info/refs?service=git-receive-pack", nil)) 240 | addBasicAuth(request, username, password) 241 | response := check(client.Do(request)) 242 | defer response.Body.Close() 243 | data := check(io.ReadAll(response.Body)) 244 | assert(response.StatusCode == 200, "expected status 200, got %d", response.StatusCode) 245 | lines := extractLines(data) 246 | assert(lines[0] == "# service=git-receive-pack\n", "invalid service line %q", lines[0]) 247 | assert(lines[1] == "", "expected empty second line, got %q", lines[1]) 248 | if lines[2][:40] == strings.Repeat("0", 40) { 249 | return "" 250 | } 251 | hashRef := strings.Split(lines[2], "\x00")[0] 252 | fields := strings.Fields(hashRef) 253 | hash, ref := fields[0], fields[1] 254 | assert(ref == "refs/heads/master", `expected "refs/heads/master", got %q`, ref) 255 | assert(len(hash) == 40, "expected 40-char hash, got %q (%d)", hash, len(hash)) 256 | return hash 257 | } 258 | 259 | // Add basic authentication header to request. 260 | func addBasicAuth(request *http.Request, username, password string) { 261 | auth := username + ":" + password 262 | value := "Basic " + base64.StdEncoding.EncodeToString([]byte(auth)) 263 | request.Header.Set("Authorization", value) 264 | } 265 | 266 | // Extract list of lines from given server data. 267 | func extractLines(data []byte) []string { 268 | var lines []string 269 | for i := 0; i < len(data); { 270 | length := check(strconv.ParseInt(string(data[i:i+4]), 16, 32)) 271 | var line []byte 272 | if length != 0 { 273 | line = data[i+4 : i+int(length)] 274 | } 275 | lines = append(lines, string(line)) 276 | if length == 0 { 277 | i += 4 278 | } else { 279 | i += int(length) 280 | } 281 | } 282 | return lines 283 | } 284 | 285 | // Find sorted list of object hashes in local commit that are missing at the remote. 286 | func findMissingObjects(localHash, remoteHash string) []string { 287 | localObjects := findCommitObjects(localHash) 288 | if remoteHash != "" { 289 | remoteObjects := findCommitObjects(remoteHash) 290 | for object := range remoteObjects { 291 | delete(localObjects, object) 292 | } 293 | } 294 | missing := make([]string, 0, len(localObjects)) 295 | for object := range localObjects { 296 | missing = append(missing, object) 297 | } 298 | sort.Strings(missing) 299 | return missing 300 | } 301 | 302 | // Find set of object hashes in this commit (recursively), its tree, its 303 | // parents, and the hash of the commit itself. 304 | func findCommitObjects(commitHash string) map[string]struct{} { 305 | objects := map[string]struct{}{commitHash: {}} 306 | objType, data := readObject(commitHash) 307 | assert(objType == "commit", "expected commit, got %s", objType) 308 | lines := strings.Split(string(data), "\n") 309 | tree := "" 310 | var parents []string 311 | for _, line := range lines { 312 | if strings.HasPrefix(line, "tree ") { 313 | tree = line[5:45] 314 | } else if strings.HasPrefix(line, "parent ") { 315 | parents = append(parents, line[7:47]) 316 | } 317 | } 318 | assert(tree != "", "tree not found in commit %s", commitHash) 319 | treeObjects := findTreeObjects(tree) 320 | for object := range treeObjects { 321 | objects[object] = struct{}{} 322 | } 323 | for _, parent := range parents { 324 | parentObjects := findCommitObjects(parent) 325 | for object := range parentObjects { 326 | objects[object] = struct{}{} 327 | } 328 | } 329 | return objects 330 | } 331 | 332 | // Find set of object hashes in this tree, including the hash of the tree itself. 333 | func findTreeObjects(treeHash string) map[string]struct{} { 334 | objType, data := readObject(treeHash) 335 | assert(objType == "tree", "expected tree, got %s", objType) 336 | objects := map[string]struct{}{treeHash: {}} 337 | for i := 0; ; { 338 | end := bytes.IndexByte(data[i:], 0) 339 | if end < 0 { 340 | return objects 341 | } 342 | chunk := string(data[i : i+end]) 343 | modeStr, _, ok := strings.Cut(chunk, " ") 344 | assert(ok, "expected space in %q", chunk) 345 | mode := check(strconv.ParseInt(modeStr, 8, 64)) 346 | assert(mode&0o040000 == 0, "sub-trees not supported") 347 | hash := hex.EncodeToString(data[i+end+1 : i+end+21]) 348 | objects[hash] = struct{}{} 349 | i += end + 1 + 20 350 | } 351 | } 352 | 353 | // Create pack file containing all objects in given list of object hashes. 354 | func createPack(objects []string) []byte { 355 | var buf bytes.Buffer 356 | header := []byte("PACK") 357 | header = binary.BigEndian.AppendUint32(header, 2) 358 | header = binary.BigEndian.AppendUint32(header, uint32(len(objects))) 359 | buf.Write(header) 360 | for _, object := range objects { 361 | buf.Write(encodePackObject(object)) 362 | } 363 | sha := sha1.New() 364 | sha.Write(buf.Bytes()) 365 | buf.Write(sha.Sum(nil)) 366 | return buf.Bytes() 367 | } 368 | 369 | // Encode a single object in pack file. 370 | func encodePackObject(object string) []byte { 371 | objType, data := readObject(object) 372 | typeNum := objTypes[objType] 373 | size := len(data) 374 | b := byte(typeNum<<4) | byte(size&0x0f) 375 | size >>= 4 376 | var header []byte 377 | for size != 0 { 378 | header = append(header, b|0x80) 379 | b = byte(size & 0x7f) 380 | size >>= 7 381 | } 382 | header = append(header, b) 383 | return append(header, compress(data)...) 384 | } 385 | 386 | var objTypes = map[string]int{"commit": 1, "tree": 2, "blob": 3} 387 | 388 | // Helper to zlib-compress a slice of bytes. 389 | func compress(data []byte) []byte { 390 | var buf bytes.Buffer 391 | compressor := zlib.NewWriter(&buf) 392 | check(compressor.Write(data)) 393 | check0(compressor.Close()) 394 | return buf.Bytes() 395 | } 396 | --------------------------------------------------------------------------------