diff --git a/CHANGELOG.md b/CHANGELOG.md index 81658ce..1048446 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ 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 ### Added diff --git a/VERSION b/VERSION index 804b3de..b166f00 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.21.3 \ No newline at end of file +0.21.4 \ No newline at end of file diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9ddc392..2d66f6e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -88,7 +88,7 @@ v0.21.1 Workspace ✅ v0.21.3 Surface Infra ✅ v0.21.2 Workspace ✅ v0.21.5 Editor Surface 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 └───────┬──────────────┘ │ @@ -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) - [ ] 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. -- [ ] `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) -- [ ] Git operations via exec: clone, pull, push, status, diff, commit, log, branch, checkout -- [ ] Workspace tools (git-aware): `git_status`, `git_diff`, `git_commit`, `git_log`, `git_branch` -- [ ] Post-operation hooks: clone/pull/checkout trigger reconcile + re-index changed files +- [x] `workspaces` additions: git_remote_url, git_branch, git_credential_id, git_last_sync +- [x] `git_credentials` table: auth_type (https_pat/https_basic/ssh_key), encrypted_data (AES-256-GCM) +- [x] Postgres migration (012_v0214_git.sql) + SQLite migration (011_v0214_git.sql) +- [x] `models.GitCredential`, `GitCredentialSummary`, `GitStatus`, `GitFileStatus`, `GitLogEntry` +- [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) diff --git a/server/database/migrations/012_v0214_git.sql b/server/database/migrations/012_v0214_git.sql new file mode 100644 index 0000000..306ff57 --- /dev/null +++ b/server/database/migrations/012_v0214_git.sql @@ -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; diff --git a/server/database/migrations/sqlite/011_v0214_git.sql b/server/database/migrations/sqlite/011_v0214_git.sql new file mode 100644 index 0000000..7e48568 --- /dev/null +++ b/server/database/migrations/sqlite/011_v0214_git.sql @@ -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; diff --git a/server/handlers/completion.go b/server/handlers/completion.go index 19ebbdc..28fe3f6 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -402,11 +402,14 @@ func (h *CompletionHandler) buildToolDefs(ctx context.Context, userID string, in disabled[name] = true } - // If no workspace is bound, disable workspace tools + // If no workspace is bound, disable workspace + git tools if workspaceID == "" { for _, name := range tools.WorkspaceToolNames() { disabled[name] = true } + for _, name := range tools.GitToolNames() { + disabled[name] = true + } } allTools := tools.AllDefinitionsFiltered(disabled) diff --git a/server/handlers/git.go b/server/handlers/git.go new file mode 100644 index 0000000..3565435 --- /dev/null +++ b/server/handlers/git.go @@ -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}) +} diff --git a/server/main.go b/server/main.go index d10813e..589c866 100644 --- a/server/main.go +++ b/server/main.go @@ -205,6 +205,15 @@ func main() { // Register workspace_search tool (needs embedder for query embedding) 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) tools.RegisterAttachmentRecall(stores, objStore) tools.RegisterConversationSearch(stores) @@ -458,8 +467,28 @@ func main() { protected.POST("/workspaces/:id/reconcile", wsH.Reconcile) protected.GET("/workspaces/:id/stats", wsH.Stats) 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) attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue) protected.POST("/channels/:id/attachments", attachH.Upload) diff --git a/server/models/models_git.go b/server/models/models_git.go new file mode 100644 index 0000000..efaabe4 --- /dev/null +++ b/server/models/models_git.go @@ -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"` +} diff --git a/server/models/models_workspace.go b/server/models/models_workspace.go index bf5cb43..b05a19e 100644 --- a/server/models/models_workspace.go +++ b/server/models/models_workspace.go @@ -33,6 +33,12 @@ type Workspace struct { 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) FileCount int `json:"file_count,omitempty"` TotalBytes int64 `json:"total_bytes,omitempty"` @@ -44,6 +50,11 @@ type WorkspacePatch struct { MaxBytes *int64 `json:"max_bytes,omitempty"` Status *string `json:"status,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. diff --git a/server/store/interfaces.go b/server/store/interfaces.go index aea865a..6977ebe 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -43,6 +43,7 @@ type Stores struct { Notifications NotificationStore NotifPrefs NotificationPreferenceStore Workspaces WorkspaceStore + GitCredentials GitCredentialStore } // ========================================= @@ -545,6 +546,20 @@ type WorkspaceStore interface { DeleteChunksByFile(ctx context.Context, fileID string) 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 + + // 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 } // ========================================= diff --git a/server/store/postgres/git_credentials.go b/server/store/postgres/git_credentials.go new file mode 100644 index 0000000..7158783 --- /dev/null +++ b/server/store/postgres/git_credentials.go @@ -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 +} diff --git a/server/store/postgres/stores.go b/server/store/postgres/stores.go index 7caba64..8a7fee6 100644 --- a/server/store/postgres/stores.go +++ b/server/store/postgres/stores.go @@ -36,5 +36,6 @@ func NewStores(db *sql.DB) store.Stores { Notifications: NewNotificationStore(), NotifPrefs: NewNotificationPreferenceStore(), Workspaces: NewWorkspaceStore(), + GitCredentials: &GitCredentialStore{}, } } diff --git a/server/store/postgres/workspace.go b/server/store/postgres/workspace.go index 3acff0e..7a8bd9d 100644 --- a/server/store/postgres/workspace.go +++ b/server/store/postgres/workspace.go @@ -16,7 +16,7 @@ func NewWorkspaceStore() *WorkspaceStore { return &WorkspaceStore{} } // ── 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` // ── 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) { var w models.Workspace var maxBytes sql.NullInt64 + var gitRemote, gitBranch, gitCredID sql.NullString + var gitSync sql.NullTime err := row.Scan( &w.ID, &w.OwnerType, &w.OwnerID, &w.Name, &w.RootPath, &maxBytes, &w.Status, &w.IndexingEnabled, + &gitRemote, &gitBranch, &gitCredID, &gitSync, &w.CreatedAt, &w.UpdatedAt, ) if err != nil { @@ -35,6 +38,18 @@ func scanWorkspace(row interface{ Scan(dest ...interface{}) error }) (*models.Wo if maxBytes.Valid { 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 } @@ -106,6 +121,15 @@ func (s *WorkspaceStore) Update(ctx context.Context, id string, patch models.Wor if patch.IndexingEnabled != nil { 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() { return nil } @@ -365,3 +389,10 @@ func (s *WorkspaceStore) UpdateFileIndexStatus(ctx context.Context, fileID, stat status, chunkCount, fileID) 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 +} diff --git a/server/store/sqlite/git_credentials.go b/server/store/sqlite/git_credentials.go new file mode 100644 index 0000000..184582d --- /dev/null +++ b/server/store/sqlite/git_credentials.go @@ -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 +} diff --git a/server/store/sqlite/stores.go b/server/store/sqlite/stores.go index 6b09b79..b63263e 100644 --- a/server/store/sqlite/stores.go +++ b/server/store/sqlite/stores.go @@ -36,5 +36,6 @@ func NewStores(db *sql.DB) store.Stores { Notifications: NewNotificationStore(), NotifPrefs: NewNotificationPreferenceStore(), Workspaces: NewWorkspaceStore(), + GitCredentials: &GitCredentialStore{}, } } diff --git a/server/store/sqlite/workspace.go b/server/store/sqlite/workspace.go index abad1e0..5d73a78 100644 --- a/server/store/sqlite/workspace.go +++ b/server/store/sqlite/workspace.go @@ -20,7 +20,7 @@ func NewWorkspaceStore() *WorkspaceStore { return &WorkspaceStore{} } // ── 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` // ── 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) { var w models.Workspace var maxBytes sql.NullInt64 + var gitRemote, gitBranch, gitCredID sql.NullString err := row.Scan( &w.ID, &w.OwnerType, &w.OwnerID, &w.Name, &w.RootPath, &maxBytes, &w.Status, &w.IndexingEnabled, + &gitRemote, &gitBranch, &gitCredID, stN(&w.GitLastSync), st(&w.CreatedAt), st(&w.UpdatedAt), ) if err != nil { @@ -39,6 +41,15 @@ func scanWorkspace(row interface{ Scan(dest ...interface{}) error }) (*models.Wo if maxBytes.Valid { 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 } @@ -107,6 +118,15 @@ func (s *WorkspaceStore) Update(ctx context.Context, id string, patch models.Wor if patch.IndexingEnabled != nil { 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() { return nil } @@ -406,6 +426,13 @@ func (s *WorkspaceStore) UpdateFileIndexStatus(ctx context.Context, fileID, stat 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. // Named to avoid collision with the KB package-level function. func wsCosineSimilarity(a, b []float64) float64 { diff --git a/server/tools/git.go b/server/tools/git.go new file mode 100644 index 0000000..0c71705 --- /dev/null +++ b/server/tools/git.go @@ -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 +} diff --git a/server/workspace/fs_test.go b/server/workspace/fs_test.go index 144d470..5981a1a 100644 --- a/server/workspace/fs_test.go +++ b/server/workspace/fs_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" "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 { return nil } +func (m *mockWorkspaceStore) SetGitLastSync(ctx context.Context, workspaceID string, t time.Time) error { + return nil +} func TestWriteReadRoundTrip(t *testing.T) { tmpDir := t.TempDir() diff --git a/server/workspace/gitops.go b/server/workspace/gitops.go new file mode 100644 index 0000000..e6391aa --- /dev/null +++ b/server/workspace/gitops.go @@ -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 +} diff --git a/src/css/styles.css b/src/css/styles.css index dd560be..49ee9fd 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -1750,6 +1750,12 @@ select option { background: var(--bg-surface); color: var(--text); } /* ── 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 { display: flex; gap: 2px; padding: 6px 8px; border-bottom: 1px solid var(--border);