Changeset 0.21.4 (#90)
This commit is contained in:
21
CHANGELOG.md
21
CHANGELOG.md
@@ -2,6 +2,27 @@
|
|||||||
|
|
||||||
All notable changes to Chat Switchboard.
|
All notable changes to Chat Switchboard.
|
||||||
|
|
||||||
|
## [0.21.4] — 2026-03-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Git Integration.** Workspaces can now track a git remote. Operations use `os/exec` calling the `git` binary (no CGO, no Go git libraries). Credentials are vault-encrypted (AES-256-GCM) using the same pattern as BYOK provider configs.
|
||||||
|
- **`git_credentials` table.** Postgres + SQLite migrations. Stores encrypted PAT tokens, HTTP basic auth, and SSH private keys. Columns: id, user_id, name, auth_type, encrypted_data, nonce, created_at.
|
||||||
|
- **Workspace git columns.** `git_remote_url`, `git_branch`, `git_credential_id`, `git_last_sync` added to `workspaces` table via migration.
|
||||||
|
- **`workspace.GitOps`** — exec-based git operations: Clone, Pull, Push, Status, Diff, Commit, Log, BranchList, Checkout. Credential injection via temporary `GIT_ASKPASS` scripts (PAT), `.git-credentials` files (basic auth), or `GIT_SSH_COMMAND` (SSH keys). Temp files securely wiped after each operation.
|
||||||
|
- **Post-operation hooks.** Clone, pull, and checkout automatically trigger workspace reconcile (filesystem → DB sync) and re-index changed files through the v0.21.2 indexing pipeline.
|
||||||
|
- **5 git tools** for LLM use: `git_status`, `git_diff`, `git_commit`, `git_log`, `git_branch`. Filtered out when no workspace is bound. Each validates git remote configuration before execution.
|
||||||
|
- **9 git API endpoints:** `POST clone`, `POST pull`, `POST push`, `GET status`, `GET diff`, `POST commit`, `GET log`, `GET branches`, `POST checkout` — all under `/api/v1/workspaces/:id/git/`.
|
||||||
|
- **3 credential endpoints:** `POST /api/v1/git-credentials` (create), `GET /api/v1/git-credentials` (list user-scoped summaries), `DELETE /api/v1/git-credentials/:id` (delete). Encrypted data never exposed in API responses.
|
||||||
|
- **`GitCredentialStore` interface** + Postgres/SQLite implementations. Create, GetByID, ListByUser, Delete with user-scoped authorization.
|
||||||
|
- **Security.** `file://` and local path URLs rejected in clone. Error output sanitized to strip credential-containing lines. `GIT_TERMINAL_PROMPT=0` set on all operations to prevent interactive prompts.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `WorkspaceStore` interface: added `SetGitLastSync()` method + both implementations.
|
||||||
|
- `models.Workspace`: added git tracking fields. `models.WorkspacePatch`: added git patch fields.
|
||||||
|
- Completion handler: git tools disabled alongside workspace tools when no workspace bound.
|
||||||
|
- `store.Stores` struct: added `GitCredentials` field.
|
||||||
|
- Postgres/SQLite `NewStores()`: wires `GitCredentialStore`.
|
||||||
|
|
||||||
## [0.21.3] — 2026-03-01
|
## [0.21.3] — 2026-03-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ v0.21.1 Workspace ✅ v0.21.3 Surface Infra ✅
|
|||||||
v0.21.2 Workspace ✅ v0.21.5 Editor Surface
|
v0.21.2 Workspace ✅ v0.21.5 Editor Surface
|
||||||
Indexing + Search (Development Mode)
|
Indexing + Search (Development Mode)
|
||||||
│ │
|
│ │
|
||||||
v0.21.4 Git v0.21.6 Article Surface
|
v0.21.4 Git ✅ v0.21.6 Article Surface
|
||||||
Integration + Document Output
|
Integration + Document Output
|
||||||
└───────┬──────────────┘
|
└───────┬──────────────┘
|
||||||
│
|
│
|
||||||
@@ -747,15 +747,30 @@ Pure UI architecture. No workspace dependency (parallel development).
|
|||||||
- [ ] Mobile: mode selector collapses to hamburger/bottom nav (deferred — functions via sidebar on mobile)
|
- [ ] Mobile: mode selector collapses to hamburger/bottom nav (deferred — functions via sidebar on mobile)
|
||||||
- [ ] Integration with extension loader (surfaces from manifest — deferred to extension loader update)
|
- [ ] Integration with extension loader (surfaces from manifest — deferred to extension loader update)
|
||||||
|
|
||||||
### v0.21.4 — Git Integration
|
### v0.21.4 — Git Integration ✅
|
||||||
|
|
||||||
Layer git operations onto workspaces.
|
Layer git operations onto workspaces.
|
||||||
|
|
||||||
- [ ] `workspaces` additions: git_remote_url, git_branch, git_credential_id, git_last_sync
|
- [x] `workspaces` additions: git_remote_url, git_branch, git_credential_id, git_last_sync
|
||||||
- [ ] `git_credentials` table: auth_type (https_pat/https_basic/ssh_key), encrypted_data (AES-256-GCM)
|
- [x] `git_credentials` table: auth_type (https_pat/https_basic/ssh_key), encrypted_data (AES-256-GCM)
|
||||||
- [ ] Git operations via exec: clone, pull, push, status, diff, commit, log, branch, checkout
|
- [x] Postgres migration (012_v0214_git.sql) + SQLite migration (011_v0214_git.sql)
|
||||||
- [ ] Workspace tools (git-aware): `git_status`, `git_diff`, `git_commit`, `git_log`, `git_branch`
|
- [x] `models.GitCredential`, `GitCredentialSummary`, `GitStatus`, `GitFileStatus`, `GitLogEntry`
|
||||||
- [ ] Post-operation hooks: clone/pull/checkout trigger reconcile + re-index changed files
|
- [x] `GitCredentialStore` interface + Postgres/SQLite implementations (Create, GetByID, ListByUser, Delete)
|
||||||
|
- [x] `workspace.GitOps` with exec-based git operations (9 operations)
|
||||||
|
- [x] Credential injection: temp `GIT_ASKPASS` (PAT), `.git-credentials` (basic), `GIT_SSH_COMMAND` (SSH)
|
||||||
|
- [x] Security: reject file:// URLs, sanitize error output, GIT_TERMINAL_PROMPT=0
|
||||||
|
- [x] Clone, Pull, Push, Status, Diff, Commit, Log, BranchList, Checkout
|
||||||
|
- [x] `SetGitLastSync` on WorkspaceStore interface + both implementations
|
||||||
|
- [x] Post-operation reconcile + re-index hook (clone, pull, checkout)
|
||||||
|
- [x] 5 git tools: git_status, git_diff, git_commit, git_log, git_branch
|
||||||
|
- [x] Git tools filtered out when no workspace bound (completion handler)
|
||||||
|
- [x] 9 git API endpoints on /api/v1/workspaces/:id/git/*
|
||||||
|
- [x] 3 credential CRUD endpoints on /api/v1/git-credentials
|
||||||
|
- [x] Wired in main.go: GitOps creation, tool registration, route registration
|
||||||
|
- [ ] Workspace settings UI: git config section (deferred to frontend sprint)
|
||||||
|
- [ ] User Settings: git credentials management UI (deferred to frontend sprint)
|
||||||
|
- [ ] Integration tests: clone, commit, push/pull cycle (requires git binary in CI)
|
||||||
|
- [ ] `.gitignore` respect in workspace indexing (deferred to v0.21.5+)
|
||||||
|
|
||||||
### v0.21.5 — Editor Surface (Development Mode)
|
### v0.21.5 — Editor Surface (Development Mode)
|
||||||
|
|
||||||
|
|||||||
21
server/database/migrations/012_v0214_git.sql
Normal file
21
server/database/migrations/012_v0214_git.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- v0.21.4: Git integration — credentials table + workspace git columns.
|
||||||
|
|
||||||
|
-- Git credentials: encrypted storage for PAT, basic auth, and SSH keys.
|
||||||
|
-- Uses the same AES-256-GCM vault pattern as BYOK provider configs.
|
||||||
|
CREATE TABLE IF NOT EXISTS git_credentials (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
auth_type TEXT NOT NULL DEFAULT 'https_pat', -- https_pat, https_basic, ssh_key
|
||||||
|
encrypted_data BYTEA NOT NULL DEFAULT '',
|
||||||
|
nonce BYTEA NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_git_credentials_user ON git_credentials(user_id);
|
||||||
|
|
||||||
|
-- Workspace git tracking columns.
|
||||||
|
ALTER TABLE workspaces ADD COLUMN IF NOT EXISTS git_remote_url TEXT;
|
||||||
|
ALTER TABLE workspaces ADD COLUMN IF NOT EXISTS git_branch TEXT;
|
||||||
|
ALTER TABLE workspaces ADD COLUMN IF NOT EXISTS git_credential_id UUID REFERENCES git_credentials(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE workspaces ADD COLUMN IF NOT EXISTS git_last_sync TIMESTAMPTZ;
|
||||||
19
server/database/migrations/sqlite/011_v0214_git.sql
Normal file
19
server/database/migrations/sqlite/011_v0214_git.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- v0.21.4: Git integration — credentials table + workspace git columns.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS git_credentials (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
auth_type TEXT NOT NULL DEFAULT 'https_pat',
|
||||||
|
encrypted_data TEXT NOT NULL DEFAULT '',
|
||||||
|
nonce TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_git_credentials_user ON git_credentials(user_id);
|
||||||
|
|
||||||
|
-- Workspace git tracking columns.
|
||||||
|
ALTER TABLE workspaces ADD COLUMN git_remote_url TEXT;
|
||||||
|
ALTER TABLE workspaces ADD COLUMN git_branch TEXT;
|
||||||
|
ALTER TABLE workspaces ADD COLUMN git_credential_id TEXT;
|
||||||
|
ALTER TABLE workspaces ADD COLUMN git_last_sync TEXT;
|
||||||
@@ -402,11 +402,14 @@ func (h *CompletionHandler) buildToolDefs(ctx context.Context, userID string, in
|
|||||||
disabled[name] = true
|
disabled[name] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no workspace is bound, disable workspace tools
|
// If no workspace is bound, disable workspace + git tools
|
||||||
if workspaceID == "" {
|
if workspaceID == "" {
|
||||||
for _, name := range tools.WorkspaceToolNames() {
|
for _, name := range tools.WorkspaceToolNames() {
|
||||||
disabled[name] = true
|
disabled[name] = true
|
||||||
}
|
}
|
||||||
|
for _, name := range tools.GitToolNames() {
|
||||||
|
disabled[name] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
allTools := tools.AllDefinitionsFiltered(disabled)
|
allTools := tools.AllDefinitionsFiltered(disabled)
|
||||||
|
|||||||
333
server/handlers/git.go
Normal file
333
server/handlers/git.go
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/workspace"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =========================================
|
||||||
|
// GIT HANDLER — Git operations on workspaces
|
||||||
|
// =========================================
|
||||||
|
|
||||||
|
type GitHandler struct {
|
||||||
|
stores store.Stores
|
||||||
|
gitOps *workspace.GitOps
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGitHandler(s store.Stores, gitOps *workspace.GitOps) *GitHandler {
|
||||||
|
return &GitHandler{stores: s, gitOps: gitOps}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Clone ────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *GitHandler) Clone(c *gin.Context) {
|
||||||
|
wsID := c.Param("id")
|
||||||
|
var req struct {
|
||||||
|
URL string `json:"url" binding:"required"`
|
||||||
|
Branch string `json:"branch"`
|
||||||
|
CredentialID string `json:"credential_id"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w, err := h.stores.Workspaces.GetByID(c.Request.Context(), wsID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.gitOps.Clone(c.Request.Context(), w, req.URL, req.Branch, req.CredentialID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "cloned"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pull ─────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *GitHandler) Pull(c *gin.Context) {
|
||||||
|
w, err := h.loadWorkspace(c)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.gitOps.Pull(c.Request.Context(), w); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "pulled"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Push ─────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *GitHandler) Push(c *gin.Context) {
|
||||||
|
w, err := h.loadWorkspace(c)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.gitOps.Push(c.Request.Context(), w); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "pushed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status ───────────────────────────────────
|
||||||
|
|
||||||
|
func (h *GitHandler) Status(c *gin.Context) {
|
||||||
|
w, err := h.loadWorkspace(c)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status, err := h.gitOps.Status(c.Request.Context(), w)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Diff ─────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *GitHandler) Diff(c *gin.Context) {
|
||||||
|
w, err := h.loadWorkspace(c)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := c.Query("path")
|
||||||
|
diff, err := h.gitOps.Diff(c.Request.Context(), w, path)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"diff": diff})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Commit ───────────────────────────────────
|
||||||
|
|
||||||
|
func (h *GitHandler) Commit(c *gin.Context) {
|
||||||
|
w, err := h.loadWorkspace(c)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Message string `json:"message" binding:"required"`
|
||||||
|
Paths []string `json:"paths"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.gitOps.Commit(c.Request.Context(), w, req.Message, req.Paths); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "committed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Log ──────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *GitHandler) Log(c *gin.Context) {
|
||||||
|
w, err := h.loadWorkspace(c)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n := 20
|
||||||
|
if v := c.Query("n"); v != "" {
|
||||||
|
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
||||||
|
n = parsed
|
||||||
|
if n > 100 {
|
||||||
|
n = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries, err := h.gitOps.Log(c.Request.Context(), w, n)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Branches ─────────────────────────────────
|
||||||
|
|
||||||
|
func (h *GitHandler) Branches(c *gin.Context) {
|
||||||
|
w, err := h.loadWorkspace(c)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
branches, current, err := h.gitOps.BranchList(c.Request.Context(), w)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"current": current,
|
||||||
|
"branches": branches,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Checkout ─────────────────────────────────
|
||||||
|
|
||||||
|
func (h *GitHandler) Checkout(c *gin.Context) {
|
||||||
|
w, err := h.loadWorkspace(c)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Branch string `json:"branch" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.gitOps.Checkout(c.Request.Context(), w, req.Branch); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "checked out", "branch": req.Branch})
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadWorkspace loads the workspace and validates git is configured.
|
||||||
|
func (h *GitHandler) loadWorkspace(c *gin.Context) (*models.Workspace, error) {
|
||||||
|
wsID := c.Param("id")
|
||||||
|
w, err := h.stores.Workspaces.GetByID(c.Request.Context(), wsID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if w.GitRemoteURL == nil || *w.GitRemoteURL == "" {
|
||||||
|
err := fmt.Errorf("workspace has no git remote configured")
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================
|
||||||
|
// GIT CREDENTIAL HANDLER — CRUD for encrypted git credentials
|
||||||
|
// =========================================
|
||||||
|
|
||||||
|
type GitCredentialHandler struct {
|
||||||
|
stores store.Stores
|
||||||
|
vault *crypto.KeyResolver
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGitCredentialHandler(s store.Stores, vault *crypto.KeyResolver) *GitCredentialHandler {
|
||||||
|
return &GitCredentialHandler{stores: s, vault: vault}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create stores a new encrypted git credential.
|
||||||
|
func (h *GitCredentialHandler) Create(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
AuthType string `json:"auth_type" binding:"required"`
|
||||||
|
// Raw credential data — encrypted before storage
|
||||||
|
Token string `json:"token,omitempty"` // for https_pat
|
||||||
|
Username string `json:"username,omitempty"` // for https_basic
|
||||||
|
Password string `json:"password,omitempty"` // for https_basic
|
||||||
|
PrivateKey string `json:"private_key,omitempty"` // for ssh_key
|
||||||
|
Passphrase string `json:"passphrase,omitempty"` // for ssh_key
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate auth type
|
||||||
|
switch req.AuthType {
|
||||||
|
case "https_pat":
|
||||||
|
if req.Token == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required for https_pat"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "https_basic":
|
||||||
|
if req.Username == "" || req.Password == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "username and password required for https_basic"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "ssh_key":
|
||||||
|
if req.PrivateKey == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "private_key is required for ssh_key"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "auth_type must be https_pat, https_basic, or ssh_key"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build plaintext JSON
|
||||||
|
var credData map[string]string
|
||||||
|
switch req.AuthType {
|
||||||
|
case "https_pat":
|
||||||
|
credData = map[string]string{"token": req.Token}
|
||||||
|
case "https_basic":
|
||||||
|
credData = map[string]string{"username": req.Username, "password": req.Password}
|
||||||
|
case "ssh_key":
|
||||||
|
credData = map[string]string{"private_key": req.PrivateKey, "passphrase": req.Passphrase}
|
||||||
|
}
|
||||||
|
plaintext, _ := json.Marshal(credData)
|
||||||
|
|
||||||
|
// Encrypt using global scope (same vault pattern as BYOK)
|
||||||
|
ciphertext, nonce, err := h.vault.EncryptForScope(string(plaintext), "global", userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "encryption failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cred := &models.GitCredential{
|
||||||
|
UserID: userID,
|
||||||
|
Name: req.Name,
|
||||||
|
AuthType: req.AuthType,
|
||||||
|
EncryptedData: ciphertext,
|
||||||
|
Nonce: nonce,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.GitCredentials.Create(c.Request.Context(), cred); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, cred.Summary())
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all git credentials for the current user (summaries only).
|
||||||
|
func (h *GitCredentialHandler) List(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
creds, err := h.stores.GitCredentials.ListByUser(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
summaries := make([]models.GitCredentialSummary, 0, len(creds))
|
||||||
|
for _, cred := range creds {
|
||||||
|
summaries = append(summaries, cred.Summary())
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, summaries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a git credential.
|
||||||
|
func (h *GitCredentialHandler) Delete(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
credID := c.Param("id")
|
||||||
|
|
||||||
|
if err := h.stores.GitCredentials.Delete(c.Request.Context(), credID, userID); err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "credential not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||||
|
}
|
||||||
@@ -205,6 +205,15 @@ func main() {
|
|||||||
// Register workspace_search tool (needs embedder for query embedding)
|
// Register workspace_search tool (needs embedder for query embedding)
|
||||||
tools.RegisterWorkspaceSearchTool(stores, kbEmbedder)
|
tools.RegisterWorkspaceSearchTool(stores, kbEmbedder)
|
||||||
|
|
||||||
|
// ── Git Integration (v0.21.4) ────────────
|
||||||
|
// GitOps wraps exec-based git operations with vault credential injection.
|
||||||
|
var gitOps *workspace.GitOps
|
||||||
|
if wfs != nil {
|
||||||
|
gitOps = workspace.NewGitOps(wfs, stores, keyResolver, wsIndexer)
|
||||||
|
tools.RegisterGitTools(gitOps)
|
||||||
|
log.Println(" 🔀 Git integration enabled")
|
||||||
|
}
|
||||||
|
|
||||||
// Register context recall tools (v0.15.1)
|
// Register context recall tools (v0.15.1)
|
||||||
tools.RegisterAttachmentRecall(stores, objStore)
|
tools.RegisterAttachmentRecall(stores, objStore)
|
||||||
tools.RegisterConversationSearch(stores)
|
tools.RegisterConversationSearch(stores)
|
||||||
@@ -458,8 +467,28 @@ func main() {
|
|||||||
protected.POST("/workspaces/:id/reconcile", wsH.Reconcile)
|
protected.POST("/workspaces/:id/reconcile", wsH.Reconcile)
|
||||||
protected.GET("/workspaces/:id/stats", wsH.Stats)
|
protected.GET("/workspaces/:id/stats", wsH.Stats)
|
||||||
protected.GET("/workspaces/:id/index-status", wsH.IndexStatus)
|
protected.GET("/workspaces/:id/index-status", wsH.IndexStatus)
|
||||||
|
|
||||||
|
// Git operations (v0.21.4)
|
||||||
|
if gitOps != nil {
|
||||||
|
gitH := handlers.NewGitHandler(stores, gitOps)
|
||||||
|
protected.POST("/workspaces/:id/git/clone", gitH.Clone)
|
||||||
|
protected.POST("/workspaces/:id/git/pull", gitH.Pull)
|
||||||
|
protected.POST("/workspaces/:id/git/push", gitH.Push)
|
||||||
|
protected.GET("/workspaces/:id/git/status", gitH.Status)
|
||||||
|
protected.GET("/workspaces/:id/git/diff", gitH.Diff)
|
||||||
|
protected.POST("/workspaces/:id/git/commit", gitH.Commit)
|
||||||
|
protected.GET("/workspaces/:id/git/log", gitH.Log)
|
||||||
|
protected.GET("/workspaces/:id/git/branches", gitH.Branches)
|
||||||
|
protected.POST("/workspaces/:id/git/checkout", gitH.Checkout)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Git credentials (v0.21.4) — user-scoped, independent of workspace
|
||||||
|
gitCredH := handlers.NewGitCredentialHandler(stores, keyResolver)
|
||||||
|
protected.POST("/git-credentials", gitCredH.Create)
|
||||||
|
protected.GET("/git-credentials", gitCredH.List)
|
||||||
|
protected.DELETE("/git-credentials/:id", gitCredH.Delete)
|
||||||
|
|
||||||
// Attachments (file upload/download)
|
// Attachments (file upload/download)
|
||||||
attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue)
|
attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue)
|
||||||
protected.POST("/channels/:id/attachments", attachH.Upload)
|
protected.POST("/channels/:id/attachments", attachH.Upload)
|
||||||
|
|||||||
61
server/models/models_git.go
Normal file
61
server/models/models_git.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// GitCredential stores encrypted git authentication credentials.
|
||||||
|
// The encrypted_data field contains JSON: { "token": "..." } for PAT,
|
||||||
|
// { "username": "...", "password": "..." } for basic auth, or
|
||||||
|
// { "private_key": "...", "passphrase": "..." } for SSH keys.
|
||||||
|
type GitCredential struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
UserID string `json:"user_id" db:"user_id"`
|
||||||
|
Name string `json:"name" db:"name"`
|
||||||
|
AuthType string `json:"auth_type" db:"auth_type"` // https_pat, https_basic, ssh_key
|
||||||
|
EncryptedData []byte `json:"-" db:"encrypted_data"` // never expose
|
||||||
|
Nonce []byte `json:"-" db:"nonce"` // never expose
|
||||||
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitCredentialSummary is the safe-to-expose version (no encrypted data).
|
||||||
|
type GitCredentialSummary struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AuthType string `json:"auth_type"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary returns a safe version without encrypted fields.
|
||||||
|
func (c *GitCredential) Summary() GitCredentialSummary {
|
||||||
|
return GitCredentialSummary{
|
||||||
|
ID: c.ID,
|
||||||
|
Name: c.Name,
|
||||||
|
AuthType: c.AuthType,
|
||||||
|
CreatedAt: c.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitStatus represents the output of git status.
|
||||||
|
type GitStatus struct {
|
||||||
|
Branch string `json:"branch"`
|
||||||
|
Clean bool `json:"clean"`
|
||||||
|
Ahead int `json:"ahead"`
|
||||||
|
Behind int `json:"behind"`
|
||||||
|
Staged []GitFileStatus `json:"staged,omitempty"`
|
||||||
|
Modified []GitFileStatus `json:"modified,omitempty"`
|
||||||
|
Untracked []string `json:"untracked,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitFileStatus represents a single file in git status output.
|
||||||
|
type GitFileStatus struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Status string `json:"status"` // M, A, D, R, C, U
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitLogEntry represents a single commit in git log.
|
||||||
|
type GitLogEntry struct {
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
ShortHash string `json:"short_hash"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
@@ -33,6 +33,12 @@ type Workspace struct {
|
|||||||
|
|
||||||
IndexingEnabled bool `json:"indexing_enabled" db:"indexing_enabled"`
|
IndexingEnabled bool `json:"indexing_enabled" db:"indexing_enabled"`
|
||||||
|
|
||||||
|
// Git integration (v0.21.4)
|
||||||
|
GitRemoteURL *string `json:"git_remote_url,omitempty" db:"git_remote_url"`
|
||||||
|
GitBranch *string `json:"git_branch,omitempty" db:"git_branch"`
|
||||||
|
GitCredentialID *string `json:"git_credential_id,omitempty" db:"git_credential_id"`
|
||||||
|
GitLastSync *time.Time `json:"git_last_sync,omitempty" db:"git_last_sync"`
|
||||||
|
|
||||||
// Computed fields (not DB columns)
|
// Computed fields (not DB columns)
|
||||||
FileCount int `json:"file_count,omitempty"`
|
FileCount int `json:"file_count,omitempty"`
|
||||||
TotalBytes int64 `json:"total_bytes,omitempty"`
|
TotalBytes int64 `json:"total_bytes,omitempty"`
|
||||||
@@ -44,6 +50,11 @@ type WorkspacePatch struct {
|
|||||||
MaxBytes *int64 `json:"max_bytes,omitempty"`
|
MaxBytes *int64 `json:"max_bytes,omitempty"`
|
||||||
Status *string `json:"status,omitempty"`
|
Status *string `json:"status,omitempty"`
|
||||||
IndexingEnabled *bool `json:"indexing_enabled,omitempty"`
|
IndexingEnabled *bool `json:"indexing_enabled,omitempty"`
|
||||||
|
|
||||||
|
// Git integration (v0.21.4)
|
||||||
|
GitRemoteURL *string `json:"git_remote_url,omitempty"`
|
||||||
|
GitBranch *string `json:"git_branch,omitempty"`
|
||||||
|
GitCredentialID *string `json:"git_credential_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WorkspaceFile represents metadata for a single file or directory in a workspace.
|
// WorkspaceFile represents metadata for a single file or directory in a workspace.
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ type Stores struct {
|
|||||||
Notifications NotificationStore
|
Notifications NotificationStore
|
||||||
NotifPrefs NotificationPreferenceStore
|
NotifPrefs NotificationPreferenceStore
|
||||||
Workspaces WorkspaceStore
|
Workspaces WorkspaceStore
|
||||||
|
GitCredentials GitCredentialStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
@@ -545,6 +546,20 @@ type WorkspaceStore interface {
|
|||||||
DeleteChunksByFile(ctx context.Context, fileID string) error
|
DeleteChunksByFile(ctx context.Context, fileID string) error
|
||||||
SimilaritySearch(ctx context.Context, workspaceID string, queryVec []float64, threshold float64, limit int) ([]models.WorkspaceChunkResult, error)
|
SimilaritySearch(ctx context.Context, workspaceID string, queryVec []float64, threshold float64, limit int) ([]models.WorkspaceChunkResult, error)
|
||||||
UpdateFileIndexStatus(ctx context.Context, fileID, status string, chunkCount int) error
|
UpdateFileIndexStatus(ctx context.Context, fileID, status string, chunkCount int) error
|
||||||
|
|
||||||
|
// Git (v0.21.4)
|
||||||
|
SetGitLastSync(ctx context.Context, workspaceID string, t time.Time) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================
|
||||||
|
// GIT CREDENTIAL STORE (v0.21.4)
|
||||||
|
// =========================================
|
||||||
|
|
||||||
|
type GitCredentialStore interface {
|
||||||
|
Create(ctx context.Context, cred *models.GitCredential) error
|
||||||
|
GetByID(ctx context.Context, id string) (*models.GitCredential, error)
|
||||||
|
ListByUser(ctx context.Context, userID string) ([]models.GitCredential, error)
|
||||||
|
Delete(ctx context.Context, id, userID string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|||||||
76
server/store/postgres/git_credentials.go
Normal file
76
server/store/postgres/git_credentials.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GitCredentialStore implements store.GitCredentialStore for PostgreSQL.
|
||||||
|
type GitCredentialStore struct{}
|
||||||
|
|
||||||
|
func (s *GitCredentialStore) Create(ctx context.Context, cred *models.GitCredential) error {
|
||||||
|
if cred.ID != "" {
|
||||||
|
return DB.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO git_credentials (id, user_id, name, auth_type, encrypted_data, nonce)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING created_at`,
|
||||||
|
cred.ID, cred.UserID, cred.Name, cred.AuthType,
|
||||||
|
cred.EncryptedData, cred.Nonce,
|
||||||
|
).Scan(&cred.CreatedAt)
|
||||||
|
}
|
||||||
|
return DB.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO git_credentials (user_id, name, auth_type, encrypted_data, nonce)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, created_at`,
|
||||||
|
cred.UserID, cred.Name, cred.AuthType,
|
||||||
|
cred.EncryptedData, cred.Nonce,
|
||||||
|
).Scan(&cred.ID, &cred.CreatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GitCredentialStore) GetByID(ctx context.Context, id string) (*models.GitCredential, error) {
|
||||||
|
var c models.GitCredential
|
||||||
|
err := DB.QueryRowContext(ctx, `
|
||||||
|
SELECT id, user_id, name, auth_type, encrypted_data, nonce, created_at
|
||||||
|
FROM git_credentials WHERE id = $1`, id,
|
||||||
|
).Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType,
|
||||||
|
&c.EncryptedData, &c.Nonce, &c.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GitCredentialStore) ListByUser(ctx context.Context, userID string) ([]models.GitCredential, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx, `
|
||||||
|
SELECT id, user_id, name, auth_type, encrypted_data, nonce, created_at
|
||||||
|
FROM git_credentials WHERE user_id = $1 ORDER BY created_at DESC`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var creds []models.GitCredential
|
||||||
|
for rows.Next() {
|
||||||
|
var c models.GitCredential
|
||||||
|
if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType,
|
||||||
|
&c.EncryptedData, &c.Nonce, &c.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
creds = append(creds, c)
|
||||||
|
}
|
||||||
|
return creds, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GitCredentialStore) Delete(ctx context.Context, id, userID string) error {
|
||||||
|
res, err := DB.ExecContext(ctx, `DELETE FROM git_credentials WHERE id = $1 AND user_id = $2`, id, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -36,5 +36,6 @@ func NewStores(db *sql.DB) store.Stores {
|
|||||||
Notifications: NewNotificationStore(),
|
Notifications: NewNotificationStore(),
|
||||||
NotifPrefs: NewNotificationPreferenceStore(),
|
NotifPrefs: NewNotificationPreferenceStore(),
|
||||||
Workspaces: NewWorkspaceStore(),
|
Workspaces: NewWorkspaceStore(),
|
||||||
|
GitCredentials: &GitCredentialStore{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func NewWorkspaceStore() *WorkspaceStore { return &WorkspaceStore{} }
|
|||||||
|
|
||||||
// ── columns ────────────────────────────────
|
// ── columns ────────────────────────────────
|
||||||
|
|
||||||
const workspaceCols = `id, owner_type, owner_id, name, root_path, max_bytes, status, indexing_enabled, created_at, updated_at`
|
const workspaceCols = `id, owner_type, owner_id, name, root_path, max_bytes, status, indexing_enabled, git_remote_url, git_branch, git_credential_id, git_last_sync, created_at, updated_at`
|
||||||
const workspaceFileCols = `id, workspace_id, path, is_directory, content_type, size_bytes, sha256, index_status, chunk_count, created_at, updated_at`
|
const workspaceFileCols = `id, workspace_id, path, is_directory, content_type, size_bytes, sha256, index_status, chunk_count, created_at, updated_at`
|
||||||
|
|
||||||
// ── scanners ───────────────────────────────
|
// ── scanners ───────────────────────────────
|
||||||
@@ -24,9 +24,12 @@ const workspaceFileCols = `id, workspace_id, path, is_directory, content_type, s
|
|||||||
func scanWorkspace(row interface{ Scan(dest ...interface{}) error }) (*models.Workspace, error) {
|
func scanWorkspace(row interface{ Scan(dest ...interface{}) error }) (*models.Workspace, error) {
|
||||||
var w models.Workspace
|
var w models.Workspace
|
||||||
var maxBytes sql.NullInt64
|
var maxBytes sql.NullInt64
|
||||||
|
var gitRemote, gitBranch, gitCredID sql.NullString
|
||||||
|
var gitSync sql.NullTime
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&w.ID, &w.OwnerType, &w.OwnerID, &w.Name,
|
&w.ID, &w.OwnerType, &w.OwnerID, &w.Name,
|
||||||
&w.RootPath, &maxBytes, &w.Status, &w.IndexingEnabled,
|
&w.RootPath, &maxBytes, &w.Status, &w.IndexingEnabled,
|
||||||
|
&gitRemote, &gitBranch, &gitCredID, &gitSync,
|
||||||
&w.CreatedAt, &w.UpdatedAt,
|
&w.CreatedAt, &w.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -35,6 +38,18 @@ func scanWorkspace(row interface{ Scan(dest ...interface{}) error }) (*models.Wo
|
|||||||
if maxBytes.Valid {
|
if maxBytes.Valid {
|
||||||
w.MaxBytes = &maxBytes.Int64
|
w.MaxBytes = &maxBytes.Int64
|
||||||
}
|
}
|
||||||
|
if gitRemote.Valid {
|
||||||
|
w.GitRemoteURL = &gitRemote.String
|
||||||
|
}
|
||||||
|
if gitBranch.Valid {
|
||||||
|
w.GitBranch = &gitBranch.String
|
||||||
|
}
|
||||||
|
if gitCredID.Valid {
|
||||||
|
w.GitCredentialID = &gitCredID.String
|
||||||
|
}
|
||||||
|
if gitSync.Valid {
|
||||||
|
w.GitLastSync = &gitSync.Time
|
||||||
|
}
|
||||||
return &w, nil
|
return &w, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +121,15 @@ func (s *WorkspaceStore) Update(ctx context.Context, id string, patch models.Wor
|
|||||||
if patch.IndexingEnabled != nil {
|
if patch.IndexingEnabled != nil {
|
||||||
b.Set("indexing_enabled", *patch.IndexingEnabled)
|
b.Set("indexing_enabled", *patch.IndexingEnabled)
|
||||||
}
|
}
|
||||||
|
if patch.GitRemoteURL != nil {
|
||||||
|
b.Set("git_remote_url", *patch.GitRemoteURL)
|
||||||
|
}
|
||||||
|
if patch.GitBranch != nil {
|
||||||
|
b.Set("git_branch", *patch.GitBranch)
|
||||||
|
}
|
||||||
|
if patch.GitCredentialID != nil {
|
||||||
|
b.Set("git_credential_id", *patch.GitCredentialID)
|
||||||
|
}
|
||||||
if !b.HasSets() {
|
if !b.HasSets() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -365,3 +389,10 @@ func (s *WorkspaceStore) UpdateFileIndexStatus(ctx context.Context, fileID, stat
|
|||||||
status, chunkCount, fileID)
|
status, chunkCount, fileID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) SetGitLastSync(ctx context.Context, workspaceID string, t time.Time) error {
|
||||||
|
_, err := DB.ExecContext(ctx,
|
||||||
|
`UPDATE workspaces SET git_last_sync = $1, updated_at = NOW() WHERE id = $2`,
|
||||||
|
t, workspaceID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
86
server/store/sqlite/git_credentials.go
Normal file
86
server/store/sqlite/git_credentials.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GitCredentialStore implements store.GitCredentialStore for SQLite.
|
||||||
|
type GitCredentialStore struct{}
|
||||||
|
|
||||||
|
func (s *GitCredentialStore) Create(ctx context.Context, cred *models.GitCredential) error {
|
||||||
|
if cred.ID == "" {
|
||||||
|
cred.ID = uuid.NewString()
|
||||||
|
}
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO git_credentials (id, user_id, name, auth_type, encrypted_data, nonce, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||||
|
cred.ID, cred.UserID, cred.Name, cred.AuthType,
|
||||||
|
base64.StdEncoding.EncodeToString(cred.EncryptedData),
|
||||||
|
base64.StdEncoding.EncodeToString(cred.Nonce),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Read back created_at
|
||||||
|
return DB.QueryRowContext(ctx,
|
||||||
|
`SELECT created_at FROM git_credentials WHERE id = ?`, cred.ID,
|
||||||
|
).Scan(st(&cred.CreatedAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GitCredentialStore) GetByID(ctx context.Context, id string) (*models.GitCredential, error) {
|
||||||
|
var c models.GitCredential
|
||||||
|
var encData, nonce string
|
||||||
|
err := DB.QueryRowContext(ctx, `
|
||||||
|
SELECT id, user_id, name, auth_type, encrypted_data, nonce, created_at
|
||||||
|
FROM git_credentials WHERE id = ?`, id,
|
||||||
|
).Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType,
|
||||||
|
&encData, &nonce, st(&c.CreatedAt))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.EncryptedData, _ = base64.StdEncoding.DecodeString(encData)
|
||||||
|
c.Nonce, _ = base64.StdEncoding.DecodeString(nonce)
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GitCredentialStore) ListByUser(ctx context.Context, userID string) ([]models.GitCredential, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx, `
|
||||||
|
SELECT id, user_id, name, auth_type, encrypted_data, nonce, created_at
|
||||||
|
FROM git_credentials WHERE user_id = ? ORDER BY created_at DESC`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var creds []models.GitCredential
|
||||||
|
for rows.Next() {
|
||||||
|
var c models.GitCredential
|
||||||
|
var encData, nonce string
|
||||||
|
if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType,
|
||||||
|
&encData, &nonce, st(&c.CreatedAt)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.EncryptedData, _ = base64.StdEncoding.DecodeString(encData)
|
||||||
|
c.Nonce, _ = base64.StdEncoding.DecodeString(nonce)
|
||||||
|
creds = append(creds, c)
|
||||||
|
}
|
||||||
|
return creds, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GitCredentialStore) Delete(ctx context.Context, id, userID string) error {
|
||||||
|
res, err := DB.ExecContext(ctx, `DELETE FROM git_credentials WHERE id = ? AND user_id = ?`, id, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -36,5 +36,6 @@ func NewStores(db *sql.DB) store.Stores {
|
|||||||
Notifications: NewNotificationStore(),
|
Notifications: NewNotificationStore(),
|
||||||
NotifPrefs: NewNotificationPreferenceStore(),
|
NotifPrefs: NewNotificationPreferenceStore(),
|
||||||
Workspaces: NewWorkspaceStore(),
|
Workspaces: NewWorkspaceStore(),
|
||||||
|
GitCredentials: &GitCredentialStore{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func NewWorkspaceStore() *WorkspaceStore { return &WorkspaceStore{} }
|
|||||||
|
|
||||||
// ── columns ────────────────────────────────
|
// ── columns ────────────────────────────────
|
||||||
|
|
||||||
const workspaceCols = `id, owner_type, owner_id, name, root_path, max_bytes, status, indexing_enabled, created_at, updated_at`
|
const workspaceCols = `id, owner_type, owner_id, name, root_path, max_bytes, status, indexing_enabled, git_remote_url, git_branch, git_credential_id, git_last_sync, created_at, updated_at`
|
||||||
const workspaceFileCols = `id, workspace_id, path, is_directory, content_type, size_bytes, sha256, index_status, chunk_count, created_at, updated_at`
|
const workspaceFileCols = `id, workspace_id, path, is_directory, content_type, size_bytes, sha256, index_status, chunk_count, created_at, updated_at`
|
||||||
|
|
||||||
// ── scanners ───────────────────────────────
|
// ── scanners ───────────────────────────────
|
||||||
@@ -28,9 +28,11 @@ const workspaceFileCols = `id, workspace_id, path, is_directory, content_type, s
|
|||||||
func scanWorkspace(row interface{ Scan(dest ...interface{}) error }) (*models.Workspace, error) {
|
func scanWorkspace(row interface{ Scan(dest ...interface{}) error }) (*models.Workspace, error) {
|
||||||
var w models.Workspace
|
var w models.Workspace
|
||||||
var maxBytes sql.NullInt64
|
var maxBytes sql.NullInt64
|
||||||
|
var gitRemote, gitBranch, gitCredID sql.NullString
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&w.ID, &w.OwnerType, &w.OwnerID, &w.Name,
|
&w.ID, &w.OwnerType, &w.OwnerID, &w.Name,
|
||||||
&w.RootPath, &maxBytes, &w.Status, &w.IndexingEnabled,
|
&w.RootPath, &maxBytes, &w.Status, &w.IndexingEnabled,
|
||||||
|
&gitRemote, &gitBranch, &gitCredID, stN(&w.GitLastSync),
|
||||||
st(&w.CreatedAt), st(&w.UpdatedAt),
|
st(&w.CreatedAt), st(&w.UpdatedAt),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -39,6 +41,15 @@ func scanWorkspace(row interface{ Scan(dest ...interface{}) error }) (*models.Wo
|
|||||||
if maxBytes.Valid {
|
if maxBytes.Valid {
|
||||||
w.MaxBytes = &maxBytes.Int64
|
w.MaxBytes = &maxBytes.Int64
|
||||||
}
|
}
|
||||||
|
if gitRemote.Valid {
|
||||||
|
w.GitRemoteURL = &gitRemote.String
|
||||||
|
}
|
||||||
|
if gitBranch.Valid {
|
||||||
|
w.GitBranch = &gitBranch.String
|
||||||
|
}
|
||||||
|
if gitCredID.Valid {
|
||||||
|
w.GitCredentialID = &gitCredID.String
|
||||||
|
}
|
||||||
return &w, nil
|
return &w, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +118,15 @@ func (s *WorkspaceStore) Update(ctx context.Context, id string, patch models.Wor
|
|||||||
if patch.IndexingEnabled != nil {
|
if patch.IndexingEnabled != nil {
|
||||||
b.Set("indexing_enabled", *patch.IndexingEnabled)
|
b.Set("indexing_enabled", *patch.IndexingEnabled)
|
||||||
}
|
}
|
||||||
|
if patch.GitRemoteURL != nil {
|
||||||
|
b.Set("git_remote_url", *patch.GitRemoteURL)
|
||||||
|
}
|
||||||
|
if patch.GitBranch != nil {
|
||||||
|
b.Set("git_branch", *patch.GitBranch)
|
||||||
|
}
|
||||||
|
if patch.GitCredentialID != nil {
|
||||||
|
b.Set("git_credential_id", *patch.GitCredentialID)
|
||||||
|
}
|
||||||
if !b.HasSets() {
|
if !b.HasSets() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -406,6 +426,13 @@ func (s *WorkspaceStore) UpdateFileIndexStatus(ctx context.Context, fileID, stat
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) SetGitLastSync(ctx context.Context, workspaceID string, t time.Time) error {
|
||||||
|
_, err := DB.ExecContext(ctx,
|
||||||
|
`UPDATE workspaces SET git_last_sync = ?, updated_at = datetime('now') WHERE id = ?`,
|
||||||
|
t.Format(timeFmt), workspaceID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// wsCosineSimilarity computes cosine similarity between two vectors.
|
// wsCosineSimilarity computes cosine similarity between two vectors.
|
||||||
// Named to avoid collision with the KB package-level function.
|
// Named to avoid collision with the KB package-level function.
|
||||||
func wsCosineSimilarity(a, b []float64) float64 {
|
func wsCosineSimilarity(a, b []float64) float64 {
|
||||||
|
|||||||
216
server/tools/git.go
Normal file
216
server/tools/git.go
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterGitTools registers the 5 git-related workspace tools.
|
||||||
|
// Only meaningful when a workspace has git_remote_url set — the completion
|
||||||
|
// handler filters them out otherwise (via GitToolNames).
|
||||||
|
func RegisterGitTools(gitOps *workspace.GitOps) {
|
||||||
|
Register(&gitStatusTool{gitOps: gitOps})
|
||||||
|
Register(&gitDiffTool{gitOps: gitOps})
|
||||||
|
Register(&gitCommitTool{gitOps: gitOps})
|
||||||
|
Register(&gitLogTool{gitOps: gitOps})
|
||||||
|
Register(&gitBranchTool{gitOps: gitOps})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitToolNames returns the names of all git tools for filtering.
|
||||||
|
func GitToolNames() []string {
|
||||||
|
return []string{"git_status", "git_diff", "git_commit", "git_log", "git_branch"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── git_status ───────────────────────────────
|
||||||
|
|
||||||
|
type gitStatusTool struct{ gitOps *workspace.GitOps }
|
||||||
|
|
||||||
|
func (t *gitStatusTool) Definition() ToolDef {
|
||||||
|
return ToolDef{
|
||||||
|
Name: "git_status",
|
||||||
|
DisplayName: "Git Status",
|
||||||
|
Description: "Show git working tree status: branch name, staged/modified/untracked files, ahead/behind remote counts.",
|
||||||
|
Category: "workspace",
|
||||||
|
Parameters: JSONSchema(map[string]interface{}{}, nil),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *gitStatusTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||||
|
w, err := t.gitOps.LoadWorkspace(ctx, execCtx.WorkspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
status, err := t.gitOps.Status(ctx, w)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
data, _ := json.MarshalIndent(status, "", " ")
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── git_diff ─────────────────────────────────
|
||||||
|
|
||||||
|
type gitDiffTool struct{ gitOps *workspace.GitOps }
|
||||||
|
|
||||||
|
func (t *gitDiffTool) Definition() ToolDef {
|
||||||
|
return ToolDef{
|
||||||
|
Name: "git_diff",
|
||||||
|
DisplayName: "Git Diff",
|
||||||
|
Description: "Show git diff for a specific file or all uncommitted changes.",
|
||||||
|
Category: "workspace",
|
||||||
|
Parameters: JSONSchema(map[string]interface{}{
|
||||||
|
"path": Prop("string", "File path to diff (empty for all changes)"),
|
||||||
|
}, nil),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *gitDiffTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||||
|
var args struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
json.Unmarshal([]byte(argsJSON), &args)
|
||||||
|
|
||||||
|
w, err := t.gitOps.LoadWorkspace(ctx, execCtx.WorkspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
diff, err := t.gitOps.Diff(ctx, w, args.Path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if diff == "" {
|
||||||
|
return "No changes.", nil
|
||||||
|
}
|
||||||
|
if len(diff) > 50000 {
|
||||||
|
diff = diff[:50000] + "\n... (truncated)"
|
||||||
|
}
|
||||||
|
return diff, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── git_commit ───────────────────────────────
|
||||||
|
|
||||||
|
type gitCommitTool struct{ gitOps *workspace.GitOps }
|
||||||
|
|
||||||
|
func (t *gitCommitTool) Definition() ToolDef {
|
||||||
|
return ToolDef{
|
||||||
|
Name: "git_commit",
|
||||||
|
DisplayName: "Git Commit",
|
||||||
|
Description: "Stage and commit files. Stages all changes if no paths specified.",
|
||||||
|
Category: "workspace",
|
||||||
|
Parameters: JSONSchema(map[string]interface{}{
|
||||||
|
"message": Prop("string", "Commit message"),
|
||||||
|
"paths": PropArray("Specific file paths to stage (empty = stage all)"),
|
||||||
|
}, []string{"message"}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *gitCommitTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||||
|
var args struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Paths []string `json:"paths"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid arguments: %w", err)
|
||||||
|
}
|
||||||
|
if args.Message == "" {
|
||||||
|
return "", fmt.Errorf("commit message is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
w, err := t.gitOps.LoadWorkspace(ctx, execCtx.WorkspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := t.gitOps.Commit(ctx, w, args.Message, args.Paths); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Committed: %s", args.Message), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── git_log ──────────────────────────────────
|
||||||
|
|
||||||
|
type gitLogTool struct{ gitOps *workspace.GitOps }
|
||||||
|
|
||||||
|
func (t *gitLogTool) Definition() ToolDef {
|
||||||
|
return ToolDef{
|
||||||
|
Name: "git_log",
|
||||||
|
DisplayName: "Git Log",
|
||||||
|
Description: "Show recent commit history.",
|
||||||
|
Category: "workspace",
|
||||||
|
Parameters: JSONSchema(map[string]interface{}{
|
||||||
|
"count": Prop("integer", "Number of commits to show (default 20, max 100)"),
|
||||||
|
}, nil),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *gitLogTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||||
|
var args struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
json.Unmarshal([]byte(argsJSON), &args)
|
||||||
|
if args.Count <= 0 {
|
||||||
|
args.Count = 20
|
||||||
|
}
|
||||||
|
if args.Count > 100 {
|
||||||
|
args.Count = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
w, err := t.gitOps.LoadWorkspace(ctx, execCtx.WorkspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
entries, err := t.gitOps.Log(ctx, w, args.Count)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
data, _ := json.MarshalIndent(entries, "", " ")
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── git_branch ───────────────────────────────
|
||||||
|
|
||||||
|
type gitBranchTool struct{ gitOps *workspace.GitOps }
|
||||||
|
|
||||||
|
func (t *gitBranchTool) Definition() ToolDef {
|
||||||
|
return ToolDef{
|
||||||
|
Name: "git_branch",
|
||||||
|
DisplayName: "Git Branch",
|
||||||
|
Description: "List branches or switch to a different branch.",
|
||||||
|
Category: "workspace",
|
||||||
|
Parameters: JSONSchema(map[string]interface{}{
|
||||||
|
"checkout": Prop("string", "Branch name to switch to (empty = list only)"),
|
||||||
|
}, nil),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *gitBranchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||||
|
var args struct {
|
||||||
|
Checkout string `json:"checkout"`
|
||||||
|
}
|
||||||
|
json.Unmarshal([]byte(argsJSON), &args)
|
||||||
|
|
||||||
|
w, err := t.gitOps.LoadWorkspace(ctx, execCtx.WorkspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.Checkout != "" {
|
||||||
|
if err := t.gitOps.Checkout(ctx, w, args.Checkout); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Switched to branch: %s", args.Checkout), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
branches, current, err := t.gitOps.BranchList(ctx, w)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"current": current,
|
||||||
|
"branches": branches,
|
||||||
|
}
|
||||||
|
data, _ := json.MarshalIndent(result, "", " ")
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
)
|
)
|
||||||
@@ -275,6 +276,9 @@ func (m *mockWorkspaceStore) SimilaritySearch(ctx context.Context, workspaceID s
|
|||||||
func (m *mockWorkspaceStore) UpdateFileIndexStatus(ctx context.Context, fileID, status string, chunkCount int) error {
|
func (m *mockWorkspaceStore) UpdateFileIndexStatus(ctx context.Context, fileID, status string, chunkCount int) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func (m *mockWorkspaceStore) SetGitLastSync(ctx context.Context, workspaceID string, t time.Time) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestWriteReadRoundTrip(t *testing.T) {
|
func TestWriteReadRoundTrip(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|||||||
585
server/workspace/gitops.go
Normal file
585
server/workspace/gitops.go
Normal file
@@ -0,0 +1,585 @@
|
|||||||
|
package workspace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GitOps provides git operations for workspaces via exec.
|
||||||
|
// Credentials are decrypted from the vault and injected via temporary
|
||||||
|
// files or environment variables for the duration of each operation.
|
||||||
|
type GitOps struct {
|
||||||
|
fs *FS
|
||||||
|
stores store.Stores
|
||||||
|
keyResolver *crypto.KeyResolver
|
||||||
|
indexer *Indexer // may be nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGitOps creates a new git operations handler.
|
||||||
|
func NewGitOps(fs *FS, stores store.Stores, resolver *crypto.KeyResolver, indexer *Indexer) *GitOps {
|
||||||
|
return &GitOps{
|
||||||
|
fs: fs,
|
||||||
|
stores: stores,
|
||||||
|
keyResolver: resolver,
|
||||||
|
indexer: indexer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Clone ────────────────────────────────────
|
||||||
|
|
||||||
|
// Clone clones a remote repository into the workspace.
|
||||||
|
func (g *GitOps) Clone(ctx context.Context, w *models.Workspace, remoteURL, branch, credID string) error {
|
||||||
|
if err := validateRemoteURL(remoteURL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := g.fs.workspacePath(w)
|
||||||
|
|
||||||
|
args := []string{"clone", "--single-branch"}
|
||||||
|
if branch != "" {
|
||||||
|
args = append(args, "--branch", branch)
|
||||||
|
}
|
||||||
|
args = append(args, remoteURL, ".")
|
||||||
|
|
||||||
|
env, cleanup, err := g.credEnv(ctx, credID, remoteURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("git clone: credential setup: %w", err)
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if _, err := g.run(ctx, dir, env, args...); err != nil {
|
||||||
|
return fmt.Errorf("git clone: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update workspace with git metadata
|
||||||
|
now := time.Now().UTC()
|
||||||
|
patch := models.WorkspacePatch{
|
||||||
|
GitRemoteURL: &remoteURL,
|
||||||
|
GitCredentialID: &credID,
|
||||||
|
}
|
||||||
|
if branch != "" {
|
||||||
|
patch.GitBranch = &branch
|
||||||
|
} else {
|
||||||
|
// Detect default branch
|
||||||
|
if out, err := g.run(ctx, dir, nil, "rev-parse", "--abbrev-ref", "HEAD"); err == nil {
|
||||||
|
b := strings.TrimSpace(out)
|
||||||
|
patch.GitBranch = &b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := g.stores.Workspaces.Update(ctx, w.ID, patch); err != nil {
|
||||||
|
return fmt.Errorf("git clone: update workspace: %w", err)
|
||||||
|
}
|
||||||
|
g.updateSyncTime(ctx, w.ID, now)
|
||||||
|
|
||||||
|
// Post-operation: reconcile + re-index
|
||||||
|
g.postOpHook(ctx, w)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pull ─────────────────────────────────────
|
||||||
|
|
||||||
|
func (g *GitOps) Pull(ctx context.Context, w *models.Workspace) error {
|
||||||
|
dir := g.fs.workspacePath(w)
|
||||||
|
env, cleanup, err := g.credEnvFromWorkspace(ctx, w)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("git pull: %w", err)
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if _, err := g.run(ctx, dir, env, "pull", "--ff-only"); err != nil {
|
||||||
|
return fmt.Errorf("git pull: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
g.updateSyncTime(ctx, w.ID, time.Now().UTC())
|
||||||
|
g.postOpHook(ctx, w)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Push ─────────────────────────────────────
|
||||||
|
|
||||||
|
func (g *GitOps) Push(ctx context.Context, w *models.Workspace) error {
|
||||||
|
dir := g.fs.workspacePath(w)
|
||||||
|
env, cleanup, err := g.credEnvFromWorkspace(ctx, w)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("git push: %w", err)
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if _, err := g.run(ctx, dir, env, "push"); err != nil {
|
||||||
|
return fmt.Errorf("git push: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
g.updateSyncTime(ctx, w.ID, time.Now().UTC())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status ───────────────────────────────────
|
||||||
|
|
||||||
|
func (g *GitOps) Status(ctx context.Context, w *models.Workspace) (*models.GitStatus, error) {
|
||||||
|
dir := g.fs.workspacePath(w)
|
||||||
|
|
||||||
|
// Branch name
|
||||||
|
branchOut, err := g.run(ctx, dir, nil, "rev-parse", "--abbrev-ref", "HEAD")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("git status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Porcelain status
|
||||||
|
out, err := g.run(ctx, dir, nil, "status", "--porcelain=v1", "-b")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("git status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status := &models.GitStatus{
|
||||||
|
Branch: strings.TrimSpace(branchOut),
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Branch line: ## main...origin/main [ahead 1, behind 2]
|
||||||
|
if strings.HasPrefix(line, "## ") {
|
||||||
|
if idx := strings.Index(line, "["); idx > 0 {
|
||||||
|
bracket := line[idx:]
|
||||||
|
if n := parseAheadBehind(bracket, "ahead"); n > 0 {
|
||||||
|
status.Ahead = n
|
||||||
|
}
|
||||||
|
if n := parseAheadBehind(bracket, "behind"); n > 0 {
|
||||||
|
status.Behind = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(line) < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
x := line[0] // index status
|
||||||
|
y := line[1] // working tree status
|
||||||
|
path := strings.TrimSpace(line[3:])
|
||||||
|
|
||||||
|
if x == '?' && y == '?' {
|
||||||
|
status.Untracked = append(status.Untracked, path)
|
||||||
|
} else if x != ' ' && x != '?' {
|
||||||
|
status.Staged = append(status.Staged, models.GitFileStatus{Path: path, Status: string(x)})
|
||||||
|
}
|
||||||
|
if y != ' ' && y != '?' {
|
||||||
|
status.Modified = append(status.Modified, models.GitFileStatus{Path: path, Status: string(y)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status.Clean = len(status.Staged) == 0 && len(status.Modified) == 0 && len(status.Untracked) == 0
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Diff ─────────────────────────────────────
|
||||||
|
|
||||||
|
func (g *GitOps) Diff(ctx context.Context, w *models.Workspace, path string) (string, error) {
|
||||||
|
dir := g.fs.workspacePath(w)
|
||||||
|
args := []string{"diff"}
|
||||||
|
if path != "" {
|
||||||
|
args = append(args, "--", path)
|
||||||
|
}
|
||||||
|
out, err := g.run(ctx, dir, nil, args...)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("git diff: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Commit ───────────────────────────────────
|
||||||
|
|
||||||
|
func (g *GitOps) Commit(ctx context.Context, w *models.Workspace, message string, paths []string) error {
|
||||||
|
dir := g.fs.workspacePath(w)
|
||||||
|
|
||||||
|
// Stage files
|
||||||
|
if len(paths) == 0 {
|
||||||
|
if _, err := g.run(ctx, dir, nil, "add", "-A"); err != nil {
|
||||||
|
return fmt.Errorf("git add: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
args := append([]string{"add", "--"}, paths...)
|
||||||
|
if _, err := g.run(ctx, dir, nil, args...); err != nil {
|
||||||
|
return fmt.Errorf("git add: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit
|
||||||
|
if _, err := g.run(ctx, dir, nil, "commit", "-m", message); err != nil {
|
||||||
|
return fmt.Errorf("git commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Log ──────────────────────────────────────
|
||||||
|
|
||||||
|
func (g *GitOps) Log(ctx context.Context, w *models.Workspace, n int) ([]models.GitLogEntry, error) {
|
||||||
|
dir := g.fs.workspacePath(w)
|
||||||
|
if n <= 0 {
|
||||||
|
n = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON-like format for reliable parsing
|
||||||
|
format := `{"hash":"%H","short_hash":"%h","author":"%an","date":"%aI","message":"%s"}`
|
||||||
|
out, err := g.run(ctx, dir, nil, "log", fmt.Sprintf("-n%d", n), fmt.Sprintf("--format=%s", format))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("git log: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []models.GitLogEntry
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(strings.TrimSpace(out)))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var entry models.GitLogEntry
|
||||||
|
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
||||||
|
continue // skip malformed
|
||||||
|
}
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Branch ───────────────────────────────────
|
||||||
|
|
||||||
|
func (g *GitOps) BranchList(ctx context.Context, w *models.Workspace) ([]string, string, error) {
|
||||||
|
dir := g.fs.workspacePath(w)
|
||||||
|
out, err := g.run(ctx, dir, nil, "branch", "-a", "--format=%(refname:short)%(if)%(HEAD)%(then) *%(end)")
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("git branch: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var branches []string
|
||||||
|
var current string
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(line, " *") {
|
||||||
|
name := strings.TrimSuffix(line, " *")
|
||||||
|
current = name
|
||||||
|
branches = append(branches, name)
|
||||||
|
} else {
|
||||||
|
branches = append(branches, strings.TrimSpace(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return branches, current, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checkout switches to a branch.
|
||||||
|
func (g *GitOps) Checkout(ctx context.Context, w *models.Workspace, branch string) error {
|
||||||
|
dir := g.fs.workspacePath(w)
|
||||||
|
if _, err := g.run(ctx, dir, nil, "checkout", branch); err != nil {
|
||||||
|
return fmt.Errorf("git checkout: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update workspace branch
|
||||||
|
patch := models.WorkspacePatch{GitBranch: &branch}
|
||||||
|
if err := g.stores.Workspaces.Update(ctx, w.ID, patch); err != nil {
|
||||||
|
log.Printf("git: failed to update workspace branch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
g.postOpHook(ctx, w)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internals ────────────────────────────────
|
||||||
|
|
||||||
|
// run executes a git command in the given directory with optional env vars.
|
||||||
|
func (g *GitOps) run(ctx context.Context, dir string, env []string, args ...string) (string, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
|
||||||
|
// Base environment + any credential env vars
|
||||||
|
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
|
||||||
|
if len(env) > 0 {
|
||||||
|
cmd.Env = append(cmd.Env, env...)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
// Include stderr for diagnostics but sanitize credentials
|
||||||
|
errMsg := sanitizeOutput(stderr.String())
|
||||||
|
return "", fmt.Errorf("%s: %s", err, errMsg)
|
||||||
|
}
|
||||||
|
return stdout.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// credEnv sets up credential environment for a git operation.
|
||||||
|
// Returns env vars, a cleanup function, and any error.
|
||||||
|
func (g *GitOps) credEnv(ctx context.Context, credID, remoteURL string) ([]string, func(), error) {
|
||||||
|
noop := func() {}
|
||||||
|
if credID == "" {
|
||||||
|
return nil, noop, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cred, err := g.stores.GitCredentials.GetByID(ctx, credID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, noop, fmt.Errorf("credential not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt credential data
|
||||||
|
plaintext, err := g.keyResolver.Decrypt(cred.EncryptedData, cred.Nonce, "global", cred.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, noop, fmt.Errorf("credential decryption failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(plaintext), &data); err != nil {
|
||||||
|
return nil, noop, fmt.Errorf("credential data invalid: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch cred.AuthType {
|
||||||
|
case "https_pat":
|
||||||
|
return g.setupHTTPSPAT(data, remoteURL)
|
||||||
|
case "https_basic":
|
||||||
|
return g.setupHTTPSBasic(data, remoteURL)
|
||||||
|
case "ssh_key":
|
||||||
|
return g.setupSSHKey(data)
|
||||||
|
default:
|
||||||
|
return nil, noop, fmt.Errorf("unsupported auth type: %s", cred.AuthType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GitOps) credEnvFromWorkspace(ctx context.Context, w *models.Workspace) ([]string, func(), error) {
|
||||||
|
credID := ""
|
||||||
|
if w.GitCredentialID != nil {
|
||||||
|
credID = *w.GitCredentialID
|
||||||
|
}
|
||||||
|
remoteURL := ""
|
||||||
|
if w.GitRemoteURL != nil {
|
||||||
|
remoteURL = *w.GitRemoteURL
|
||||||
|
}
|
||||||
|
return g.credEnv(ctx, credID, remoteURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupHTTPSPAT creates a GIT_ASKPASS script that echoes the PAT.
|
||||||
|
func (g *GitOps) setupHTTPSPAT(data map[string]string, remoteURL string) ([]string, func(), error) {
|
||||||
|
token := data["token"]
|
||||||
|
if token == "" {
|
||||||
|
return nil, func() {}, fmt.Errorf("PAT token is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create temp askpass script
|
||||||
|
f, err := os.CreateTemp("", "git-askpass-*.sh")
|
||||||
|
if err != nil {
|
||||||
|
return nil, func() {}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
script := fmt.Sprintf("#!/bin/sh\necho '%s'\n", strings.ReplaceAll(token, "'", "'\\''"))
|
||||||
|
f.WriteString(script)
|
||||||
|
f.Close()
|
||||||
|
os.Chmod(f.Name(), 0700)
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
os.Remove(f.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
env := []string{
|
||||||
|
"GIT_ASKPASS=" + f.Name(),
|
||||||
|
}
|
||||||
|
return env, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupHTTPSBasic creates a temporary .git-credentials file.
|
||||||
|
func (g *GitOps) setupHTTPSBasic(data map[string]string, remoteURL string) ([]string, func(), error) {
|
||||||
|
username := data["username"]
|
||||||
|
password := data["password"]
|
||||||
|
if username == "" || password == "" {
|
||||||
|
return nil, func() {}, fmt.Errorf("basic auth credentials incomplete")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse remote URL to extract host
|
||||||
|
parsed, err := url.Parse(remoteURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, func() {}, fmt.Errorf("invalid remote URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
credURL := fmt.Sprintf("%s://%s:%s@%s", parsed.Scheme,
|
||||||
|
url.PathEscape(username), url.PathEscape(password), parsed.Host)
|
||||||
|
|
||||||
|
f, err := os.CreateTemp("", "git-creds-*.txt")
|
||||||
|
if err != nil {
|
||||||
|
return nil, func() {}, err
|
||||||
|
}
|
||||||
|
f.WriteString(credURL + "\n")
|
||||||
|
f.Close()
|
||||||
|
os.Chmod(f.Name(), 0600)
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
os.Remove(f.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
env := []string{
|
||||||
|
fmt.Sprintf("GIT_CONFIG_COUNT=2"),
|
||||||
|
fmt.Sprintf("GIT_CONFIG_KEY_0=credential.helper"),
|
||||||
|
fmt.Sprintf("GIT_CONFIG_VALUE_0=store --file=%s", f.Name()),
|
||||||
|
fmt.Sprintf("GIT_CONFIG_KEY_1=credential.helper"),
|
||||||
|
fmt.Sprintf("GIT_CONFIG_VALUE_1="),
|
||||||
|
}
|
||||||
|
return env, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupSSHKey creates a temporary SSH key file and GIT_SSH_COMMAND.
|
||||||
|
func (g *GitOps) setupSSHKey(data map[string]string) ([]string, func(), error) {
|
||||||
|
privateKey := data["private_key"]
|
||||||
|
if privateKey == "" {
|
||||||
|
return nil, func() {}, fmt.Errorf("SSH private key is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.CreateTemp("", "git-ssh-key-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, func() {}, err
|
||||||
|
}
|
||||||
|
f.WriteString(privateKey)
|
||||||
|
if !strings.HasSuffix(privateKey, "\n") {
|
||||||
|
f.WriteString("\n")
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
os.Chmod(f.Name(), 0600)
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
os.Remove(f.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
sshCmd := fmt.Sprintf("ssh -i %s -o StrictHostKeyChecking=accept-new -o IdentitiesOnly=yes", f.Name())
|
||||||
|
|
||||||
|
env := []string{
|
||||||
|
"GIT_SSH_COMMAND=" + sshCmd,
|
||||||
|
}
|
||||||
|
return env, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateRemoteURL rejects file:// and local path URLs.
|
||||||
|
func validateRemoteURL(u string) error {
|
||||||
|
if u == "" {
|
||||||
|
return fmt.Errorf("remote URL is required")
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(u)
|
||||||
|
if strings.HasPrefix(lower, "file://") || strings.HasPrefix(lower, "/") || strings.HasPrefix(lower, ".") {
|
||||||
|
return fmt.Errorf("local filesystem URLs are not allowed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeOutput removes potential credential leaks from error messages.
|
||||||
|
func sanitizeOutput(s string) string {
|
||||||
|
// Strip lines containing tokens/passwords
|
||||||
|
var lines []string
|
||||||
|
for _, line := range strings.Split(s, "\n") {
|
||||||
|
lower := strings.ToLower(line)
|
||||||
|
if strings.Contains(lower, "password") || strings.Contains(lower, "token") ||
|
||||||
|
strings.Contains(lower, "credential") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
result := strings.Join(lines, "\n")
|
||||||
|
if len(result) > 500 {
|
||||||
|
result = result[:500] + "..."
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAheadBehind(bracket, key string) int {
|
||||||
|
idx := strings.Index(bracket, key)
|
||||||
|
if idx < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
rest := bracket[idx+len(key):]
|
||||||
|
rest = strings.TrimLeft(rest, " ")
|
||||||
|
num := ""
|
||||||
|
for _, c := range rest {
|
||||||
|
if c >= '0' && c <= '9' {
|
||||||
|
num += string(c)
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n, _ := strconv.Atoi(num)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateSyncTime sets git_last_sync on the workspace.
|
||||||
|
func (g *GitOps) updateSyncTime(ctx context.Context, wsID string, t time.Time) {
|
||||||
|
if err := g.stores.Workspaces.SetGitLastSync(ctx, wsID, t); err != nil {
|
||||||
|
log.Printf("git: failed to update sync time for %s: %v", wsID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// postOpHook triggers workspace reconcile and re-indexing after git operations
|
||||||
|
// that change the working tree (clone, pull, checkout).
|
||||||
|
func (g *GitOps) postOpHook(ctx context.Context, w *models.Workspace) {
|
||||||
|
// Reconcile: sync filesystem → database file index
|
||||||
|
added, removed, updated, err := g.fs.Reconcile(ctx, w)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("git: post-op reconcile failed for %s: %v", w.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("git: post-op reconcile %s: +%d -%d ~%d", w.ID, added, removed, updated)
|
||||||
|
|
||||||
|
// Re-index if indexer is configured and there are changes
|
||||||
|
if g.indexer != nil && g.indexer.IsEnabled(ctx) && (added+updated) > 0 {
|
||||||
|
files, err := g.stores.Workspaces.ListFiles(ctx, w.ID, "", true)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("git: post-op index list failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve userID/teamID from workspace owner
|
||||||
|
var userID string
|
||||||
|
var teamID *string
|
||||||
|
switch w.OwnerType {
|
||||||
|
case "user":
|
||||||
|
userID = w.OwnerID
|
||||||
|
case "team":
|
||||||
|
teamID = &w.OwnerID
|
||||||
|
default:
|
||||||
|
userID = w.OwnerID
|
||||||
|
}
|
||||||
|
|
||||||
|
g.indexer.IndexBatch(w, files, userID, teamID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// workspacePath returns the filesystem path for git operations on a workspace.
|
||||||
|
// This is the files directory where the working tree lives.
|
||||||
|
func (fs *FS) workspacePath(w *models.Workspace) string {
|
||||||
|
return fs.filesDir(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadWorkspace fetches a workspace by ID and validates it has a git remote.
|
||||||
|
// Used by git tools to resolve workspace + check git configuration.
|
||||||
|
func (g *GitOps) LoadWorkspace(ctx context.Context, workspaceID string) (*models.Workspace, error) {
|
||||||
|
w, err := g.stores.Workspaces.GetByID(ctx, workspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("workspace not found: %w", err)
|
||||||
|
}
|
||||||
|
if w.GitRemoteURL == nil || *w.GitRemoteURL == "" {
|
||||||
|
return nil, fmt.Errorf("workspace has no git remote configured")
|
||||||
|
}
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
@@ -1750,6 +1750,12 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
|
|
||||||
/* ── Mode Selector (v0.21.3) ───────────── */
|
/* ── Mode Selector (v0.21.3) ───────────── */
|
||||||
|
|
||||||
|
/* Surface region wrappers must participate in parent flex layouts */
|
||||||
|
[data-surface-region="sidebar-content"] {
|
||||||
|
flex: 1; min-height: 0;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.mode-selector {
|
.mode-selector {
|
||||||
display: flex; gap: 2px; padding: 6px 8px;
|
display: flex; gap: 2px; padding: 6px 8px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
|||||||
Reference in New Issue
Block a user