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 }