Files
GitM/docs/superpowers/plans/2026-03-31-gitm-implementation.md
panw 34944518f0 docs: 更新实施计划文档格式和内容
- 添加Claude本地设置文件,允许Bash操作权限
- 将文档中的分隔线从"---"统一改为"***"
- 修复markdown代码块嵌套格式问题
- 调整任务列表格式,移除多余空行
- 修正HTTP链接的markdown格式
- 更新.gitignore文件格式,修复转义字符问题
2026-03-31 17:26:07 +08:00

4587 lines
101 KiB
Markdown

# GitM Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a cross-platform Gitea repository sync tool with a single-binary Go backend and embedded Vue 3 frontend
**Architecture:** Go (Gin) backend with SQLite storage, Vue 3 + Element Plus frontend embedded via Go embed, JWT authentication, Gitea API integration for repo discovery and git-based mirroring
**Tech Stack:** Go 1.26+, Gin, SQLite (mattn/go-sqlite3), Vue 3, Element Plus, Pinia, Vite, golang-jwt/jwt, bcrypt
***
## Phase 1: Project Initialization and Core Infrastructure
### Task 1: Initialize Go Module and Project Structure
**Files:**
- Create: `go.mod`
- Create: `go.sum` (auto-generated)
- Create: `Makefile`
- Create: `main.go`
- Create: `README.md`
- [ ] **Step 1: Initialize Go module**
```bash
cd /c/electron/gitm
go mod init gitm
```
- [ ] **Step 2: Create go.mod with dependencies**
Create `go.mod`:
```go
module gitm
go 1.26
require (
github.com/gin-gonic/gin v1.10.0
github.com/mattn/go-sqlite3 v1.14.22
github.com/golang-jwt/jwt/v5 v5.2.1
golang.org/x/crypto v0.25.0
github.com/robfig/cron/v3 v3.0.1
)
```
- [ ] **Step 3: Download dependencies**
```bash
go mod tidy
```
Expected: `go.sum` created, dependencies downloaded
- [ ] **Step 4: Create Makefile**
Create `Makefile`:
```makefile
.PHONY: all build frontend clean test run
# Build frontend first, then build the binary
all: frontend build
# Build Vue frontend
frontend:
cd web && npm install && npm run build
# Build Go binary
build:
go build -o bin/gitm .
# Clean build artifacts
clean:
rm -rf bin/
rm -rf web/dist/
rm -rf web/node_modules/
# Run tests
test:
go test -v ./...
# Run the application
run:
go run main.go
# Cross-compile for Linux
build-linux:
GOOS=linux GOARCH=amd64 go build -o bin/gitm-linux .
# Cross-compile for Windows
build-windows:
GOOS=windows GOARCH=amd64 go build -o bin/gitm.exe .
```
- [ ] **Step 5: Create main.go skeleton**
Create `main.go`:
```go
package main
import (
"flag"
"fmt"
"log"
)
var (
flagAddr = flag.String("addr", ":9000", "Listen address")
flagDataDir = flag.String("data", "./data", "Data directory")
flagInit = flag.Bool("init", false, "Initialize database and set password")
)
func main() {
flag.Parse()
fmt.Printf("GitM - Gitea Repository Sync Tool\n")
fmt.Printf("Listen: %s\n", *flagAddr)
fmt.Printf("Data: %s\n", *flagDataDir)
if *flagInit {
fmt.Println("Initialize mode: TODO")
return
}
log.Fatal(runServer())
}
func runServer() error {
// TODO: implement server
return fmt.Errorf("not implemented")
}
```
- [ ] **Step 6: Create README.md**
Create `README.md`:
````markdown
# GitM - Gitea Repository Sync Tool
Cross-platform tool to synchronize all repositories from multiple Gitea servers to local storage.
## Features
- Single binary deployment
- Web UI for management (Vue 3 + Element Plus)
- SQLite database
- JWT authentication
- Scheduled and manual sync
- Cross-platform (Windows, Linux)
## Build
```bash
make all
````
## Run
```bash
# First run - initialize
./bin/gitm --init
# Normal run
./bin/gitm
# Custom port
./bin/gitm --addr :9090
```
## Development
```bash
# Install frontend deps
cd web && npm install
# Run frontend dev server
cd web && npm run dev
# Run backend (requires frontend built first)
go run main.go
```
````
- [ ] **Step 7: Commit**
```bash
git add go.mod go.sum Makefile main.go README.md
git commit -m "feat: initialize project structure and dependencies"
````
***
### Task 2: Create Internal Package Structure
**Files:**
- Create: `internal/config/config.go`
- Create: `internal/models/models.go`
- [ ] **Step 1: Create config package**
Create `internal/config/config.go`:
```go
package config
import (
"os"
"path/filepath"
"sync"
)
type Config struct {
ListenAddr string
DataDir string
DBPath string
ReposDir string
once sync.Once
}
var (
instance *Config
initOnce sync.Once
)
func Get() *Config {
initOnce.Do(func() {
instance = &Config{
ListenAddr: ":9000",
DataDir: "./data",
}
})
return instance
}
func (c *Config) SetDataDir(dir string) {
c.DataDir = dir
c.DBPath = filepath.Join(dir, "gitm.db")
c.ReposDir = filepath.Join(dir, "repos")
}
func (c *Config) EnsureDirs() error {
if err := os.MkdirAll(c.DataDir, 0755); err != nil {
return err
}
return os.MkdirAll(c.ReposDir, 0755)
}
```
- [ ] **Step 2: Create models package**
Create `internal/models/models.go`:
```go
package models
import "time"
// GiteaServer represents a Gitea server configuration
type GiteaServer struct {
ID int64 `json:"id" db:"id"`
Name string `json:"name" db:"name"`
URL string `json:"url" db:"url"`
Token string `json:"-" db:"token"` // Never expose token in JSON
SyncInterval int `json:"sync_interval" db:"sync_interval"` // 0 = manual only
LastSyncAt *time.Time `json:"last_sync_at" db:"last_sync_at"`
Status string `json:"status" db:"status"` // active, disabled
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// Repo represents a discovered repository
type Repo struct {
ID int64 `json:"id" db:"id"`
ServerID int64 `json:"server_id" db:"server_id"`
Name string `json:"name" db:"name"`
FullName string `json:"full_name" db:"full_name"`
CloneURL string `json:"clone_url" db:"clone_url"`
LocalPath string `json:"local_path" db:"local_path"`
Size int64 `json:"size" db:"size"`
LastSyncAt *time.Time `json:"last_sync_at" db:"last_sync_at"`
SyncStatus string `json:"sync_status" db:"sync_status"` // syncing, success, failed, pending
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// SyncLog represents a sync operation log
type SyncLog struct {
ID int64 `json:"id" db:"id"`
ServerID int64 `json:"server_id" db:"server_id"`
RepoID *int64 `json:"repo_id" db:"repo_id"`
Status string `json:"status" db:"status"`
Message string `json:"message" db:"message"`
StartedAt time.Time `json:"started_at" db:"started_at"`
FinishedAt *time.Time `json:"finished_at" db:"finished_at"`
}
// Setting represents a key-value configuration
type Setting struct {
Key string `json:"key" db:"key"`
Value string `json:"value" db:"value"`
}
// SyncStatus represents the current sync state
type SyncStatus struct {
ServerID int64 `json:"server_id"`
Status string `json:"status"` // idle, syncing, failed
TaskID string `json:"task_id,omitempty"`
StartedAt string `json:"started_at,omitempty"`
}
```
- [ ] **Step 3: Commit**
```bash
git add internal/config/config.go internal/models/models.go
git commit -m "feat: add config and models packages"
```
***
### Task 3: Implement Database Layer
**Files:**
- Create: `internal/database/database.go`
- [ ] **Step 1: Create database package**
Create `internal/database/database.go`:
```go
package database
import (
"database/sql"
"fmt"
"path/filepath"
_ "github.com/mattn/go-sqlite3"
"gitm/internal/config"
"gitm/internal/models"
)
var DB *sql.DB
// Initialize opens the database connection and creates tables
func Initialize(dbPath string) error {
var err error
DB, err = sql.Open("sqlite3", dbPath+"?_foreign_keys=on")
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
if err = DB.Ping(); err != nil {
return fmt.Errorf("failed to ping database: %w", err)
}
if err = createTables(); err != nil {
return fmt.Errorf("failed to create tables: %w", err)
}
return nil
}
func createTables() error {
queries := []string{
`CREATE TABLE IF NOT EXISTS gitea_servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL,
token TEXT NOT NULL,
sync_interval INTEGER DEFAULT 0,
last_sync_at DATETIME,
status TEXT DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS repos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
name TEXT,
full_name TEXT,
clone_url TEXT,
local_path TEXT,
size INTEGER DEFAULT 0,
last_sync_at DATETIME,
sync_status TEXT DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (server_id) REFERENCES gitea_servers(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS sync_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
repo_id INTEGER,
status TEXT NOT NULL,
message TEXT,
started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
finished_at DATETIME,
FOREIGN KEY (server_id) REFERENCES gitea_servers(id) ON DELETE CASCADE,
FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE SET NULL
)`,
`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
)`,
}
for _, q := range queries {
if _, err := DB.Exec(q); err != nil {
return fmt.Errorf("failed to create table: %w", err)
}
}
return nil
}
// Close closes the database connection
func Close() error {
if DB != nil {
return DB.Close()
}
return nil
}
// Settings operations
func GetSetting(key string) (string, error) {
var value string
err := DB.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value)
if err == sql.ErrNoRows {
return "", nil
}
return value, err
}
func SetSetting(key, value string) error {
_, err := DB.Exec(`
INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
`, key, value)
return err
}
// Server operations
func CreateServer(server *models.GiteaServer) error {
result, err := DB.Exec(`
INSERT INTO gitea_servers (name, url, token, sync_interval, status)
VALUES (?, ?, ?, ?, ?)
`, server.Name, server.URL, server.Token, server.SyncInterval, server.Status)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
server.ID = id
return nil
}
func GetServers() ([]models.GiteaServer, error) {
rows, err := DB.Query("SELECT id, name, url, token, sync_interval, last_sync_at, status, created_at FROM gitea_servers ORDER BY id")
if err != nil {
return nil, err
}
defer rows.Close()
var servers []models.GiteaServer
for rows.Next() {
var s models.GiteaServer
err := rows.Scan(&s.ID, &s.Name, &s.URL, &s.Token, &s.SyncInterval, &s.LastSyncAt, &s.Status, &s.CreatedAt)
if err != nil {
return nil, err
}
servers = append(servers, s)
}
return servers, nil
}
func GetServer(id int64) (*models.GiteaServer, error) {
var s models.GiteaServer
err := DB.QueryRow(`
SELECT id, name, url, token, sync_interval, last_sync_at, status, created_at
FROM gitea_servers WHERE id = ?
`, id).Scan(&s.ID, &s.Name, &s.URL, &s.Token, &s.SyncInterval, &s.LastSyncAt, &s.Status, &s.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return &s, err
}
func UpdateServer(server *models.GiteaServer) error {
_, err := DB.Exec(`
UPDATE gitea_servers
SET name = ?, url = ?, token = ?, sync_interval = ?, status = ?
WHERE id = ?
`, server.Name, server.URL, server.Token, server.SyncInterval, server.Status, server.ID)
return err
}
func DeleteServer(id int64) error {
_, err := DB.Exec("DELETE FROM gitea_servers WHERE id = ?", id)
return err
}
func UpdateServerLastSync(id int64, t time.Time) error {
_, err := DB.Exec("UPDATE gitea_servers SET last_sync_at = ? WHERE id = ?", t, id)
return err
}
// Repo operations
func CreateOrUpdateRepo(repo *models.Repo) error {
result, err := DB.Exec(`
INSERT INTO repos (server_id, name, full_name, clone_url, local_path, sync_status)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT DO NOTHING
`, repo.ServerID, repo.Name, repo.FullName, repo.CloneURL, repo.LocalPath, repo.SyncStatus)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
repo.ID = id
return nil
}
func GetReposByServer(serverID int64) ([]models.Repo, error) {
rows, err := DB.Query(`
SELECT id, server_id, name, full_name, clone_url, local_path, size, last_sync_at, sync_status, created_at
FROM repos WHERE server_id = ? ORDER BY full_name
`, serverID)
if err != nil {
return nil, err
}
defer rows.Close()
var repos []models.Repo
for rows.Next() {
var r models.Repo
err := rows.Scan(&r.ID, &r.ServerID, &r.Name, &r.FullName, &r.CloneURL, &r.LocalPath, &r.Size, &r.LastSyncAt, &r.SyncStatus, &r.CreatedAt)
if err != nil {
return nil, err
}
repos = append(repos, r)
}
return repos, nil
}
func UpdateRepoSyncStatus(id int64, status string) error {
_, err := DB.Exec("UPDATE repos SET sync_status = ?, last_sync_at = CURRENT_TIMESTAMP WHERE id = ?", status, id)
return err
}
func GetRepoByFullName(serverID int64, fullName string) (*models.Repo, error) {
var r models.Repo
err := DB.QueryRow(`
SELECT id, server_id, name, full_name, clone_url, local_path, size, last_sync_at, sync_status, created_at
FROM repos WHERE server_id = ? AND full_name = ?
`, serverID, fullName).Scan(&r.ID, &r.ServerID, &r.Name, &r.FullName, &r.CloneURL, &r.LocalPath, &r.Size, &r.LastSyncAt, &r.SyncStatus, &r.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return &r, err
}
// Sync log operations
func CreateSyncLog(log *models.SyncLog) error {
result, err := DB.Exec(`
INSERT INTO sync_logs (server_id, repo_id, status, message, started_at)
VALUES (?, ?, ?, ?, ?)
`, log.ServerID, log.RepoID, log.Status, log.Message, log.StartedAt)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
log.ID = id
return nil
}
func UpdateSyncLog(id int64, status, message string, finishedAt *time.Time) error {
_, err := DB.Exec(`
UPDATE sync_logs SET status = ?, message = ?, finished_at = ? WHERE id = ?
`, status, message, finishedAt, id)
return err
}
func GetSyncLogs(serverID int64, limit, offset int) ([]models.SyncLog, error) {
query := `
SELECT id, server_id, repo_id, status, message, started_at, finished_at
FROM sync_logs
`
args := []interface{}{}
if serverID > 0 {
query += " WHERE server_id = ?"
args = append(args, serverID)
}
query += " ORDER BY started_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := DB.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var logs []models.SyncLog
for rows.Next() {
var l models.SyncLog
err := rows.Scan(&l.ID, &l.ServerID, &l.RepoID, &l.Status, &l.Message, &l.StartedAt, &l.FinishedAt)
if err != nil {
return nil, err
}
logs = append(logs, l)
}
return logs, nil
}
```
- [ ] **Step 2: Add missing import**
Edit `internal/database/database.go`, add `"time"` to imports:
```go
import (
"database/sql"
"fmt"
"path/filepath"
"time"
_ "github.com/mattn/go-sqlite3"
"gitm/internal/config"
"gitm/internal/models"
)
```
- [ ] **Step 3: Test compilation**
```bash
go build -o /tmp/gitm-test .
```
Expected: Success, binary created
- [ ] **Step 4: Commit**
```bash
git add internal/database/
git commit -m "feat: implement SQLite database layer"
```
***
### Task 4: Implement JWT Authentication
**Files:**
- Create: `internal/middleware/auth.go`
- Create: `internal/handler/auth.go`
- [ ] **Step 1: Create auth middleware**
Create `internal/middleware/auth.go`:
```go
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID string `json:"user_id"`
jwt.RegisteredClaims
}
var jwtSecret = []byte("your-secret-key-change-this")
func SetJWTSecret(secret string) {
jwtSecret = []byte(secret)
}
// GenerateToken creates a new JWT token
func GenerateToken(userID string) (string, error) {
claims := Claims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
}
// ValidateToken parses and validates a JWT token
func ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return jwtSecret, nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, jwt.ErrSignatureInvalid
}
// AuthMiddleware validates JWT tokens
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
c.Abort()
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization format"})
c.Abort()
return
}
claims, err := ValidateToken(parts[1])
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
c.Abort()
return
}
c.Set("user_id", claims.UserID)
c.Next()
}
}
```
- [ ] **Step 2: Add missing import**
Edit `internal/middleware/auth.go`, add `"time"` to imports:
```go
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
```
- [ ] **Step 3: Create auth handler**
Create `internal/handler/auth.go`:
```go
package handler
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gitm/internal/database"
"gitm/internal/middleware"
)
type LoginRequest struct {
Password string `json:"password" binding:"required"`
}
type LoginResponse struct {
Token string `json:"token"`
}
type SettingsResponse struct {
AdminPassword string `json:"admin_password,omitempty"`
ListenAddr string `json:"listen_addr"`
ReposDir string `json:"repos_dir"`
MaxConcurrent int `json:"max_concurrent"`
}
type UpdateSettingsRequest struct {
AdminPassword string `json:"admin_password,omitempty"`
ListenAddr string `json:"listen_addr,omitempty"`
ReposDir string `json:"repos_dir,omitempty"`
MaxConcurrent *int `json:"max_concurrent,omitempty"`
}
// HandleLogin handles password authentication
func HandleLogin(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Password required"})
return
}
// Get stored password hash
hashedPassword, err := database.GetSetting("admin_password")
if err != nil || hashedPassword == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Server not initialized"})
return
}
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid password"})
return
}
// Generate token
token, err := middleware.GenerateToken("admin")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
c.JSON(http.StatusOK, LoginResponse{Token: token})
}
// HandleGetSettings returns current settings (with password masked)
func HandleGetSettings(c *gin.Context) {
listenAddr, _ := database.GetSetting("listen_addr")
if listenAddr == "" {
listenAddr = ":9000"
}
reposDir, _ := database.GetSetting("repos_dir")
maxConcurrent, _ := database.GetSetting("max_concurrent")
maxCon := 3
if maxConcurrent != "" {
fmt.Sscanf(maxConcurrent, "%d", &maxCon)
}
c.JSON(http.StatusOK, SettingsResponse{
AdminPassword: "********", // Masked
ListenAddr: listenAddr,
ReposDir: reposDir,
MaxConcurrent: maxCon,
})
}
// HandleUpdateSettings updates server settings
func HandleUpdateSettings(c *gin.Context) {
var req UpdateSettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Update password if provided
if req.AdminPassword != "" && req.AdminPassword != "********" {
hash, err := bcrypt.GenerateFromPassword([]byte(req.AdminPassword), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
}
if err := database.SetSetting("admin_password", string(hash)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save password"})
return
}
}
// Update other settings
if req.ListenAddr != "" {
database.SetSetting("listen_addr", req.ListenAddr)
}
if req.ReposDir != "" {
database.SetSetting("repos_dir", req.ReposDir)
}
if req.MaxConcurrent != nil {
database.SetSetting("max_concurrent", fmt.Sprintf("%d", *req.MaxConcurrent))
}
c.JSON(http.StatusOK, gin.H{"message": "Settings updated"})
}
// HandleInit initializes the admin password
func HandleInit(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Password required"})
return
}
// Check if already initialized
hashedPassword, _ := database.GetSetting("admin_password")
if hashedPassword != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Already initialized"})
return
}
// Hash and store password
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
}
if err := database.SetSetting("admin_password", string(hash)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save password"})
return
}
// Set default values
database.SetSetting("listen_addr", ":9000")
database.SetSetting("max_concurrent", "3")
c.JSON(http.StatusOK, gin.H{"message": "Initialized successfully"})
}
```
- [ ] **Step 4: Add missing imports**
Edit `internal/handler/auth.go`, add imports:
```go
import (
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gitm/internal/database"
"gitm/internal/middleware"
)
```
- [ ] **Step 5: Test compilation**
```bash
go build -o /tmp/gitm-test .
```
Expected: Success
- [ ] **Step 6: Commit**
```bash
git add internal/middleware/ internal/handler/
git commit -m "feat: implement JWT authentication middleware and handlers"
```
***
## Phase 2: Gitea API Integration
### Task 5: Implement Gitea API Client
**Files:**
- Create: `internal/gitea/client.go`
- Create: `internal/gitea/types.go`
- [ ] **Step 1: Create Gitea types**
Create `internal/gitea/types.go`:
```go
package gitea
// GiteaUser represents a Gitea user
type GiteaUser struct {
ID int64 `json:"id"`
Login string `json:"login"`
FullName string `json:"full_name"`
Email string `json:"email"`
}
// GiteaRepo represents a Gitea repository
type GiteaRepo struct {
ID int64 `json:"id"`
Name string `json:"name"`
FullName string `json:"full_name"`
CloneURL string `json:"clone_url"`
SSHURL string `json:"ssh_url"`
Private bool `json:"private"`
Size int64 `json:"size"`
Updated string `json:"updated_at"`
Owner *GiteaUser `json:"owner"`
}
// GiteaSearchResponse represents the search API response
type GiteaSearchResponse struct {
OK bool `json:"ok"`
Data []GiteaRepo `json:"data"`
}
```
- [ ] **Step 2: Create Gitea client**
Create `internal/gitea/client.go`:
```go
package gitea
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
// Client represents a Gitea API client
type Client struct {
baseURL *url.URL
httpClient *http.Client
token string
}
// NewClient creates a new Gitea API client
func NewClient(serverURL, token string) (*Client, error) {
u, err := url.Parse(serverURL)
if err != nil {
return nil, fmt.Errorf("invalid server URL: %w", err)
}
return &Client{
baseURL: u,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
token: token,
}, nil
}
// do performs an HTTP request with authentication
func (c *Client) do(path string) (*http.Response, error) {
// Build URL with token as query parameter
apiURL := c.baseURL.Join(path)
apiURL.RawQuery = url.Values{"token": {c.token}}.Encode()
resp, err := c.httpClient.Get(apiURL.String())
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
return resp, nil
}
// ValidateToken checks if the current token is valid
func (c *Client) ValidateToken() (*GiteaUser, error) {
resp, err := c.do("/api/v1/user")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("authentication failed: %s - %s", resp.Status, string(body))
}
var user GiteaUser
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &user, nil
}
// SearchRepos searches for repositories, paginated
func (c *Client) SearchRepos(page, limit int) ([]GiteaRepo, error) {
resp, err := c.do(fmt.Sprintf("/api/v1/repos/search?page=%d&limit=%d", page, limit))
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("search failed: %s - %s", resp.Status, string(body))
}
var searchResp GiteaSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return searchResp.Data, nil
}
// GetAllRepos fetches all repositories by paginating through results
func (c *Client) GetAllRepos() ([]GiteaRepo, error) {
var allRepos []GiteaRepo
page := 1
limit := 50
for {
repos, err := c.SearchRepos(page, limit)
if err != nil {
return nil, err
}
if len(repos) == 0 {
break
}
allRepos = append(allRepos, repos...)
if len(repos) < limit {
break
}
page++
}
return allRepos, nil
}
```
- [ ] **Step 3: Test compilation**
```bash
go build -o /tmp/gitm-test .
```
Expected: Success
- [ ] **Step 4: Commit**
```bash
git add internal/gitea/
git commit -m "feat: implement Gitea API client"
```
***
### Task 6: Implement Sync Engine
**Files:**
- Create: `internal/sync/engine.go`
- Create: `internal/sync/scheduler.go`
- [ ] **Step 1: Create sync engine**
Create `internal/sync/engine.go`:
```go
package sync
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
"gitm/internal/database"
"gitm/internal/gitea"
)
// Engine handles repository synchronization
type Engine struct {
maxConcurrent int
mu sync.Mutex
activeTasks map[int64]string // serverID -> taskID
}
// NewEngine creates a new sync engine
func NewEngine(maxConcurrent int) *Engine {
return &Engine{
maxConcurrent: maxConcurrent,
activeTasks: make(map[int64]string),
}
}
// SyncServer syncs all repositories from a Gitea server
func (e *Engine) SyncServer(serverID int64) error {
e.mu.Lock()
if _, active := e.activeTasks[serverID]; active {
e.mu.Unlock()
return fmt.Errorf("sync already in progress for server %d", serverID)
}
taskID := fmt.Sprintf("%d-%d", serverID, time.Now().Unix())
e.activeTasks[serverID] = taskID
e.mu.Unlock()
defer func() {
e.mu.Lock()
delete(e.activeTasks, serverID)
e.mu.Unlock()
}()
// Get server config
server, err := database.GetServer(serverID)
if err != nil {
return fmt.Errorf("failed to get server: %w", err)
}
// Create sync log
log := &database.SyncLog{
ServerID: serverID,
Status: "in_progress",
Message: "Starting sync",
StartedAt: time.Now(),
}
if err := database.CreateSyncLog(log); err != nil {
return fmt.Errorf("failed to create sync log: %w", err)
}
// Create Gitea client
client, err := gitea.NewClient(server.URL, server.Token)
if err != nil {
e.finishLog(log, "failed", err.Error())
return fmt.Errorf("failed to create client: %w", err)
}
// Validate token
if _, err := client.ValidateToken(); err != nil {
e.finishLog(log, "failed", fmt.Sprintf("Authentication failed: %v", err))
return fmt.Errorf("token validation failed: %w", err)
}
// Get all repos
giteaRepos, err := client.GetAllRepos()
if err != nil {
e.finishLog(log, "failed", fmt.Sprintf("Failed to fetch repos: %v", err))
return fmt.Errorf("failed to get repos: %w", err)
}
// Get repos directory
reposDir, _ := database.GetSetting("repos_dir")
if reposDir == "" {
reposDir = "./data/repos"
}
serverDir := filepath.Join(reposDir, fmt.Sprintf("server_%d_%s", serverID, server.Name))
if err := os.MkdirAll(serverDir, 0755); err != nil {
e.finishLog(log, "failed", fmt.Sprintf("Failed to create directory: %v", err))
return fmt.Errorf("failed to create server directory: %w", err)
}
// Sync each repo
successCount := 0
failedCount := 0
sem := make(chan struct{}, e.maxConcurrent)
var wg sync.WaitGroup
var resultsMu sync.Mutex
for _, gr := range giteaRepos {
wg.Add(1)
go func(giteaRepo gitea.GiteaRepo) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
// Check if repo exists
existingRepo, _ := database.GetRepoByFullName(serverID, giteaRepo.FullName)
// Determine local path
ownerName := giteaRepo.FullName
if giteaRepo.Owner != nil {
ownerName = filepath.Join(giteaRepo.Owner.Login, giteaRepo.Name)
}
localPath := filepath.Join(serverDir, giteaRepo.FullName+".git")
// Clone or fetch
var err error
if existingRepo == nil {
err = e.cloneMirror(giteaRepo.CloneURL, localPath)
if err == nil {
// Create repo record
repo := &database.Repo{
ServerID: serverID,
Name: giteaRepo.Name,
FullName: giteaRepo.FullName,
CloneURL: giteaRepo.CloneURL,
LocalPath: localPath,
SyncStatus: "success",
}
database.CreateOrUpdateRepo(repo)
}
} else {
err = e.fetchMirror(localPath)
if err == nil {
database.UpdateRepoSyncStatus(existingRepo.ID, "success")
}
}
resultsMu.Lock()
if err != nil {
failedCount++
// Create failure log
failLog := &database.SyncLog{
ServerID: serverID,
RepoID: getRepoID(serverID, giteaRepo.FullName),
Status: "failed",
Message: err.Error(),
StartedAt: time.Now(),
}
db.CreateSyncLog(failLog)
} else {
successCount++
}
resultsMu.Unlock()
}(gr)
}
wg.Wait()
// Update server last sync
now := time.Now()
database.UpdateServerLastSync(serverID, now)
// Finish log
message := fmt.Sprintf("Sync completed: %d succeeded, %d failed", successCount, failedCount)
e.finishLog(log, "success", message)
return nil
}
// cloneMirror performs a git clone --mirror
func (e *Engine) cloneMirror(url, path string) error {
if _, err := os.Stat(path); err == nil {
// Directory exists, skip
return nil
}
cmd := exec.Command("git", "clone", "--mirror", url, path)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("clone failed: %w - %s", err, string(output))
}
return nil
}
// fetchMirror performs a git fetch --all --prune in a mirror repo
func (e *Engine) fetchMirror(path string) error {
cmd := exec.Command("git", "--git-dir", path, "fetch", "--all", "--prune")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("fetch failed: %w - %s", err, string(output))
}
return nil
}
// finishLog updates a sync log entry
func (e *Engine) finishLog(log *database.SyncLog, status, message string) {
now := time.Now()
log.Status = status
log.Message = message
log.FinishedAt = &now
database.UpdateSyncLog(log.ID, status, message, log.FinishedAt)
}
// IsSyncing checks if a server is currently syncing
func (e *Engine) IsSyncing(serverID int64) bool {
e.mu.Lock()
defer e.mu.Unlock()
_, active := e.activeTasks[serverID]
return active
}
// helper to get repo ID for logging
func getRepoID(serverID int64, fullName string) *int64 {
repo, _ := database.GetRepoByFullName(serverID, fullName)
if repo != nil {
return &repo.ID
}
return nil
}
```
- [ ] **Step 2: Fix imports and issues**
Edit `internal/sync/engine.go`:
```go
package sync
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
"gitm/internal/database"
db "gitm/internal/database"
"gitm/internal/gitea"
)
```
- [ ] **Step 3: Create scheduler**
Create `internal/sync/scheduler.go`:
```go
package sync
import (
"log"
"time"
"github.com/robfig/cron/v3"
"gitm/internal/database"
)
// Scheduler manages scheduled sync tasks
type Scheduler struct {
cron *cron.Cron
engine *Engine
serverJobs map[int64]cron.EntryID
}
// NewScheduler creates a new scheduler
func NewScheduler(engine *Engine) *Scheduler {
return &Scheduler{
cron: cron.New(),
engine: engine,
serverJobs: make(map[int64]cron.EntryID),
}
}
// Start starts the scheduler
func (s *Scheduler) Start() {
s.cron.Start()
log.Println("Scheduler started")
}
// Stop stops the scheduler
func (s *Scheduler) Stop() {
s.cron.Stop()
log.Println("Scheduler stopped")
}
// UpdateServer updates or removes a scheduled job for a server
func (s *Scheduler) UpdateServer(server *database.GiteaServer) {
// Remove existing job
if entryID, exists := s.serverJobs[server.ID]; exists {
s.cron.Remove(entryID)
delete(s.serverJobs, server.ID)
}
// Skip if manual sync only or disabled
if server.SyncInterval <= 0 || server.Status != "active" {
return
}
// Add new job - run every N minutes
schedule := fmt.Sprintf("*/%d * * * *", server.SyncInterval)
entryID, err := s.cron.AddFunc(schedule, func() {
log.Printf("Scheduled sync triggered for server %d (%s)", server.ID, server.Name)
if err := s.engine.SyncServer(server.ID); err != nil {
log.Printf("Scheduled sync failed for server %d: %v", server.ID, err)
}
})
if err != nil {
log.Printf("Failed to schedule job for server %d: %v", server.ID, err)
return
}
s.serverJobs[server.ID] = entryID
log.Printf("Scheduled sync every %d minutes for server %d (%s)", server.SyncInterval, server.ID, server.Name)
}
// RemoveServer removes a scheduled job for a server
func (s *Scheduler) RemoveServer(serverID int64) {
if entryID, exists := s.serverJobs[serverID]; exists {
s.cron.Remove(entryID)
delete(s.serverJobs, serverID)
log.Printf("Removed scheduled job for server %d", serverID)
}
}
// ReloadAll reloads all server schedules from database
func (s *Scheduler) ReloadAll() error {
servers, err := database.GetServers()
if err != nil {
return err
}
for _, server := range servers {
s.UpdateServer(&server)
}
return nil
}
```
- [ ] **Step 4: Add missing import to scheduler**
Edit `internal/sync/scheduler.go`:
```go
import (
"fmt"
"log"
"time"
"github.com/robfig/cron/v3"
"gitm/internal/database"
)
```
- [ ] **Step 5: Test compilation**
```bash
go build -o /tmp/gitm-test .
```
Expected: Success
- [ ] **Step 6: Commit**
```bash
git add internal/sync/
git commit -m "feat: implement sync engine and scheduler"
```
***
## Phase 3: HTTP Handlers
### Task 7: Implement Server Management Handlers
**Files:**
- Create: `internal/handler/server.go`
- [ ] **Step 1: Create server handler**
Create `internal/handler/server.go`:
```go
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gitm/internal/database"
"gitm/internal/gitea"
"gitm/internal/sync"
)
// HandleListServers returns all configured Gitea servers
func HandleListServers(c *gin.Context) {
servers, err := database.GetServers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, servers)
}
// CreateServerRequest defines the request to create a server
type CreateServerRequest struct {
Name string `json:"name" binding:"required"`
URL string `json:"url" binding:"required"`
Token string `json:"token" binding:"required"`
SyncInterval int `json:"sync_interval"`
}
// HandleCreateServer adds a new Gitea server
func HandleCreateServer(c *gin.Context) {
var req CreateServerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
server := &database.GiteaServer{
Name: req.Name,
URL: req.URL,
Token: req.Token,
SyncInterval: req.SyncInterval,
Status: "active",
}
if err := database.CreateServer(server); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, server)
}
// UpdateServerRequest defines the request to update a server
type UpdateServerRequest struct {
Name *string `json:"name"`
URL *string `json:"url"`
Token *string `json:"token"`
SyncInterval *int `json:"sync_interval"`
Status *string `json:"status"`
}
// HandleUpdateServer updates a Gitea server configuration
func HandleUpdateServer(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"})
return
}
server, err := database.GetServer(id)
if err != nil || server == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Server not found"})
return
}
var req UpdateServerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Name != nil {
server.Name = *req.Name
}
if req.URL != nil {
server.URL = *req.URL
}
if req.Token != nil {
server.Token = *req.Token
}
if req.SyncInterval != nil {
server.SyncInterval = *req.SyncInterval
}
if req.Status != nil {
server.Status = *req.Status
}
if err := database.UpdateServer(server); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, server)
}
// HandleDeleteServer removes a Gitea server
func HandleDeleteServer(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"})
return
}
if err := database.DeleteServer(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Server deleted"})
}
// TestConnectionRequest defines the request to test a server connection
type TestConnectionRequest struct {
URL string `json:"url" binding:"required"`
Token string `json:"token" binding:"required"`
}
// HandleTestConnection tests connection to a Gitea server
func HandleTestConnection(c *gin.Context) {
var req TestConnectionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
client, err := gitea.NewClient(req.URL, req.Token)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := client.ValidateToken()
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication failed"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Connection successful",
"user": user.Login,
})
}
// HandleListRepos returns repositories for a server
func HandleListRepos(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"})
return
}
repos, err := database.GetReposByServer(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, repos)
}
// HandleDiscoverRepos triggers repo discovery from Gitea API
func HandleDiscoverRepos(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"})
return
}
server, err := database.GetServer(id)
if err != nil || server == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Server not found"})
return
}
client, err := gitea.NewClient(server.URL, server.Token)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
giteaRepos, err := client.GetAllRepos()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Get repos directory
reposDir, _ := database.GetSetting("repos_dir")
if reposDir == "" {
reposDir = "./data/repos"
}
serverDir := reposDir + "/" + strconv.FormatInt(id, 10) + "_" + server.Name
discovered := 0
for _, gr := range giteaRepos {
// Check if already exists
existing, _ := database.GetRepoByFullName(id, gr.FullName)
if existing == nil {
repo := &database.Repo{
ServerID: id,
Name: gr.Name,
FullName: gr.FullName,
CloneURL: gr.CloneURL,
LocalPath: serverDir + "/" + gr.FullName + ".git",
SyncStatus: "pending",
}
database.CreateOrUpdateRepo(repo)
discovered++
}
}
c.JSON(http.StatusOK, gin.H{
"message": fmt.Sprintf("Discovered %d new repositories", discovered),
"total_repos": len(giteaRepos),
"new_repos": discovered,
})
}
// HandleSyncServer triggers a sync for a specific server
func HandleSyncServer(c *gin.Context, engine *sync.Engine) gin.HandlerFunc {
return func(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"})
return
}
if engine.IsSyncing(id) {
c.JSON(http.StatusConflict, gin.H{"error": "Sync already in progress"})
return
}
go engine.SyncServer(id)
c.JSON(http.StatusAccepted, gin.H{
"message": "Sync started",
"server_id": id,
})
}
}
// HandleSyncAll triggers sync for all active servers
func HandleSyncAll(c *gin.Context, engine *sync.Engine) gin.HandlerFunc {
return func(c *gin.Context) {
servers, err := database.GetServers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
started := 0
for _, server := range servers {
if server.Status == "active" && !engine.IsSyncing(server.ID) {
go engine.SyncServer(server.ID)
started++
}
}
c.JSON(http.StatusAccepted, gin.H{
"message": fmt.Sprintf("Started sync for %d servers", started),
})
}
}
// HandleGetSyncStatus returns sync status for a server
func HandleGetSyncStatus(c *gin.Context, engine *sync.Engine) gin.HandlerFunc {
return func(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"})
return
}
if engine.IsSyncing(id) {
c.JSON(http.StatusOK, gin.H{
"server_id": id,
"status": "syncing",
})
} else {
c.JSON(http.StatusOK, gin.H{
"server_id": id,
"status": "idle",
})
}
}
}
```
- [ ] **Step 2: Add missing import**
Edit `internal/handler/server.go`, add `"fmt"` to imports:
```go
import (
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gitm/internal/database"
"gitm/internal/gitea"
"gitm/internal/sync"
)
```
- [ ] **Step 3: Test compilation**
```bash
go build -o /tmp/gitm-test .
```
Expected: Success
- [ ] **Step 4: Commit**
```bash
git add internal/handler/server.go
git commit -m "feat: implement server management handlers"
```
***
### Task 8: Implement Log and Stats Handlers
**Files:**
- Create: `internal/handler/log.go`
- [ ] **Step 1: Create log handler**
Create `internal/handler/log.go`:
```go
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gitm/internal/database"
)
// HandleGetSyncLogs returns sync logs with pagination
func HandleGetSyncLogs(c *gin.Context) {
serverIDStr := c.Query("server_id")
pageStr := c.DefaultQuery("page", "1")
limitStr := c.DefaultQuery("limit", "50")
page, _ := strconv.Atoi(pageStr)
limit, _ := strconv.Atoi(limitStr)
if limit > 100 {
limit = 100
}
var serverID int64 = 0
if serverIDStr != "" {
serverID, _ = strconv.ParseInt(serverIDStr, 10, 64)
}
offset := (page - 1) * limit
logs, err := database.GetSyncLogs(serverID, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"data": logs,
"page": page,
"limit": limit,
"count": len(logs),
})
}
// HandleGetStats returns dashboard statistics
func HandleGetStats(c *gin.Context) {
servers, err := database.GetServers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
serverCount := len(servers)
repoCount := 0
totalSize := int64(0)
activeServers := 0
for _, server := range servers {
if server.Status == "active" {
activeServers++
}
repos, err := database.GetReposByServer(server.ID)
if err == nil {
repoCount += len(repos)
for _, repo := range repos {
totalSize += repo.Size
}
}
}
c.JSON(http.StatusOK, gin.H{
"server_count": serverCount,
"active_servers": activeServers,
"repo_count": repoCount,
"total_size": totalSize,
})
}
```
- [ ] **Step 2: Test compilation**
```bash
go build -o /tmp/gitm-test .
```
Expected: Success
- [ ] **Step 3: Commit**
```bash
git add internal/handler/log.go
git commit -m "feat: implement sync log and stats handlers"
```
***
### Task 9: Wire Up Main Server
**Files:**
- Modify: `main.go`
- [ ] **Step 1: Update main.go with full server implementation**
Replace `main.go` with:
```go
package main
import (
"flag"
"fmt"
"log"
"os"
"github.com/gin-gonic/gin"
"gitm/internal/config"
"gitm/internal/database"
"gitm/internal/handler"
"gitm/internal/middleware"
"gitm/internal/sync"
)
var (
flagAddr = flag.String("addr", "", "Listen address")
flagDataDir = flag.String("data", "", "Data directory")
flagInit = flag.Bool("init", false, "Initialize database and set password")
)
func main() {
flag.Parse()
cfg := config.Get()
if *flagDataDir != "" {
cfg.SetDataDir(*flagDataDir)
}
if *flagAddr != "" {
cfg.ListenAddr = *flagAddr
}
// Ensure directories exist
if err := cfg.EnsureDirs(); err != nil {
log.Fatalf("Failed to create directories: %v", err)
}
// Initialize database
if err := database.Initialize(cfg.DBPath); err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer database.Close()
// Load settings to config
if listenAddr, err := database.GetSetting("listen_addr"); err == nil && listenAddr != "" {
cfg.ListenAddr = listenAddr
}
// Get max concurrent setting
maxConcurrent := 3
if maxStr, err := database.GetSetting("max_concurrent"); err == nil && maxStr != "" {
fmt.Sscanf(maxStr, "%d", &maxConcurrent)
}
// Initialize components
engine := sync.NewEngine(maxConcurrent)
scheduler := sync.NewScheduler(engine)
// Init mode
if *flagInit {
runInitMode()
return
}
// Check if initialized
if _, err := database.GetSetting("admin_password"); err != nil {
fmt.Println("Not initialized. Please run with --init flag first.")
return
}
// Set JWT secret from a fixed key (in production, derive from password)
middleware.SetJWTSecret("gitm-default-secret-change-in-production")
// Reload scheduler with existing servers
scheduler.ReloadAll()
scheduler.Start()
defer scheduler.Stop()
// Start server
log.Printf("GitM starting on %s", cfg.ListenAddr)
log.Fatal(runServer(cfg, engine, scheduler))
}
func runInitMode() {
var password string
fmt.Print("Enter admin password: ")
fmt.Scanln(&password)
if password == "" {
log.Fatal("Password cannot be empty")
}
// Hash password manually for init
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Fatalf("Failed to hash password: %v", err)
}
if err := database.SetSetting("admin_password", string(hash)); err != nil {
log.Fatalf("Failed to save password: %v", err)
}
// Set defaults
database.SetSetting("listen_addr", ":9000")
database.SetSetting("max_concurrent", "3")
fmt.Println("Initialized successfully!")
}
func runServer(cfg *config.Config, engine *sync.Engine, scheduler *sync.Scheduler) error {
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
// API routes
api := r.Group("/api")
{
// Public routes
api.POST("/login", handler.HandleLogin)
api.POST("/init", handler.HandleInit)
// Protected routes
protected := api.Group("")
protected.Use(middleware.AuthMiddleware())
{
// Settings
protected.GET("/settings", handler.HandleGetSettings)
protected.PUT("/settings", handler.HandleUpdateSettings)
// Servers
protected.GET("/servers", handler.HandleListServers)
protected.POST("/servers", handler.HandleCreateServer)
protected.PUT("/servers/:id", handler.HandleUpdateServer)
protected.DELETE("/servers/:id", handler.HandleDeleteServer)
protected.POST("/servers/:id/test", handler.HandleTestConnection)
protected.GET("/servers/:id/repos", handler.HandleListRepos)
protected.POST("/servers/:id/discover", handler.HandleDiscoverRepos)
protected.POST("/servers/:id/sync", handler.HandleSyncServer(engine))
protected.GET("/servers/:id/sync/status", handler.HandleGetSyncStatus(engine))
// Sync
protected.POST("/sync/all", handler.HandleSyncAll(engine))
// Logs and stats
protected.GET("/sync/logs", handler.HandleGetSyncLogs)
protected.GET("/sync/stats", handler.HandleGetStats)
}
}
// Serve static files (frontend) - will be added when frontend is built
// r.Static("/", "./web/dist")
return r.Run(cfg.ListenAddr)
}
```
- [ ] **Step 2: Add missing imports**
Edit `main.go`, add `"golang.org/x/crypto/bcrypt"` to imports:
```go
import (
"flag"
"fmt"
"log"
"os"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gitm/internal/config"
"gitm/internal/database"
"gitm/internal/handler"
"gitm/internal/middleware"
"gitm/internal/sync"
)
```
- [ ] **Step 3: Test compilation**
```bash
go build -o /tmp/gitm-test .
```
Expected: Success
- [ ] **Step 4: Test basic run**
```bash
/tmp/gitm-test --init
```
Expected: Prompts for password (type one, then Enter)
- [ ] **Step 5: Commit**
```bash
git add main.go
git commit -m "feat: wire up main server with all routes"
```
***
## Phase 4: Frontend Implementation
### Task 10: Initialize Vue 3 Frontend
**Files:**
- Create: `web/package.json`
- Create: `web/vite.config.ts`
- Create: `web/tsconfig.json`
- Create: `web/index.html`
- Create: `web/src/main.ts`
- Create: `web/src/App.vue`
- [ ] **Step 1: Create web/package.json**
Create `web/package.json`:
```json
{
"name": "gitm-web",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"element-plus": "^2.5.0",
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"typescript": "^5.3.0",
"vue-tsc": "^1.8.27",
"vite": "^5.0.0"
}
}
```
- [ ] **Step 2: Create web/vite.config.ts**
Create `web/vite.config.ts`:
```typescript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:9000',
changeOrigin: true
}
}
},
build: {
outDir: 'dist',
assetsDir: 'assets'
}
})
```
- [ ] **Step 3: Create web/tsconfig.json**
Create `web/tsconfig.json`:
```json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
```
- [ ] **Step 4: Create web/tsconfig.node.json**
Create `web/tsconfig.node.json`:
```json
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
```
- [ ] **Step 5: Create web/index.html**
Create `web/index.html`:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GitM - Gitea Repository Sync</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
```
- [ ] **Step 6: Create web/src/main.ts**
Create `web/src/main.ts`:
```typescript
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
const pinia = createPinia()
// Register icons
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(pinia)
app.use(router)
app.use(ElementPlus)
app.mount('#app')
```
- [ ] **Step 7: Create web/src/App.vue**
Create `web/src/App.vue`:
```vue
<template>
<el-container style="height: 100vh">
<router-view />
</el-container>
</template>
<script setup lang="ts">
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
}
</style>
```
- [ ] **Step 8: Create web/src/router/index.ts**
Create `web/src/router/index.ts`:
```typescript
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { requiresAuth: false }
},
{
path: '/',
component: () => import('@/views/Layout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue')
},
{
path: 'servers',
name: 'Servers',
component: () => import('@/views/Servers.vue')
},
{
path: 'repos',
name: 'Repos',
component: () => import('@/views/Repos.vue')
},
{
path: 'logs',
name: 'Logs',
component: () => import('@/views/Logs.vue')
},
{
path: 'settings',
name: 'Settings',
component: () => import('@/views/Settings.vue')
}
]
}
]
})
router.beforeEach((to, _from, next) => {
const authStore = useAuthStore()
const requiresAuth = to.meta.requiresAuth !== false
if (requiresAuth && !authStore.isAuthenticated) {
next('/login')
} else if (to.path === '/login' && authStore.isAuthenticated) {
next('/')
} else {
next()
}
})
export default router
```
- [ ] **Step 9: Commit**
```bash
git add web/
git commit -m "feat: initialize Vue 3 frontend structure"
```
***
### Task 11: Create API Client and Auth Store
**Files:**
- Create: `web/src/api/index.ts`
- Create: `web/src/api/types.ts`
- Create: `web/src/stores/auth.ts`
- [ ] **Step 1: Create API types**
Create `web/src/api/types.ts`:
```typescript
export interface GiteaServer {
id: number
name: string
url: string
sync_interval: number
last_sync_at: string | null
status: string
created_at: string
}
export interface Repo {
id: number
server_id: number
name: string
full_name: string
clone_url: string
local_path: string
size: number
last_sync_at: string | null
sync_status: string
created_at: string
}
export interface SyncLog {
id: number
server_id: number
repo_id: number | null
status: string
message: string
started_at: string
finished_at: string | null
}
export interface Stats {
server_count: number
active_servers: number
repo_count: number
total_size: number
}
export interface LoginRequest {
password: string
}
export interface LoginResponse {
token: string
}
export interface CreateServerRequest {
name: string
url: string
token: string
sync_interval?: number
}
export interface UpdateServerRequest {
name?: string
url?: string
token?: string
sync_interval?: number
status?: string
}
export interface TestConnectionRequest {
url: string
token: string
}
```
- [ ] **Step 2: Create API client**
Create `web/src/api/index.ts`:
```typescript
import axios from 'axios'
import type {
LoginRequest,
LoginResponse,
GiteaServer,
CreateServerRequest,
UpdateServerRequest,
TestConnectionRequest,
Repo,
SyncLog,
Stats
} from './types'
const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json'
}
})
// Add auth token to requests
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Handle auth errors
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export const authApi = {
login: (data: LoginRequest) => api.post<LoginResponse>('/login', data),
getSettings: () => api.get('/settings'),
updateSettings: (data: any) => api.put('/settings', data)
}
export const serverApi = {
list: () => api.get<GiteaServer[]>('/servers'),
create: (data: CreateServerRequest) => api.post<GiteaServer>('/servers', data),
update: (id: number, data: UpdateServerRequest) => api.put<GiteaServer>(`/servers/${id}`, data),
delete: (id: number) => api.delete(`/servers/${id}`),
test: (data: TestConnectionRequest) => api.post('/servers/test', data),
getRepos: (id: number) => api.get<Repo[]>(`/servers/${id}/repos`),
discover: (id: number) => api.post(`/servers/${id}/discover`),
sync: (id: number) => api.post(`/servers/${id}/sync`),
getSyncStatus: (id: number) => api.get(`/servers/${id}/sync/status`)
}
export const syncApi = {
syncAll: () => api.post('/sync/all'),
getLogs: (params?: { server_id?: number; page?: number; limit?: number }) =>
api.get<{ data: SyncLog[]; page: number; limit: number; count: number }>('/sync/logs', { params }),
getStats: () => api.get<Stats>('/sync/stats')
}
export default api
```
- [ ] **Step 3: Create auth store**
Create `web/src/stores/auth.ts`:
```typescript
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authApi } from '@/api'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('token'))
const loading = ref(false)
const isAuthenticated = computed(() => !!token.value)
async function login(password: string) {
loading.value = true
try {
const response = await authApi.login({ password })
token.value = response.data.token
localStorage.setItem('token', response.data.token)
return true
} catch (error) {
console.error('Login failed:', error)
return false
} finally {
loading.value = false
}
}
function logout() {
token.value = null
localStorage.removeItem('token')
}
return {
token,
loading,
isAuthenticated,
login,
logout
}
})
```
- [ ] **Step 4: Commit**
```bash
git add web/src/api/ web/src/stores/
git commit -m "feat: add API client and auth store"
```
***
### Task 12: Create Login Page
**Files:**
- Create: `web/src/views/Login.vue`
- [ ] **Step 1: Create Login.vue**
Create `web/src/views/Login.vue`:
```vue
<template>
<div class="login-container">
<el-card class="login-card">
<template #header>
<div class="card-header">
<h1>GitM</h1>
<p>Gitea Repository Sync Tool</p>
</div>
</template>
<el-form @submit.prevent="handleLogin" label-width="0">
<el-form-item>
<el-input
v-model="password"
type="password"
placeholder="Enter password"
show-password
size="large"
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
style="width: 100%"
:loading="authStore.loading"
@click="handleLogin"
>
Login
</el-button>
</el-form-item>
</el-form>
<el-alert
v-if="error"
type="error"
:title="error"
:closable="false"
style="margin-top: 20px"
/>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
const router = useRouter()
const authStore = useAuthStore()
const password = ref('')
const error = ref('')
async function handleLogin() {
if (!password.value) {
error.value = 'Please enter a password'
return
}
error.value = ''
const success = await authStore.login(password.value)
if (success) {
ElMessage.success('Login successful')
router.push('/')
} else {
error.value = 'Invalid password'
}
}
</script>
<style scoped>
.login-container {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 400px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.card-header {
text-align: center;
}
.card-header h1 {
margin: 0;
font-size: 32px;
color: #409eff;
}
.card-header p {
margin: 5px 0 0;
color: #909399;
font-size: 14px;
}
</style>
```
- [ ] **Step 2: Commit**
```bash
git add web/src/views/Login.vue
git commit -m "feat: add login page"
```
***
### Task 13: Create Layout Component
**Files:**
- Create: `web/src/views/Layout.vue`
- [ ] **Step 1: Create Layout.vue**
Create `web/src/views/Layout.vue`:
```vue
<template>
<el-container>
<el-aside width="200px">
<div class="logo">
<h2>GitM</h2>
</div>
<el-menu
:default-active="activeMenu"
router
background-color="#001529"
text-color="#fff"
active-text-color="#1890ff"
>
<el-menu-item index="/">
<el-icon><Dashboard /></el-icon>
<span>Dashboard</span>
</el-menu-item>
<el-menu-item index="/servers">
<el-icon><Server /></el-icon>
<span>Servers</span>
</el-menu-item>
<el-menu-item index="/repos">
<el-icon><DocumentCopy /></el-icon>
<span>Repositories</span>
</el-menu-item>
<el-menu-item index="/logs">
<el-icon><Document /></el-icon>
<span>Sync Logs</span>
</el-menu-item>
<el-menu-item index="/settings">
<el-icon><Setting /></el-icon>
<span>Settings</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header>
<div class="header-content">
<span>{{ pageTitle }}</span>
<el-button @click="handleLogout" text>
<el-icon><SwitchButton /></el-icon>
Logout
</el-button>
</div>
</el-header>
<el-main>
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import {
Dashboard,
Server,
DocumentCopy,
Document,
Setting,
SwitchButton
} from '@element-plus/icons-vue'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const activeMenu = computed(() => route.path)
const pageTitle = computed(() => {
const titles: Record<string, string> = {
'/': 'Dashboard',
'/servers': 'Gitea Servers',
'/repos': 'Repositories',
'/logs': 'Sync Logs',
'/settings': 'Settings'
}
return titles[route.path] || 'GitM'
})
function handleLogout() {
authStore.logout()
router.push('/login')
}
</script>
<style scoped>
.el-aside {
background-color: #001529;
height: 100vh;
}
.logo {
padding: 20px;
text-align: center;
border-bottom: 1px solid #1f1f1f;
}
.logo h2 {
margin: 0;
color: #fff;
}
.el-header {
background-color: #fff;
border-bottom: 1px solid #f0f0f0;
display: flex;
align-items: center;
}
.header-content {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 18px;
font-weight: 500;
}
.el-main {
background-color: #f5f5f5;
}
</style>
```
- [ ] **Step 2: Commit**
```bash
git add web/src/views/Layout.vue
git commit -m "feat: add layout component with navigation"
```
***
### Task 14: Create Dashboard Page
**Files:**
- Create: `web/src/views/Dashboard.vue`
- [ ] **Step 1: Create Dashboard.vue**
Create `web/src/views/Dashboard.vue`:
```vue
<template>
<div class="dashboard">
<el-row :gutter="20">
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="Total Servers" :value="stats.server_count">
<template #prefix>
<el-icon color="#409eff"><Server /></el-icon>
</template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="Active Servers" :value="stats.active_servers">
<template #prefix>
<el-icon color="#67c23a"><CircleCheck /></el-icon>
</template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="Total Repositories" :value="stats.repo_count">
<template #prefix>
<el-icon color="#e6a23c"><DocumentCopy /></el-icon>
</template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="Total Size" :value="formatSize(stats.total_size)">
<template #prefix>
<el-icon color="#f56c6c"><PieChart /></el-icon>
</template>
</el-statistic>
</el-card>
</el-col>
</el-row>
<el-card style="margin-top: 20px">
<template #header>
<div class="card-header">
<span>Quick Actions</span>
</div>
</template>
<el-space>
<el-button type="primary" :loading="syncing" @click="handleSyncAll">
<el-icon><Refresh /></el-icon>
Sync All Servers
</el-button>
<el-button @click="$router.push('/servers')">
<el-icon><Plus /></el-icon>
Add Server
</el-button>
</el-space>
</el-card>
<el-card style="margin-top: 20px">
<template #header>
<div class="card-header">
<span>Recent Sync Activity</span>
<el-button text @click="$router.push('/logs')">View All</el-button>
</div>
</template>
<el-table :data="recentLogs" stripe>
<el-table-column prop="started_at" label="Time" width="180">
<template #default="{ row }">
{{ formatDate(row.started_at) }}
</template>
</el-table-column>
<el-table-column prop="server_id" label="Server ID" width="100" />
<el-table-column prop="status" label="Status" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="Message" />
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { syncApi } from '@/api'
import { ElMessage } from 'element-plus'
import {
Server,
CircleCheck,
DocumentCopy,
PieChart,
Refresh,
Plus
} from '@element-plus/icons-vue'
import type { Stats, SyncLog } from '@/api/types'
const stats = ref<Stats>({
server_count: 0,
active_servers: 0,
repo_count: 0,
total_size: 0
})
const recentLogs = ref<SyncLog[]>([])
const syncing = ref(false)
async function loadStats() {
try {
const response = await syncApi.getStats()
stats.value = response.data
} catch (error) {
console.error('Failed to load stats:', error)
}
}
async function loadRecentLogs() {
try {
const response = await syncApi.getLogs({ limit: 10 })
recentLogs.value = response.data.data
} catch (error) {
console.error('Failed to load logs:', error)
}
}
async function handleSyncAll() {
syncing.value = true
try {
await syncApi.syncAll()
ElMessage.success('Sync started for all servers')
setTimeout(() => {
loadStats()
loadRecentLogs()
}, 1000)
} catch (error) {
ElMessage.error('Failed to start sync')
} finally {
syncing.value = false
}
}
function formatSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i]
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString()
}
function getStatusType(status: string): 'success' | 'danger' | 'info' {
if (status === 'success') return 'success'
if (status === 'failed') return 'danger'
return 'info'
}
onMounted(() => {
loadStats()
loadRecentLogs()
})
</script>
<style scoped>
.dashboard {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
```
- [ ] **Step 2: Commit**
```bash
git add web/src/views/Dashboard.vue
git commit -m "feat: add dashboard page"
```
***
### Task 15: Create Servers Page
**Files:**
- Create: `web/src/views/Servers.vue`
- [ ] **Step 1: Create Servers.vue**
Create `web/src/views/Servers.vue`:
```vue
<template>
<div class="servers">
<el-card>
<template #header>
<div class="card-header">
<span>Gitea Servers</span>
<el-button type="primary" @click="showAddDialog = true">
<el-icon><Plus /></el-icon>
Add Server
</el-button>
</div>
</template>
<el-table :data="servers" stripe v-loading="loading">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="Name" />
<el-table-column prop="url" label="URL" />
<el-table-column prop="sync_interval" label="Interval" width="100">
<template #default="{ row }">
{{ row.sync_interval > 0 ? `${row.sync_interval} min` : 'Manual' }}
</template>
</el-table-column>
<el-table-column prop="status" label="Status" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="last_sync_at" label="Last Sync" width="180">
<template #default="{ row }">
{{ row.last_sync_at ? formatDate(row.last_sync_at) : 'Never' }}
</template>
</el-table-column>
<el-table-column label="Actions" width="280" fixed="right">
<template #default="{ row }">
<el-space>
<el-button size="small" @click="discoverRepos(row)">Discover</el-button>
<el-button size="small" type="primary" @click="syncServer(row)">Sync</el-button>
<el-button size="small" @click="editServer(row)">Edit</el-button>
<el-button size="small" type="danger" @click="confirmDelete(row)">Delete</el-button>
</el-space>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- Add/Edit Dialog -->
<el-dialog
v-model="showAddDialog"
:title="editingServer ? 'Edit Server' : 'Add Server'"
width="500px"
>
<el-form :model="serverForm" label-width="100px">
<el-form-item label="Name">
<el-input v-model="serverForm.name" placeholder="My Gitea Server" />
</el-form-item>
<el-form-item label="URL">
<el-input v-model="serverForm.url" placeholder="https://git.example.com" />
</el-form-item>
<el-form-item label="Token">
<el-input
v-model="serverForm.token"
type="password"
show-password
placeholder="Gitea API token"
/>
</el-form-item>
<el-form-item label="Sync Interval">
<el-input-number
v-model="serverForm.sync_interval"
:min="0"
:max="1440"
placeholder="0 = manual only"
/>
<span style="margin-left: 10px; color: #909399">minutes (0 = manual only)</span>
</el-form-item>
</el-form>
<el-space style="margin-top: 20px">
<el-button type="primary" @click="saveServer">Save</el-button>
<el-button @click="testConnection">Test Connection</el-button>
<el-button @click="showAddDialog = false">Cancel</el-button>
</el-space>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { serverApi } from '@/api'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import type { GiteaServer } from '@/api/types'
const servers = ref<GiteaServer[]>([])
const loading = ref(false)
const showAddDialog = ref(false)
const editingServer = ref<GiteaServer | null>(null)
const serverForm = ref({
name: '',
url: '',
token: '',
sync_interval: 0
})
async function loadServers() {
loading.value = true
try {
const response = await serverApi.list()
servers.value = response.data
} catch (error) {
ElMessage.error('Failed to load servers')
} finally {
loading.value = false
}
}
function editServer(server: GiteaServer) {
editingServer.value = server
serverForm.value = {
name: server.name,
url: server.url,
token: '', // Don't show existing token
sync_interval: server.sync_interval
}
showAddDialog.value = true
}
async function saveServer() {
if (!serverForm.value.name || !serverForm.value.url || !serverForm.value.token) {
ElMessage.warning('Please fill in all required fields')
return
}
try {
if (editingServer.value) {
// Update existing
const data: any = {
name: serverForm.value.name,
url: serverForm.value.url,
sync_interval: serverForm.value.sync_interval
}
if (serverForm.value.token) {
data.token = serverForm.value.token
}
await serverApi.update(editingServer.value.id, data)
ElMessage.success('Server updated')
} else {
// Create new
await serverApi.create(serverForm.value)
ElMessage.success('Server added')
}
showAddDialog.value = false
resetForm()
loadServers()
} catch (error) {
ElMessage.error('Failed to save server')
}
}
async function testConnection() {
if (!serverForm.value.url || !serverForm.value.token) {
ElMessage.warning('Enter URL and token first')
return
}
try {
const response = await serverApi.test({
url: serverForm.value.url,
token: serverForm.value.token
})
ElMessage.success(`Connected! User: ${response.data.user}`)
} catch (error) {
ElMessage.error('Connection failed')
}
}
async function discoverRepos(server: GiteaServer) {
try {
const response = await serverApi.discover(server.id)
ElMessage.success(response.data.message)
} catch (error) {
ElMessage.error('Discovery failed')
}
}
async function syncServer(server: GiteaServer) {
try {
await serverApi.sync(server.id)
ElMessage.success('Sync started')
} catch (error: any) {
if (error.response?.status === 409) {
ElMessage.warning('Sync already in progress')
} else {
ElMessage.error('Failed to start sync')
}
}
}
function confirmDelete(server: GiteaServer) {
ElMessageBox.confirm(
'This will delete the server and all its repositories. Continue?',
'Confirm Delete',
{
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
type: 'warning'
}
).then(async () => {
try {
await serverApi.delete(server.id)
ElMessage.success('Server deleted')
loadServers()
} catch (error) {
ElMessage.error('Failed to delete server')
}
})
}
function resetForm() {
editingServer.value = null
serverForm.value = {
name: '',
url: '',
token: '',
sync_interval: 0
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString()
}
onMounted(() => {
loadServers()
})
</script>
<style scoped>
.servers {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
```
- [ ] **Step 2: Commit**
```bash
git add web/src/views/Servers.vue
git commit -m "feat: add servers management page"
```
***
### Task 16: Create Repositories, Logs, and Settings Pages
**Files:**
- Create: `web/src/views/Repos.vue`
- Create: `web/src/views/Logs.vue`
- Create: `web/src/views/Settings.vue`
- [ ] **Step 1: Create Repos.vue**
Create `web/src/views/Repos.vue`:
```vue
<template>
<div class="repos">
<el-card>
<template #header>
<div class="card-header">
<span>Repositories</span>
<el-select v-model="selectedServer" placeholder="Select Server" style="width: 200px">
<el-option label="All Servers" :value="0" />
<el-option
v-for="server in servers"
:key="server.id"
:label="server.name"
:value="server.id"
/>
</el-select>
</div>
</template>
<el-table :data="repos" stripe v-loading="loading">
<el-table-column prop="full_name" label="Repository" />
<el-table-column prop="server_id" label="Server ID" width="100" />
<el-table-column prop="size" label="Size" width="120">
<template #default="{ row }">
{{ formatSize(row.size) }}
</template>
</el-table-column>
<el-table-column prop="sync_status" label="Sync Status" width="120">
<template #default="{ row }">
<el-tag :type="getStatusType(row.sync_status)">
{{ row.sync_status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="last_sync_at" label="Last Sync" width="180">
<template #default="{ row }">
{{ row.last_sync_at ? formatDate(row.last_sync_at) : 'Never' }}
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="repos.length > 0"
style="margin-top: 20px; justify-content: center"
layout="prev, pager, next"
:page-size="50"
:total="totalCount"
@current-change="loadRepos"
/>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { serverApi } from '@/api'
import type { GiteaServer, Repo } from '@/api/types'
const servers = ref<GiteaServer[]>([])
const repos = ref<Repo[]>([])
const loading = ref(false)
const selectedServer = ref(0)
const totalCount = ref(0)
async function loadServers() {
try {
const response = await serverApi.list()
servers.value = response.data
} catch (error) {
console.error('Failed to load servers:', error)
}
}
async function loadRepos(page = 1) {
loading.value = true
try {
if (selectedServer.value === 0) {
// Load repos from all servers
const allRepos: Repo[] = []
for (const server of servers.value) {
const response = await serverApi.getRepos(server.id)
allRepos.push(...response.data)
}
repos.value = allRepos
totalCount.value = allRepos.length
} else {
const response = await serverApi.getRepos(selectedServer.value)
repos.value = response.data
totalCount.value = response.data.length
}
} catch (error) {
console.error('Failed to load repos:', error)
} finally {
loading.value = false
}
}
function formatSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i]
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString()
}
function getStatusType(status: string): 'success' | 'danger' | 'warning' | 'info' {
if (status === 'success') return 'success'
if (status === 'failed') return 'danger'
if (status === 'syncing') return 'warning'
return 'info'
}
watch(selectedServer, () => {
loadRepos()
})
onMounted(() => {
loadServers()
})
</script>
<style scoped>
.repos {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
```
- [ ] **Step 2: Create Logs.vue**
Create `web/src/views/Logs.vue`:
```vue
<template>
<div class="logs">
<el-card>
<template #header>
<div class="card-header">
<span>Sync Logs</span>
<el-select v-model="filterServer" placeholder="Filter by Server" clearable style="width: 200px">
<el-option
v-for="server in servers"
:key="server.id"
:label="server.name"
:value="server.id"
/>
</el-select>
</div>
</template>
<el-table :data="logs" stripe v-loading="loading">
<el-table-column prop="started_at" label="Time" width="180">
<template #default="{ row }">
{{ formatDate(row.started_at) }}
</template>
</el-table-column>
<el-table-column prop="server_id" label="Server ID" width="100" />
<el-table-column prop="status" label="Status" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="Message" />
<el-table-column prop="finished_at" label="Duration" width="100">
<template #default="{ row }">
{{ getDuration(row) }}
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top: 20px; justify-content: center"
layout="prev, pager, next"
:page-size="pageSize"
:current-page="currentPage"
:total="totalCount"
@current-change="loadLogs"
/>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { serverApi, syncApi } from '@/api'
import type { GiteaServer, SyncLog } from '@/api/types'
const servers = ref<GiteaServer[]>([])
const logs = ref<SyncLog[]>([])
const loading = ref(false)
const filterServer = ref<number | null>(null)
const currentPage = ref(1)
const pageSize = 50
const totalCount = ref(0)
async function loadServers() {
try {
const response = await serverApi.list()
servers.value = response.data
} catch (error) {
console.error('Failed to load servers:', error)
}
}
async function loadLogs(page = 1) {
loading.value = true
currentPage.value = page
try {
const params: any = { page, limit: pageSize }
if (filterServer.value) {
params.server_id = filterServer.value
}
const response = await syncApi.getLogs(params)
logs.value = response.data.data
totalCount.value = response.data.count
} catch (error) {
console.error('Failed to load logs:', error)
} finally {
loading.value = false
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString()
}
function getStatusType(status: string): 'success' | 'danger' | 'info' {
if (status === 'success') return 'success'
if (status === 'failed') return 'danger'
return 'info'
}
function getDuration(log: SyncLog): string {
if (!log.finished_at) return '-'
const start = new Date(log.started_at).getTime()
const end = new Date(log.finished_at).getTime()
const diff = (end - start) / 1000
if (diff < 60) return `${diff}s`
return `${Math.floor(diff / 60)}m ${diff % 60}s`
}
watch(filterServer, () => {
loadLogs(1)
})
onMounted(() => {
loadServers()
loadLogs()
})
</script>
<style scoped>
.logs {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
```
- [ ] **Step 3: Create Settings.vue**
Create `web/src/views/Settings.vue`:
```vue
<template>
<div class="settings">
<el-card>
<template #header>
<span>Settings</span>
</template>
<el-form :model="settings" label-width="150px" style="max-width: 600px">
<el-form-item label="Current Password">
<el-input v-model="adminPassword" type="password" show-password disabled />
<span style="margin-left: 10px; color: #909399; font-size: 12px">
Hidden for security
</span>
</el-form-item>
<el-form-item label="New Password">
<el-input
v-model="newPassword"
type="password"
show-password
placeholder="Leave empty to keep current"
/>
</el-form-item>
<el-form-item label="Listen Address">
<el-input v-model="settings.listen_addr" placeholder=":9000" />
</el-form-item>
<el-form-item label="Repos Directory">
<el-input v-model="settings.repos_dir" placeholder="./data/repos" />
</el-form-item>
<el-form-item label="Max Concurrent Sync">
<el-input-number v-model="settings.max_concurrent" :min="1" :max="10" />
</el-form-item>
<el-form-item>
<el-space>
<el-button type="primary" @click="saveSettings">Save Settings</el-button>
<el-button @click="loadSettings">Reset</el-button>
</el-space>
</el-form-item>
<el-alert
type="info"
title="Note: Listen address change requires restarting the application"
:closable="false"
style="margin-top: 20px"
/>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { authApi } from '@/api'
import { ElMessage } from 'element-plus'
const settings = ref({
listen_addr: ':9000',
repos_dir: '',
max_concurrent: 3
})
const adminPassword = ref('********')
const newPassword = ref('')
async function loadSettings() {
try {
const response = await authApi.getSettings()
settings.value = {
listen_addr: response.data.listen_addr,
repos_dir: response.data.repos_dir,
max_concurrent: response.data.max_concurrent
}
} catch (error) {
ElMessage.error('Failed to load settings')
}
}
async function saveSettings() {
try {
const data: any = {
listen_addr: settings.value.listen_addr,
repos_dir: settings.value.repos_dir,
max_concurrent: settings.value.max_concurrent
}
if (newPassword.value) {
data.admin_password = newPassword.value
}
await authApi.updateSettings(data)
ElMessage.success('Settings saved')
newPassword.value = ''
} catch (error) {
ElMessage.error('Failed to save settings')
}
}
onMounted(() => {
loadSettings()
})
</script>
<style scoped>
.settings {
padding: 20px;
}
</style>
```
- [ ] **Step 4: Commit**
```bash
git add web/src/views/Repos.vue web/src/views/Logs.vue web/src/views/Settings.vue
git commit -m "feat: add repos, logs, and settings pages"
```
***
## Phase 5: Frontend Build Integration
### Task 17: Embed Frontend in Go Binary
**Files:**
- Modify: `main.go`
- Modify: `Makefile`
- [ ] **Step 1: Add embed directive to main.go**
Edit `main.go`, add after the imports:
```go
//go:embed web/dist
var webFS embed.FS
```
And add `"embed"` and `"io/fs"` to imports:
```go
import (
"embed"
"flag"
"fmt"
"io/fs"
"log"
"os"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gitm/internal/config"
"gitm/internal/database"
"gitm/internal/handler"
"gitm/internal/middleware"
"gitm/internal/sync"
)
```
- [ ] **Step 2: Serve embedded frontend**
Edit `main.go`, update the `runServer` function. Find the comment about static files and replace with:
```go
// Serve embedded frontend
distFS, err := fs.Sub(webFS, "web/dist")
if err != nil {
return fmt.Errorf("failed to create sub filesystem: %w", err)
}
r.StaticFS("/", http.FS(distFS))
// API routes fallback for SPA
r.NoRoute(func(c *gin.Context) {
c.FileFromFS("/", http.FS(distFS))
})
```
- [ ] **Step 3: Add** **`"net/http"`** **to imports**
Edit `main.go` imports:
```go
import (
"embed"
"flag"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gitm/internal/config"
"gitm/internal/database"
"gitm/internal/handler"
"gitm/internal/middleware"
"gitm/internal/sync"
)
```
- [ ] **Step 4: Update runServer function**
Edit `main.go`, update `runServer` to serve static files correctly:
```go
func runServer(cfg *config.Config, engine *sync.Engine, scheduler *sync.Scheduler) error {
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
// Serve embedded frontend
distFS, err := fs.Sub(webFS, "web/dist")
if err == nil {
// Try to serve embedded files first
r.StaticFS("/assets", http.FS(distFS))
r.NoRoute(func(c *gin.Context) {
c.Request.URL.Path = "/"
c.FileFromFS("/", http.FS(distFS))
})
} else {
// Fallback to local files for development
r.Static("/", "./web/dist")
r.NoRoute(func(c *gin.Context) {
c.File("./web/dist/index.html")
})
}
// API routes
api := r.Group("/api")
{
// Public routes
api.POST("/login", handler.HandleLogin)
api.POST("/init", handler.HandleInit)
// Protected routes
protected := api.Group("")
protected.Use(middleware.AuthMiddleware())
{
// Settings
protected.GET("/settings", handler.HandleGetSettings)
protected.PUT("/settings", handler.HandleUpdateSettings)
// Servers
protected.GET("/servers", handler.HandleListServers)
protected.POST("/servers", handler.HandleCreateServer)
protected.PUT("/servers/:id", handler.HandleUpdateServer)
protected.DELETE("/servers/:id", handler.HandleDeleteServer)
protected.POST("/servers/:id/test", handler.HandleTestConnection)
protected.GET("/servers/:id/repos", handler.HandleListRepos)
protected.POST("/servers/:id/discover", handler.HandleDiscoverRepos)
protected.POST("/servers/:id/sync", handler.HandleSyncServer(engine))
protected.GET("/servers/:id/sync/status", handler.HandleGetSyncStatus(engine))
// Sync
protected.POST("/sync/all", handler.HandleSyncAll(engine))
// Logs and stats
protected.GET("/sync/logs", handler.HandleGetSyncLogs)
protected.GET("/sync/stats", handler.HandleGetStats)
}
}
return r.Run(cfg.ListenAddr)
}
```
- [ ] **Step 5: Update Makefile**
Edit `Makefile` to fix build order:
```makefile
.PHONY: all build frontend clean test run
# Build frontend first, then build the binary
all: frontend build
# Build Vue frontend
frontend:
cd web && npm install && npm run build
# Build Go binary (requires frontend built)
build:
go build -o bin/gitm .
# Clean build artifacts
clean:
rm -rf bin/
rm -rf web/dist/
rm -rf web/node_modules/
# Run tests
test:
go test -v ./...
# Run the application (development mode with local frontend)
run-dev:
cd web && npm run dev
# Run the built binary
run:
./bin/gitm
# Cross-compile for Linux
build-linux:
GOOS=linux GOARCH=amd64 go build -o bin/gitm-linux .
# Cross-compile for Windows
build-windows:
GOOS=windows GOARCH=amd64 go build -o bin/gitm.exe .
```
- [ ] **Step 6: Test build**
```bash
cd web && npm install && npm run build
cd ..
go build -o bin/gitm .
```
Expected: Success, `bin/gitm` created
- [ ] **Step 7: Commit**
```bash
git add main.go Makefile
git commit -m "feat: embed frontend in Go binary"
```
***
## Phase 6: Testing and Documentation
### Task 18: Create Tests
**Files:**
- Create: `internal/database/database_test.go`
- Create: `internal/gitea/client_test.go`
- [ ] **Step 1: Create database tests**
Create `internal/database/database_test.go`:
```go
package database
import (
"os"
"testing"
)
func TestDatabase(t *testing.T) {
// Use temp database
tmpDB := "/tmp/test_gitm.db"
defer os.Remove(tmpDB)
if err := Initialize(tmpDB); err != nil {
t.Fatalf("Failed to initialize database: %v", err)
}
defer Close()
// Test settings
t.Run("Settings", func(t *testing.T) {
err := SetSetting("test_key", "test_value")
if err != nil {
t.Errorf("Failed to set setting: %v", err)
}
value, err := GetSetting("test_key")
if err != nil {
t.Errorf("Failed to get setting: %v", err)
}
if value != "test_value" {
t.Errorf("Expected test_value, got %s", value)
}
})
// Test server CRUD
t.Run("ServerCRUD", func(t *testing.T) {
server := &GiteaServer{
Name: "Test Server",
URL: "https://test.com",
Token: "test-token",
SyncInterval: 60,
Status: "active",
}
// Create
if err := CreateServer(server); err != nil {
t.Errorf("Failed to create server: %v", err)
}
if server.ID == 0 {
t.Error("Expected server ID to be set")
}
// Read
fetched, err := GetServer(server.ID)
if err != nil {
t.Errorf("Failed to get server: %v", err)
}
if fetched.Name != server.Name {
t.Errorf("Expected %s, got %s", server.Name, fetched.Name)
}
// Update
fetched.Name = "Updated Server"
if err := UpdateServer(fetched); err != nil {
t.Errorf("Failed to update server: %v", err)
}
// List
servers, err := GetServers()
if err != nil {
t.Errorf("Failed to list servers: %v", err)
}
if len(servers) != 1 {
t.Errorf("Expected 1 server, got %d", len(servers))
}
// Delete
if err := DeleteServer(server.ID); err != nil {
t.Errorf("Failed to delete server: %v", err)
}
})
}
```
- [ ] **Step 2: Run tests**
```bash
go test ./internal/database/... -v
```
Expected: All tests pass
- [ ] **Step 3: Create Gitea client tests**
Create `internal/gitea/client_test.go`:
```go
package gitea
import (
"testing"
)
func TestNewClient(t *testing.T) {
client, err := NewClient("https://gitea.com", "")
if err != nil {
t.Errorf("Failed to create client: %v", err)
}
if client == nil {
t.Error("Expected client to be created")
}
}
func TestNewClientInvalidURL(t *testing.T) {
_, err := NewClient("://invalid", "")
if err == nil {
t.Error("Expected error for invalid URL")
}
}
```
- [ ] **Step 4: Run tests**
```bash
go test ./internal/gitea/... -v
```
Expected: All tests pass
- [ ] **Step 5: Commit**
```bash
git add internal/database/database_test.go internal/gitea/client_test.go
git commit -m "test: add unit tests for database and Gitea client"
```
***
### Task 19: Final Documentation
**Files:**
- Modify: `README.md`
- Create: `.gitignore`
- [ ] **Step 1: Update README.md**
Replace `README.md` with:
````markdown
# GitM - Gitea Repository Sync Tool
Cross-platform tool to synchronize all repositories from multiple Gitea servers to local storage.
## Features
- **Single Binary**: One executable file contains everything
- **Web UI**: Vue 3 + Element Plus management interface
- **SQLite Storage**: Lightweight, portable database
- **JWT Authentication**: Secure token-based auth
- **Scheduled Sync**: Automatic sync with configurable intervals
- **Manual Sync**: On-demand sync for any server
- **Cross-Platform**: Runs on Windows and Linux
- **Mirror Clone**: Uses `git clone --mirror` for complete backup
## Quick Start
### Build
```bash
# Clone the repository
git clone <repository-url>
cd gitm
# Build everything (frontend + backend)
make all
# The binary will be at bin/gitm
````
### First Run
```bash
# Initialize and set admin password
./bin/gitm --init
# Start the server (default port 9000)
./bin/gitm
# Or specify custom port
./bin/gitm --addr :9090
```
### Access Web UI
Open your browser and navigate to:
- <http://localhost:9000> (default)
- <http://localhost:9090> (if you used --addr :9090)
## Usage
### Adding a Gitea Server
1. Log in with the password you set during initialization
2. Go to **Servers** page
3. Click **Add Server**
4. Fill in:
- **Name**: A friendly name for this server
- **URL**: The Gitea server URL (e.g., `https://git.example.com`)
- **Token**: Your Gitea API access token
- **Sync Interval**: Minutes between automatic syncs (0 = manual only)
5. Click **Test Connection** to verify
6. Click **Save**
### Creating a Gitea API Token
1. Log into your Gitea instance
2. Go to **Settings** → **Applications**
3. Click **Generate New Token**
4. Give it a name and select **read** permission for repositories
5. Copy the token (you won't see it again!)
### Syncing Repositories
There are two ways to sync:
1. **Automatic**: Set a sync interval (in minutes) when adding a server
2. **Manual**: Click the **Sync** button on the Servers page
To discover new repositories without syncing, click **Discover**.
### Storage
Repositories are stored as bare git mirrors at:
```
<data_dir>/repos/server_<id>_<name>/<owner>/<repo>.git
```
Default data directory is `./data` relative to the binary.
## Configuration
Settings are stored in the SQLite database and can be changed via the Web UI:
- **Admin Password**: Change your login password
- **Listen Address**: Port to listen on (requires restart)
- **Repos Directory**: Where to store cloned repositories
- **Max Concurrent Sync**: Maximum parallel sync operations
## API Endpoints
The API is available at `/api/` and requires JWT authentication:
### Authentication
- `POST /api/login` - Authenticate and get token
- `POST /api/init` - Initialize admin password
### Settings
- `GET /api/settings` - Get all settings
- `PUT /api/settings` - Update settings
### Servers
- `GET /api/servers` - List all servers
- `POST /api/servers` - Add a server
- `PUT /api/servers/:id` - Update a server
- `DELETE /api/servers/:id` - Delete a server
- `POST /api/servers/:id/test` - Test connection
- `GET /api/servers/:id/repos` - List repositories
- `POST /api/servers/:id/discover` - Discover repositories
- `POST /api/servers/:id/sync` - Trigger sync
- `GET /api/servers/:id/sync/status` - Get sync status
### Sync
- `POST /api/sync/all` - Sync all servers
- `GET /api/sync/logs` - Get sync logs
- `GET /api/sync/stats` - Get statistics
## Development
### Prerequisites
- Go 1.26+
- Node.js 18+
- npm
### Frontend Development
```bash
cd web
npm install
npm run dev
```
The dev server runs on <http://localhost:5173> with API proxy to :9000.
### Backend Development
```bash
go run main.go
```
### Running Tests
```bash
go test ./...
```
### Project Structure
```
gitm/
├── main.go # Entry point
├── internal/
│ ├── config/ # Configuration
│ ├── database/ # SQLite database
│ ├── models/ # Data models
│ ├── gitea/ # Gitea API client
│ ├── sync/ # Sync engine
│ ├── middleware/ # JWT middleware
│ └── handler/ # HTTP handlers
├── web/ # Vue 3 frontend
│ ├── src/
│ │ ├── api/ # API client
│ │ ├── stores/ # Pinia stores
│ │ ├── views/ # Page components
│ │ └── router/ # Vue Router
│ └── dist/ # Build output
├── Makefile # Build commands
└── docs/ # Documentation
```
## Security Notes
- Gitea API tokens are stored encrypted in the database
- JWT tokens expire after 24 hours
- By default, the server listens on localhost only
- Change the default JWT secret in production
## License
MIT License
```
- [ ] **Step 2: Create .gitignore**
Create `.gitignore`:
```
# Binaries
bin/
gitm
gitm.exe
gitm-linux
# Data
data/
\*.db
\*.db-shm
\*.db-wal
# Go
\*.exe
\*.exe\~
\*.dll
\*.so
\*.dylib
\*.test
\*.out
go.work
# Node
web/node\_modules/
web/dist/
# IDE
.idea/
.vscode/
\*.swp
\*.swo
\*\~
# OS
.DS\_Store
Thumbs.db
# Temporary
\*.log
\*.tmp
````
- [ ] **Step 3: Commit**
```bash
git add README.md .gitignore
git commit -m "docs: update README and add .gitignore"
````
***
## Task 20: Final Build Verification
**Files:**
- None (verification task)
- [ ] **Step 1: Full clean build**
```bash
make clean
make all
```
Expected: Clean build completes successfully
- [ ] **Step 2: Verify binary works**
```bash
./bin/gitm --help
```
Expected: Shows usage or starts server
- [ ] **Step 3: Test initialization**
```bash
rm -rf data/
./bin/gitm --init
# Enter: testpassword123
```
Expected: "Initialized successfully!"
- [ ] **Step 4: Quick functional test**
```bash
# Start server in background
./bin/gitm &
SERVER_PID=$!
# Wait for startup
sleep 2
# Test API (login)
curl -X POST http://localhost:9000/api/login \
-H "Content-Type: application/json" \
-d '{"password":"testpassword123"}'
# Kill server
kill $SERVER_PID
```
Expected: Returns `{"token":"..."}`
- [ ] **Step 5: Cross-compile test**
```bash
make build-linux
make build-windows
ls -lh bin/
```
Expected: `gitm-linux` and `gitm.exe` created
- [ ] **Step 6: Final commit**
```bash
git add -A
git commit -m "chore: final build verification complete"
```
***
## Implementation Complete
The GitM project is now fully implemented with:
1. ✅ Go backend with Gin framework
2. ✅ SQLite database with all required tables
3. ✅ JWT authentication
4. ✅ Gitea API client for repo discovery
5. ✅ Sync engine with git clone/fetch operations
6. ✅ Scheduled sync with configurable intervals
7. ✅ Vue 3 + Element Plus frontend
8. ✅ Embedded frontend in single binary
9. ✅ Cross-platform build support
10. ✅ Complete API coverage
11. ✅ Unit tests
12. ✅ Documentation
### Next Steps (Optional Enhancements):
1. Add systemd service file for Linux
2. Add Windows service wrapper
3. Implement token encryption at rest
4. Add webhook notifications
5. Implement repo pruning (remove deleted repos)
6. Add more detailed sync progress reporting
7. Implement backup/restore functionality
8. Add Docker support