Compare commits

...

10 Commits

Author SHA1 Message Date
panw
34944518f0 docs: 更新实施计划文档格式和内容
- 添加Claude本地设置文件,允许Bash操作权限
- 将文档中的分隔线从"---"统一改为"***"
- 修复markdown代码块嵌套格式问题
- 调整任务列表格式,移除多余空行
- 修正HTTP链接的markdown格式
- 更新.gitignore文件格式,修复转义字符问题
2026-03-31 17:26:07 +08:00
panw
a7cfa381e7 docs: update .gitignore and README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:45:41 +08:00
panw
6e8cbb38d4 test: add unit tests for database and Gitea client
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:44:43 +08:00
panw
932b367806 feat: embed frontend in Go binary
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:42:54 +08:00
panw
373cb70633 feat: implement Vue 3 frontend with all pages
Complete web UI built with Vue 3, TypeScript, Element Plus, and Pinia.
Includes Login, Dashboard, Servers, Repositories, Sync Logs, and Settings pages
with API client, auth store, and Vue Router configuration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:38:45 +08:00
panw
333e999d76 feat: wire up main server with all routes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:24:36 +08:00
panw
56deffc6a4 feat: implement sync log and stats handlers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:24:28 +08:00
panw
86b21ccc31 feat: implement server management handlers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:24:20 +08:00
panw
e55c892488 feat: implement sync engine and scheduler
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:17:50 +08:00
panw
872bab34b6 feat: implement Gitea API client
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:17:39 +08:00
32 changed files with 3425 additions and 83 deletions

View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(mkdir:*)",
"Bash(rm:*)"
]
}
}

10
.gitignore vendored
View File

@@ -1,6 +1,12 @@
bin/ bin/
data/
*.db
*.db-shm
*.db-wal
web/node_modules/
web/dist/
*.exe *.exe
*.test *.test
*.out *.out
web/dist/ .idea/
web/node_modules/ .vscode/

View File

@@ -4,7 +4,7 @@ Cross-platform tool to synchronize all repositories from multiple Gitea servers
## Features ## Features
- Single binary deployment - Single binary deployment (frontend embedded)
- Web UI for management (Vue 3 + Element Plus) - Web UI for management (Vue 3 + Element Plus)
- SQLite database - SQLite database
- JWT authentication - JWT authentication
@@ -14,20 +14,47 @@ Cross-platform tool to synchronize all repositories from multiple Gitea servers
## Build ## Build
```bash ```bash
make all # Build binary with embedded frontend
go build -o bin/gitm .
# Or with CGO for SQLite support
CGO_ENABLED=1 go build -o bin/gitm .
``` ```
## Run ## Run
```bash ```bash
# First-time initialization (set admin password)
./bin/gitm --init ./bin/gitm --init
# Start server (default :9000)
./bin/gitm ./bin/gitm
# Custom address
./bin/gitm --addr :9090 ./bin/gitm --addr :9090
# Custom data directory
./bin/gitm --data /path/to/data
``` ```
## Usage
1. Initialize: `./bin/gitm --init` -- sets admin password
2. Start: `./bin/gitm` -- starts web server
3. Open browser to `http://localhost:9000`
4. Login and add Gitea servers
5. Discover repos and sync
## Development ## Development
```bash ```bash
# Frontend dev server
cd web && npm install && npm run dev cd web && npm install && npm run dev
# Backend
go run main.go go run main.go
# Run tests
go test ./internal/gitea/... -v
CGO_ENABLED=1 go test ./internal/database/... -v
``` ```

View File

@@ -8,19 +8,19 @@
**Tech Stack:** Go 1.26+, Gin, SQLite (mattn/go-sqlite3), Vue 3, Element Plus, Pinia, Vite, golang-jwt/jwt, bcrypt **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 ## Phase 1: Project Initialization and Core Infrastructure
### Task 1: Initialize Go Module and Project Structure ### Task 1: Initialize Go Module and Project Structure
**Files:** **Files:**
- Create: `go.mod` - Create: `go.mod`
- Create: `go.sum` (auto-generated) - Create: `go.sum` (auto-generated)
- Create: `Makefile` - Create: `Makefile`
- Create: `main.go` - Create: `main.go`
- Create: `README.md` - Create: `README.md`
- [ ] **Step 1: Initialize Go module** - [ ] **Step 1: Initialize Go module**
```bash ```bash
@@ -139,7 +139,7 @@ func runServer() error {
Create `README.md`: Create `README.md`:
```markdown ````markdown
# GitM - Gitea Repository Sync Tool # GitM - Gitea Repository Sync Tool
Cross-platform tool to synchronize all repositories from multiple Gitea servers to local storage. Cross-platform tool to synchronize all repositories from multiple Gitea servers to local storage.
@@ -157,7 +157,7 @@ Cross-platform tool to synchronize all repositories from multiple Gitea servers
```bash ```bash
make all make all
``` ````
## Run ## Run
@@ -184,23 +184,24 @@ cd web && npm run dev
# Run backend (requires frontend built first) # Run backend (requires frontend built first)
go run main.go go run main.go
``` ```
```
````
- [ ] **Step 7: Commit** - [ ] **Step 7: Commit**
```bash ```bash
git add go.mod go.sum Makefile main.go README.md git add go.mod go.sum Makefile main.go README.md
git commit -m "feat: initialize project structure and dependencies" git commit -m "feat: initialize project structure and dependencies"
``` ````
--- ***
### Task 2: Create Internal Package Structure ### Task 2: Create Internal Package Structure
**Files:** **Files:**
- Create: `internal/config/config.go` - Create: `internal/config/config.go`
- Create: `internal/models/models.go` - Create: `internal/models/models.go`
- [ ] **Step 1: Create config package** - [ ] **Step 1: Create config package**
Create `internal/config/config.go`: Create `internal/config/config.go`:
@@ -319,13 +320,13 @@ git add internal/config/config.go internal/models/models.go
git commit -m "feat: add config and models packages" git commit -m "feat: add config and models packages"
``` ```
--- ***
### Task 3: Implement Database Layer ### Task 3: Implement Database Layer
**Files:** **Files:**
- Create: `internal/database/database.go`
- Create: `internal/database/database.go`
- [ ] **Step 1: Create database package** - [ ] **Step 1: Create database package**
Create `internal/database/database.go`: Create `internal/database/database.go`:
@@ -655,14 +656,14 @@ git add internal/database/
git commit -m "feat: implement SQLite database layer" git commit -m "feat: implement SQLite database layer"
``` ```
--- ***
### Task 4: Implement JWT Authentication ### Task 4: Implement JWT Authentication
**Files:** **Files:**
- Create: `internal/middleware/auth.go` - Create: `internal/middleware/auth.go`
- Create: `internal/handler/auth.go` - Create: `internal/handler/auth.go`
- [ ] **Step 1: Create auth middleware** - [ ] **Step 1: Create auth middleware**
Create `internal/middleware/auth.go`: Create `internal/middleware/auth.go`:
@@ -961,16 +962,16 @@ git add internal/middleware/ internal/handler/
git commit -m "feat: implement JWT authentication middleware and handlers" git commit -m "feat: implement JWT authentication middleware and handlers"
``` ```
--- ***
## Phase 2: Gitea API Integration ## Phase 2: Gitea API Integration
### Task 5: Implement Gitea API Client ### Task 5: Implement Gitea API Client
**Files:** **Files:**
- Create: `internal/gitea/client.go` - Create: `internal/gitea/client.go`
- Create: `internal/gitea/types.go` - Create: `internal/gitea/types.go`
- [ ] **Step 1: Create Gitea types** - [ ] **Step 1: Create Gitea types**
Create `internal/gitea/types.go`: Create `internal/gitea/types.go`:
@@ -1145,14 +1146,14 @@ git add internal/gitea/
git commit -m "feat: implement Gitea API client" git commit -m "feat: implement Gitea API client"
``` ```
--- ***
### Task 6: Implement Sync Engine ### Task 6: Implement Sync Engine
**Files:** **Files:**
- Create: `internal/sync/engine.go` - Create: `internal/sync/engine.go`
- Create: `internal/sync/scheduler.go` - Create: `internal/sync/scheduler.go`
- [ ] **Step 1: Create sync engine** - [ ] **Step 1: Create sync engine**
Create `internal/sync/engine.go`: Create `internal/sync/engine.go`:
@@ -1532,15 +1533,15 @@ git add internal/sync/
git commit -m "feat: implement sync engine and scheduler" git commit -m "feat: implement sync engine and scheduler"
``` ```
--- ***
## Phase 3: HTTP Handlers ## Phase 3: HTTP Handlers
### Task 7: Implement Server Management Handlers ### Task 7: Implement Server Management Handlers
**Files:** **Files:**
- Create: `internal/handler/server.go`
- Create: `internal/handler/server.go`
- [ ] **Step 1: Create server handler** - [ ] **Step 1: Create server handler**
Create `internal/handler/server.go`: Create `internal/handler/server.go`:
@@ -1885,13 +1886,13 @@ git add internal/handler/server.go
git commit -m "feat: implement server management handlers" git commit -m "feat: implement server management handlers"
``` ```
--- ***
### Task 8: Implement Log and Stats Handlers ### Task 8: Implement Log and Stats Handlers
**Files:** **Files:**
- Create: `internal/handler/log.go`
- Create: `internal/handler/log.go`
- [ ] **Step 1: Create log handler** - [ ] **Step 1: Create log handler**
Create `internal/handler/log.go`: Create `internal/handler/log.go`:
@@ -1991,13 +1992,13 @@ git add internal/handler/log.go
git commit -m "feat: implement sync log and stats handlers" git commit -m "feat: implement sync log and stats handlers"
``` ```
--- ***
### Task 9: Wire Up Main Server ### Task 9: Wire Up Main Server
**Files:** **Files:**
- Modify: `main.go`
- Modify: `main.go`
- [ ] **Step 1: Update main.go with full server implementation** - [ ] **Step 1: Update main.go with full server implementation**
Replace `main.go` with: Replace `main.go` with:
@@ -2204,20 +2205,20 @@ git add main.go
git commit -m "feat: wire up main server with all routes" git commit -m "feat: wire up main server with all routes"
``` ```
--- ***
## Phase 4: Frontend Implementation ## Phase 4: Frontend Implementation
### Task 10: Initialize Vue 3 Frontend ### Task 10: Initialize Vue 3 Frontend
**Files:** **Files:**
- Create: `web/package.json` - Create: `web/package.json`
- Create: `web/vite.config.ts` - Create: `web/vite.config.ts`
- Create: `web/tsconfig.json` - Create: `web/tsconfig.json`
- Create: `web/index.html` - Create: `web/index.html`
- Create: `web/src/main.ts` - Create: `web/src/main.ts`
- Create: `web/src/App.vue` - Create: `web/src/App.vue`
- [ ] **Step 1: Create web/package.json** - [ ] **Step 1: Create web/package.json**
Create `web/package.json`: Create `web/package.json`:
@@ -2478,15 +2479,15 @@ git add web/
git commit -m "feat: initialize Vue 3 frontend structure" git commit -m "feat: initialize Vue 3 frontend structure"
``` ```
--- ***
### Task 11: Create API Client and Auth Store ### Task 11: Create API Client and Auth Store
**Files:** **Files:**
- Create: `web/src/api/index.ts` - Create: `web/src/api/index.ts`
- Create: `web/src/api/types.ts` - Create: `web/src/api/types.ts`
- Create: `web/src/stores/auth.ts` - Create: `web/src/stores/auth.ts`
- [ ] **Step 1: Create API types** - [ ] **Step 1: Create API types**
Create `web/src/api/types.ts`: Create `web/src/api/types.ts`:
@@ -2687,13 +2688,13 @@ git add web/src/api/ web/src/stores/
git commit -m "feat: add API client and auth store" git commit -m "feat: add API client and auth store"
``` ```
--- ***
### Task 12: Create Login Page ### Task 12: Create Login Page
**Files:** **Files:**
- Create: `web/src/views/Login.vue`
- Create: `web/src/views/Login.vue`
- [ ] **Step 1: Create Login.vue** - [ ] **Step 1: Create Login.vue**
Create `web/src/views/Login.vue`: Create `web/src/views/Login.vue`:
@@ -2814,13 +2815,13 @@ git add web/src/views/Login.vue
git commit -m "feat: add login page" git commit -m "feat: add login page"
``` ```
--- ***
### Task 13: Create Layout Component ### Task 13: Create Layout Component
**Files:** **Files:**
- Create: `web/src/views/Layout.vue`
- Create: `web/src/views/Layout.vue`
- [ ] **Step 1: Create Layout.vue** - [ ] **Step 1: Create Layout.vue**
Create `web/src/views/Layout.vue`: Create `web/src/views/Layout.vue`:
@@ -2962,13 +2963,13 @@ git add web/src/views/Layout.vue
git commit -m "feat: add layout component with navigation" git commit -m "feat: add layout component with navigation"
``` ```
--- ***
### Task 14: Create Dashboard Page ### Task 14: Create Dashboard Page
**Files:** **Files:**
- Create: `web/src/views/Dashboard.vue`
- Create: `web/src/views/Dashboard.vue`
- [ ] **Step 1: Create Dashboard.vue** - [ ] **Step 1: Create Dashboard.vue**
Create `web/src/views/Dashboard.vue`: Create `web/src/views/Dashboard.vue`:
@@ -3162,13 +3163,13 @@ git add web/src/views/Dashboard.vue
git commit -m "feat: add dashboard page" git commit -m "feat: add dashboard page"
``` ```
--- ***
### Task 15: Create Servers Page ### Task 15: Create Servers Page
**Files:** **Files:**
- Create: `web/src/views/Servers.vue`
- Create: `web/src/views/Servers.vue`
- [ ] **Step 1: Create Servers.vue** - [ ] **Step 1: Create Servers.vue**
Create `web/src/views/Servers.vue`: Create `web/src/views/Servers.vue`:
@@ -3433,15 +3434,15 @@ git add web/src/views/Servers.vue
git commit -m "feat: add servers management page" git commit -m "feat: add servers management page"
``` ```
--- ***
### Task 16: Create Repositories, Logs, and Settings Pages ### Task 16: Create Repositories, Logs, and Settings Pages
**Files:** **Files:**
- Create: `web/src/views/Repos.vue` - Create: `web/src/views/Repos.vue`
- Create: `web/src/views/Logs.vue` - Create: `web/src/views/Logs.vue`
- Create: `web/src/views/Settings.vue` - Create: `web/src/views/Settings.vue`
- [ ] **Step 1: Create Repos.vue** - [ ] **Step 1: Create Repos.vue**
Create `web/src/views/Repos.vue`: Create `web/src/views/Repos.vue`:
@@ -3847,16 +3848,16 @@ git add web/src/views/Repos.vue web/src/views/Logs.vue web/src/views/Settings.vu
git commit -m "feat: add repos, logs, and settings pages" git commit -m "feat: add repos, logs, and settings pages"
``` ```
--- ***
## Phase 5: Frontend Build Integration ## Phase 5: Frontend Build Integration
### Task 17: Embed Frontend in Go Binary ### Task 17: Embed Frontend in Go Binary
**Files:** **Files:**
- Modify: `main.go` - Modify: `main.go`
- Modify: `Makefile` - Modify: `Makefile`
- [ ] **Step 1: Add embed directive to main.go** - [ ] **Step 1: Add embed directive to main.go**
Edit `main.go`, add after the imports: Edit `main.go`, add after the imports:
@@ -3904,7 +3905,7 @@ Edit `main.go`, update the `runServer` function. Find the comment about static f
}) })
``` ```
- [ ] **Step 3: Add `"net/http"` to imports** - [ ] **Step 3: Add** **`"net/http"`** **to imports**
Edit `main.go` imports: Edit `main.go` imports:
@@ -4055,16 +4056,16 @@ git add main.go Makefile
git commit -m "feat: embed frontend in Go binary" git commit -m "feat: embed frontend in Go binary"
``` ```
--- ***
## Phase 6: Testing and Documentation ## Phase 6: Testing and Documentation
### Task 18: Create Tests ### Task 18: Create Tests
**Files:** **Files:**
- Create: `internal/database/database_test.go` - Create: `internal/database/database_test.go`
- Create: `internal/gitea/client_test.go` - Create: `internal/gitea/client_test.go`
- [ ] **Step 1: Create database tests** - [ ] **Step 1: Create database tests**
Create `internal/database/database_test.go`: Create `internal/database/database_test.go`:
@@ -4205,19 +4206,19 @@ git add internal/database/database_test.go internal/gitea/client_test.go
git commit -m "test: add unit tests for database and Gitea client" git commit -m "test: add unit tests for database and Gitea client"
``` ```
--- ***
### Task 19: Final Documentation ### Task 19: Final Documentation
**Files:** **Files:**
- Modify: `README.md` - Modify: `README.md`
- Create: `.gitignore` - Create: `.gitignore`
- [ ] **Step 1: Update README.md** - [ ] **Step 1: Update README.md**
Replace `README.md` with: Replace `README.md` with:
```markdown ````markdown
# GitM - Gitea Repository Sync Tool # GitM - Gitea Repository Sync Tool
Cross-platform tool to synchronize all repositories from multiple Gitea servers to local storage. Cross-platform tool to synchronize all repositories from multiple Gitea servers to local storage.
@@ -4246,7 +4247,7 @@ cd gitm
make all make all
# The binary will be at bin/gitm # The binary will be at bin/gitm
``` ````
### First Run ### First Run
@@ -4264,8 +4265,9 @@ make all
### Access Web UI ### Access Web UI
Open your browser and navigate to: Open your browser and navigate to:
- http://localhost:9000 (default)
- http://localhost:9090 (if you used --addr :9090) - <http://localhost:9000> (default)
- <http://localhost:9090> (if you used --addr :9090)
## Usage ## Usage
@@ -4302,6 +4304,7 @@ To discover new repositories without syncing, click **Discover**.
### Storage ### Storage
Repositories are stored as bare git mirrors at: Repositories are stored as bare git mirrors at:
``` ```
<data_dir>/repos/server_<id>_<name>/<owner>/<repo>.git <data_dir>/repos/server_<id>_<name>/<owner>/<repo>.git
``` ```
@@ -4322,14 +4325,17 @@ Settings are stored in the SQLite database and can be changed via the Web UI:
The API is available at `/api/` and requires JWT authentication: The API is available at `/api/` and requires JWT authentication:
### Authentication ### Authentication
- `POST /api/login` - Authenticate and get token - `POST /api/login` - Authenticate and get token
- `POST /api/init` - Initialize admin password - `POST /api/init` - Initialize admin password
### Settings ### Settings
- `GET /api/settings` - Get all settings - `GET /api/settings` - Get all settings
- `PUT /api/settings` - Update settings - `PUT /api/settings` - Update settings
### Servers ### Servers
- `GET /api/servers` - List all servers - `GET /api/servers` - List all servers
- `POST /api/servers` - Add a server - `POST /api/servers` - Add a server
- `PUT /api/servers/:id` - Update a server - `PUT /api/servers/:id` - Update a server
@@ -4341,6 +4347,7 @@ The API is available at `/api/` and requires JWT authentication:
- `GET /api/servers/:id/sync/status` - Get sync status - `GET /api/servers/:id/sync/status` - Get sync status
### Sync ### Sync
- `POST /api/sync/all` - Sync all servers - `POST /api/sync/all` - Sync all servers
- `GET /api/sync/logs` - Get sync logs - `GET /api/sync/logs` - Get sync logs
- `GET /api/sync/stats` - Get statistics - `GET /api/sync/stats` - Get statistics
@@ -4361,7 +4368,7 @@ npm install
npm run dev npm run dev
``` ```
The dev server runs on http://localhost:5173 with API proxy to :9000. The dev server runs on <http://localhost:5173> with API proxy to :9000.
### Backend Development ### Backend Development
@@ -4409,6 +4416,7 @@ gitm/
## License ## License
MIT License MIT License
``` ```
- [ ] **Step 2: Create .gitignore** - [ ] **Step 2: Create .gitignore**
@@ -4416,62 +4424,71 @@ MIT License
Create `.gitignore`: Create `.gitignore`:
``` ```
# Binaries # Binaries
bin/ bin/
gitm gitm
gitm.exe gitm.exe
gitm-linux gitm-linux
# Data # Data
data/ data/
*.db \*.db
*.db-shm \*.db-shm
*.db-wal \*.db-wal
# Go # Go
*.exe
*.exe~ \*.exe
*.dll \*.exe\~
*.so \*.dll
*.dylib \*.so
*.test \*.dylib
*.out \*.test
\*.out
go.work go.work
# Node # Node
web/node_modules/
web/node\_modules/
web/dist/ web/dist/
# IDE # IDE
.idea/ .idea/
.vscode/ .vscode/
*.swp \*.swp
*.swo \*.swo
*~ \*\~
# OS # OS
.DS_Store
.DS\_Store
Thumbs.db Thumbs.db
# Temporary # Temporary
*.log
*.tmp \*.log
``` \*.tmp
````
- [ ] **Step 3: Commit** - [ ] **Step 3: Commit**
```bash ```bash
git add README.md .gitignore git add README.md .gitignore
git commit -m "docs: update README and add .gitignore" git commit -m "docs: update README and add .gitignore"
``` ````
--- ***
## Task 20: Final Build Verification ## Task 20: Final Build Verification
**Files:** **Files:**
- None (verification task)
- None (verification task)
- [ ] **Step 1: Full clean build** - [ ] **Step 1: Full clean build**
```bash ```bash
@@ -4537,7 +4554,7 @@ git add -A
git commit -m "chore: final build verification complete" git commit -m "chore: final build verification complete"
``` ```
--- ***
## Implementation Complete ## Implementation Complete
@@ -4566,3 +4583,4 @@ The GitM project is now fully implemented with:
6. Add more detailed sync progress reporting 6. Add more detailed sync progress reporting
7. Implement backup/restore functionality 7. Implement backup/restore functionality
8. Add Docker support 8. Add Docker support

View File

@@ -0,0 +1,108 @@
package database
import (
"os"
"testing"
"gitm/internal/models"
)
func TestDatabase(t *testing.T) {
tmpDB := "/tmp/test_gitm.db"
defer os.Remove(tmpDB)
if err := Initialize(tmpDB); err != nil {
t.Fatalf("Initialize failed: %v", err)
}
defer Close()
t.Run("Settings", func(t *testing.T) {
if err := SetSetting("test_key", "test_value"); err != nil {
t.Fatal(err)
}
val, err := GetSetting("test_key")
if err != nil {
t.Fatal(err)
}
if val != "test_value" {
t.Errorf("expected test_value, got %s", val)
}
})
t.Run("ServerCRUD", func(t *testing.T) {
s := &models.GiteaServer{Name: "Test", URL: "https://test.com", Token: "tok", Status: "active"}
if err := CreateServer(s); err != nil {
t.Fatal(err)
}
if s.ID == 0 {
t.Error("ID not set")
}
got, err := GetServer(s.ID)
if err != nil {
t.Fatal(err)
}
if got.Name != "Test" {
t.Error("name mismatch")
}
servers, err := GetServers()
if err != nil {
t.Fatal(err)
}
if len(servers) != 1 {
t.Errorf("expected 1, got %d", len(servers))
}
if err := DeleteServer(s.ID); err != nil {
t.Fatal(err)
}
})
t.Run("RepoCRUD", func(t *testing.T) {
s := &models.GiteaServer{Name: "RepoTest", URL: "https://test.com", Token: "tok", Status: "active"}
CreateServer(s)
r := &models.Repo{ServerID: s.ID, Name: "repo1", FullName: "user/repo1", CloneURL: "https://test.com/user/repo1.git", SyncStatus: "pending"}
if err := CreateRepo(r); err != nil {
t.Fatal(err)
}
got, err := GetRepoByFullName(s.ID, "user/repo1")
if err != nil {
t.Fatal(err)
}
if got == nil || got.Name != "repo1" {
t.Error("repo not found")
}
repos, err := GetReposByServer(s.ID)
if err != nil {
t.Fatal(err)
}
if len(repos) != 1 {
t.Errorf("expected 1, got %d", len(repos))
}
DeleteServer(s.ID)
})
t.Run("SyncLogs", func(t *testing.T) {
s := &models.GiteaServer{Name: "LogTest", URL: "https://test.com", Token: "tok", Status: "active"}
CreateServer(s)
l := &models.SyncLog{ServerID: s.ID, Status: "success", Message: "test"}
if err := CreateSyncLog(l); err != nil {
t.Fatal(err)
}
logs, err := GetSyncLogs(s.ID, 10, 0)
if err != nil {
t.Fatal(err)
}
if len(logs) != 1 {
t.Errorf("expected 1, got %d", len(logs))
}
DeleteServer(s.ID)
})
}

98
internal/gitea/client.go Normal file
View File

@@ -0,0 +1,98 @@
package gitea
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
type Client struct {
baseURL *url.URL
httpClient *http.Client
token string
}
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
}
func (c *Client) do(path string) (*http.Response, error) {
apiURL := c.baseURL.JoinPath(path)
q := apiURL.Query()
q.Set("token", c.token)
apiURL.RawQuery = q.Encode()
resp, err := c.httpClient.Get(apiURL.String())
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
return resp, nil
}
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
}
func (c *Client) SearchRepos(page, limit int) ([]GiteaRepo, error) {
path := fmt.Sprintf("/api/v1/repos/search?page=%d&limit=%d", page, limit)
resp, err := c.do(path)
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
}
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
}

View File

@@ -0,0 +1,20 @@
package gitea
import "testing"
func TestNewClient(t *testing.T) {
client, err := NewClient("https://gitea.com", "")
if err != nil {
t.Fatal(err)
}
if client == nil {
t.Error("client is nil")
}
}
func TestNewClientInvalidURL(t *testing.T) {
_, err := NewClient("://invalid", "")
if err == nil {
t.Error("expected error for invalid URL")
}
}

25
internal/gitea/types.go Normal file
View File

@@ -0,0 +1,25 @@
package gitea
type GiteaUser struct {
ID int64 `json:"id"`
Login string `json:"login"`
FullName string `json:"full_name"`
Email string `json:"email"`
}
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"`
}
type GiteaSearchResponse struct {
OK bool `json:"ok"`
Data []GiteaRepo `json:"data"`
}

61
internal/handler/log.go Normal file
View File

@@ -0,0 +1,61 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gitm/internal/database"
)
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
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)})
}
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,
})
}

238
internal/handler/server.go Normal file
View File

@@ -0,0 +1,238 @@
package handler
import (
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gitm/internal/database"
"gitm/internal/gitea"
"gitm/internal/models"
"gitm/internal/sync"
)
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)
}
func HandleCreateServer(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
URL string `json:"url" binding:"required"`
Token string `json:"token" binding:"required"`
SyncInterval int `json:"sync_interval"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
server := &models.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)
}
func HandleUpdateServer(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 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 struct {
Name *string `json:"name"`
URL *string `json:"url"`
Token *string `json:"token"`
SyncInterval *int `json:"sync_interval"`
Status *string `json:"status"`
}
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)
}
func HandleDeleteServer(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 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"})
}
func HandleTestConnection(c *gin.Context) {
var req struct {
URL string `json:"url" binding:"required"`
Token string `json:"token" binding:"required"`
}
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})
}
func HandleListRepos(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 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)
}
func HandleDiscoverRepos(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 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
}
reposDir, _ := database.GetSetting("repos_dir")
if reposDir == "" {
reposDir = "./data/repos"
}
serverDir := reposDir + "/" + strconv.FormatInt(id, 10) + "_" + server.Name
discovered := 0
for _, gr := range giteaRepos {
existing, _ := database.GetRepoByFullName(id, gr.FullName)
if existing == nil {
repo := &models.Repo{
ServerID: id,
Name: gr.Name,
FullName: gr.FullName,
CloneURL: gr.CloneURL,
LocalPath: serverDir + "/" + gr.FullName + ".git",
SyncStatus: "pending",
}
database.CreateRepo(repo)
discovered++
}
}
c.JSON(http.StatusOK, gin.H{
"message": fmt.Sprintf("Discovered %d new repositories", discovered),
"total_repos": len(giteaRepos),
"new_repos": discovered,
})
}
func HandleSyncServer(engine *sync.Engine) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 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})
}
}
func HandleSyncAll(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)})
}
}
func HandleGetSyncStatus(engine *sync.Engine) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"})
return
}
status := "idle"
if engine.IsSyncing(id) {
status = "syncing"
}
c.JSON(http.StatusOK, gin.H{"server_id": id, "status": status})
}
}

190
internal/sync/engine.go Normal file
View File

@@ -0,0 +1,190 @@
package sync
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
"gitm/internal/database"
"gitm/internal/gitea"
"gitm/internal/models"
)
type Engine struct {
maxConcurrent int
mu sync.Mutex
activeTasks map[int64]string
}
func NewEngine(maxConcurrent int) *Engine {
return &Engine{
maxConcurrent: maxConcurrent,
activeTasks: make(map[int64]string),
}
}
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()
}()
server, err := database.GetServer(serverID)
if err != nil || server == nil {
return fmt.Errorf("failed to get server: %w", err)
}
syncLog := &models.SyncLog{
ServerID: serverID,
Status: "in_progress",
Message: "Starting sync",
StartedAt: time.Now(),
}
if err := database.CreateSyncLog(syncLog); err != nil {
return fmt.Errorf("failed to create sync log: %w", err)
}
client, err := gitea.NewClient(server.URL, server.Token)
if err != nil {
e.finishLog(syncLog, "failed", fmt.Sprintf("Failed to create client: %v", err))
return err
}
if _, err := client.ValidateToken(); err != nil {
e.finishLog(syncLog, "failed", fmt.Sprintf("Authentication failed: %v", err))
return err
}
giteaRepos, err := client.GetAllRepos()
if err != nil {
e.finishLog(syncLog, "failed", fmt.Sprintf("Failed to fetch repos: %v", err))
return err
}
reposDir, _ := database.GetSetting("repos_dir")
if reposDir == "" {
reposDir = "./data/repos"
}
serverDir := filepath.Join(reposDir, fmt.Sprintf("server_%d_%s", serverID, server.Name))
os.MkdirAll(serverDir, 0755)
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 }()
localPath := filepath.Join(serverDir, giteaRepo.FullName+".git")
existingRepo, _ := database.GetRepoByFullName(serverID, giteaRepo.FullName)
var err error
if existingRepo == nil {
err = e.cloneMirror(giteaRepo.CloneURL, localPath)
if err == nil {
repo := &models.Repo{
ServerID: serverID,
Name: giteaRepo.Name,
FullName: giteaRepo.FullName,
CloneURL: giteaRepo.CloneURL,
LocalPath: localPath,
SyncStatus: "success",
}
database.CreateRepo(repo)
}
} else {
err = e.fetchMirror(localPath)
if err == nil {
database.UpdateRepoSyncStatus(existingRepo.ID, "success")
}
}
resultsMu.Lock()
if err != nil {
failedCount++
failLog := &models.SyncLog{
ServerID: serverID,
RepoID: getRepoID(serverID, giteaRepo.FullName),
Status: "failed",
Message: err.Error(),
StartedAt: time.Now(),
}
database.CreateSyncLog(failLog)
} else {
successCount++
}
resultsMu.Unlock()
}(gr)
}
wg.Wait()
database.UpdateServerLastSync(serverID)
message := fmt.Sprintf("Sync completed: %d succeeded, %d failed", successCount, failedCount)
e.finishLog(syncLog, "success", message)
return nil
}
func (e *Engine) cloneMirror(url, path string) error {
if _, err := os.Stat(path); err == nil {
return nil
}
os.MkdirAll(filepath.Dir(path), 0755)
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
}
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
}
func (e *Engine) finishLog(log *models.SyncLog, status, message string) {
now := time.Now()
log.Status = status
log.Message = message
log.FinishedAt = &now
database.UpdateSyncLog(log.ID, status, message, log.FinishedAt)
}
func (e *Engine) IsSyncing(serverID int64) bool {
e.mu.Lock()
defer e.mu.Unlock()
_, active := e.activeTasks[serverID]
return active
}
func getRepoID(serverID int64, fullName string) *int64 {
repo, _ := database.GetRepoByFullName(serverID, fullName)
if repo != nil {
return &repo.ID
}
return nil
}

View File

@@ -0,0 +1,74 @@
package sync
import (
"fmt"
"log"
"github.com/robfig/cron/v3"
"gitm/internal/database"
"gitm/internal/models"
)
type Scheduler struct {
cron *cron.Cron
engine *Engine
serverJobs map[int64]cron.EntryID
}
func NewScheduler(engine *Engine) *Scheduler {
return &Scheduler{
cron: cron.New(),
engine: engine,
serverJobs: make(map[int64]cron.EntryID),
}
}
func (s *Scheduler) Start() {
s.cron.Start()
log.Println("Scheduler started")
}
func (s *Scheduler) Stop() {
s.cron.Stop()
log.Println("Scheduler stopped")
}
func (s *Scheduler) UpdateServer(server *models.GiteaServer) {
if entryID, exists := s.serverJobs[server.ID]; exists {
s.cron.Remove(entryID)
delete(s.serverJobs, server.ID)
}
if server.SyncInterval <= 0 || server.Status != "active" {
return
}
schedule := fmt.Sprintf("*/%d * * * *", server.SyncInterval)
entryID, err := s.cron.AddFunc(schedule, func() {
log.Printf("Scheduled sync 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 for server %d: %v", server.ID, err)
return
}
s.serverJobs[server.ID] = entryID
}
func (s *Scheduler) RemoveServer(serverID int64) {
if entryID, exists := s.serverJobs[serverID]; exists {
s.cron.Remove(entryID)
delete(s.serverJobs, serverID)
}
}
func (s *Scheduler) ReloadAll() error {
servers, err := database.GetServers()
if err != nil {
return err
}
for _, server := range servers {
s.UpdateServer(&server)
}
return nil
}

127
main.go
View File

@@ -1,32 +1,141 @@
package main package main
import ( import (
"embed"
"flag" "flag"
"fmt" "fmt"
"io/fs"
"log" "log"
"net/http"
"os"
"strings"
"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"
) )
//go:embed all:web/dist
var webFS embed.FS
var ( var (
flagAddr = flag.String("addr", ":9000", "Listen address") flagAddr = flag.String("addr", "", "Listen address")
flagDataDir = flag.String("data", "./data", "Data directory") flagDataDir = flag.String("data", "", "Data directory")
flagInit = flag.Bool("init", false, "Initialize database and set password") flagInit = flag.Bool("init", false, "Initialize database and set password")
) )
func main() { func main() {
flag.Parse() flag.Parse()
cfg := config.Get()
if *flagDataDir != "" {
cfg.SetDataDir(*flagDataDir)
} else {
cfg.SetDataDir(cfg.DataDir)
}
if *flagAddr != "" {
cfg.ListenAddr = *flagAddr
}
if err := cfg.EnsureDirs(); err != nil {
log.Fatalf("Failed to create directories: %v", err)
}
if err := database.Initialize(cfg.DBPath); err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer database.Close()
fmt.Printf("GitM - Gitea Repository Sync Tool\n") if listenAddr, err := database.GetSetting("listen_addr"); err == nil && listenAddr != "" {
fmt.Printf("Listen: %s\n", *flagAddr) cfg.ListenAddr = listenAddr
fmt.Printf("Data: %s\n", *flagDataDir) }
maxConcurrent := 3
if maxStr, err := database.GetSetting("max_concurrent"); err == nil && maxStr != "" {
fmt.Sscanf(maxStr, "%d", &maxConcurrent)
}
engine := sync.NewEngine(maxConcurrent)
scheduler := sync.NewScheduler(engine)
if *flagInit { if *flagInit {
fmt.Println("Initialize mode: TODO") runInitMode()
return return
} }
log.Fatal(runServer()) if pwd, _ := database.GetSetting("admin_password"); pwd == "" {
fmt.Println("Not initialized. Please run with --init flag first.")
os.Exit(1)
}
middleware.SetJWTSecret("gitm-default-secret")
scheduler.ReloadAll()
scheduler.Start()
defer scheduler.Stop()
log.Printf("GitM starting on %s", cfg.ListenAddr)
log.Fatal(runServer(cfg, engine))
} }
func runServer() error { func runInitMode() {
return fmt.Errorf("not implemented") var password string
fmt.Print("Enter admin password: ")
fmt.Scanln(&password)
if password == "" {
log.Fatal("Password cannot be empty")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Fatalf("Failed to hash password: %v", err)
}
database.SetSetting("admin_password", string(hash))
database.SetSetting("listen_addr", ":9000")
database.SetSetting("max_concurrent", "3")
fmt.Println("Initialized successfully!")
}
func runServer(cfg *config.Config, engine *sync.Engine) error {
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
// Serve embedded frontend
distFS, err := fs.Sub(webFS, "web/dist")
if err == nil {
// Serve assets
r.StaticFS("/assets", http.FS(distFS))
// SPA fallback - serve index.html for non-API routes
r.NoRoute(func(c *gin.Context) {
if !strings.HasPrefix(c.Request.URL.Path, "/api") {
data, _ := webFS.ReadFile("web/dist/index.html")
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
} else {
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
}
})
}
api := r.Group("/api")
{
api.POST("/login", handler.HandleLogin)
api.POST("/init", handler.HandleInit)
protected := api.Group("")
protected.Use(middleware.AuthMiddleware())
{
protected.GET("/settings", handler.HandleGetSettings)
protected.PUT("/settings", handler.HandleUpdateSettings)
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))
protected.POST("/sync/all", handler.HandleSyncAll(engine))
protected.GET("/sync/logs", handler.HandleGetSyncLogs)
protected.GET("/sync/stats", handler.HandleGetStats)
}
}
return r.Run(cfg.ListenAddr)
} }

12
web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!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>

1774
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
web/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "gitm-web",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "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",
"vite": "^5.0.0"
}
}

3
web/src/App.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

46
web/src/api/index.ts Normal file
View File

@@ -0,0 +1,46 @@
import axios from 'axios'
const api = axios.create({ baseURL: '/api' })
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
api.interceptors.response.use(
(r) => r,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export const authApi = {
login: (password: string) => api.post('/login', { password }),
getSettings: () => api.get('/settings'),
updateSettings: (data: any) => api.put('/settings', data),
}
export const serverApi = {
list: () => api.get('/servers'),
create: (data: any) => api.post('/servers', data),
update: (id: number, data: any) => api.put(`/servers/${id}`, data),
delete: (id: number) => api.delete(`/servers/${id}`),
test: (data: any) => api.post('/servers/test', data),
getRepos: (id: number) => api.get(`/servers/${id}/repos`),
discover: (id: number) => api.post(`/servers/${id}/discover`),
sync: (id: number) => api.post(`/servers/${id}/sync`),
syncStatus: (id: number) => api.get(`/servers/${id}/sync/status`),
}
export const syncApi = {
syncAll: () => api.post('/sync/all'),
getLogs: (params?: any) => api.get('/sync/logs', { params }),
getStats: () => api.get('/sync/stats'),
}
export default api

39
web/src/api/types.ts Normal file
View File

@@ -0,0 +1,39 @@
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
}

6
web/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

16
web/src/main.ts Normal file
View File

@@ -0,0 +1,16 @@
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)
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.mount('#app')

37
web/src/router/index.ts Normal file
View File

@@ -0,0 +1,37 @@
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')
},
{
path: '/',
component: () => import('@/views/Layout.vue'),
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 token = localStorage.getItem('token')
if (to.path !== '/login' && !token) {
next('/login')
} else if (to.path === '/login' && token) {
next('/')
} else {
next()
}
})
export default router

27
web/src/stores/auth.ts Normal file
View File

@@ -0,0 +1,27 @@
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 res = await authApi.login(password)
token.value = res.data.token
localStorage.setItem('token', res.data.token)
return true
} catch { return false }
finally { loading.value = false }
}
function logout() {
token.value = null
localStorage.removeItem('token')
}
return { token, loading, isAuthenticated, login, logout }
})

View File

@@ -0,0 +1,53 @@
<template>
<div style="padding:20px">
<el-row :gutter="20">
<el-col :span="6"><el-card shadow="hover"><el-statistic title="Total Servers" :value="stats.server_count" /></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><el-statistic title="Active Servers" :value="stats.active_servers" /></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><el-statistic title="Total Repos" :value="stats.repo_count" /></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><el-statistic title="Total Size" :value="formatSize(stats.total_size)" /></el-card></el-col>
</el-row>
<el-card style="margin-top:20px">
<template #header><span>Quick Actions</span></template>
<el-space>
<el-button type="primary" :loading="syncing" @click="syncAll">Sync All</el-button>
<el-button @click="$router.push('/servers')">Add Server</el-button>
</el-space>
</el-card>
<el-card style="margin-top:20px">
<template #header><div style="display:flex;justify-content:space-between"><span>Recent Activity</span><el-button text @click="$router.push('/logs')">View All</el-button></div></template>
<el-table :data="logs" 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" width="80" />
<el-table-column prop="status" label="Status" width="100"><template #default="{row}"><el-tag :type="row.status==='success'?'success':row.status==='failed'?'danger':'info'">{{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'
const stats = ref({ server_count:0, active_servers:0, repo_count:0, total_size:0 })
const logs = ref<any[]>([])
const syncing = ref(false)
async function loadData() {
try { stats.value = (await syncApi.getStats()).data } catch {}
try { logs.value = (await syncApi.getLogs({limit:10})).data.data || [] } catch {}
}
async function syncAll() {
syncing.value = true
try { await syncApi.syncAll(); ElMessage.success('Sync started'); setTimeout(loadData, 1000) }
catch { ElMessage.error('Failed') }
finally { syncing.value = false }
}
function formatSize(b:number) { if(!b)return '0 B'; const k=1024,s=['B','KB','MB','GB']; const i=Math.floor(Math.log(b)/Math.log(k)); return (b/Math.pow(k,i)).toFixed(1)+' '+s[i] }
function formatDate(d:string) { return new Date(d).toLocaleString() }
onMounted(loadData)
</script>

42
web/src/views/Layout.vue Normal file
View File

@@ -0,0 +1,42 @@
<template>
<el-container style="height:100vh">
<el-aside width="200px" style="background:#001529">
<div style="padding:20px;text-align:center;border-bottom:1px solid #1f1f1f">
<h2 style="margin:0;color:#fff">GitM</h2>
</div>
<el-menu :default-active="$route.path" router background-color="#001529" text-color="#fff" active-text-color="#1890ff">
<el-menu-item index="/"><el-icon><DataBoard /></el-icon><span>Dashboard</span></el-menu-item>
<el-menu-item index="/servers"><el-icon><Monitor /></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 style="background:#fff;border-bottom:1px solid #f0f0f0;display:flex;align-items:center">
<div style="width:100%;display:flex;justify-content:space-between;align-items:center">
<span style="font-size:18px;font-weight:500">{{ pageTitle }}</span>
<el-button text @click="handleLogout"><el-icon><SwitchButton /></el-icon> Logout</el-button>
</div>
</el-header>
<el-main style="background:#f5f5f5"><router-view /></el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
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>

44
web/src/views/Login.vue Normal file
View File

@@ -0,0 +1,44 @@
<template>
<div style="display:flex;align-items:center;justify-content:center;height:100vh;background:linear-gradient(135deg,#667eea,#764ba2)">
<el-card style="width:400px">
<template #header>
<div style="text-align:center">
<h1 style="margin:0;color:#409eff">GitM</h1>
<p style="margin:5px 0 0;color:#909399;font-size:14px">Gitea Repository Sync Tool</p>
</div>
</template>
<el-form @submit.prevent="handleLogin">
<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="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:16px" />
</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('')
const loading = ref(false)
async function handleLogin() {
if (!password.value) { error.value = 'Please enter password'; return }
error.value = ''
loading.value = true
const ok = await authStore.login(password.value)
loading.value = false
if (ok) { ElMessage.success('Login successful'); router.push('/') }
else { error.value = 'Invalid password' }
}
</script>

36
web/src/views/Logs.vue Normal file
View File

@@ -0,0 +1,36 @@
<template>
<div style="padding:20px">
<el-card>
<template #header><div style="display:flex;justify-content:space-between;align-items:center"><span>Sync Logs</span>
<el-select v-model="filterServer" clearable style="width:200px"><el-option v-for="s in servers" :key="s.id" :label="s.name" :value="s.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}">{{new Date(row.started_at).toLocaleString()}}</template></el-table-column>
<el-table-column prop="server_id" label="Server" width="80" />
<el-table-column prop="status" label="Status" width="100"><template #default="{row}"><el-tag :type="row.status==='success'?'success':row.status==='failed'?'danger':'info'">{{row.status}}</el-tag></template></el-table-column>
<el-table-column prop="message" label="Message" />
</el-table>
<el-pagination style="margin-top:20px;justify-content:center" layout="prev,pager,next" :page-size="50" @current-change="loadLogs" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { serverApi, syncApi } from '@/api'
const servers=ref<any[]>([])
const logs=ref<any[]>([])
const loading=ref(false)
const filterServer=ref<number|null>(null)
async function load(){try{servers.value=(await serverApi.list()).data}catch{}}
async function loadLogs(page=1){
loading.value=true
try{const params:any={page,limit:50};if(filterServer.value)params.server_id=filterServer.value;const r=(await syncApi.getLogs(params)).data;logs.value=r.data||[];console.log(r)}
catch{}finally{loading.value=false}
}
watch(filterServer,()=>loadLogs(1))
onMounted(async()=>{await load();loadLogs()})
</script>

45
web/src/views/Repos.vue Normal file
View File

@@ -0,0 +1,45 @@
<template>
<div style="padding:20px">
<el-card>
<template #header><div style="display:flex;justify-content:space-between;align-items:center"><span>Repositories</span>
<el-select v-model="selectedServer" style="width:200px"><el-option label="All Servers" :value="0" /><el-option v-for="s in servers" :key="s.id" :label="s.name" :value="s.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" width="80" />
<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="Status" width="100"><template #default="{row}"><el-tag :type="row.sync_status==='success'?'success':row.sync_status==='failed'?'danger':row.sync_status==='syncing'?'warning':'info'">{{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?new Date(row.last_sync_at).toLocaleString():'Never'}}</template></el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { serverApi } from '@/api'
const servers = ref<any[]>([])
const repos = ref<any[]>([])
const loading = ref(false)
const selectedServer = ref(0)
async function load() { try { servers.value=(await serverApi.list()).data } catch{} }
async function loadRepos() {
loading.value=true
try {
const all:any[]=[]
for(const s of servers.value) {
if(selectedServer.value===0||selectedServer.value===s.id) {
const r=(await serverApi.getRepos(s.id)).data; all.push(...r)
}
}
repos.value=all
} catch{} finally{loading.value=false}
}
function formatSize(b:number){if(!b)return'0 B';const k=1024,s=['B','KB','MB','GB'];const i=Math.floor(Math.log(b)/Math.log(k));return(b/Math.pow(k,i)).toFixed(1)+' '+s[i]}
watch(selectedServer,loadRepos)
onMounted(async()=>{await load();loadRepos()})
</script>

78
web/src/views/Servers.vue Normal file
View File

@@ -0,0 +1,78 @@
<template>
<div style="padding:20px">
<el-card>
<template #header><div style="display:flex;justify-content:space-between;align-items:center"><span>Gitea Servers</span><el-button type="primary" @click="showDialog=true;editMode=false;form={name:'',url:'',token:'',sync_interval:0}">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?new Date(row.last_sync_at).toLocaleString():'Never'}}</template></el-table-column>
<el-table-column label="Actions" width="280" fixed="right">
<template #default="{row}">
<el-button size="small" @click="discover(row)">Discover</el-button>
<el-button size="small" type="primary" @click="syncOne(row)">Sync</el-button>
<el-button size="small" @click="edit(row)">Edit</el-button>
<el-button size="small" type="danger" @click="remove(row)">Delete</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="showDialog" :title="editMode?'Edit Server':'Add Server'" width="500px">
<el-form :model="form" label-width="120px">
<el-form-item label="Name"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="URL"><el-input v-model="form.url" placeholder="https://git.example.com" /></el-form-item>
<el-form-item label="Token"><el-input v-model="form.token" type="password" show-password /></el-form-item>
<el-form-item label="Sync Interval"><el-input-number v-model="form.sync_interval" :min="0" :max="1440" /><span style="margin-left:10px;color:#909399">min (0=manual)</span></el-form-item>
</el-form>
<el-space style="margin-top:16px">
<el-button type="primary" @click="save">Save</el-button>
<el-button @click="testConn">Test Connection</el-button>
<el-button @click="showDialog=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'
const servers = ref<any[]>([])
const loading = ref(false)
const showDialog = ref(false)
const editMode = ref(false)
const editingId = ref(0)
const form = ref({ name:'', url:'', token:'', sync_interval:0 })
async function load() { loading.value=true; try { servers.value=(await serverApi.list()).data } catch{} finally{loading.value=false} }
function edit(row:any) { editMode.value=true; editingId.value=row.id; form.value={name:row.name,url:row.url,token:'',sync_interval:row.sync_interval}; showDialog.value=true }
async function save() {
try {
if(editMode.value) {
const data:any={name:form.value.name,url:form.value.url,sync_interval:form.value.sync_interval}
if(form.value.token) data.token=form.value.token
await serverApi.update(editingId.value, data)
} else {
await serverApi.create(form.value)
}
ElMessage.success(editMode.value?'Updated':'Added'); showDialog.value=false; load()
} catch { ElMessage.error('Failed') }
}
async function testConn() {
try { const r=(await serverApi.test({url:form.value.url,token:form.value.token})).data; ElMessage.success('Connected! User: '+r.user) }
catch { ElMessage.error('Connection failed') }
}
async function discover(row:any) { try { const r=(await serverApi.discover(row.id)).data; ElMessage.success(r.message) } catch { ElMessage.error('Failed') } }
async function syncOne(row:any) { try { await serverApi.sync(row.id); ElMessage.success('Sync started') } catch(e:any) { if(e.response?.status===409) ElMessage.warning('Already syncing'); else ElMessage.error('Failed') } }
function remove(row:any) { ElMessageBox.confirm('Delete this server?','Confirm',{type:'warning'}).then(async()=>{ await serverApi.delete(row.id); ElMessage.success('Deleted'); load() }).catch(()=>{}) }
onMounted(load)
</script>

View File

@@ -0,0 +1,38 @@
<template>
<div style="padding:20px">
<el-card>
<template #header><span>Settings</span></template>
<el-form :model="form" label-width="150px" style="max-width:600px">
<el-form-item label="New Password"><el-input v-model="form.password" type="password" show-password placeholder="Leave empty to keep" /></el-form-item>
<el-form-item label="Listen Address"><el-input v-model="form.listen_addr" /></el-form-item>
<el-form-item label="Repos Directory"><el-input v-model="form.repos_dir" /></el-form-item>
<el-form-item label="Max Concurrent"><el-input-number v-model="form.max_concurrent" :min="1" :max="10" /></el-form-item>
<el-form-item><el-button type="primary" @click="save">Save</el-button></el-form-item>
</el-form>
<el-alert type="info" title="Listen address change requires restart" :closable="false" style="margin-top:16px" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { authApi } from '@/api'
import { ElMessage } from 'element-plus'
const form=ref({password:'',listen_addr:':9000',repos_dir:'',max_concurrent:3})
async function load(){
try{const r=(await authApi.getSettings()).data;form.value={password:'',listen_addr:r.listen_addr||':9000',repos_dir:r.repos_dir||'',max_concurrent:r.max_concurrent||3}}
catch{}
}
async function save(){
try{
const data:any={listen_addr:form.value.listen_addr,repos_dir:form.value.repos_dir,max_concurrent:form.value.max_concurrent}
if(form.value.password)data.admin_password=form.value.password
await authApi.updateSettings(data);ElMessage.success('Saved');form.value.password=''
}catch{ElMessage.error('Failed')}
}
onMounted(load)
</script>

16
web/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}

25
web/vite.config.ts Normal file
View File

@@ -0,0 +1,25 @@
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'
}
})