commit b012bd3e5ea83354b21efd70013ba25048343b94 Author: Owen Qwen Date: Mon Jun 8 12:50:40 2026 -0500 inital commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9459199 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# build outputs +/gitocean +/bin/ + +# runtime storage +/storage/ + +# local env +.env diff --git a/PHASE_2_PLAN.md b/PHASE_2_PLAN.md new file mode 100644 index 0000000..5783aa2 --- /dev/null +++ b/PHASE_2_PLAN.md @@ -0,0 +1,521 @@ +# Gitocean Phase 2 Plan: Usability + +Phase 2 focuses on making Gitocean feel usable as a day-to-day personal Git hosting environment. This phase is not about GitLab enterprise parity, CI/CD, or a web UI. It is about setup, common workflows, repository inspection, pull request ergonomics, and maintenance. + +## Goals + +- Make first-time setup simple. +- Make CLI output readable by humans by default. +- Support the common workflow of publishing an existing local repository. +- Provide enough CLI inspection tools to compensate for no web UI. +- Improve pull request workflows with diff and checkout commands. +- Add simple repository metadata and settings. +- Add lightweight collaboration support. +- Add comments, token management, and basic admin/backup tooling. + +--- + +## 1. Config + `gitocean init` + +### Add server config file + +Create a persistent server config file, likely: + +```text +storage/config.json +``` + +Example: + +```json +{ + "addr": ":8080", + "public_url": "http://localhost:8080", + "storage": "storage", + "mysql_dsn": "root:pass@tcp(127.0.0.1:3306)/gitocean?parseTime=true" +} +``` + +### Add command + +```bash +gitocean init +``` + +It should: + +1. Prompt for server listen address. +2. Prompt for public URL. +3. Prompt for storage directory. +4. Prompt for MySQL DSN or individual MySQL fields. +5. Create the database if missing. +6. Run migrations. +7. Create storage directories. +8. Optionally create the first user account. +9. Write config file. +10. Print next-step instructions. + +### Update server command + +Allow: + +```bash +gitocean server +``` + +without requiring `--dsn` if config exists. + +Still allow overrides: + +```bash +gitocean server --config storage/config.json +``` + +```bash +gitocean server --dsn '...' --addr :8080 --storage storage +``` + +--- + +## 2. Friendly CLI output + +Most commands currently print JSON. Phase 2 should make human-friendly output the default. + +### Add global or per-command JSON output + +Support: + +```bash +gitocean repo search test --json +``` + +or eventually a global flag: + +```bash +gitocean --json repo search test +``` + +### Examples + +Repo creation should print: + +```text +Created public repository owen/test + +Clone: + git clone http://localhost:8080/owen/test.git + +Add existing repo: + git remote add origin http://localhost:8080/owen/test.git + git branch -M main + git push -u origin main +``` + +Search should print a table: + +```text +REPOSITORY VISIBILITY DESCRIPTION UPDATED +owen/test public Test repository 2m ago +alice/demo public Demo project 3h ago +``` + +PR list should print: + +```text +#1 open Add config support bob:feature-config -> main +#2 merged Fix clone auth alice:fix-auth -> main +``` + +--- + +## 3. `repo publish` + +Add a command for publishing an existing local Git repository. + +```bash +gitocean repo publish NAME --public +gitocean repo publish NAME --private +``` + +Optional flags: + +```bash +gitocean repo publish NAME --public --remote origin +gitocean repo publish NAME --public --branch main +gitocean repo publish NAME --public --description "My project" +``` + +### Behavior + +From inside a local Git repository: + +1. Verify current directory is a Git repository. +2. Create remote repository through API. +3. Add remote if missing. +4. Set or rename current branch to `main` by default. +5. Push current branch. +6. Set upstream. +7. Print success and clone URL. + +Equivalent workflow: + +```bash +gitocean repo create test --public +git remote add origin http://localhost:8080/owen/test.git +git branch -M main +git push -u origin main +``` + +--- + +## 4. `repo view`, branches, tags + +Since there is no web UI, add basic repository inspection commands. + +### Repo view + +```bash +gitocean repo view OWNER/REPO +``` + +Output: + +```text +owen/test +Visibility: public +Default branch: main +Description: My test project + +Clone: + git clone http://localhost:8080/owen/test.git + +Open PRs: 2 +Branches: 4 +Tags: 1 +``` + +### Branch listing + +```bash +gitocean repo branches OWNER/REPO +``` + +Output: + +```text +BRANCH COMMIT UPDATED MESSAGE +main 8f31abc 2 hours ago Initial commit +feature-x 1ab22df 1 day ago Add feature +``` + +### Tag listing + +```bash +gitocean repo tags OWNER/REPO +``` + +Output: + +```text +TAG COMMIT DATE MESSAGE +v0.1.0 8f31abc 2026-06-08 First release +``` + +### API additions + +```http +GET /api/repos/{owner}/{repo}/branches +GET /api/repos/{owner}/{repo}/tags +``` + +--- + +## 5. PR diff + PR checkout + +Pull requests are difficult to use without seeing and checking out changes. + +### PR diff + +```bash +gitocean pr diff OWNER/REPO NUMBER +``` + +Server should generate a diff between: + +```text +source_repo:source_branch +``` + +and: + +```text +target_repo:target_branch +``` + +API: + +```http +GET /api/repos/{owner}/{repo}/pulls/{number}/diff +``` + +Return `text/plain` patch/diff output. + +### PR checkout + +```bash +gitocean pr checkout OWNER/REPO NUMBER +``` + +From inside a local Git repo, it should: + +1. Fetch PR source branch from source repository URL. +2. Create or update a local branch such as `pr-1`. +3. Check out that branch. + +Equivalent: + +```bash +git fetch http://localhost:8080/bob/test.git fix:pr-1 +git checkout pr-1 +``` + +--- + +## 6. Repo description/default branch/visibility update + +Add basic repository settings. + +### Schema additions + +Add columns to `repositories`: + +- `description TEXT NOT NULL DEFAULT ''` +- `default_branch VARCHAR(200) NOT NULL DEFAULT 'main'` +- `archived BOOLEAN NOT NULL DEFAULT false` + +### Commands + +```bash +gitocean repo set OWNER/REPO --description "My project" +gitocean repo set OWNER/REPO --visibility private +gitocean repo set OWNER/REPO --visibility public +gitocean repo set OWNER/REPO --default-branch main +gitocean repo archive OWNER/REPO +gitocean repo unarchive OWNER/REPO +``` + +### API additions + +```http +PATCH /api/repos/{owner}/{repo} +``` + +Owner-only. + +--- + +## 7. Collaborators + +Owner-only permissions are too limiting for personal collaboration. Add simple collaborators. + +### Roles + +- `read`: can clone/fetch private repositories. +- `write`: can clone/fetch/push. + +Owner remains the only user who can: + +- delete repository +- update settings +- manage collaborators +- merge/close PRs, unless explicitly changed later + +### Schema + +Add table: + +```text +repository_collaborators + id + repository_id + user_id + role: read/write + created_at +``` + +Unique key: + +```text +(repository_id, user_id) +``` + +### Commands + +```bash +gitocean repo collaborators OWNER/REPO +gitocean repo collaborator add OWNER/REPO USER --role read +gitocean repo collaborator add OWNER/REPO USER --role write +gitocean repo collaborator remove OWNER/REPO USER +``` + +### API additions + +```http +GET /api/repos/{owner}/{repo}/collaborators +POST /api/repos/{owner}/{repo}/collaborators +DELETE /api/repos/{owner}/{repo}/collaborators/{username} +``` + +--- + +## 8. PR comments + +Add simple general comments on pull requests. Line comments can wait. + +### Schema + +Add table: + +```text +pull_request_comments + id + pull_request_id + author_user_id + body + created_at + updated_at +``` + +### Commands + +```bash +gitocean pr comment OWNER/REPO NUMBER "Looks good" +gitocean pr comments OWNER/REPO NUMBER +``` + +Optional editor support later: + +```bash +gitocean pr comment OWNER/REPO NUMBER --editor +``` + +### API additions + +```http +GET /api/repos/{owner}/{repo}/pulls/{number}/comments +POST /api/repos/{owner}/{repo}/pulls/{number}/comments +``` + +--- + +## 9. Token management + +Tokens expire after 7 days, but users need visibility and revocation. + +### Commands + +```bash +gitocean token list +gitocean token revoke TOKEN_ID +gitocean token prune +``` + +### Behavior + +- `token list`: show active tokens for current user. +- `token revoke`: revoke one token. +- `token prune`: remove expired/revoked tokens from database. Owner/admin-only if global, current-user only otherwise. + +### API additions + +```http +GET /api/tokens +DELETE /api/tokens/{id} +POST /api/tokens/prune +``` + +### CLI expired-session UX + +When API returns unauthorized, show: + +```text +Your session may have expired. Run: + gitocean login +``` + +--- + +## 10. Backup/admin commands + +Personal servers need reliable maintenance tooling. + +### Admin commands + +```bash +gitocean admin users list +gitocean admin users delete USER +gitocean admin repos list +gitocean admin storage check +gitocean admin tokens prune +``` + +For MVP, admin can be: + +- the first registered user, or +- a boolean `is_admin` column on `users`. + +### Backup commands + +```bash +gitocean backup create backup.tar.gz +gitocean backup restore backup.tar.gz +``` + +Backup should include: + +- server config +- `storage/repos/` +- MySQL dump of metadata + +Possible implementation: + +```bash +mysqldump ... > metadata.sql +tar -czf backup.tar.gz storage/config.json storage/repos metadata.sql +``` + +Restore should: + +1. Stop or warn about running server. +2. Restore config. +3. Restore repositories. +4. Restore database from SQL dump. +5. Run migrations after restore. + +--- + +## Recommended implementation order + +1. Config file support and `gitocean init`. +2. Friendly CLI output with `--json` escape hatch. +3. `repo publish`. +4. Repository metadata fields and `repo view`. +5. Branch/tag listing. +6. PR diff. +7. PR checkout. +8. Collaborators. +9. PR comments. +10. Token management. +11. Backup/admin commands. + +## Success criteria + +Phase 2 is successful when a user can comfortably: + +1. Initialize the server without memorizing flags. +2. Register/login from CLI. +3. Publish an existing local Git repository with one command. +4. Clone, push, search, and inspect repositories. +5. Open, inspect, checkout, and merge PRs. +6. Share private repositories with specific users. +7. Revoke tokens and back up the server. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..95fe535 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,194 @@ +# Gitocean Implementation Plan + +## Product decisions + +- Build a Git repository hosting platform in Go. +- Store bare Git repositories under `storage/repos/{owner}/{repo}.git`. +- Use MySQL for metadata. +- Support HTTP Smart Git only. SSH is out of scope for MVP. +- Support CLI + API only. No web UI. +- Registration is open, but registration happens through the CLI/API only. +- Users register with email, username, and password. +- Users can log in with either email + password or username + password. +- Login returns a temporary auth token that expires after 7 days. +- The CLI stores this token locally for API calls and can approve it into Git's credential helper for Git HTTP operations. +- Repository names allow lowercase letters, numbers, `.`, `_`, and `-`. +- Usernames allow lowercase letters, numbers, `_`, and `-`. +- New repositories use `main` as the default branch. +- Pull request numbers are per target repository. + +## Permissions + +### Repository visibility + +- `public`: anyone can clone/fetch. Only the owner can push/delete/manage. +- `private`: only the owner can clone/fetch/push/delete/manage. + +### Writes + +- MVP is owner-only. +- There are no collaborators, organizations, teams, or fine-grained permissions yet. +- Open collaboration is supported through pull requests against public repositories. + +## Storage layout + +```text +storage/ + repos/ + alice/ + demo.git/ + bob/ + project.git/ +``` + +Each repository directory is a bare Git repository. + +## MySQL schema + +Core tables: + +- `users` + - id + - email + - username + - password_hash + - created_at +- `auth_tokens` + - id + - user_id + - token_hash + - expires_at + - revoked_at + - created_at +- `repositories` + - id + - owner_user_id + - name + - visibility: `public` or `private` + - forked_from_repository_id nullable + - created_at + - updated_at +- `pull_requests` + - id + - target_repository_id + - number + - author_user_id + - source_repository_id + - source_branch + - target_branch + - title + - description + - status: `open`, `closed`, or `merged` + - closed_at nullable + - merged_at nullable + - created_at + - updated_at + +## API + +### Auth + +```http +POST /api/register +POST /api/login +POST /api/logout +GET /api/me +``` + +### Repositories + +```http +POST /api/repos +GET /api/repos/search?q=demo&scope=all +GET /api/repos/search?q=demo&scope=mine +GET /api/repos/{owner}/{repo} +DELETE /api/repos/{owner}/{repo}?force=false +POST /api/repos/{owner}/{repo}/fork +``` + +### Pull requests + +```http +POST /api/repos/{owner}/{repo}/pulls +GET /api/repos/{owner}/{repo}/pulls +GET /api/repos/{owner}/{repo}/pulls/{number} +POST /api/repos/{owner}/{repo}/pulls/{number}/close +POST /api/repos/{owner}/{repo}/pulls/{number}/merge +``` + +### Git HTTP + +```http +/{owner}/{repo}.git/info/refs +/{owner}/{repo}.git/git-upload-pack +/{owner}/{repo}.git/git-receive-pack +``` + +## CLI commands + +```bash +gitocean server --addr :8080 --dsn 'user:pass@tcp(127.0.0.1:3306)/gitocean?parseTime=true' --storage storage + +gitocean register +gitocean login +gitocean logout +gitocean whoami + +gitocean repo create NAME --public +gitocean repo create NAME --private +gitocean repo delete OWNER/NAME [--force] +gitocean repo search QUERY --all +gitocean repo search QUERY --mine +gitocean repo fork OWNER/NAME [--name NEW_NAME] + +gitocean clone OWNER/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 +``` + +## Pull request rules + +### Same-repository PRs + +- Source and target repository are the same. +- Only the repository owner can create same-repo PRs. +- Source and target branches must both exist. + +### Cross-repository PRs + +- Source repository branch points to target repository branch. +- Source repository must be owned by the PR author. +- Target repository must be public, unless the author is also the target owner. +- Source and target branches must both exist. +- Only the target repository owner can close or merge the PR. + +## Merge behavior + +MVP merge mode is a normal merge commit: + +1. Clone target repo into a temporary worktree. +2. Checkout target branch. +3. Add/fetch source repo branch. +4. Run `git merge --no-ff FETCH_HEAD`. +5. Push the result back to the target branch. +6. Mark PR as merged. + +If conflicts occur, return an error and keep the PR open. + +## Implementation milestones + +1. Project scaffold and build setup. +2. MySQL migrations. +3. User registration, login, logout, and token auth. +4. Repository create, delete, detail, search. +5. HTTP Smart Git integration using `git http-backend`. +6. CLI auth and repository commands. +7. Fork support. +8. Pull request create/list/view/close. +9. Pull request merge. +10. Tests, hardening, and UX polish. diff --git a/cmd/gitocean/main.go b/cmd/gitocean/main.go new file mode 100644 index 0000000..241f526 --- /dev/null +++ b/cmd/gitocean/main.go @@ -0,0 +1,1736 @@ +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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0f5aab5 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module gitocean + +go 1.23 + +require ( + github.com/go-sql-driver/mysql v1.9.0 + golang.org/x/crypto v0.31.0 +) + +require filippo.io/edwards25519 v1.1.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b5738ed --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo= +github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= diff --git a/test b/test new file mode 160000 index 0000000..b00f141 --- /dev/null +++ b/test @@ -0,0 +1 @@ +Subproject commit b00f1418894b084cb84ff9db7a6917ba934a0a32