Changeset 0.21.4 (#90)
This commit is contained in:
21
server/database/migrations/012_v0214_git.sql
Normal file
21
server/database/migrations/012_v0214_git.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- v0.21.4: Git integration — credentials table + workspace git columns.
|
||||
|
||||
-- Git credentials: encrypted storage for PAT, basic auth, and SSH keys.
|
||||
-- Uses the same AES-256-GCM vault pattern as BYOK provider configs.
|
||||
CREATE TABLE IF NOT EXISTS git_credentials (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
auth_type TEXT NOT NULL DEFAULT 'https_pat', -- https_pat, https_basic, ssh_key
|
||||
encrypted_data BYTEA NOT NULL DEFAULT '',
|
||||
nonce BYTEA NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_git_credentials_user ON git_credentials(user_id);
|
||||
|
||||
-- Workspace git tracking columns.
|
||||
ALTER TABLE workspaces ADD COLUMN IF NOT EXISTS git_remote_url TEXT;
|
||||
ALTER TABLE workspaces ADD COLUMN IF NOT EXISTS git_branch TEXT;
|
||||
ALTER TABLE workspaces ADD COLUMN IF NOT EXISTS git_credential_id UUID REFERENCES git_credentials(id) ON DELETE SET NULL;
|
||||
ALTER TABLE workspaces ADD COLUMN IF NOT EXISTS git_last_sync TIMESTAMPTZ;
|
||||
19
server/database/migrations/sqlite/011_v0214_git.sql
Normal file
19
server/database/migrations/sqlite/011_v0214_git.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- v0.21.4: Git integration — credentials table + workspace git columns.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS git_credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
auth_type TEXT NOT NULL DEFAULT 'https_pat',
|
||||
encrypted_data TEXT NOT NULL DEFAULT '',
|
||||
nonce TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_git_credentials_user ON git_credentials(user_id);
|
||||
|
||||
-- Workspace git tracking columns.
|
||||
ALTER TABLE workspaces ADD COLUMN git_remote_url TEXT;
|
||||
ALTER TABLE workspaces ADD COLUMN git_branch TEXT;
|
||||
ALTER TABLE workspaces ADD COLUMN git_credential_id TEXT;
|
||||
ALTER TABLE workspaces ADD COLUMN git_last_sync TEXT;
|
||||
@@ -402,11 +402,14 @@ func (h *CompletionHandler) buildToolDefs(ctx context.Context, userID string, in
|
||||
disabled[name] = true
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
333
server/handlers/git.go
Normal file
333
server/handlers/git.go
Normal file
@@ -0,0 +1,333 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/workspace"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// =========================================
|
||||
// GIT HANDLER — Git operations on workspaces
|
||||
// =========================================
|
||||
|
||||
type GitHandler struct {
|
||||
stores store.Stores
|
||||
gitOps *workspace.GitOps
|
||||
}
|
||||
|
||||
func NewGitHandler(s store.Stores, gitOps *workspace.GitOps) *GitHandler {
|
||||
return &GitHandler{stores: s, gitOps: gitOps}
|
||||
}
|
||||
|
||||
// ── Clone ────────────────────────────────────
|
||||
|
||||
func (h *GitHandler) Clone(c *gin.Context) {
|
||||
wsID := c.Param("id")
|
||||
var req struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
Branch string `json:"branch"`
|
||||
CredentialID string `json:"credential_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
w, err := h.stores.Workspaces.GetByID(c.Request.Context(), wsID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.gitOps.Clone(c.Request.Context(), w, req.URL, req.Branch, req.CredentialID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "cloned"})
|
||||
}
|
||||
|
||||
// ── Pull ─────────────────────────────────────
|
||||
|
||||
func (h *GitHandler) Pull(c *gin.Context) {
|
||||
w, err := h.loadWorkspace(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := h.gitOps.Pull(c.Request.Context(), w); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "pulled"})
|
||||
}
|
||||
|
||||
// ── Push ─────────────────────────────────────
|
||||
|
||||
func (h *GitHandler) Push(c *gin.Context) {
|
||||
w, err := h.loadWorkspace(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := h.gitOps.Push(c.Request.Context(), w); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "pushed"})
|
||||
}
|
||||
|
||||
// ── Status ───────────────────────────────────
|
||||
|
||||
func (h *GitHandler) Status(c *gin.Context) {
|
||||
w, err := h.loadWorkspace(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
status, err := h.gitOps.Status(c.Request.Context(), w)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, status)
|
||||
}
|
||||
|
||||
// ── Diff ─────────────────────────────────────
|
||||
|
||||
func (h *GitHandler) Diff(c *gin.Context) {
|
||||
w, err := h.loadWorkspace(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
path := c.Query("path")
|
||||
diff, err := h.gitOps.Diff(c.Request.Context(), w, path)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"diff": diff})
|
||||
}
|
||||
|
||||
// ── Commit ───────────────────────────────────
|
||||
|
||||
func (h *GitHandler) Commit(c *gin.Context) {
|
||||
w, err := h.loadWorkspace(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Message string `json:"message" binding:"required"`
|
||||
Paths []string `json:"paths"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.gitOps.Commit(c.Request.Context(), w, req.Message, req.Paths); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "committed"})
|
||||
}
|
||||
|
||||
// ── Log ──────────────────────────────────────
|
||||
|
||||
func (h *GitHandler) Log(c *gin.Context) {
|
||||
w, err := h.loadWorkspace(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
n := 20
|
||||
if v := c.Query("n"); v != "" {
|
||||
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
||||
n = parsed
|
||||
if n > 100 {
|
||||
n = 100
|
||||
}
|
||||
}
|
||||
}
|
||||
entries, err := h.gitOps.Log(c.Request.Context(), w, n)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, entries)
|
||||
}
|
||||
|
||||
// ── Branches ─────────────────────────────────
|
||||
|
||||
func (h *GitHandler) Branches(c *gin.Context) {
|
||||
w, err := h.loadWorkspace(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
branches, current, err := h.gitOps.BranchList(c.Request.Context(), w)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"current": current,
|
||||
"branches": branches,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Checkout ─────────────────────────────────
|
||||
|
||||
func (h *GitHandler) Checkout(c *gin.Context) {
|
||||
w, err := h.loadWorkspace(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Branch string `json:"branch" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.gitOps.Checkout(c.Request.Context(), w, req.Branch); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "checked out", "branch": req.Branch})
|
||||
}
|
||||
|
||||
// loadWorkspace loads the workspace and validates git is configured.
|
||||
func (h *GitHandler) loadWorkspace(c *gin.Context) (*models.Workspace, error) {
|
||||
wsID := c.Param("id")
|
||||
w, err := h.stores.Workspaces.GetByID(c.Request.Context(), wsID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||
return nil, err
|
||||
}
|
||||
if w.GitRemoteURL == nil || *w.GitRemoteURL == "" {
|
||||
err := fmt.Errorf("workspace has no git remote configured")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return nil, err
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// GIT CREDENTIAL HANDLER — CRUD for encrypted git credentials
|
||||
// =========================================
|
||||
|
||||
type GitCredentialHandler struct {
|
||||
stores store.Stores
|
||||
vault *crypto.KeyResolver
|
||||
}
|
||||
|
||||
func NewGitCredentialHandler(s store.Stores, vault *crypto.KeyResolver) *GitCredentialHandler {
|
||||
return &GitCredentialHandler{stores: s, vault: vault}
|
||||
}
|
||||
|
||||
// Create stores a new encrypted git credential.
|
||||
func (h *GitCredentialHandler) Create(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
AuthType string `json:"auth_type" binding:"required"`
|
||||
// Raw credential data — encrypted before storage
|
||||
Token string `json:"token,omitempty"` // for https_pat
|
||||
Username string `json:"username,omitempty"` // for https_basic
|
||||
Password string `json:"password,omitempty"` // for https_basic
|
||||
PrivateKey string `json:"private_key,omitempty"` // for ssh_key
|
||||
Passphrase string `json:"passphrase,omitempty"` // for ssh_key
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate auth type
|
||||
switch req.AuthType {
|
||||
case "https_pat":
|
||||
if req.Token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required for https_pat"})
|
||||
return
|
||||
}
|
||||
case "https_basic":
|
||||
if req.Username == "" || req.Password == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "username and password required for https_basic"})
|
||||
return
|
||||
}
|
||||
case "ssh_key":
|
||||
if req.PrivateKey == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "private_key is required for ssh_key"})
|
||||
return
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "auth_type must be https_pat, https_basic, or ssh_key"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build plaintext JSON
|
||||
var credData map[string]string
|
||||
switch req.AuthType {
|
||||
case "https_pat":
|
||||
credData = map[string]string{"token": req.Token}
|
||||
case "https_basic":
|
||||
credData = map[string]string{"username": req.Username, "password": req.Password}
|
||||
case "ssh_key":
|
||||
credData = map[string]string{"private_key": req.PrivateKey, "passphrase": req.Passphrase}
|
||||
}
|
||||
plaintext, _ := json.Marshal(credData)
|
||||
|
||||
// Encrypt using global scope (same vault pattern as BYOK)
|
||||
ciphertext, nonce, err := h.vault.EncryptForScope(string(plaintext), "global", userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "encryption failed"})
|
||||
return
|
||||
}
|
||||
|
||||
cred := &models.GitCredential{
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
AuthType: req.AuthType,
|
||||
EncryptedData: ciphertext,
|
||||
Nonce: nonce,
|
||||
}
|
||||
|
||||
if err := h.stores.GitCredentials.Create(c.Request.Context(), cred); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, cred.Summary())
|
||||
}
|
||||
|
||||
// List returns all git credentials for the current user (summaries only).
|
||||
func (h *GitCredentialHandler) List(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
creds, err := h.stores.GitCredentials.ListByUser(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
summaries := make([]models.GitCredentialSummary, 0, len(creds))
|
||||
for _, cred := range creds {
|
||||
summaries = append(summaries, cred.Summary())
|
||||
}
|
||||
c.JSON(http.StatusOK, summaries)
|
||||
}
|
||||
|
||||
// Delete removes a git credential.
|
||||
func (h *GitCredentialHandler) Delete(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
credID := c.Param("id")
|
||||
|
||||
if err := h.stores.GitCredentials.Delete(c.Request.Context(), credID, userID); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "credential not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
@@ -205,6 +205,15 @@ func main() {
|
||||
// Register workspace_search tool (needs embedder for query embedding)
|
||||
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)
|
||||
|
||||
61
server/models/models_git.go
Normal file
61
server/models/models_git.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// GitCredential stores encrypted git authentication credentials.
|
||||
// The encrypted_data field contains JSON: { "token": "..." } for PAT,
|
||||
// { "username": "...", "password": "..." } for basic auth, or
|
||||
// { "private_key": "...", "passphrase": "..." } for SSH keys.
|
||||
type GitCredential struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
AuthType string `json:"auth_type" db:"auth_type"` // https_pat, https_basic, ssh_key
|
||||
EncryptedData []byte `json:"-" db:"encrypted_data"` // never expose
|
||||
Nonce []byte `json:"-" db:"nonce"` // never expose
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
}
|
||||
|
||||
// GitCredentialSummary is the safe-to-expose version (no encrypted data).
|
||||
type GitCredentialSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AuthType string `json:"auth_type"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Summary returns a safe version without encrypted fields.
|
||||
func (c *GitCredential) Summary() GitCredentialSummary {
|
||||
return GitCredentialSummary{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
AuthType: c.AuthType,
|
||||
CreatedAt: c.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// GitStatus represents the output of git status.
|
||||
type GitStatus struct {
|
||||
Branch string `json:"branch"`
|
||||
Clean bool `json:"clean"`
|
||||
Ahead int `json:"ahead"`
|
||||
Behind int `json:"behind"`
|
||||
Staged []GitFileStatus `json:"staged,omitempty"`
|
||||
Modified []GitFileStatus `json:"modified,omitempty"`
|
||||
Untracked []string `json:"untracked,omitempty"`
|
||||
}
|
||||
|
||||
// GitFileStatus represents a single file in git status output.
|
||||
type GitFileStatus struct {
|
||||
Path string `json:"path"`
|
||||
Status string `json:"status"` // M, A, D, R, C, U
|
||||
}
|
||||
|
||||
// GitLogEntry represents a single commit in git log.
|
||||
type GitLogEntry struct {
|
||||
Hash string `json:"hash"`
|
||||
ShortHash string `json:"short_hash"`
|
||||
Author string `json:"author"`
|
||||
Date time.Time `json:"date"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@@ -33,6 +33,12 @@ type Workspace struct {
|
||||
|
||||
IndexingEnabled bool `json:"indexing_enabled" db:"indexing_enabled"`
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// =========================================
|
||||
|
||||
76
server/store/postgres/git_credentials.go
Normal file
76
server/store/postgres/git_credentials.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
// GitCredentialStore implements store.GitCredentialStore for PostgreSQL.
|
||||
type GitCredentialStore struct{}
|
||||
|
||||
func (s *GitCredentialStore) Create(ctx context.Context, cred *models.GitCredential) error {
|
||||
if cred.ID != "" {
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO git_credentials (id, user_id, name, auth_type, encrypted_data, nonce)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING created_at`,
|
||||
cred.ID, cred.UserID, cred.Name, cred.AuthType,
|
||||
cred.EncryptedData, cred.Nonce,
|
||||
).Scan(&cred.CreatedAt)
|
||||
}
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO git_credentials (user_id, name, auth_type, encrypted_data, nonce)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, created_at`,
|
||||
cred.UserID, cred.Name, cred.AuthType,
|
||||
cred.EncryptedData, cred.Nonce,
|
||||
).Scan(&cred.ID, &cred.CreatedAt)
|
||||
}
|
||||
|
||||
func (s *GitCredentialStore) GetByID(ctx context.Context, id string) (*models.GitCredential, error) {
|
||||
var c models.GitCredential
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, user_id, name, auth_type, encrypted_data, nonce, created_at
|
||||
FROM git_credentials WHERE id = $1`, id,
|
||||
).Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType,
|
||||
&c.EncryptedData, &c.Nonce, &c.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (s *GitCredentialStore) ListByUser(ctx context.Context, userID string) ([]models.GitCredential, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, user_id, name, auth_type, encrypted_data, nonce, created_at
|
||||
FROM git_credentials WHERE user_id = $1 ORDER BY created_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var creds []models.GitCredential
|
||||
for rows.Next() {
|
||||
var c models.GitCredential
|
||||
if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType,
|
||||
&c.EncryptedData, &c.Nonce, &c.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
creds = append(creds, c)
|
||||
}
|
||||
return creds, rows.Err()
|
||||
}
|
||||
|
||||
func (s *GitCredentialStore) Delete(ctx context.Context, id, userID string) error {
|
||||
res, err := DB.ExecContext(ctx, `DELETE FROM git_credentials WHERE id = $1 AND user_id = $2`, id, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -36,5 +36,6 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
Notifications: NewNotificationStore(),
|
||||
NotifPrefs: NewNotificationPreferenceStore(),
|
||||
Workspaces: NewWorkspaceStore(),
|
||||
GitCredentials: &GitCredentialStore{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
86
server/store/sqlite/git_credentials.go
Normal file
86
server/store/sqlite/git_credentials.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// GitCredentialStore implements store.GitCredentialStore for SQLite.
|
||||
type GitCredentialStore struct{}
|
||||
|
||||
func (s *GitCredentialStore) Create(ctx context.Context, cred *models.GitCredential) error {
|
||||
if cred.ID == "" {
|
||||
cred.ID = uuid.NewString()
|
||||
}
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO git_credentials (id, user_id, name, auth_type, encrypted_data, nonce, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||
cred.ID, cred.UserID, cred.Name, cred.AuthType,
|
||||
base64.StdEncoding.EncodeToString(cred.EncryptedData),
|
||||
base64.StdEncoding.EncodeToString(cred.Nonce),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Read back created_at
|
||||
return DB.QueryRowContext(ctx,
|
||||
`SELECT created_at FROM git_credentials WHERE id = ?`, cred.ID,
|
||||
).Scan(st(&cred.CreatedAt))
|
||||
}
|
||||
|
||||
func (s *GitCredentialStore) GetByID(ctx context.Context, id string) (*models.GitCredential, error) {
|
||||
var c models.GitCredential
|
||||
var encData, nonce string
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, user_id, name, auth_type, encrypted_data, nonce, created_at
|
||||
FROM git_credentials WHERE id = ?`, id,
|
||||
).Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType,
|
||||
&encData, &nonce, st(&c.CreatedAt))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.EncryptedData, _ = base64.StdEncoding.DecodeString(encData)
|
||||
c.Nonce, _ = base64.StdEncoding.DecodeString(nonce)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (s *GitCredentialStore) ListByUser(ctx context.Context, userID string) ([]models.GitCredential, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, user_id, name, auth_type, encrypted_data, nonce, created_at
|
||||
FROM git_credentials WHERE user_id = ? ORDER BY created_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var creds []models.GitCredential
|
||||
for rows.Next() {
|
||||
var c models.GitCredential
|
||||
var encData, nonce string
|
||||
if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType,
|
||||
&encData, &nonce, st(&c.CreatedAt)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.EncryptedData, _ = base64.StdEncoding.DecodeString(encData)
|
||||
c.Nonce, _ = base64.StdEncoding.DecodeString(nonce)
|
||||
creds = append(creds, c)
|
||||
}
|
||||
return creds, rows.Err()
|
||||
}
|
||||
|
||||
func (s *GitCredentialStore) Delete(ctx context.Context, id, userID string) error {
|
||||
res, err := DB.ExecContext(ctx, `DELETE FROM git_credentials WHERE id = ? AND user_id = ?`, id, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -36,5 +36,6 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
Notifications: NewNotificationStore(),
|
||||
NotifPrefs: NewNotificationPreferenceStore(),
|
||||
Workspaces: NewWorkspaceStore(),
|
||||
GitCredentials: &GitCredentialStore{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
216
server/tools/git.go
Normal file
216
server/tools/git.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/workspace"
|
||||
)
|
||||
|
||||
// RegisterGitTools registers the 5 git-related workspace tools.
|
||||
// Only meaningful when a workspace has git_remote_url set — the completion
|
||||
// handler filters them out otherwise (via GitToolNames).
|
||||
func RegisterGitTools(gitOps *workspace.GitOps) {
|
||||
Register(&gitStatusTool{gitOps: gitOps})
|
||||
Register(&gitDiffTool{gitOps: gitOps})
|
||||
Register(&gitCommitTool{gitOps: gitOps})
|
||||
Register(&gitLogTool{gitOps: gitOps})
|
||||
Register(&gitBranchTool{gitOps: gitOps})
|
||||
}
|
||||
|
||||
// GitToolNames returns the names of all git tools for filtering.
|
||||
func GitToolNames() []string {
|
||||
return []string{"git_status", "git_diff", "git_commit", "git_log", "git_branch"}
|
||||
}
|
||||
|
||||
// ── git_status ───────────────────────────────
|
||||
|
||||
type gitStatusTool struct{ gitOps *workspace.GitOps }
|
||||
|
||||
func (t *gitStatusTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "git_status",
|
||||
DisplayName: "Git Status",
|
||||
Description: "Show git working tree status: branch name, staged/modified/untracked files, ahead/behind remote counts.",
|
||||
Category: "workspace",
|
||||
Parameters: JSONSchema(map[string]interface{}{}, nil),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *gitStatusTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
w, err := t.gitOps.LoadWorkspace(ctx, execCtx.WorkspaceID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
status, err := t.gitOps.Status(ctx, w)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, _ := json.MarshalIndent(status, "", " ")
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// ── git_diff ─────────────────────────────────
|
||||
|
||||
type gitDiffTool struct{ gitOps *workspace.GitOps }
|
||||
|
||||
func (t *gitDiffTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "git_diff",
|
||||
DisplayName: "Git Diff",
|
||||
Description: "Show git diff for a specific file or all uncommitted changes.",
|
||||
Category: "workspace",
|
||||
Parameters: JSONSchema(map[string]interface{}{
|
||||
"path": Prop("string", "File path to diff (empty for all changes)"),
|
||||
}, nil),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *gitDiffTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
var args struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
json.Unmarshal([]byte(argsJSON), &args)
|
||||
|
||||
w, err := t.gitOps.LoadWorkspace(ctx, execCtx.WorkspaceID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
diff, err := t.gitOps.Diff(ctx, w, args.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if diff == "" {
|
||||
return "No changes.", nil
|
||||
}
|
||||
if len(diff) > 50000 {
|
||||
diff = diff[:50000] + "\n... (truncated)"
|
||||
}
|
||||
return diff, nil
|
||||
}
|
||||
|
||||
// ── git_commit ───────────────────────────────
|
||||
|
||||
type gitCommitTool struct{ gitOps *workspace.GitOps }
|
||||
|
||||
func (t *gitCommitTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "git_commit",
|
||||
DisplayName: "Git Commit",
|
||||
Description: "Stage and commit files. Stages all changes if no paths specified.",
|
||||
Category: "workspace",
|
||||
Parameters: JSONSchema(map[string]interface{}{
|
||||
"message": Prop("string", "Commit message"),
|
||||
"paths": PropArray("Specific file paths to stage (empty = stage all)"),
|
||||
}, []string{"message"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *gitCommitTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
var args struct {
|
||||
Message string `json:"message"`
|
||||
Paths []string `json:"paths"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
if args.Message == "" {
|
||||
return "", fmt.Errorf("commit message is required")
|
||||
}
|
||||
|
||||
w, err := t.gitOps.LoadWorkspace(ctx, execCtx.WorkspaceID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := t.gitOps.Commit(ctx, w, args.Message, args.Paths); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Committed: %s", args.Message), nil
|
||||
}
|
||||
|
||||
// ── git_log ──────────────────────────────────
|
||||
|
||||
type gitLogTool struct{ gitOps *workspace.GitOps }
|
||||
|
||||
func (t *gitLogTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "git_log",
|
||||
DisplayName: "Git Log",
|
||||
Description: "Show recent commit history.",
|
||||
Category: "workspace",
|
||||
Parameters: JSONSchema(map[string]interface{}{
|
||||
"count": Prop("integer", "Number of commits to show (default 20, max 100)"),
|
||||
}, nil),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *gitLogTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
var args struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
json.Unmarshal([]byte(argsJSON), &args)
|
||||
if args.Count <= 0 {
|
||||
args.Count = 20
|
||||
}
|
||||
if args.Count > 100 {
|
||||
args.Count = 100
|
||||
}
|
||||
|
||||
w, err := t.gitOps.LoadWorkspace(ctx, execCtx.WorkspaceID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
entries, err := t.gitOps.Log(ctx, w, args.Count)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, _ := json.MarshalIndent(entries, "", " ")
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// ── git_branch ───────────────────────────────
|
||||
|
||||
type gitBranchTool struct{ gitOps *workspace.GitOps }
|
||||
|
||||
func (t *gitBranchTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "git_branch",
|
||||
DisplayName: "Git Branch",
|
||||
Description: "List branches or switch to a different branch.",
|
||||
Category: "workspace",
|
||||
Parameters: JSONSchema(map[string]interface{}{
|
||||
"checkout": Prop("string", "Branch name to switch to (empty = list only)"),
|
||||
}, nil),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *gitBranchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
var args struct {
|
||||
Checkout string `json:"checkout"`
|
||||
}
|
||||
json.Unmarshal([]byte(argsJSON), &args)
|
||||
|
||||
w, err := t.gitOps.LoadWorkspace(ctx, execCtx.WorkspaceID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if args.Checkout != "" {
|
||||
if err := t.gitOps.Checkout(ctx, w, args.Checkout); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Switched to branch: %s", args.Checkout), nil
|
||||
}
|
||||
|
||||
branches, current, err := t.gitOps.BranchList(ctx, w)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result := map[string]interface{}{
|
||||
"current": current,
|
||||
"branches": branches,
|
||||
}
|
||||
data, _ := json.MarshalIndent(result, "", " ")
|
||||
return string(data), nil
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"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()
|
||||
|
||||
585
server/workspace/gitops.go
Normal file
585
server/workspace/gitops.go
Normal file
@@ -0,0 +1,585 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// GitOps provides git operations for workspaces via exec.
|
||||
// Credentials are decrypted from the vault and injected via temporary
|
||||
// files or environment variables for the duration of each operation.
|
||||
type GitOps struct {
|
||||
fs *FS
|
||||
stores store.Stores
|
||||
keyResolver *crypto.KeyResolver
|
||||
indexer *Indexer // may be nil
|
||||
}
|
||||
|
||||
// NewGitOps creates a new git operations handler.
|
||||
func NewGitOps(fs *FS, stores store.Stores, resolver *crypto.KeyResolver, indexer *Indexer) *GitOps {
|
||||
return &GitOps{
|
||||
fs: fs,
|
||||
stores: stores,
|
||||
keyResolver: resolver,
|
||||
indexer: indexer,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Clone ────────────────────────────────────
|
||||
|
||||
// Clone clones a remote repository into the workspace.
|
||||
func (g *GitOps) Clone(ctx context.Context, w *models.Workspace, remoteURL, branch, credID string) error {
|
||||
if err := validateRemoteURL(remoteURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir := g.fs.workspacePath(w)
|
||||
|
||||
args := []string{"clone", "--single-branch"}
|
||||
if branch != "" {
|
||||
args = append(args, "--branch", branch)
|
||||
}
|
||||
args = append(args, remoteURL, ".")
|
||||
|
||||
env, cleanup, err := g.credEnv(ctx, credID, remoteURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("git clone: credential setup: %w", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
if _, err := g.run(ctx, dir, env, args...); err != nil {
|
||||
return fmt.Errorf("git clone: %w", err)
|
||||
}
|
||||
|
||||
// Update workspace with git metadata
|
||||
now := time.Now().UTC()
|
||||
patch := models.WorkspacePatch{
|
||||
GitRemoteURL: &remoteURL,
|
||||
GitCredentialID: &credID,
|
||||
}
|
||||
if branch != "" {
|
||||
patch.GitBranch = &branch
|
||||
} else {
|
||||
// Detect default branch
|
||||
if out, err := g.run(ctx, dir, nil, "rev-parse", "--abbrev-ref", "HEAD"); err == nil {
|
||||
b := strings.TrimSpace(out)
|
||||
patch.GitBranch = &b
|
||||
}
|
||||
}
|
||||
if err := g.stores.Workspaces.Update(ctx, w.ID, patch); err != nil {
|
||||
return fmt.Errorf("git clone: update workspace: %w", err)
|
||||
}
|
||||
g.updateSyncTime(ctx, w.ID, now)
|
||||
|
||||
// Post-operation: reconcile + re-index
|
||||
g.postOpHook(ctx, w)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Pull ─────────────────────────────────────
|
||||
|
||||
func (g *GitOps) Pull(ctx context.Context, w *models.Workspace) error {
|
||||
dir := g.fs.workspacePath(w)
|
||||
env, cleanup, err := g.credEnvFromWorkspace(ctx, w)
|
||||
if err != nil {
|
||||
return fmt.Errorf("git pull: %w", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
if _, err := g.run(ctx, dir, env, "pull", "--ff-only"); err != nil {
|
||||
return fmt.Errorf("git pull: %w", err)
|
||||
}
|
||||
|
||||
g.updateSyncTime(ctx, w.ID, time.Now().UTC())
|
||||
g.postOpHook(ctx, w)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Push ─────────────────────────────────────
|
||||
|
||||
func (g *GitOps) Push(ctx context.Context, w *models.Workspace) error {
|
||||
dir := g.fs.workspacePath(w)
|
||||
env, cleanup, err := g.credEnvFromWorkspace(ctx, w)
|
||||
if err != nil {
|
||||
return fmt.Errorf("git push: %w", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
if _, err := g.run(ctx, dir, env, "push"); err != nil {
|
||||
return fmt.Errorf("git push: %w", err)
|
||||
}
|
||||
|
||||
g.updateSyncTime(ctx, w.ID, time.Now().UTC())
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Status ───────────────────────────────────
|
||||
|
||||
func (g *GitOps) Status(ctx context.Context, w *models.Workspace) (*models.GitStatus, error) {
|
||||
dir := g.fs.workspacePath(w)
|
||||
|
||||
// Branch name
|
||||
branchOut, err := g.run(ctx, dir, nil, "rev-parse", "--abbrev-ref", "HEAD")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("git status: %w", err)
|
||||
}
|
||||
|
||||
// Porcelain status
|
||||
out, err := g.run(ctx, dir, nil, "status", "--porcelain=v1", "-b")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("git status: %w", err)
|
||||
}
|
||||
|
||||
status := &models.GitStatus{
|
||||
Branch: strings.TrimSpace(branchOut),
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
for _, line := range lines {
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
// Branch line: ## main...origin/main [ahead 1, behind 2]
|
||||
if strings.HasPrefix(line, "## ") {
|
||||
if idx := strings.Index(line, "["); idx > 0 {
|
||||
bracket := line[idx:]
|
||||
if n := parseAheadBehind(bracket, "ahead"); n > 0 {
|
||||
status.Ahead = n
|
||||
}
|
||||
if n := parseAheadBehind(bracket, "behind"); n > 0 {
|
||||
status.Behind = n
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(line) < 4 {
|
||||
continue
|
||||
}
|
||||
x := line[0] // index status
|
||||
y := line[1] // working tree status
|
||||
path := strings.TrimSpace(line[3:])
|
||||
|
||||
if x == '?' && y == '?' {
|
||||
status.Untracked = append(status.Untracked, path)
|
||||
} else if x != ' ' && x != '?' {
|
||||
status.Staged = append(status.Staged, models.GitFileStatus{Path: path, Status: string(x)})
|
||||
}
|
||||
if y != ' ' && y != '?' {
|
||||
status.Modified = append(status.Modified, models.GitFileStatus{Path: path, Status: string(y)})
|
||||
}
|
||||
}
|
||||
|
||||
status.Clean = len(status.Staged) == 0 && len(status.Modified) == 0 && len(status.Untracked) == 0
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// ── Diff ─────────────────────────────────────
|
||||
|
||||
func (g *GitOps) Diff(ctx context.Context, w *models.Workspace, path string) (string, error) {
|
||||
dir := g.fs.workspacePath(w)
|
||||
args := []string{"diff"}
|
||||
if path != "" {
|
||||
args = append(args, "--", path)
|
||||
}
|
||||
out, err := g.run(ctx, dir, nil, args...)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("git diff: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── Commit ───────────────────────────────────
|
||||
|
||||
func (g *GitOps) Commit(ctx context.Context, w *models.Workspace, message string, paths []string) error {
|
||||
dir := g.fs.workspacePath(w)
|
||||
|
||||
// Stage files
|
||||
if len(paths) == 0 {
|
||||
if _, err := g.run(ctx, dir, nil, "add", "-A"); err != nil {
|
||||
return fmt.Errorf("git add: %w", err)
|
||||
}
|
||||
} else {
|
||||
args := append([]string{"add", "--"}, paths...)
|
||||
if _, err := g.run(ctx, dir, nil, args...); err != nil {
|
||||
return fmt.Errorf("git add: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Commit
|
||||
if _, err := g.run(ctx, dir, nil, "commit", "-m", message); err != nil {
|
||||
return fmt.Errorf("git commit: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Log ──────────────────────────────────────
|
||||
|
||||
func (g *GitOps) Log(ctx context.Context, w *models.Workspace, n int) ([]models.GitLogEntry, error) {
|
||||
dir := g.fs.workspacePath(w)
|
||||
if n <= 0 {
|
||||
n = 20
|
||||
}
|
||||
|
||||
// JSON-like format for reliable parsing
|
||||
format := `{"hash":"%H","short_hash":"%h","author":"%an","date":"%aI","message":"%s"}`
|
||||
out, err := g.run(ctx, dir, nil, "log", fmt.Sprintf("-n%d", n), fmt.Sprintf("--format=%s", format))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("git log: %w", err)
|
||||
}
|
||||
|
||||
var entries []models.GitLogEntry
|
||||
scanner := bufio.NewScanner(strings.NewReader(strings.TrimSpace(out)))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var entry models.GitLogEntry
|
||||
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
||||
continue // skip malformed
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// ── Branch ───────────────────────────────────
|
||||
|
||||
func (g *GitOps) BranchList(ctx context.Context, w *models.Workspace) ([]string, string, error) {
|
||||
dir := g.fs.workspacePath(w)
|
||||
out, err := g.run(ctx, dir, nil, "branch", "-a", "--format=%(refname:short)%(if)%(HEAD)%(then) *%(end)")
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("git branch: %w", err)
|
||||
}
|
||||
|
||||
var branches []string
|
||||
var current string
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(line, " *") {
|
||||
name := strings.TrimSuffix(line, " *")
|
||||
current = name
|
||||
branches = append(branches, name)
|
||||
} else {
|
||||
branches = append(branches, strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
return branches, current, nil
|
||||
}
|
||||
|
||||
// Checkout switches to a branch.
|
||||
func (g *GitOps) Checkout(ctx context.Context, w *models.Workspace, branch string) error {
|
||||
dir := g.fs.workspacePath(w)
|
||||
if _, err := g.run(ctx, dir, nil, "checkout", branch); err != nil {
|
||||
return fmt.Errorf("git checkout: %w", err)
|
||||
}
|
||||
|
||||
// Update workspace branch
|
||||
patch := models.WorkspacePatch{GitBranch: &branch}
|
||||
if err := g.stores.Workspaces.Update(ctx, w.ID, patch); err != nil {
|
||||
log.Printf("git: failed to update workspace branch: %v", err)
|
||||
}
|
||||
|
||||
g.postOpHook(ctx, w)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Internals ────────────────────────────────
|
||||
|
||||
// run executes a git command in the given directory with optional env vars.
|
||||
func (g *GitOps) run(ctx context.Context, dir string, env []string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
cmd.Dir = dir
|
||||
|
||||
// Base environment + any credential env vars
|
||||
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
|
||||
if len(env) > 0 {
|
||||
cmd.Env = append(cmd.Env, env...)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
// Include stderr for diagnostics but sanitize credentials
|
||||
errMsg := sanitizeOutput(stderr.String())
|
||||
return "", fmt.Errorf("%s: %s", err, errMsg)
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
// credEnv sets up credential environment for a git operation.
|
||||
// Returns env vars, a cleanup function, and any error.
|
||||
func (g *GitOps) credEnv(ctx context.Context, credID, remoteURL string) ([]string, func(), error) {
|
||||
noop := func() {}
|
||||
if credID == "" {
|
||||
return nil, noop, nil
|
||||
}
|
||||
|
||||
cred, err := g.stores.GitCredentials.GetByID(ctx, credID)
|
||||
if err != nil {
|
||||
return nil, noop, fmt.Errorf("credential not found: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt credential data
|
||||
plaintext, err := g.keyResolver.Decrypt(cred.EncryptedData, cred.Nonce, "global", cred.UserID)
|
||||
if err != nil {
|
||||
return nil, noop, fmt.Errorf("credential decryption failed: %w", err)
|
||||
}
|
||||
|
||||
var data map[string]string
|
||||
if err := json.Unmarshal([]byte(plaintext), &data); err != nil {
|
||||
return nil, noop, fmt.Errorf("credential data invalid: %w", err)
|
||||
}
|
||||
|
||||
switch cred.AuthType {
|
||||
case "https_pat":
|
||||
return g.setupHTTPSPAT(data, remoteURL)
|
||||
case "https_basic":
|
||||
return g.setupHTTPSBasic(data, remoteURL)
|
||||
case "ssh_key":
|
||||
return g.setupSSHKey(data)
|
||||
default:
|
||||
return nil, noop, fmt.Errorf("unsupported auth type: %s", cred.AuthType)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *GitOps) credEnvFromWorkspace(ctx context.Context, w *models.Workspace) ([]string, func(), error) {
|
||||
credID := ""
|
||||
if w.GitCredentialID != nil {
|
||||
credID = *w.GitCredentialID
|
||||
}
|
||||
remoteURL := ""
|
||||
if w.GitRemoteURL != nil {
|
||||
remoteURL = *w.GitRemoteURL
|
||||
}
|
||||
return g.credEnv(ctx, credID, remoteURL)
|
||||
}
|
||||
|
||||
// setupHTTPSPAT creates a GIT_ASKPASS script that echoes the PAT.
|
||||
func (g *GitOps) setupHTTPSPAT(data map[string]string, remoteURL string) ([]string, func(), error) {
|
||||
token := data["token"]
|
||||
if token == "" {
|
||||
return nil, func() {}, fmt.Errorf("PAT token is empty")
|
||||
}
|
||||
|
||||
// Create temp askpass script
|
||||
f, err := os.CreateTemp("", "git-askpass-*.sh")
|
||||
if err != nil {
|
||||
return nil, func() {}, err
|
||||
}
|
||||
|
||||
script := fmt.Sprintf("#!/bin/sh\necho '%s'\n", strings.ReplaceAll(token, "'", "'\\''"))
|
||||
f.WriteString(script)
|
||||
f.Close()
|
||||
os.Chmod(f.Name(), 0700)
|
||||
|
||||
cleanup := func() {
|
||||
os.Remove(f.Name())
|
||||
}
|
||||
|
||||
env := []string{
|
||||
"GIT_ASKPASS=" + f.Name(),
|
||||
}
|
||||
return env, cleanup, nil
|
||||
}
|
||||
|
||||
// setupHTTPSBasic creates a temporary .git-credentials file.
|
||||
func (g *GitOps) setupHTTPSBasic(data map[string]string, remoteURL string) ([]string, func(), error) {
|
||||
username := data["username"]
|
||||
password := data["password"]
|
||||
if username == "" || password == "" {
|
||||
return nil, func() {}, fmt.Errorf("basic auth credentials incomplete")
|
||||
}
|
||||
|
||||
// Parse remote URL to extract host
|
||||
parsed, err := url.Parse(remoteURL)
|
||||
if err != nil {
|
||||
return nil, func() {}, fmt.Errorf("invalid remote URL: %w", err)
|
||||
}
|
||||
|
||||
credURL := fmt.Sprintf("%s://%s:%s@%s", parsed.Scheme,
|
||||
url.PathEscape(username), url.PathEscape(password), parsed.Host)
|
||||
|
||||
f, err := os.CreateTemp("", "git-creds-*.txt")
|
||||
if err != nil {
|
||||
return nil, func() {}, err
|
||||
}
|
||||
f.WriteString(credURL + "\n")
|
||||
f.Close()
|
||||
os.Chmod(f.Name(), 0600)
|
||||
|
||||
cleanup := func() {
|
||||
os.Remove(f.Name())
|
||||
}
|
||||
|
||||
env := []string{
|
||||
fmt.Sprintf("GIT_CONFIG_COUNT=2"),
|
||||
fmt.Sprintf("GIT_CONFIG_KEY_0=credential.helper"),
|
||||
fmt.Sprintf("GIT_CONFIG_VALUE_0=store --file=%s", f.Name()),
|
||||
fmt.Sprintf("GIT_CONFIG_KEY_1=credential.helper"),
|
||||
fmt.Sprintf("GIT_CONFIG_VALUE_1="),
|
||||
}
|
||||
return env, cleanup, nil
|
||||
}
|
||||
|
||||
// setupSSHKey creates a temporary SSH key file and GIT_SSH_COMMAND.
|
||||
func (g *GitOps) setupSSHKey(data map[string]string) ([]string, func(), error) {
|
||||
privateKey := data["private_key"]
|
||||
if privateKey == "" {
|
||||
return nil, func() {}, fmt.Errorf("SSH private key is empty")
|
||||
}
|
||||
|
||||
f, err := os.CreateTemp("", "git-ssh-key-*")
|
||||
if err != nil {
|
||||
return nil, func() {}, err
|
||||
}
|
||||
f.WriteString(privateKey)
|
||||
if !strings.HasSuffix(privateKey, "\n") {
|
||||
f.WriteString("\n")
|
||||
}
|
||||
f.Close()
|
||||
os.Chmod(f.Name(), 0600)
|
||||
|
||||
cleanup := func() {
|
||||
os.Remove(f.Name())
|
||||
}
|
||||
|
||||
sshCmd := fmt.Sprintf("ssh -i %s -o StrictHostKeyChecking=accept-new -o IdentitiesOnly=yes", f.Name())
|
||||
|
||||
env := []string{
|
||||
"GIT_SSH_COMMAND=" + sshCmd,
|
||||
}
|
||||
return env, cleanup, nil
|
||||
}
|
||||
|
||||
// validateRemoteURL rejects file:// and local path URLs.
|
||||
func validateRemoteURL(u string) error {
|
||||
if u == "" {
|
||||
return fmt.Errorf("remote URL is required")
|
||||
}
|
||||
lower := strings.ToLower(u)
|
||||
if strings.HasPrefix(lower, "file://") || strings.HasPrefix(lower, "/") || strings.HasPrefix(lower, ".") {
|
||||
return fmt.Errorf("local filesystem URLs are not allowed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sanitizeOutput removes potential credential leaks from error messages.
|
||||
func sanitizeOutput(s string) string {
|
||||
// Strip lines containing tokens/passwords
|
||||
var lines []string
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
lower := strings.ToLower(line)
|
||||
if strings.Contains(lower, "password") || strings.Contains(lower, "token") ||
|
||||
strings.Contains(lower, "credential") {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
result := strings.Join(lines, "\n")
|
||||
if len(result) > 500 {
|
||||
result = result[:500] + "..."
|
||||
}
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
|
||||
func parseAheadBehind(bracket, key string) int {
|
||||
idx := strings.Index(bracket, key)
|
||||
if idx < 0 {
|
||||
return 0
|
||||
}
|
||||
rest := bracket[idx+len(key):]
|
||||
rest = strings.TrimLeft(rest, " ")
|
||||
num := ""
|
||||
for _, c := range rest {
|
||||
if c >= '0' && c <= '9' {
|
||||
num += string(c)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
n, _ := strconv.Atoi(num)
|
||||
return n
|
||||
}
|
||||
|
||||
// updateSyncTime sets git_last_sync on the workspace.
|
||||
func (g *GitOps) updateSyncTime(ctx context.Context, wsID string, t time.Time) {
|
||||
if err := g.stores.Workspaces.SetGitLastSync(ctx, wsID, t); err != nil {
|
||||
log.Printf("git: failed to update sync time for %s: %v", wsID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// postOpHook triggers workspace reconcile and re-indexing after git operations
|
||||
// that change the working tree (clone, pull, checkout).
|
||||
func (g *GitOps) postOpHook(ctx context.Context, w *models.Workspace) {
|
||||
// Reconcile: sync filesystem → database file index
|
||||
added, removed, updated, err := g.fs.Reconcile(ctx, w)
|
||||
if err != nil {
|
||||
log.Printf("git: post-op reconcile failed for %s: %v", w.ID, err)
|
||||
return
|
||||
}
|
||||
log.Printf("git: post-op reconcile %s: +%d -%d ~%d", w.ID, added, removed, updated)
|
||||
|
||||
// Re-index if indexer is configured and there are changes
|
||||
if g.indexer != nil && g.indexer.IsEnabled(ctx) && (added+updated) > 0 {
|
||||
files, err := g.stores.Workspaces.ListFiles(ctx, w.ID, "", true)
|
||||
if err != nil {
|
||||
log.Printf("git: post-op index list failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve userID/teamID from workspace owner
|
||||
var userID string
|
||||
var teamID *string
|
||||
switch w.OwnerType {
|
||||
case "user":
|
||||
userID = w.OwnerID
|
||||
case "team":
|
||||
teamID = &w.OwnerID
|
||||
default:
|
||||
userID = w.OwnerID
|
||||
}
|
||||
|
||||
g.indexer.IndexBatch(w, files, userID, teamID)
|
||||
}
|
||||
}
|
||||
|
||||
// workspacePath returns the filesystem path for git operations on a workspace.
|
||||
// This is the files directory where the working tree lives.
|
||||
func (fs *FS) workspacePath(w *models.Workspace) string {
|
||||
return fs.filesDir(w)
|
||||
}
|
||||
|
||||
// LoadWorkspace fetches a workspace by ID and validates it has a git remote.
|
||||
// Used by git tools to resolve workspace + check git configuration.
|
||||
func (g *GitOps) LoadWorkspace(ctx context.Context, workspaceID string) (*models.Workspace, error) {
|
||||
w, err := g.stores.Workspaces.GetByID(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("workspace not found: %w", err)
|
||||
}
|
||||
if w.GitRemoteURL == nil || *w.GitRemoteURL == "" {
|
||||
return nil, fmt.Errorf("workspace has no git remote configured")
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
Reference in New Issue
Block a user