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 ") } 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 ") } 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 }