1737 lines
51 KiB
Go
1737 lines
51 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-sql-driver/mysql"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
const (
|
|
defaultServerURL = "http://localhost:8080"
|
|
defaultStorage = "storage"
|
|
tokenTTL = 7 * 24 * time.Hour
|
|
)
|
|
|
|
var (
|
|
usernameRE = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
|
|
repoNameRE = regexp.MustCompile(`^[a-z0-9._-]{1,100}$`)
|
|
branchRE = regexp.MustCompile(`^[A-Za-z0-9._/\-]{1,200}$`)
|
|
)
|
|
|
|
type User struct {
|
|
ID int64 `json:"id"`
|
|
Email string `json:"email"`
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
type Repository struct {
|
|
ID int64 `json:"id"`
|
|
OwnerUserID int64 `json:"-"`
|
|
Owner string `json:"owner"`
|
|
Name string `json:"name"`
|
|
Visibility string `json:"visibility"`
|
|
ForkedFromID *int64 `json:"forked_from_id,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type PullRequest struct {
|
|
ID int64 `json:"id"`
|
|
Number int `json:"number"`
|
|
TargetRepositoryID int64 `json:"-"`
|
|
SourceRepositoryID int64 `json:"-"`
|
|
AuthorUserID int64 `json:"-"`
|
|
Author string `json:"author"`
|
|
SourceOwner string `json:"source_owner"`
|
|
SourceRepo string `json:"source_repo"`
|
|
SourceBranch string `json:"source_branch"`
|
|
TargetOwner string `json:"target_owner"`
|
|
TargetRepo string `json:"target_repo"`
|
|
TargetBranch string `json:"target_branch"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Status string `json:"status"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
ClosedAt *time.Time `json:"closed_at,omitempty"`
|
|
MergedAt *time.Time `json:"merged_at,omitempty"`
|
|
}
|
|
|
|
type Server struct {
|
|
db *sql.DB
|
|
storage string
|
|
}
|
|
|
|
type Config struct {
|
|
Server string `json:"server"`
|
|
Username string `json:"username"`
|
|
Token string `json:"token"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
}
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
usage()
|
|
os.Exit(2)
|
|
}
|
|
|
|
var err error
|
|
switch os.Args[1] {
|
|
case "server":
|
|
err = runServer(os.Args[2:])
|
|
case "register":
|
|
err = cliRegister(os.Args[2:])
|
|
case "login":
|
|
err = cliLogin(os.Args[2:])
|
|
case "logout":
|
|
err = cliLogout(os.Args[2:])
|
|
case "whoami":
|
|
err = cliWhoami(os.Args[2:])
|
|
case "clone":
|
|
err = cliClone(os.Args[2:])
|
|
case "repo":
|
|
err = cliRepo(os.Args[2:])
|
|
case "pr":
|
|
err = cliPR(os.Args[2:])
|
|
case "help", "-h", "--help":
|
|
usage()
|
|
default:
|
|
err = fmt.Errorf("unknown command %q", os.Args[1])
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "error:", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func usage() {
|
|
fmt.Println(`gitocean - CLI and server for a small Git hosting platform
|
|
|
|
Commands:
|
|
gitocean server --dsn DSN [--addr :8080] [--storage storage]
|
|
gitocean register [--server URL]
|
|
gitocean login [--server URL]
|
|
gitocean logout
|
|
gitocean whoami
|
|
gitocean clone OWNER/REPO
|
|
gitocean repo create NAME (--public|--private)
|
|
gitocean repo delete OWNER/REPO [--force]
|
|
gitocean repo search QUERY [--all|--mine]
|
|
gitocean repo fork OWNER/REPO [--name NEW_NAME]
|
|
gitocean pr create --from OWNER/REPO:BRANCH --to OWNER/REPO:BRANCH --title TITLE [--description DESC]
|
|
gitocean pr create --repo OWNER/REPO --from BRANCH --to BRANCH --title TITLE [--description DESC]
|
|
gitocean pr list OWNER/REPO
|
|
gitocean pr view OWNER/REPO NUMBER
|
|
gitocean pr close OWNER/REPO NUMBER
|
|
gitocean pr merge OWNER/REPO NUMBER`)
|
|
}
|
|
|
|
// ---------------- Server boot and migrations ----------------
|
|
|
|
func runServer(args []string) error {
|
|
fs := flag.NewFlagSet("server", flag.ExitOnError)
|
|
addr := fs.String("addr", ":8080", "HTTP listen address")
|
|
dsn := fs.String("dsn", os.Getenv("GITOOCEAN_DSN"), "MySQL DSN")
|
|
storage := fs.String("storage", defaultStorage, "storage directory")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
if *dsn == "" {
|
|
return errors.New("--dsn or GITOOCEAN_DSN is required")
|
|
}
|
|
|
|
db, err := openMySQLAndCreateDatabaseIfMissing(*dsn)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
if err := migrate(db); err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(*storage, "repos"), 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
s := &Server{db: db, storage: *storage}
|
|
fmt.Printf("gitocean listening on %s, storage=%s\n", *addr, *storage)
|
|
return http.ListenAndServe(*addr, s)
|
|
}
|
|
|
|
func openMySQLAndCreateDatabaseIfMissing(dsn string) (*sql.DB, error) {
|
|
db, err := sql.Open("mysql", dsn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := db.Ping(); err == nil {
|
|
return db, nil
|
|
} else {
|
|
_ = db.Close()
|
|
var mysqlErr *mysql.MySQLError
|
|
if !errors.As(err, &mysqlErr) || mysqlErr.Number != 1049 {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
cfg, err := mysql.ParseDSN(dsn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cfg.DBName == "" {
|
|
return nil, errors.New("DSN does not include a database name")
|
|
}
|
|
databaseName := cfg.DBName
|
|
cfg.DBName = ""
|
|
|
|
adminDB, err := sql.Open("mysql", cfg.FormatDSN())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer adminDB.Close()
|
|
if err := adminDB.Ping(); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := adminDB.Exec("CREATE DATABASE IF NOT EXISTS " + quoteMySQLIdentifier(databaseName) + " CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
|
|
return nil, err
|
|
}
|
|
fmt.Printf("created database %q\n", databaseName)
|
|
|
|
db, err = sql.Open("mysql", dsn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := db.Ping(); err != nil {
|
|
_ = db.Close()
|
|
return nil, err
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
func quoteMySQLIdentifier(name string) string {
|
|
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
|
|
}
|
|
|
|
func migrate(db *sql.DB) error {
|
|
stmts := []string{
|
|
`CREATE TABLE IF NOT EXISTS users (
|
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
email VARCHAR(255) NOT NULL UNIQUE,
|
|
username VARCHAR(32) NOT NULL UNIQUE,
|
|
password_hash VARCHAR(255) NOT NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
|
`CREATE TABLE IF NOT EXISTS auth_tokens (
|
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
user_id BIGINT NOT NULL,
|
|
token_hash CHAR(64) NOT NULL UNIQUE,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
revoked_at TIMESTAMP NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
INDEX idx_auth_tokens_user_id (user_id),
|
|
INDEX idx_auth_tokens_expires_at (expires_at),
|
|
CONSTRAINT fk_auth_tokens_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
|
`CREATE TABLE IF NOT EXISTS repositories (
|
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
owner_user_id BIGINT NOT NULL,
|
|
name VARCHAR(100) NOT NULL,
|
|
visibility ENUM('public','private') NOT NULL,
|
|
forked_from_repository_id BIGINT NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
UNIQUE KEY uniq_owner_name (owner_user_id, name),
|
|
INDEX idx_repositories_name (name),
|
|
CONSTRAINT fk_repositories_owner FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_repositories_fork FOREIGN KEY (forked_from_repository_id) REFERENCES repositories(id) ON DELETE SET NULL
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
|
`CREATE TABLE IF NOT EXISTS pull_requests (
|
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
target_repository_id BIGINT NOT NULL,
|
|
number INT NOT NULL,
|
|
author_user_id BIGINT NOT NULL,
|
|
source_repository_id BIGINT NOT NULL,
|
|
source_branch VARCHAR(200) NOT NULL,
|
|
target_branch VARCHAR(200) NOT NULL,
|
|
title VARCHAR(255) NOT NULL,
|
|
description TEXT NOT NULL,
|
|
status ENUM('open','closed','merged') NOT NULL DEFAULT 'open',
|
|
closed_at TIMESTAMP NULL,
|
|
merged_at TIMESTAMP NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
UNIQUE KEY uniq_target_number (target_repository_id, number),
|
|
INDEX idx_pr_target (target_repository_id),
|
|
INDEX idx_pr_source (source_repository_id),
|
|
CONSTRAINT fk_pr_target FOREIGN KEY (target_repository_id) REFERENCES repositories(id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_pr_source FOREIGN KEY (source_repository_id) REFERENCES repositories(id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_pr_author FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
|
}
|
|
for _, stmt := range stmts {
|
|
if _, err := db.Exec(stmt); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---------------- HTTP routing ----------------
|
|
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
|
s.handleAPI(w, r)
|
|
return
|
|
}
|
|
if strings.Contains(r.URL.Path, ".git") {
|
|
s.handleGitHTTP(w, r)
|
|
return
|
|
}
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
}
|
|
|
|
func (s *Server) handleAPI(w http.ResponseWriter, r *http.Request) {
|
|
path := strings.TrimPrefix(r.URL.Path, "/api")
|
|
switch {
|
|
case r.Method == http.MethodPost && path == "/register":
|
|
s.handleRegister(w, r)
|
|
case r.Method == http.MethodPost && path == "/login":
|
|
s.handleLogin(w, r)
|
|
case r.Method == http.MethodPost && path == "/logout":
|
|
s.handleLogout(w, r)
|
|
case r.Method == http.MethodGet && path == "/me":
|
|
s.handleMe(w, r)
|
|
case r.Method == http.MethodPost && path == "/repos":
|
|
s.handleRepoCreate(w, r)
|
|
case r.Method == http.MethodGet && path == "/repos/search":
|
|
s.handleRepoSearch(w, r)
|
|
case strings.HasPrefix(path, "/repos/"):
|
|
s.handleRepoSubroute(w, r, strings.TrimPrefix(path, "/repos/"))
|
|
default:
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleRepoSubroute(w http.ResponseWriter, r *http.Request, rest string) {
|
|
parts := strings.Split(rest, "/")
|
|
if len(parts) < 2 {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
owner, repo := parts[0], parts[1]
|
|
if len(parts) == 2 {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
s.handleRepoGet(w, r, owner, repo)
|
|
case http.MethodDelete:
|
|
s.handleRepoDelete(w, r, owner, repo)
|
|
default:
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
}
|
|
return
|
|
}
|
|
if len(parts) == 3 && parts[2] == "fork" && r.Method == http.MethodPost {
|
|
s.handleRepoFork(w, r, owner, repo)
|
|
return
|
|
}
|
|
if len(parts) >= 3 && parts[2] == "pulls" {
|
|
if len(parts) == 3 {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
s.handlePRList(w, r, owner, repo)
|
|
case http.MethodPost:
|
|
s.handlePRCreate(w, r, owner, repo)
|
|
default:
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
}
|
|
return
|
|
}
|
|
if len(parts) >= 4 {
|
|
n, err := strconv.Atoi(parts[3])
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid pull request number")
|
|
return
|
|
}
|
|
if len(parts) == 4 && r.Method == http.MethodGet {
|
|
s.handlePRView(w, r, owner, repo, n)
|
|
return
|
|
}
|
|
if len(parts) == 5 && r.Method == http.MethodPost && parts[4] == "close" {
|
|
s.handlePRClose(w, r, owner, repo, n)
|
|
return
|
|
}
|
|
if len(parts) == 5 && r.Method == http.MethodPost && parts[4] == "merge" {
|
|
s.handlePRMerge(w, r, owner, repo, n)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
}
|
|
|
|
// ---------------- Auth API ----------------
|
|
|
|
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
Email string `json:"email"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
if !decodeJSON(w, r, &in) {
|
|
return
|
|
}
|
|
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
|
|
in.Username = strings.ToLower(strings.TrimSpace(in.Username))
|
|
if !strings.Contains(in.Email, "@") || len(in.Email) > 255 {
|
|
writeError(w, http.StatusBadRequest, "invalid email")
|
|
return
|
|
}
|
|
if !usernameRE.MatchString(in.Username) || isReservedName(in.Username) {
|
|
writeError(w, http.StatusBadRequest, "invalid or reserved username")
|
|
return
|
|
}
|
|
if len(in.Password) < 8 {
|
|
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
|
|
return
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "password hash failed")
|
|
return
|
|
}
|
|
res, err := s.db.Exec(`INSERT INTO users (email, username, password_hash) VALUES (?, ?, ?)`, in.Email, in.Username, string(hash))
|
|
if err != nil {
|
|
writeError(w, http.StatusConflict, "email or username already exists")
|
|
return
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
writeJSON(w, http.StatusCreated, User{ID: id, Email: in.Email, Username: in.Username})
|
|
}
|
|
|
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
Login string `json:"login"`
|
|
Password string `json:"password"`
|
|
}
|
|
if !decodeJSON(w, r, &in) {
|
|
return
|
|
}
|
|
login := strings.ToLower(strings.TrimSpace(in.Login))
|
|
var user User
|
|
var hash string
|
|
err := s.db.QueryRow(`SELECT id, email, username, password_hash FROM users WHERE email = ? OR username = ?`, login, login).Scan(&user.ID, &user.Email, &user.Username, &hash)
|
|
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(in.Password)) != nil {
|
|
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
|
return
|
|
}
|
|
token, expiresAt, err := s.createToken(user.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "token creation failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"user": user, "token": token, "expires_at": expiresAt})
|
|
}
|
|
|
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
token := bearerToken(r)
|
|
if token == "" {
|
|
writeError(w, http.StatusUnauthorized, "missing token")
|
|
return
|
|
}
|
|
_, _ = s.db.Exec(`UPDATE auth_tokens SET revoked_at = UTC_TIMESTAMP() WHERE token_hash = ?`, hashToken(token))
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := s.requireBearerUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, user)
|
|
}
|
|
|
|
func (s *Server) createToken(userID int64) (string, time.Time, error) {
|
|
raw := make([]byte, 32)
|
|
if _, err := rand.Read(raw); err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
token := "go_" + base64.RawURLEncoding.EncodeToString(raw)
|
|
expiresAt := time.Now().UTC().Add(tokenTTL)
|
|
_, err := s.db.Exec(`INSERT INTO auth_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)`, userID, hashToken(token), expiresAt)
|
|
return token, expiresAt, err
|
|
}
|
|
|
|
func hashToken(token string) string {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func bearerToken(r *http.Request) string {
|
|
h := r.Header.Get("Authorization")
|
|
if strings.HasPrefix(strings.ToLower(h), "bearer ") {
|
|
return strings.TrimSpace(h[7:])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *Server) requireBearerUser(w http.ResponseWriter, r *http.Request) (User, bool) {
|
|
user, err := s.userFromToken(bearerToken(r), "")
|
|
if err != nil {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
|
return User{}, false
|
|
}
|
|
return user, true
|
|
}
|
|
|
|
func (s *Server) optionalBearerUser(r *http.Request) (User, bool) {
|
|
user, err := s.userFromToken(bearerToken(r), "")
|
|
return user, err == nil
|
|
}
|
|
|
|
func (s *Server) userFromToken(token, requiredUsername string) (User, error) {
|
|
if token == "" {
|
|
return User{}, errors.New("missing token")
|
|
}
|
|
var user User
|
|
query := `SELECT u.id, u.email, u.username
|
|
FROM auth_tokens t JOIN users u ON u.id = t.user_id
|
|
WHERE t.token_hash = ? AND t.revoked_at IS NULL AND t.expires_at > UTC_TIMESTAMP()`
|
|
args := []any{hashToken(token)}
|
|
if requiredUsername != "" {
|
|
query += ` AND u.username = ?`
|
|
args = append(args, requiredUsername)
|
|
}
|
|
err := s.db.QueryRow(query, args...).Scan(&user.ID, &user.Email, &user.Username)
|
|
if err != nil {
|
|
return User{}, err
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
func (s *Server) userFromBasic(r *http.Request) (User, bool) {
|
|
username, token, ok := r.BasicAuth()
|
|
if !ok {
|
|
return User{}, false
|
|
}
|
|
user, err := s.userFromToken(token, strings.ToLower(username))
|
|
return user, err == nil
|
|
}
|
|
|
|
// ---------------- Repository API ----------------
|
|
|
|
func (s *Server) handleRepoCreate(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := s.requireBearerUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var in struct {
|
|
Name string `json:"name"`
|
|
Visibility string `json:"visibility"`
|
|
}
|
|
if !decodeJSON(w, r, &in) {
|
|
return
|
|
}
|
|
name := strings.ToLower(strings.TrimSpace(in.Name))
|
|
visibility := strings.ToLower(strings.TrimSpace(in.Visibility))
|
|
if !repoNameRE.MatchString(name) || isReservedName(name) {
|
|
writeError(w, http.StatusBadRequest, "invalid repository name")
|
|
return
|
|
}
|
|
if visibility != "public" && visibility != "private" {
|
|
writeError(w, http.StatusBadRequest, "visibility must be public or private")
|
|
return
|
|
}
|
|
res, err := s.db.Exec(`INSERT INTO repositories (owner_user_id, name, visibility) VALUES (?, ?, ?)`, user.ID, name, visibility)
|
|
if err != nil {
|
|
writeError(w, http.StatusConflict, "repository already exists")
|
|
return
|
|
}
|
|
repoID, _ := res.LastInsertId()
|
|
path := s.repoPath(user.Username, name)
|
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
_, _ = s.db.Exec(`DELETE FROM repositories WHERE id = ?`, repoID)
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if err := gitInitBare(path); err != nil {
|
|
_, _ = s.db.Exec(`DELETE FROM repositories WHERE id = ?`, repoID)
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, Repository{ID: repoID, OwnerUserID: user.ID, Owner: user.Username, Name: name, Visibility: visibility})
|
|
}
|
|
|
|
func (s *Server) handleRepoSearch(w http.ResponseWriter, r *http.Request) {
|
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
|
scope := r.URL.Query().Get("scope")
|
|
if scope == "" {
|
|
scope = "all"
|
|
}
|
|
user, authed := s.optionalBearerUser(r)
|
|
var rows *sql.Rows
|
|
var err error
|
|
like := "%" + q + "%"
|
|
if scope == "mine" {
|
|
if !authed {
|
|
writeError(w, http.StatusUnauthorized, "scope=mine requires auth")
|
|
return
|
|
}
|
|
rows, err = s.db.Query(`SELECT r.id, r.owner_user_id, u.username, r.name, r.visibility, r.forked_from_repository_id, r.created_at, r.updated_at
|
|
FROM repositories r JOIN users u ON u.id = r.owner_user_id
|
|
WHERE r.owner_user_id = ? AND (? = '' OR r.name LIKE ?)
|
|
ORDER BY r.updated_at DESC LIMIT 100`, user.ID, q, like)
|
|
} else if scope == "all" {
|
|
if authed {
|
|
rows, err = s.db.Query(`SELECT r.id, r.owner_user_id, u.username, r.name, r.visibility, r.forked_from_repository_id, r.created_at, r.updated_at
|
|
FROM repositories r JOIN users u ON u.id = r.owner_user_id
|
|
WHERE (r.visibility = 'public' OR r.owner_user_id = ?) AND (? = '' OR r.name LIKE ? OR u.username LIKE ?)
|
|
ORDER BY r.updated_at DESC LIMIT 100`, user.ID, q, like, like)
|
|
} else {
|
|
rows, err = s.db.Query(`SELECT r.id, r.owner_user_id, u.username, r.name, r.visibility, r.forked_from_repository_id, r.created_at, r.updated_at
|
|
FROM repositories r JOIN users u ON u.id = r.owner_user_id
|
|
WHERE r.visibility = 'public' AND (? = '' OR r.name LIKE ? OR u.username LIKE ?)
|
|
ORDER BY r.updated_at DESC LIMIT 100`, q, like, like)
|
|
}
|
|
} else {
|
|
writeError(w, http.StatusBadRequest, "scope must be all or mine")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
repos, err := scanRepos(rows)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, repos)
|
|
}
|
|
|
|
func (s *Server) handleRepoGet(w http.ResponseWriter, r *http.Request, owner, name string) {
|
|
repo, err := s.loadRepo(owner, name)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "repository not found")
|
|
return
|
|
}
|
|
user, authed := s.optionalBearerUser(r)
|
|
if repo.Visibility == "private" && (!authed || user.ID != repo.OwnerUserID) {
|
|
writeError(w, http.StatusNotFound, "repository not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, repo)
|
|
}
|
|
|
|
func (s *Server) handleRepoDelete(w http.ResponseWriter, r *http.Request, owner, name string) {
|
|
user, ok := s.requireBearerUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
repo, err := s.loadRepo(owner, name)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "repository not found")
|
|
return
|
|
}
|
|
if repo.OwnerUserID != user.ID {
|
|
writeError(w, http.StatusForbidden, "only the owner can delete this repository")
|
|
return
|
|
}
|
|
force := r.URL.Query().Get("force") == "true" || r.URL.Query().Get("force") == "1"
|
|
var openCount int
|
|
_ = s.db.QueryRow(`SELECT COUNT(*) FROM pull_requests WHERE status = 'open' AND (target_repository_id = ? OR source_repository_id = ?)`, repo.ID, repo.ID).Scan(&openCount)
|
|
if openCount > 0 && !force {
|
|
writeError(w, http.StatusConflict, "repository has open pull requests; use force=true")
|
|
return
|
|
}
|
|
if force {
|
|
_, _ = s.db.Exec(`DELETE FROM pull_requests WHERE target_repository_id = ? OR source_repository_id = ?`, repo.ID, repo.ID)
|
|
}
|
|
_, err = s.db.Exec(`DELETE FROM repositories WHERE id = ?`, repo.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
_ = os.RemoveAll(s.repoPath(owner, name))
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
|
}
|
|
|
|
func (s *Server) handleRepoFork(w http.ResponseWriter, r *http.Request, owner, name string) {
|
|
user, ok := s.requireBearerUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
src, err := s.loadRepo(owner, name)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "repository not found")
|
|
return
|
|
}
|
|
if src.Visibility == "private" && src.OwnerUserID != user.ID {
|
|
writeError(w, http.StatusForbidden, "cannot fork this private repository")
|
|
return
|
|
}
|
|
var in struct {
|
|
Name string `json:"name"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&in)
|
|
newName := strings.ToLower(strings.TrimSpace(in.Name))
|
|
if newName == "" {
|
|
newName = src.Name
|
|
}
|
|
if !repoNameRE.MatchString(newName) || isReservedName(newName) {
|
|
writeError(w, http.StatusBadRequest, "invalid repository name")
|
|
return
|
|
}
|
|
res, err := s.db.Exec(`INSERT INTO repositories (owner_user_id, name, visibility, forked_from_repository_id) VALUES (?, ?, ?, ?)`, user.ID, newName, src.Visibility, src.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusConflict, "repository already exists")
|
|
return
|
|
}
|
|
newID, _ := res.LastInsertId()
|
|
dstPath := s.repoPath(user.Username, newName)
|
|
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
|
|
_, _ = s.db.Exec(`DELETE FROM repositories WHERE id = ?`, newID)
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
cmd := exec.Command("git", "clone", "--bare", s.repoPath(src.Owner, src.Name), dstPath)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
_, _ = s.db.Exec(`DELETE FROM repositories WHERE id = ?`, newID)
|
|
_ = os.RemoveAll(dstPath)
|
|
writeError(w, http.StatusInternalServerError, string(out))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, Repository{ID: newID, OwnerUserID: user.ID, Owner: user.Username, Name: newName, Visibility: src.Visibility, ForkedFromID: &src.ID})
|
|
}
|
|
|
|
func (s *Server) loadRepo(owner, name string) (Repository, error) {
|
|
var repo Repository
|
|
var fork sql.NullInt64
|
|
err := s.db.QueryRow(`SELECT r.id, r.owner_user_id, u.username, r.name, r.visibility, r.forked_from_repository_id, r.created_at, r.updated_at
|
|
FROM repositories r JOIN users u ON u.id = r.owner_user_id
|
|
WHERE u.username = ? AND r.name = ?`, strings.ToLower(owner), strings.ToLower(name)).Scan(&repo.ID, &repo.OwnerUserID, &repo.Owner, &repo.Name, &repo.Visibility, &fork, &repo.CreatedAt, &repo.UpdatedAt)
|
|
if fork.Valid {
|
|
repo.ForkedFromID = &fork.Int64
|
|
}
|
|
return repo, err
|
|
}
|
|
|
|
func scanRepos(rows *sql.Rows) ([]Repository, error) {
|
|
var repos []Repository
|
|
for rows.Next() {
|
|
var repo Repository
|
|
var fork sql.NullInt64
|
|
if err := rows.Scan(&repo.ID, &repo.OwnerUserID, &repo.Owner, &repo.Name, &repo.Visibility, &fork, &repo.CreatedAt, &repo.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if fork.Valid {
|
|
repo.ForkedFromID = &fork.Int64
|
|
}
|
|
repos = append(repos, repo)
|
|
}
|
|
return repos, rows.Err()
|
|
}
|
|
|
|
// ---------------- Pull request API ----------------
|
|
|
|
func (s *Server) handlePRCreate(w http.ResponseWriter, r *http.Request, targetOwner, targetName string) {
|
|
user, ok := s.requireBearerUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
target, err := s.loadRepo(targetOwner, targetName)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "target repository not found")
|
|
return
|
|
}
|
|
var in struct {
|
|
SourceOwner string `json:"source_owner"`
|
|
SourceRepo string `json:"source_repo"`
|
|
SourceBranch string `json:"source_branch"`
|
|
TargetBranch string `json:"target_branch"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
}
|
|
if !decodeJSON(w, r, &in) {
|
|
return
|
|
}
|
|
in.SourceOwner = strings.ToLower(strings.TrimSpace(in.SourceOwner))
|
|
in.SourceRepo = strings.ToLower(strings.TrimSpace(in.SourceRepo))
|
|
in.SourceBranch = strings.TrimSpace(in.SourceBranch)
|
|
in.TargetBranch = strings.TrimSpace(in.TargetBranch)
|
|
in.Title = strings.TrimSpace(in.Title)
|
|
if in.SourceOwner == "" {
|
|
in.SourceOwner = target.Owner
|
|
}
|
|
if in.SourceRepo == "" {
|
|
in.SourceRepo = target.Name
|
|
}
|
|
if in.Title == "" || !branchRE.MatchString(in.SourceBranch) || !branchRE.MatchString(in.TargetBranch) {
|
|
writeError(w, http.StatusBadRequest, "title and valid source/target branches are required")
|
|
return
|
|
}
|
|
source, err := s.loadRepo(in.SourceOwner, in.SourceRepo)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "source repository not found")
|
|
return
|
|
}
|
|
if source.ID == target.ID {
|
|
if target.OwnerUserID != user.ID {
|
|
writeError(w, http.StatusForbidden, "same-repository PRs require repository ownership")
|
|
return
|
|
}
|
|
} else {
|
|
if source.OwnerUserID != user.ID {
|
|
writeError(w, http.StatusForbidden, "source repository must be owned by you")
|
|
return
|
|
}
|
|
if target.Visibility != "public" && target.OwnerUserID != user.ID {
|
|
writeError(w, http.StatusForbidden, "target repository is private")
|
|
return
|
|
}
|
|
}
|
|
if !gitBranchExists(s.repoPath(source.Owner, source.Name), in.SourceBranch) {
|
|
writeError(w, http.StatusBadRequest, "source branch does not exist")
|
|
return
|
|
}
|
|
if !gitBranchExists(s.repoPath(target.Owner, target.Name), in.TargetBranch) {
|
|
writeError(w, http.StatusBadRequest, "target branch does not exist")
|
|
return
|
|
}
|
|
var number int
|
|
_ = s.db.QueryRow(`SELECT COALESCE(MAX(number), 0) + 1 FROM pull_requests WHERE target_repository_id = ?`, target.ID).Scan(&number)
|
|
res, err := s.db.Exec(`INSERT INTO pull_requests (target_repository_id, number, author_user_id, source_repository_id, source_branch, target_branch, title, description)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, target.ID, number, user.ID, source.ID, in.SourceBranch, in.TargetBranch, in.Title, in.Description)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
pr, _ := s.loadPR(target.ID, number)
|
|
pr.ID = id
|
|
writeJSON(w, http.StatusCreated, pr)
|
|
}
|
|
|
|
func (s *Server) handlePRList(w http.ResponseWriter, r *http.Request, owner, name string) {
|
|
repo, err := s.loadRepo(owner, name)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "repository not found")
|
|
return
|
|
}
|
|
user, authed := s.optionalBearerUser(r)
|
|
if repo.Visibility == "private" && (!authed || user.ID != repo.OwnerUserID) {
|
|
writeError(w, http.StatusNotFound, "repository not found")
|
|
return
|
|
}
|
|
rows, err := s.db.Query(prSelectSQL()+` WHERE pr.target_repository_id = ? ORDER BY pr.number DESC`, repo.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
prs, err := scanPRs(rows)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, prs)
|
|
}
|
|
|
|
func (s *Server) handlePRView(w http.ResponseWriter, r *http.Request, owner, name string, number int) {
|
|
repo, err := s.loadRepo(owner, name)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "repository not found")
|
|
return
|
|
}
|
|
user, authed := s.optionalBearerUser(r)
|
|
if repo.Visibility == "private" && (!authed || user.ID != repo.OwnerUserID) {
|
|
writeError(w, http.StatusNotFound, "repository not found")
|
|
return
|
|
}
|
|
pr, err := s.loadPR(repo.ID, number)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "pull request not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, pr)
|
|
}
|
|
|
|
func (s *Server) handlePRClose(w http.ResponseWriter, r *http.Request, owner, name string, number int) {
|
|
user, ok := s.requireBearerUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
repo, err := s.loadRepo(owner, name)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "repository not found")
|
|
return
|
|
}
|
|
if repo.OwnerUserID != user.ID {
|
|
writeError(w, http.StatusForbidden, "only target owner can close pull requests")
|
|
return
|
|
}
|
|
res, err := s.db.Exec(`UPDATE pull_requests SET status = 'closed', closed_at = UTC_TIMESTAMP() WHERE target_repository_id = ? AND number = ? AND status = 'open'`, repo.ID, number)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
affected, _ := res.RowsAffected()
|
|
if affected == 0 {
|
|
writeError(w, http.StatusConflict, "pull request is not open")
|
|
return
|
|
}
|
|
pr, _ := s.loadPR(repo.ID, number)
|
|
writeJSON(w, http.StatusOK, pr)
|
|
}
|
|
|
|
func (s *Server) handlePRMerge(w http.ResponseWriter, r *http.Request, owner, name string, number int) {
|
|
user, ok := s.requireBearerUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
target, err := s.loadRepo(owner, name)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "repository not found")
|
|
return
|
|
}
|
|
if target.OwnerUserID != user.ID {
|
|
writeError(w, http.StatusForbidden, "only target owner can merge pull requests")
|
|
return
|
|
}
|
|
pr, err := s.loadPR(target.ID, number)
|
|
if err != nil || pr.Status != "open" {
|
|
writeError(w, http.StatusConflict, "pull request is not open")
|
|
return
|
|
}
|
|
if err := s.mergePR(pr); err != nil {
|
|
writeError(w, http.StatusConflict, err.Error())
|
|
return
|
|
}
|
|
_, err = s.db.Exec(`UPDATE pull_requests SET status = 'merged', merged_at = UTC_TIMESTAMP() WHERE target_repository_id = ? AND number = ?`, target.ID, number)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
pr, _ = s.loadPR(target.ID, number)
|
|
writeJSON(w, http.StatusOK, pr)
|
|
}
|
|
|
|
func prSelectSQL() string {
|
|
return `SELECT pr.id, pr.number, pr.target_repository_id, pr.source_repository_id, pr.author_user_id,
|
|
au.username, su.username, sr.name, pr.source_branch, tu.username, tr.name, pr.target_branch,
|
|
pr.title, pr.description, pr.status, pr.created_at, pr.updated_at, pr.closed_at, pr.merged_at
|
|
FROM pull_requests pr
|
|
JOIN users au ON au.id = pr.author_user_id
|
|
JOIN repositories sr ON sr.id = pr.source_repository_id
|
|
JOIN users su ON su.id = sr.owner_user_id
|
|
JOIN repositories tr ON tr.id = pr.target_repository_id
|
|
JOIN users tu ON tu.id = tr.owner_user_id`
|
|
}
|
|
|
|
func (s *Server) loadPR(targetRepoID int64, number int) (PullRequest, error) {
|
|
row := s.db.QueryRow(prSelectSQL()+` WHERE pr.target_repository_id = ? AND pr.number = ?`, targetRepoID, number)
|
|
prs, err := scanOnePR(row)
|
|
return prs, err
|
|
}
|
|
|
|
type scanner interface{ Scan(dest ...any) error }
|
|
|
|
func scanOnePR(row scanner) (PullRequest, error) {
|
|
var pr PullRequest
|
|
var closedAt, mergedAt sql.NullTime
|
|
err := row.Scan(&pr.ID, &pr.Number, &pr.TargetRepositoryID, &pr.SourceRepositoryID, &pr.AuthorUserID,
|
|
&pr.Author, &pr.SourceOwner, &pr.SourceRepo, &pr.SourceBranch, &pr.TargetOwner, &pr.TargetRepo, &pr.TargetBranch,
|
|
&pr.Title, &pr.Description, &pr.Status, &pr.CreatedAt, &pr.UpdatedAt, &closedAt, &mergedAt)
|
|
if closedAt.Valid {
|
|
pr.ClosedAt = &closedAt.Time
|
|
}
|
|
if mergedAt.Valid {
|
|
pr.MergedAt = &mergedAt.Time
|
|
}
|
|
return pr, err
|
|
}
|
|
|
|
func scanPRs(rows *sql.Rows) ([]PullRequest, error) {
|
|
var prs []PullRequest
|
|
for rows.Next() {
|
|
pr, err := scanOnePR(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
prs = append(prs, pr)
|
|
}
|
|
return prs, rows.Err()
|
|
}
|
|
|
|
func (s *Server) mergePR(pr PullRequest) error {
|
|
targetPath := s.repoPath(pr.TargetOwner, pr.TargetRepo)
|
|
sourcePath := s.repoPath(pr.SourceOwner, pr.SourceRepo)
|
|
tmp, err := os.MkdirTemp("", "gitocean-merge-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.RemoveAll(tmp)
|
|
work := filepath.Join(tmp, "work")
|
|
if err := runGit("", "clone", targetPath, work); err != nil {
|
|
return err
|
|
}
|
|
if err := runGit(work, "config", "user.name", "gitocean"); err != nil {
|
|
return err
|
|
}
|
|
if err := runGit(work, "config", "user.email", "gitocean@localhost"); err != nil {
|
|
return err
|
|
}
|
|
if err := runGit(work, "checkout", pr.TargetBranch); err != nil {
|
|
return err
|
|
}
|
|
if err := runGit(work, "remote", "add", "source", sourcePath); err != nil {
|
|
return err
|
|
}
|
|
if err := runGit(work, "fetch", "source", pr.SourceBranch); err != nil {
|
|
return err
|
|
}
|
|
msg := fmt.Sprintf("Merge pull request #%d from %s/%s:%s", pr.Number, pr.SourceOwner, pr.SourceRepo, pr.SourceBranch)
|
|
if err := runGit(work, "merge", "--no-ff", "FETCH_HEAD", "-m", msg); err != nil {
|
|
return fmt.Errorf("merge failed, likely due to conflicts: %w", err)
|
|
}
|
|
if err := runGit(work, "push", "origin", "HEAD:"+pr.TargetBranch); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---------------- Git HTTP ----------------
|
|
|
|
func (s *Server) handleGitHTTP(w http.ResponseWriter, r *http.Request) {
|
|
owner, repoName, _, ok := parseGitPath(r.URL.Path)
|
|
if !ok {
|
|
writeError(w, http.StatusNotFound, "invalid git path")
|
|
return
|
|
}
|
|
repo, err := s.loadRepo(owner, repoName)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "repository not found")
|
|
return
|
|
}
|
|
service := gitService(r)
|
|
user, authed := s.userFromBasic(r)
|
|
if service == "git-receive-pack" {
|
|
if !authed {
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="gitocean"`)
|
|
writeError(w, http.StatusUnauthorized, "authentication required")
|
|
return
|
|
}
|
|
if user.ID != repo.OwnerUserID {
|
|
writeError(w, http.StatusForbidden, "only owner can push")
|
|
return
|
|
}
|
|
} else {
|
|
if repo.Visibility == "private" {
|
|
if !authed {
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="gitocean"`)
|
|
writeError(w, http.StatusUnauthorized, "authentication required")
|
|
return
|
|
}
|
|
if user.ID != repo.OwnerUserID {
|
|
writeError(w, http.StatusForbidden, "not allowed")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
s.runGitHTTPBackend(w, r, authed, user.Username)
|
|
}
|
|
|
|
func parseGitPath(path string) (owner, repo, rest string, ok bool) {
|
|
path = strings.TrimPrefix(path, "/")
|
|
idx := strings.Index(path, ".git")
|
|
if idx < 0 {
|
|
return "", "", "", false
|
|
}
|
|
repoPart := path[:idx]
|
|
rest = path[idx+len(".git"):]
|
|
parts := strings.Split(repoPart, "/")
|
|
if len(parts) != 2 {
|
|
return "", "", "", false
|
|
}
|
|
return parts[0], parts[1], rest, true
|
|
}
|
|
|
|
func gitService(r *http.Request) string {
|
|
if svc := r.URL.Query().Get("service"); svc != "" {
|
|
return svc
|
|
}
|
|
if strings.HasSuffix(r.URL.Path, "/git-receive-pack") {
|
|
return "git-receive-pack"
|
|
}
|
|
return "git-upload-pack"
|
|
}
|
|
|
|
func (s *Server) runGitHTTPBackend(w http.ResponseWriter, r *http.Request, authed bool, username string) {
|
|
root, _ := filepath.Abs(filepath.Join(s.storage, "repos"))
|
|
env := append(os.Environ(),
|
|
"GIT_PROJECT_ROOT="+root,
|
|
"GIT_HTTP_EXPORT_ALL=1",
|
|
"PATH_INFO="+r.URL.Path,
|
|
"REQUEST_METHOD="+r.Method,
|
|
"QUERY_STRING="+r.URL.RawQuery,
|
|
"REMOTE_ADDR="+r.RemoteAddr,
|
|
)
|
|
if ct := r.Header.Get("Content-Type"); ct != "" {
|
|
env = append(env, "CONTENT_TYPE="+ct)
|
|
}
|
|
if r.ContentLength >= 0 {
|
|
env = append(env, fmt.Sprintf("CONTENT_LENGTH=%d", r.ContentLength))
|
|
}
|
|
if authed {
|
|
env = append(env, "REMOTE_USER="+username, "AUTH_TYPE=Basic")
|
|
}
|
|
cmd := exec.Command("git", "http-backend")
|
|
cmd.Env = env
|
|
cmd.Stdin = r.Body
|
|
var out, errBuf bytes.Buffer
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = &errBuf
|
|
if err := cmd.Run(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, strings.TrimSpace(errBuf.String()))
|
|
return
|
|
}
|
|
writeCGIResponse(w, out.Bytes())
|
|
}
|
|
|
|
func writeCGIResponse(w http.ResponseWriter, data []byte) {
|
|
sep := []byte("\r\n\r\n")
|
|
idx := bytes.Index(data, sep)
|
|
if idx < 0 {
|
|
sep = []byte("\n\n")
|
|
idx = bytes.Index(data, sep)
|
|
}
|
|
if idx < 0 {
|
|
_, _ = w.Write(data)
|
|
return
|
|
}
|
|
headers := string(data[:idx])
|
|
body := data[idx+len(sep):]
|
|
status := http.StatusOK
|
|
for _, line := range strings.Split(headers, "\n") {
|
|
line = strings.TrimRight(line, "\r")
|
|
if line == "" {
|
|
continue
|
|
}
|
|
k, v, ok := strings.Cut(line, ":")
|
|
if !ok {
|
|
continue
|
|
}
|
|
k = strings.TrimSpace(k)
|
|
v = strings.TrimSpace(v)
|
|
if strings.EqualFold(k, "Status") {
|
|
fields := strings.Fields(v)
|
|
if len(fields) > 0 {
|
|
if n, err := strconv.Atoi(fields[0]); err == nil {
|
|
status = n
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
w.Header().Add(k, v)
|
|
}
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write(body)
|
|
}
|
|
|
|
// ---------------- CLI ----------------
|
|
|
|
func cliRegister(args []string) error {
|
|
fs := flag.NewFlagSet("register", flag.ExitOnError)
|
|
server := fs.String("server", envServer(), "server URL")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
email := prompt("Email: ")
|
|
username := prompt("Username: ")
|
|
password := prompt("Password: ")
|
|
var out User
|
|
if err := apiRequest(context.Background(), *server, http.MethodPost, "/api/register", "", map[string]string{"email": email, "username": username, "password": password}, &out); err != nil {
|
|
return err
|
|
}
|
|
printJSON(out)
|
|
return nil
|
|
}
|
|
|
|
func cliLogin(args []string) error {
|
|
fs := flag.NewFlagSet("login", flag.ExitOnError)
|
|
server := fs.String("server", envServer(), "server URL")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
login := prompt("Email or username: ")
|
|
password := prompt("Password: ")
|
|
var out struct {
|
|
User User `json:"user"`
|
|
Token string `json:"token"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
}
|
|
if err := apiRequest(context.Background(), *server, http.MethodPost, "/api/login", "", map[string]string{"login": login, "password": password}, &out); err != nil {
|
|
return err
|
|
}
|
|
cfg := Config{Server: strings.TrimRight(*server, "/"), Username: out.User.Username, Token: out.Token, ExpiresAt: out.ExpiresAt}
|
|
if err := saveConfig(cfg); err != nil {
|
|
return err
|
|
}
|
|
approveGitCredential(cfg)
|
|
fmt.Printf("logged in as %s; token expires %s\n", cfg.Username, cfg.ExpiresAt.Format(time.RFC3339))
|
|
return nil
|
|
}
|
|
|
|
func cliLogout(args []string) error {
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_ = apiRequest(context.Background(), cfg.Server, http.MethodPost, "/api/logout", cfg.Token, nil, nil)
|
|
_ = os.Remove(configPath())
|
|
fmt.Println("logged out")
|
|
return nil
|
|
}
|
|
|
|
func cliWhoami(args []string) error {
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var user User
|
|
if err := apiRequest(context.Background(), cfg.Server, http.MethodGet, "/api/me", cfg.Token, nil, &user); err != nil {
|
|
return err
|
|
}
|
|
printJSON(user)
|
|
return nil
|
|
}
|
|
|
|
func cliClone(args []string) error {
|
|
if len(args) != 1 {
|
|
return errors.New("usage: gitocean clone OWNER/REPO")
|
|
}
|
|
server := envServer()
|
|
if cfg, err := loadConfig(); err == nil && cfg.Server != "" {
|
|
server = cfg.Server
|
|
}
|
|
owner, repo, err := splitOwnerRepo(args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
remote := fmt.Sprintf("%s/%s/%s.git", strings.TrimRight(server, "/"), owner, repo)
|
|
cmd := exec.Command("git", "clone", remote)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
cmd.Stdin = os.Stdin
|
|
return cmd.Run()
|
|
}
|
|
|
|
func cliRepo(args []string) error {
|
|
if len(args) < 1 {
|
|
return errors.New("usage: gitocean repo <create|delete|search|fork>")
|
|
}
|
|
switch args[0] {
|
|
case "create":
|
|
return cliRepoCreate(args[1:])
|
|
case "delete":
|
|
return cliRepoDelete(args[1:])
|
|
case "search":
|
|
return cliRepoSearch(args[1:])
|
|
case "fork":
|
|
return cliRepoFork(args[1:])
|
|
default:
|
|
return fmt.Errorf("unknown repo command %q", args[0])
|
|
}
|
|
}
|
|
|
|
func cliRepoCreate(args []string) error {
|
|
var name string
|
|
pub, priv := false, false
|
|
for _, arg := range args {
|
|
switch arg {
|
|
case "--public", "-public":
|
|
pub = true
|
|
case "--private", "-private":
|
|
priv = true
|
|
default:
|
|
if strings.HasPrefix(arg, "-") {
|
|
return fmt.Errorf("unknown flag %s", arg)
|
|
}
|
|
if name != "" {
|
|
return errors.New("usage: gitocean repo create NAME (--public|--private)")
|
|
}
|
|
name = arg
|
|
}
|
|
}
|
|
if name == "" || pub == priv {
|
|
return errors.New("usage: gitocean repo create NAME (--public|--private)")
|
|
}
|
|
visibility := "private"
|
|
if pub {
|
|
visibility = "public"
|
|
}
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var out Repository
|
|
if err := apiRequest(context.Background(), cfg.Server, http.MethodPost, "/api/repos", cfg.Token, map[string]string{"name": name, "visibility": visibility}, &out); err != nil {
|
|
return err
|
|
}
|
|
printJSON(out)
|
|
fmt.Printf("remote: %s/%s/%s.git\n", strings.TrimRight(cfg.Server, "/"), out.Owner, out.Name)
|
|
return nil
|
|
}
|
|
|
|
func cliRepoDelete(args []string) error {
|
|
var repoRef string
|
|
force := false
|
|
for _, arg := range args {
|
|
switch arg {
|
|
case "--force", "-force":
|
|
force = true
|
|
default:
|
|
if strings.HasPrefix(arg, "-") {
|
|
return fmt.Errorf("unknown flag %s", arg)
|
|
}
|
|
if repoRef != "" {
|
|
return errors.New("usage: gitocean repo delete OWNER/REPO [--force]")
|
|
}
|
|
repoRef = arg
|
|
}
|
|
}
|
|
if repoRef == "" {
|
|
return errors.New("usage: gitocean repo delete OWNER/REPO [--force]")
|
|
}
|
|
owner, repo, err := splitOwnerRepo(repoRef)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
path := fmt.Sprintf("/api/repos/%s/%s?force=%v", owner, repo, force)
|
|
var out any
|
|
if err := apiRequest(context.Background(), cfg.Server, http.MethodDelete, path, cfg.Token, nil, &out); err != nil {
|
|
return err
|
|
}
|
|
printJSON(out)
|
|
return nil
|
|
}
|
|
|
|
func cliRepoSearch(args []string) error {
|
|
var query string
|
|
scope := "all"
|
|
for _, arg := range args {
|
|
switch arg {
|
|
case "--mine", "-mine":
|
|
scope = "mine"
|
|
case "--all", "-all":
|
|
scope = "all"
|
|
default:
|
|
if strings.HasPrefix(arg, "-") {
|
|
return fmt.Errorf("unknown flag %s", arg)
|
|
}
|
|
if query != "" {
|
|
return errors.New("usage: gitocean repo search QUERY [--all|--mine]")
|
|
}
|
|
query = arg
|
|
}
|
|
}
|
|
if query == "" {
|
|
return errors.New("usage: gitocean repo search QUERY [--all|--mine]")
|
|
}
|
|
server := envServer()
|
|
token := ""
|
|
if cfg, err := loadConfig(); err == nil {
|
|
server, token = cfg.Server, cfg.Token
|
|
}
|
|
path := "/api/repos/search?q=" + url.QueryEscape(query) + "&scope=" + scope
|
|
var out []Repository
|
|
if err := apiRequest(context.Background(), server, http.MethodGet, path, token, nil, &out); err != nil {
|
|
return err
|
|
}
|
|
printJSON(out)
|
|
return nil
|
|
}
|
|
|
|
func cliRepoFork(args []string) error {
|
|
var repoRef string
|
|
newName := ""
|
|
for i := 0; i < len(args); i++ {
|
|
arg := args[i]
|
|
switch {
|
|
case arg == "--name" || arg == "-name":
|
|
if i+1 >= len(args) {
|
|
return errors.New("--name requires a value")
|
|
}
|
|
i++
|
|
newName = args[i]
|
|
case strings.HasPrefix(arg, "--name="):
|
|
newName = strings.TrimPrefix(arg, "--name=")
|
|
case strings.HasPrefix(arg, "-name="):
|
|
newName = strings.TrimPrefix(arg, "-name=")
|
|
default:
|
|
if strings.HasPrefix(arg, "-") {
|
|
return fmt.Errorf("unknown flag %s", arg)
|
|
}
|
|
if repoRef != "" {
|
|
return errors.New("usage: gitocean repo fork OWNER/REPO [--name NEW_NAME]")
|
|
}
|
|
repoRef = arg
|
|
}
|
|
}
|
|
if repoRef == "" {
|
|
return errors.New("usage: gitocean repo fork OWNER/REPO [--name NEW_NAME]")
|
|
}
|
|
owner, repo, err := splitOwnerRepo(repoRef)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var out Repository
|
|
if err := apiRequest(context.Background(), cfg.Server, http.MethodPost, fmt.Sprintf("/api/repos/%s/%s/fork", owner, repo), cfg.Token, map[string]string{"name": newName}, &out); err != nil {
|
|
return err
|
|
}
|
|
printJSON(out)
|
|
return nil
|
|
}
|
|
|
|
func cliPR(args []string) error {
|
|
if len(args) < 1 {
|
|
return errors.New("usage: gitocean pr <create|list|view|close|merge>")
|
|
}
|
|
switch args[0] {
|
|
case "create":
|
|
return cliPRCreate(args[1:])
|
|
case "list":
|
|
return cliPRList(args[1:])
|
|
case "view":
|
|
return cliPRView(args[1:])
|
|
case "close":
|
|
return cliPRAction(args[1:], "close")
|
|
case "merge":
|
|
return cliPRAction(args[1:], "merge")
|
|
default:
|
|
return fmt.Errorf("unknown pr command %q", args[0])
|
|
}
|
|
}
|
|
|
|
func cliPRCreate(args []string) error {
|
|
fs := flag.NewFlagSet("pr create", flag.ExitOnError)
|
|
repoFlag := fs.String("repo", "", "target repo for same-repository PR")
|
|
from := fs.String("from", "", "source branch or OWNER/REPO:BRANCH")
|
|
to := fs.String("to", "", "target branch or OWNER/REPO:BRANCH")
|
|
title := fs.String("title", "", "title")
|
|
desc := fs.String("description", "", "description")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
if *from == "" || *to == "" || *title == "" {
|
|
return errors.New("usage: gitocean pr create --from SRC --to DST --title TITLE [--description DESC]")
|
|
}
|
|
var targetOwner, targetRepo, targetBranch, sourceOwner, sourceRepo, sourceBranch string
|
|
if *repoFlag != "" {
|
|
var err error
|
|
targetOwner, targetRepo, err = splitOwnerRepo(*repoFlag)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sourceOwner, sourceRepo = targetOwner, targetRepo
|
|
sourceBranch, targetBranch = *from, *to
|
|
} else {
|
|
var err error
|
|
sourceOwner, sourceRepo, sourceBranch, err = splitRepoBranch(*from)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
targetOwner, targetRepo, targetBranch, err = splitRepoBranch(*to)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body := map[string]string{"source_owner": sourceOwner, "source_repo": sourceRepo, "source_branch": sourceBranch, "target_branch": targetBranch, "title": *title, "description": *desc}
|
|
var out PullRequest
|
|
if err := apiRequest(context.Background(), cfg.Server, http.MethodPost, fmt.Sprintf("/api/repos/%s/%s/pulls", targetOwner, targetRepo), cfg.Token, body, &out); err != nil {
|
|
return err
|
|
}
|
|
printJSON(out)
|
|
return nil
|
|
}
|
|
|
|
func cliPRList(args []string) error {
|
|
if len(args) != 1 {
|
|
return errors.New("usage: gitocean pr list OWNER/REPO")
|
|
}
|
|
owner, repo, err := splitOwnerRepo(args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
server, token := envServer(), ""
|
|
if cfg, err := loadConfig(); err == nil {
|
|
server, token = cfg.Server, cfg.Token
|
|
}
|
|
var out []PullRequest
|
|
if err := apiRequest(context.Background(), server, http.MethodGet, fmt.Sprintf("/api/repos/%s/%s/pulls", owner, repo), token, nil, &out); err != nil {
|
|
return err
|
|
}
|
|
printJSON(out)
|
|
return nil
|
|
}
|
|
|
|
func cliPRView(args []string) error {
|
|
if len(args) != 2 {
|
|
return errors.New("usage: gitocean pr view OWNER/REPO NUMBER")
|
|
}
|
|
owner, repo, err := splitOwnerRepo(args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
server, token := envServer(), ""
|
|
if cfg, err := loadConfig(); err == nil {
|
|
server, token = cfg.Server, cfg.Token
|
|
}
|
|
var out PullRequest
|
|
if err := apiRequest(context.Background(), server, http.MethodGet, fmt.Sprintf("/api/repos/%s/%s/pulls/%s", owner, repo, args[1]), token, nil, &out); err != nil {
|
|
return err
|
|
}
|
|
printJSON(out)
|
|
return nil
|
|
}
|
|
|
|
func cliPRAction(args []string, action string) error {
|
|
if len(args) != 2 {
|
|
return fmt.Errorf("usage: gitocean pr %s OWNER/REPO NUMBER", action)
|
|
}
|
|
owner, repo, err := splitOwnerRepo(args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var out PullRequest
|
|
if err := apiRequest(context.Background(), cfg.Server, http.MethodPost, fmt.Sprintf("/api/repos/%s/%s/pulls/%s/%s", owner, repo, args[1], action), cfg.Token, nil, &out); err != nil {
|
|
return err
|
|
}
|
|
printJSON(out)
|
|
return nil
|
|
}
|
|
|
|
// ---------------- CLI helpers ----------------
|
|
|
|
func apiRequest(ctx context.Context, server, method, path, token string, body any, out any) error {
|
|
server = strings.TrimRight(server, "/")
|
|
var rdr io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rdr = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, server+path, rdr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
b, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 300 {
|
|
var e struct {
|
|
Error string `json:"error"`
|
|
}
|
|
if json.Unmarshal(b, &e) == nil && e.Error != "" {
|
|
return fmt.Errorf("%s", e.Error)
|
|
}
|
|
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
|
}
|
|
if out != nil && len(b) > 0 {
|
|
if err := json.Unmarshal(b, out); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func envServer() string {
|
|
if v := os.Getenv("GITOOCEAN_SERVER"); v != "" {
|
|
return strings.TrimRight(v, "/")
|
|
}
|
|
return defaultServerURL
|
|
}
|
|
|
|
func configPath() string {
|
|
if v := os.Getenv("GITOOCEAN_CONFIG"); v != "" {
|
|
return v
|
|
}
|
|
dir, err := os.UserConfigDir()
|
|
if err != nil {
|
|
dir = "."
|
|
}
|
|
return filepath.Join(dir, "gitocean", "config.json")
|
|
}
|
|
|
|
func loadConfig() (Config, error) {
|
|
b, err := os.ReadFile(configPath())
|
|
if err != nil {
|
|
return Config{}, errors.New("not logged in; run gitocean login")
|
|
}
|
|
var cfg Config
|
|
if err := json.Unmarshal(b, &cfg); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.Server == "" || cfg.Token == "" {
|
|
return Config{}, errors.New("invalid config; run gitocean login")
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func saveConfig(cfg Config) error {
|
|
path := configPath()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
|
return err
|
|
}
|
|
b, _ := json.MarshalIndent(cfg, "", " ")
|
|
return os.WriteFile(path, b, 0600)
|
|
}
|
|
|
|
func approveGitCredential(cfg Config) {
|
|
u, err := url.Parse(cfg.Server)
|
|
if err != nil || u.Host == "" || u.Scheme == "" {
|
|
return
|
|
}
|
|
cmd := exec.Command("git", "credential", "approve")
|
|
cmd.Stdin = strings.NewReader(fmt.Sprintf("protocol=%s\nhost=%s\nusername=%s\npassword=%s\n\n", u.Scheme, u.Host, cfg.Username, cfg.Token))
|
|
_ = cmd.Run()
|
|
}
|
|
|
|
func prompt(label string) string {
|
|
fmt.Print(label)
|
|
var s string
|
|
fmt.Scanln(&s)
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
func printJSON(v any) {
|
|
b, _ := json.MarshalIndent(v, "", " ")
|
|
fmt.Println(string(b))
|
|
}
|
|
|
|
func splitOwnerRepo(s string) (string, string, error) {
|
|
parts := strings.Split(s, "/")
|
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
|
return "", "", fmt.Errorf("expected OWNER/REPO, got %q", s)
|
|
}
|
|
return strings.ToLower(parts[0]), strings.ToLower(parts[1]), nil
|
|
}
|
|
|
|
func splitRepoBranch(s string) (owner, repo, branch string, err error) {
|
|
left, branch, ok := strings.Cut(s, ":")
|
|
if !ok || branch == "" {
|
|
return "", "", "", fmt.Errorf("expected OWNER/REPO:BRANCH, got %q", s)
|
|
}
|
|
owner, repo, err = splitOwnerRepo(left)
|
|
return owner, repo, branch, err
|
|
}
|
|
|
|
// ---------------- Shared helpers ----------------
|
|
|
|
func decodeJSON(w http.ResponseWriter, r *http.Request, out any) bool {
|
|
defer r.Body.Close()
|
|
dec := json.NewDecoder(r.Body)
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(out); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid json: "+err.Error())
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
}
|
|
|
|
func isReservedName(s string) bool {
|
|
switch s {
|
|
case "api", "admin", "storage", "git", "repos", ".", "..":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *Server) repoPath(owner, name string) string {
|
|
return filepath.Join(s.storage, "repos", strings.ToLower(owner), strings.ToLower(name)+".git")
|
|
}
|
|
|
|
func gitInitBare(path string) error {
|
|
if err := runGit("", "init", "--bare", path); err != nil {
|
|
return err
|
|
}
|
|
return runGit("", "--git-dir", path, "symbolic-ref", "HEAD", "refs/heads/main")
|
|
}
|
|
|
|
func gitBranchExists(repoPath, branch string) bool {
|
|
if !branchRE.MatchString(branch) {
|
|
return false
|
|
}
|
|
cmd := exec.Command("git", "--git-dir", repoPath, "rev-parse", "--verify", "refs/heads/"+branch)
|
|
return cmd.Run() == nil
|
|
}
|
|
|
|
func runGit(dir string, args ...string) error {
|
|
cmd := exec.Command("git", args...)
|
|
if dir != "" {
|
|
cmd.Dir = dir
|
|
}
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("git %s failed: %s", strings.Join(args, " "), strings.TrimSpace(string(out)))
|
|
}
|
|
return nil
|
|
}
|