Changeset 0.21.4 (#90)

This commit is contained in:
2026-03-01 19:42:22 +00:00
parent bbbbe65bfa
commit d67cfd37c2
21 changed files with 1572 additions and 11 deletions

View File

@@ -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

View File

@@ -1 +1 @@
0.21.3 0.21.4

View File

@@ -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)

View 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;

View 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;

View File

@@ -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
View 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})
}

View File

@@ -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)

View 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"`
}

View File

@@ -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.

View File

@@ -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
} }
// ========================================= // =========================================

View 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
}

View File

@@ -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{},
} }
} }

View File

@@ -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
}

View 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
}

View File

@@ -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{},
} }
} }

View File

@@ -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
View 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
}

View File

@@ -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
View 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
}

View File

@@ -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);