This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/workspace/gitops.go
2026-03-01 19:42:22 +00:00

586 lines
16 KiB
Go

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
}