Connor McCutcheon
/ SkyCode
code.go
go
package controllers
import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strings"
	"time"
	"theskyscape.com/repo/skycode/models"
	"theskyscape.com/repo/skykit"
)
// Controller constants
const (
	// Git command timeout
	gitTimeout = 60 * time.Second
	// Maximum request body size (10MB)
	maxRequestBodySize = 10 * 1024 * 1024
	// Search limits
	maxSearchResults    = 100
	maxRegexLength      = 200
	searchContextWindow = 30
	// Session cookie expiry (30 minutes)
	sessionCookieMaxAge = 30 * 60
	// Clone redirect cookie expiry (10 minutes)
	cloneCookieMaxAge = 600
	// Settings validation limits
	minFontSize = 10
	maxFontSize = 32
	minTabSize  = 1
	maxTabSize  = 8
	// Shell metacharacters that could be used for command injection
	shellMetaChars         = ";&|`$(){}[]<>\\\"'"
	shellMetaCharsWithWS   = ";&|`$(){}[]<>\\\"' \t\n"
)
// isValidGitURL validates a git clone URL for protocol and shell safety
func isValidGitURL(url string) bool {
	// Must use a valid protocol
	if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "git://") {
		return false
	}
	// Block shell metacharacters
	if strings.ContainsAny(url, shellMetaChars) {
		return false
	}
	return true
}
// isValidGitRef validates a git branch or remote name for shell safety
func isValidGitRef(name string) bool {
	return !strings.ContainsAny(name, shellMetaCharsWithWS)
}
// gitCommand creates a git command with timeout context
func gitCommand(ctx context.Context, dir string, args ...string) *exec.Cmd {
	cmd := exec.CommandContext(ctx, "git", args...)
	cmd.Dir = dir
	return cmd
}
// gitCommandWithCredentials creates a git command that uses provided credentials
// via GIT_ASKPASS for authentication (credentials are not saved)
func gitCommandWithCredentials(ctx context.Context, dir, username, password string, args ...string) *exec.Cmd {
	cmd := exec.CommandContext(ctx, "git", args...)
	cmd.Dir = dir
	// Create environment with credential helper
	// GIT_ASKPASS is called with "Username for ..." or "Password for ..." as argument
	// We use a simple shell command that echoes the appropriate credential
	askpassScript := fmt.Sprintf(`#!/bin/sh
case "$1" in
  *Username*|*username*) echo '%s' ;;
  *Password*|*password*|*token*|*Token*) echo '%s' ;;
esac`, username, password)
	// Write temporary askpass script
	tmpFile, err := os.CreateTemp("", "git-askpass-*.sh")
	if err == nil {
		tmpFile.WriteString(askpassScript)
		tmpFile.Chmod(0700)
		tmpFile.Close()
		// Set environment to use the askpass script
		cmd.Env = append(os.Environ(),
			"GIT_ASKPASS="+tmpFile.Name(),
			"GIT_TERMINAL_PROMPT=0",
		)
		// Clean up temp file after command completes (in a goroutine)
		go func() {
			// Wait a bit for the command to use the file
			time.Sleep(5 * time.Second)
			os.Remove(tmpFile.Name())
		}()
	}
	return cmd
}
// isAuthError checks if git output indicates an authentication failure
func isAuthError(output string) bool {
	authPatterns := []string{
		"Authentication failed",
		"could not read Username",
		"could not read Password",
		"Invalid username or password",
		"fatal: Authentication failed",
		"remote: Invalid username or password",
		"Authorization failed",
		"403",
		"401",
		"Permission denied",
		"support for password authentication was removed",
	}
	outputLower := strings.ToLower(output)
	for _, pattern := range authPatterns {
		if strings.Contains(outputLower, strings.ToLower(pattern)) {
			return true
		}
	}
	return false
}
func Code() (string, skykit.Handler) {
	return "code", &CodeController{}
}
type CodeController struct {
	skykit.Controller
}
func (c *CodeController) Setup(app *skykit.Application) {
	c.Controller.Setup(app)
	// Public routes - home page checks for pending clone
	http.Handle("GET /", c.Serve("home.html", c.checkPendingClone))
	// Protected routes - require authentication
	http.Handle("GET /editor", c.Serve("editor.html", c.requireAuth))
	// API routes
	http.HandleFunc("GET /api/files", c.Protect(c.listFiles, c.requireAuth))
	http.HandleFunc("GET /api/files/open", c.Protect(c.openFile, c.requireAuth))
	http.HandleFunc("POST /api/files/save", c.Protect(c.saveFile, c.requireAuth))
	http.HandleFunc("POST /api/files/rename", c.Protect(c.renameFile, c.requireAuth))
	http.HandleFunc("DELETE /api/files", c.Protect(c.deleteFile, c.requireAuth))
	http.HandleFunc("GET /api/search", c.Protect(c.searchFiles, c.requireAuth))
	// Git API routes
	http.HandleFunc("GET /api/git/status", c.Protect(c.gitStatus, c.requireAuth))
	http.HandleFunc("GET /api/git/diff", c.Protect(c.gitDiff, c.requireAuth))
	http.HandleFunc("POST /api/git/stage", c.Protect(c.gitStage, c.requireAuth))
	http.HandleFunc("POST /api/git/unstage", c.Protect(c.gitUnstage, c.requireAuth))
	http.HandleFunc("POST /api/git/commit", c.Protect(c.gitCommit, c.requireAuth))
	http.HandleFunc("POST /api/git/init", c.Protect(c.gitInit, c.requireAuth))
	// Settings API routes
	http.HandleFunc("GET /api/settings", c.Protect(c.getSettings, c.requireAuth))
	http.HandleFunc("PUT /api/settings", c.Protect(c.saveSettings, c.requireAuth))
	// Workspace sync and clone
	http.HandleFunc("POST /api/workspace/sync", c.Protect(c.syncWorkspace, c.requireAuth))
	http.HandleFunc("POST /api/workspace/clone", c.Protect(c.cloneRepo, c.requireAuth))
	// Public clone endpoint - redirects to signin if needed, then clones repo
	http.HandleFunc("GET /clone", c.handleClone)
	// Project API routes
	http.HandleFunc("GET /api/projects", c.Protect(c.listProjects, c.requireAuth))
	http.HandleFunc("POST /api/projects", c.Protect(c.createProject, c.requireAuth))
	http.HandleFunc("DELETE /api/projects/{id}", c.Protect(c.deleteProject, c.requireAuth))
	http.HandleFunc("POST /api/projects/{id}/default", c.Protect(c.setDefaultProject, c.requireAuth))
	// Git branch operations
	http.HandleFunc("GET /api/git/branches", c.Protect(c.gitBranches, c.requireAuth))
	http.HandleFunc("POST /api/git/checkout", c.Protect(c.gitCheckout, c.requireAuth))
	http.HandleFunc("POST /api/git/push", c.Protect(c.gitPush, c.requireAuth))
	http.HandleFunc("POST /api/git/pull", c.Protect(c.gitPull, c.requireAuth))
}
func (c CodeController) Handle(r *http.Request) skykit.Handler {
	c.Request = r
	return &c
}
// checkPendingClone redirects to /clone if there's a pending clone cookie
func (c *CodeController) checkPendingClone(app *skykit.Application, w http.ResponseWriter, r *http.Request) bool {
	if cookie, err := r.Cookie("clone_repo"); err == nil && cookie.Value != "" {
		http.Redirect(w, r, "/clone", http.StatusSeeOther)
		return false
	}
	return true
}
// jsonError writes a JSON error response
func jsonError(w http.ResponseWriter, msg string, status int) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
// requireAuth redirects unauthenticated users to sign in
func (c *CodeController) requireAuth(app *skykit.Application, w http.ResponseWriter, r *http.Request) bool {
	user, err := app.Users.Authenticate(r)
	if err != nil || user == nil {
		// For API routes, return 401
		if r.Header.Get("Accept") == "application/json" || strings.HasPrefix(r.URL.Path, "/api") {
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusUnauthorized)
			json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
			return false
		}
		// For page routes, redirect to signin
		http.Redirect(w, r, "/auth/signin", http.StatusSeeOther)
		return false
	}
	return true
}
// Template method - user's files for sidebar
func (c *CodeController) UserFiles() []*models.File {
	user, err := c.Users.Authenticate(c.Request)
	if err != nil || user == nil {
		return nil
	}
	return models.ListFiles(user.ID)
}
// GET /api/files - list all files for current user
func (c *CodeController) listFiles(w http.ResponseWriter, r *http.Request) {
	user, _ := c.Users.Authenticate(r)
	files := models.ListFiles(user.ID)
	type fileInfo struct {
		Path     string `json:"path"`
		Language string `json:"language"`
	}
	out := make([]fileInfo, 0, len(files))
	for _, f := range files {
		out = append(out, fileInfo{Path: f.Path, Language: f.Language})
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(out)
}
// GET /api/files/open?path=main.go - open a file
func (c *CodeController) openFile(w http.ResponseWriter, r *http.Request) {
	user, _ := c.Users.Authenticate(r)
	path := r.URL.Query().Get("path")
	if path == "" {
		jsonError(w, "path required", http.StatusBadRequest)
		return
	}
	file, err := models.GetFile(user.ID, path)
	if err != nil {
		jsonError(w, "file not found", http.StatusNotFound)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]string{
		"path":     file.Path,
		"content":  file.Content,
		"language": file.Language,
	})
}
// POST /api/files/save - save a file
func (c *CodeController) saveFile(w http.ResponseWriter, r *http.Request) {
	user, _ := c.Users.Authenticate(r)
	// Limit request body size
	r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
	var body struct {
		Path    string `json:"path"`
		Content string `json:"content"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		jsonError(w, "invalid JSON or file too large", http.StatusBadRequest)
		return
	}
	if body.Path == "" {
		jsonError(w, "path required", http.StatusBadRequest)
		return
	}
	// Check if user wants to persist workspace
	settings := models.GetSettings(user.ID)
	var file *models.File
	var err error
	if settings.PersistWorkspace {
		// Save to database
		file, err = models.SaveFile(user.ID, body.Path, body.Content)
		if err != nil {
			jsonError(w, err.Error(), http.StatusInternalServerError)
			return
		}
	} else {
		// Just create a file object for workspace sync (don't persist to DB)
		file = &models.File{
			Path:    body.Path,
			Content: body.Content,
		}
	}
	// Sync to workspace if session exists
	sessionID := c.getSessionID(r)
	sess, err := c.getWorkspaceSession(r)
	if err != nil {
		models.LogError("saveFile:session_error", "sessionID="+sessionID+" err="+err.Error())
	} else if sess == nil {
		models.LogError("saveFile:no_session", "sessionID="+sessionID+" (terminal not opened yet?)")
	} else if !sess.IsInitialized() {
		models.LogError("saveFile:not_initialized", "sessionID="+sessionID+" workDir="+sess.WorkDir)
	} else {
		if err := sess.SyncFileToWorkspace(file.Path, file.Content); err != nil {
			models.LogError("saveFile:sync_failed", "path="+file.Path+" workDir="+sess.WorkDir+" err="+err.Error())
		}
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{
		"ok":       true,
		"path":     file.Path,
		"language": file.Language,
	})
}
// POST /api/files/rename - rename a file
func (c *CodeController) renameFile(w http.ResponseWriter, r *http.Request) {
	user, _ := c.Users.Authenticate(r)
	var body struct {
		OldPath string `json:"oldPath"`
		NewPath string `json:"newPath"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		jsonError(w, "invalid JSON", http.StatusBadRequest)
		return
	}
	if body.OldPath == "" || body.NewPath == "" {
		jsonError(w, "oldPath and newPath required", http.StatusBadRequest)
		return
	}
	if err := models.RenameFile(user.ID, body.OldPath, body.NewPath); err != nil {
		jsonError(w, err.Error(), http.StatusBadRequest)
		return
	}
	// Rename in workspace if session exists
	if sess, _ := c.getWorkspaceSession(r); sess != nil {
		_ = sess.RenameFileInWorkspace(body.OldPath, body.NewPath)
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
// DELETE /api/files?path=main.go - delete a file
func (c *CodeController) deleteFile(w http.ResponseWriter, r *http.Request) {
	user, _ := c.Users.Authenticate(r)
	path := r.URL.Query().Get("path")
	if path == "" {
		jsonError(w, "path required", http.StatusBadRequest)
		return
	}
	if err := models.DeleteFile(user.ID, path); err != nil {
		jsonError(w, err.Error(), http.StatusNotFound)
		return
	}
	// Delete from workspace if session exists
	if sess, _ := c.getWorkspaceSession(r); sess != nil {
		_ = sess.DeleteFileFromWorkspace(path) // Ignore error - DB is source of truth
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
func (c *CodeController) getSessionID(r *http.Request) string {
	cookie, err := r.Cookie("skycode_session")
	if err != nil {
		return ""
	}
	return cookie.Value
}
// SearchResult represents a single search match
type SearchResult struct {
	Path    string `json:"path"`
	Line    int    `json:"line"`
	Text    string `json:"text"`
	Column  int    `json:"column"`
	Context string `json:"context"` // Line with match highlighted
}
// GET /api/search?q=pattern&regex=true - search across all files
func (c *CodeController) searchFiles(w http.ResponseWriter, r *http.Request) {
	user, _ := c.Users.Authenticate(r)
	query := r.URL.Query().Get("q")
	isRegex := r.URL.Query().Get("regex") == "true"
	caseSensitive := r.URL.Query().Get("case") == "true"
	if query == "" {
		jsonError(w, "query required", http.StatusBadRequest)
		return
	}
	files := models.ListFiles(user.ID)
	results := []SearchResult{}
	var re *regexp.Regexp
	var err error
	if isRegex {
		// Limit regex length to prevent ReDoS
		if len(query) > maxRegexLength {
			jsonError(w, "regex pattern too long", http.StatusBadRequest)
			return
		}
		if caseSensitive {
			re, err = regexp.Compile(query)
		} else {
			re, err = regexp.Compile("(?i)" + query)
		}
		if err != nil {
			jsonError(w, "invalid regex: "+err.Error(), http.StatusBadRequest)
			return
		}
	} else if !caseSensitive {
		query = strings.ToLower(query)
	}
	for _, f := range files {
		if len(results) >= maxSearchResults {
			break
		}
		content := f.Content
		lines := strings.Split(content, "\n")
		for lineNum, line := range lines {
			if len(results) >= maxSearchResults {
				break
			}
			var matches [][]int
			if isRegex {
				matches = re.FindAllStringIndex(line, -1)
			} else {
				searchLine := line
				if !caseSensitive {
					searchLine = strings.ToLower(line)
				}
				idx := 0
				for {
					pos := strings.Index(searchLine[idx:], query)
					if pos == -1 {
						break
					}
					matches = append(matches, []int{idx + pos, idx + pos + len(query)})
					idx = idx + pos + 1
				}
			}
			for _, match := range matches {
				if len(results) >= maxSearchResults {
					break
				}
				// Get a context window around the match
				contextStart := max(0, match[0]-searchContextWindow)
				contextEnd := min(len(line), match[1]+searchContextWindow)
				context := ""
				if contextStart > 0 {
					context = "..."
				}
				context += line[contextStart:contextEnd]
				if contextEnd < len(line) {
					context += "..."
				}
				results = append(results, SearchResult{
					Path:    f.Path,
					Line:    lineNum + 1, // 1-indexed
					Column:  match[0] + 1,
					Text:    line[match[0]:match[1]],
					Context: context,
				})
			}
		}
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(results)
}
// GitFileStatus represents the status of a single file
type GitFileStatus struct {
	Path   string `json:"path"`
	Status string `json:"status"` // M, A, D, ??, etc.
	Staged bool   `json:"staged"`
}
// getWorkspaceSession returns the workspace session for the current user (does not create)
func (c *CodeController) getWorkspaceSession(r *http.Request) (*models.Session, error) {
	user, _ := c.Users.Authenticate(r)
	if user == nil {
		return nil, nil
	}
	sessionID := c.getSessionID(r)
	if sessionID == "" {
		return nil, nil
	}
	return models.GetOrCreateSession(sessionID, user.ID)
}
// getOrCreateWorkspaceSession returns or creates a workspace session, setting cookie if needed
func (c *CodeController) getOrCreateWorkspaceSession(w http.ResponseWriter, r *http.Request) (*models.Session, error) {
	user, _ := c.Users.Authenticate(r)
	if user == nil {
		return nil, nil
	}
	sessionID := c.getSessionID(r)
	if sessionID == "" {
		// Create new session ID and set cookie
		sessionID = models.GenerateSessionID()
		http.SetCookie(w, &http.Cookie{
			Name:     "skycode_session",
			Value:    sessionID,
			Path:     "/",
			HttpOnly: true,
			Secure:   true,
			SameSite: http.SameSiteLaxMode,
			MaxAge:   sessionCookieMaxAge,
		})
	}
	return models.GetOrCreateSession(sessionID, user.ID)
}
// getProjectDir returns the working directory for git operations (workspace or project subfolder)
func (c *CodeController) getProjectDir(sess *models.Session, r *http.Request) string {
	project := r.URL.Query().Get("project")
	if project == "" {
		return sess.WorkDir
	}
	// Clean the project path to prevent traversal
	project = models.NormalizePath(project)
	if project == "" {
		return sess.WorkDir
	}
	// Use filepath.Join to safely construct path and validate it stays within workspace
	projectDir := filepath.Join(sess.WorkDir, project)
	// Ensure the path doesn't escape the workspace directory
	if !strings.HasPrefix(projectDir, sess.WorkDir+string(filepath.Separator)) && projectDir != sess.WorkDir {
		return sess.WorkDir
	}
	return projectDir
}
// GET /api/git/status - get git status
func (c *CodeController) gitStatus(w http.ResponseWriter, r *http.Request) {
	sess, err := c.getOrCreateWorkspaceSession(w, r)
	if err != nil || sess == nil {
		jsonError(w, "failed to create workspace session", http.StatusInternalServerError)
		return
	}
	// Initialize workspace if needed (thread-safe, runs only once)
	if err := sess.InitializeOnce(); err != nil {
		jsonError(w, "failed to initialize workspace: "+err.Error(), http.StatusInternalServerError)
		return
	}
	workDir := c.getProjectDir(sess, r)
	// Check if git is initialized
	cmd := exec.Command("git", "rev-parse", "--git-dir")
	cmd.Dir = workDir
	if err := cmd.Run(); err != nil {
		// Not a git repo
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(map[string]any{
			"initialized": false,
			"files":       []GitFileStatus{},
		})
		return
	}
	// Get status
	cmd = exec.Command("git", "status", "--porcelain")
	cmd.Dir = workDir
	output, err := cmd.Output()
	if err != nil {
		jsonError(w, "git status failed", http.StatusInternalServerError)
		return
	}
	files := []GitFileStatus{}
	lines := strings.Split(string(output), "\n")
	for _, line := range lines {
		if len(line) < 4 {
			continue
		}
		staged := line[0]
		unstaged := line[1]
		path := strings.TrimSpace(line[3:])
		// Staged changes
		if staged != ' ' && staged != '?' {
			files = append(files, GitFileStatus{
				Path:   path,
				Status: string(staged),
				Staged: true,
			})
		}
		// Unstaged changes
		if unstaged != ' ' || staged == '?' {
			status := string(unstaged)
			if staged == '?' {
				status = "?"
			}
			files = append(files, GitFileStatus{
				Path:   path,
				Status: status,
				Staged: false,
			})
		}
	}
	// Get current branch
	cmd = exec.Command("git", "branch", "--show-current")
	cmd.Dir = workDir
	branchOutput, _ := cmd.Output()
	branch := strings.TrimSpace(string(branchOutput))
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{
		"initialized": true,
		"branch":      branch,
		"files":       files,
	})
}
// GET /api/git/diff?path=file.go - get diff for a file
func (c *CodeController) gitDiff(w http.ResponseWriter, r *http.Request) {
	sess, err := c.getOrCreateWorkspaceSession(w, r)
	if err != nil || sess == nil {
		jsonError(w, "failed to create workspace session", http.StatusInternalServerError)
		return
	}
	workDir := c.getProjectDir(sess, r)
	path := r.URL.Query().Get("path")
	staged := r.URL.Query().Get("staged") == "true"
	args := []string{"diff"}
	if staged {
		args = append(args, "--cached")
	}
	if path != "" {
		args = append(args, "--", path)
	}
	cmd := exec.Command("git", args...)
	cmd.Dir = workDir
	output, err := cmd.Output()
	if err != nil {
		// Empty diff or error
		output = []byte{}
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]string{
		"diff": string(output),
		"path": path,
	})
}
// POST /api/git/stage - stage files
func (c *CodeController) gitStage(w http.ResponseWriter, r *http.Request) {
	sess, err := c.getOrCreateWorkspaceSession(w, r)
	if err != nil || sess == nil {
		jsonError(w, "failed to create workspace session", http.StatusInternalServerError)
		return
	}
	// Ensure workspace is initialized (thread-safe, runs only once)
	if err := sess.InitializeOnce(); err != nil {
		jsonError(w, "failed to initialize workspace: "+err.Error(), http.StatusInternalServerError)
		return
	}
	workDir := c.getProjectDir(sess, r)
	var body struct {
		Paths []string `json:"paths"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		jsonError(w, "invalid JSON", http.StatusBadRequest)
		return
	}
	if len(body.Paths) == 0 {
		// Stage all
		cmd := exec.Command("git", "add", "-A")
		cmd.Dir = workDir
		if err := cmd.Run(); err != nil {
			jsonError(w, "git add failed", http.StatusInternalServerError)
			return
		}
	} else {
		args := append([]string{"add", "--"}, body.Paths...)
		cmd := exec.Command("git", args...)
		cmd.Dir = workDir
		if err := cmd.Run(); err != nil {
			jsonError(w, "git add failed", http.StatusInternalServerError)
			return
		}
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
// POST /api/git/unstage - unstage files
func (c *CodeController) gitUnstage(w http.ResponseWriter, r *http.Request) {
	sess, err := c.getOrCreateWorkspaceSession(w, r)
	if err != nil || sess == nil {
		jsonError(w, "failed to create workspace session", http.StatusInternalServerError)
		return
	}
	workDir := c.getProjectDir(sess, r)
	var body struct {
		Paths []string `json:"paths"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		jsonError(w, "invalid JSON", http.StatusBadRequest)
		return
	}
	if len(body.Paths) == 0 {
		// Unstage all
		cmd := exec.Command("git", "reset", "HEAD")
		cmd.Dir = workDir
		cmd.Run() // Ignore error (fails if nothing staged)
	} else {
		args := append([]string{"reset", "HEAD", "--"}, body.Paths...)
		cmd := exec.Command("git", args...)
		cmd.Dir = workDir
		cmd.Run()
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
// POST /api/git/commit - create a commit
func (c *CodeController) gitCommit(w http.ResponseWriter, r *http.Request) {
	sess, err := c.getOrCreateWorkspaceSession(w, r)
	if err != nil || sess == nil {
		jsonError(w, "failed to create workspace session", http.StatusInternalServerError)
		return
	}
	// Ensure workspace is initialized (thread-safe, runs only once)
	if err := sess.InitializeOnce(); err != nil {
		jsonError(w, "failed to initialize workspace: "+err.Error(), http.StatusInternalServerError)
		return
	}
	workDir := c.getProjectDir(sess, r)
	var body struct {
		Message string `json:"message"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		jsonError(w, "invalid JSON", http.StatusBadRequest)
		return
	}
	if body.Message == "" {
		jsonError(w, "commit message required", http.StatusBadRequest)
		return
	}
	// Configure git user in this repo
	user, _ := c.Users.Authenticate(r)
	gitCommand(context.Background(), workDir, "config", "user.email", user.Email).Run()
	gitCommand(context.Background(), workDir, "config", "user.name", user.Name).Run()
	ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
	defer cancel()
	cmd := gitCommand(ctx, workDir, "commit", "-m", body.Message)
	output, err := cmd.CombinedOutput()
	if err != nil {
		if ctx.Err() == context.DeadlineExceeded {
			jsonError(w, "commit timed out", http.StatusGatewayTimeout)
			return
		}
		jsonError(w, "commit failed: "+string(output), http.StatusBadRequest)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{
		"ok":      true,
		"message": string(output),
	})
}
// POST /api/git/init - initialize git repo
func (c *CodeController) gitInit(w http.ResponseWriter, r *http.Request) {
	sess, err := c.getOrCreateWorkspaceSession(w, r)
	if err != nil || sess == nil {
		jsonError(w, "failed to create workspace session", http.StatusInternalServerError)
		return
	}
	// Materialize files from DB to workspace first (thread-safe, runs only once)
	if err := sess.InitializeOnce(); err != nil {
		jsonError(w, "failed to initialize workspace: "+err.Error(), http.StatusInternalServerError)
		return
	}
	workDir := c.getProjectDir(sess, r)
	// Ensure project directory exists
	if err := os.MkdirAll(workDir, 0755); err != nil {
		jsonError(w, "failed to create project directory", http.StatusInternalServerError)
		return
	}
	cmd := exec.Command("git", "init", "-b", "main")
	cmd.Dir = workDir
	if err := cmd.Run(); err != nil {
		jsonError(w, "git init failed", http.StatusInternalServerError)
		return
	}
	// Configure git user in this repo
	user, _ := c.Users.Authenticate(r)
	emailCmd := exec.Command("git", "config", "user.email", user.Email)
	emailCmd.Dir = workDir
	emailCmd.Run()
	nameCmd := exec.Command("git", "config", "user.name", user.Name)
	nameCmd.Dir = workDir
	nameCmd.Run()
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
// GET /api/settings - get user settings
func (c *CodeController) getSettings(w http.ResponseWriter, r *http.Request) {
	user, _ := c.Users.Authenticate(r)
	settings := models.GetSettings(user.ID)
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(settings)
}
// PUT /api/settings - save user settings
func (c *CodeController) saveSettings(w http.ResponseWriter, r *http.Request) {
	user, _ := c.Users.Authenticate(r)
	var settings models.UserSettings
	if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
		jsonError(w, "invalid JSON", http.StatusBadRequest)
		return
	}
	// Validate settings
	if settings.FontSize < minFontSize || settings.FontSize > maxFontSize {
		settings.FontSize = 14
	}
	if settings.TabSize < minTabSize || settings.TabSize > maxTabSize {
		settings.TabSize = 4
	}
	// DaisyUI themes
	validThemes := map[string]bool{"dark": true, "light": true, "dracula": true, "nord": true, "sunset": true}
	if !validThemes[settings.Theme] {
		settings.Theme = "dark"
	}
	if err := models.SaveSettings(user.ID, &settings); err != nil {
		jsonError(w, "failed to save settings", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
// POST /api/workspace/sync - bidirectional sync between filesystem and database
func (c *CodeController) syncWorkspace(w http.ResponseWriter, r *http.Request) {
	sess, err := c.getOrCreateWorkspaceSession(w, r)
	if err != nil || sess == nil {
		jsonError(w, "failed to get workspace session", http.StatusInternalServerError)
		return
	}
	// Mark as initialized (user is explicitly syncing)
	sess.SetInitialized()
	// 1. Sync filesystem → database (picks up terminal changes)
	if err := sess.SyncWorkspaceToDB(); err != nil {
		jsonError(w, "sync failed: "+err.Error(), http.StatusInternalServerError)
		return
	}
	// 2. Sync database → filesystem (picks up Monaco editor changes not yet in workspace)
	if err := sess.SyncDBToWorkspace(); err != nil {
		jsonError(w, "sync failed: "+err.Error(), http.StatusInternalServerError)
		return
	}
	// 3. Ensure home project exists for orphan files
	user, _ := c.Users.Authenticate(r)
	models.GetOrCreateHomeProject(user.ID)
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
// POST /api/workspace/clone - clone a git repository
func (c *CodeController) cloneRepo(w http.ResponseWriter, r *http.Request) {
	sess, err := c.getOrCreateWorkspaceSession(w, r)
	if err != nil || sess == nil {
		jsonError(w, "failed to get workspace session", http.StatusInternalServerError)
		return
	}
	// Materialize existing files first (thread-safe, runs only once)
	if err := sess.InitializeOnce(); err != nil {
		jsonError(w, "failed to initialize workspace: "+err.Error(), http.StatusInternalServerError)
		return
	}
	var body struct {
		URL    string `json:"url"`
		Folder string `json:"folder"` // Optional: target folder name
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		jsonError(w, "invalid JSON", http.StatusBadRequest)
		return
	}
	if body.URL == "" {
		jsonError(w, "repository URL required", http.StatusBadRequest)
		return
	}
	// Validate URL for protocol and shell safety
	if !isValidGitURL(body.URL) {
		jsonError(w, "invalid repository URL", http.StatusBadRequest)
		return
	}
	// Determine target folder - extract repo name from URL if not specified
	folder := body.Folder
	if folder == "" {
		// Extract repo name from URL (e.g., https://github.com/user/repo.git -> repo)
		url := strings.TrimSuffix(body.URL, ".git")
		parts := strings.Split(url, "/")
		if len(parts) > 0 {
			folder = parts[len(parts)-1]
		}
	}
	folder = models.NormalizePath(folder)
	if folder == "" {
		folder = "cloned-repo"
	}
	targetDir := filepath.Join(sess.WorkDir, folder)
	// Run git clone with timeout (shallow clone, explicitly clone main branch)
	ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
	defer cancel()
	cmd := gitCommand(ctx, sess.WorkDir, "clone", "--depth", "1", "--branch", "main", "--single-branch", body.URL, targetDir)
	output, err := cmd.CombinedOutput()
	if err != nil {
		// If main branch doesn't exist, try default branch
		ctx2, cancel2 := context.WithTimeout(context.Background(), gitTimeout)
		defer cancel2()
		cmd = gitCommand(ctx2, sess.WorkDir, "clone", "--depth", "1", body.URL, targetDir)
		output, err = cmd.CombinedOutput()
		if err != nil {
			if ctx2.Err() == context.DeadlineExceeded {
				jsonError(w, "clone timed out", http.StatusGatewayTimeout)
				return
			}
			jsonError(w, "clone failed: "+string(output), http.StatusBadRequest)
			return
		}
	}
	// Configure git user in cloned repo (user already validated at start of handler)
	user, _ := c.Users.Authenticate(r)
	gitCommand(context.Background(), targetDir, "config", "user.email", user.Email).Run()
	gitCommand(context.Background(), targetDir, "config", "user.name", user.Name).Run()
	// Create or update project record with remote URL (needed for re-cloning on session restore)
	models.GetOrCreateProject(user.ID, folder, folder, body.URL)
	// Sync cloned files to database
	sess.SyncWorkspaceToDB() // Ignore error - files are cloned
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{
		"ok":      true,
		"path":    folder,
		"message": string(output),
	})
}
// GET /api/projects - list all projects for current user
func (c *CodeController) listProjects(w http.ResponseWriter, r *http.Request) {
	user, _ := c.Users.Authenticate(r)
	projects := models.ListProjects(user.ID)
	// Ensure at least default project exists
	if len(projects) == 0 {
		defaultProject, err := models.GetDefaultProject(user.ID)
		if err == nil && defaultProject != nil {
			projects = []*models.Project{defaultProject}
		}
	}
	type projectInfo struct {
		ID        string `json:"id"`
		Name      string `json:"name"`
		Path      string `json:"path"`
		RemoteURL string `json:"remoteUrl"`
		IsDefault bool   `json:"isDefault"`
	}
	out := make([]projectInfo, 0, len(projects))
	for _, p := range projects {
		out = append(out, projectInfo{
			ID:        p.ID,
			Name:      p.Name,
			Path:      p.Path,
			RemoteURL: p.RemoteURL,
			IsDefault: p.IsDefault,
		})
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(out)
}
// POST /api/projects - create a new project
func (c *CodeController) createProject(w http.ResponseWriter, r *http.Request) {
	user, _ := c.Users.Authenticate(r)
	var body struct {
		Name      string `json:"name"`
		RemoteURL string `json:"remoteUrl"` // Optional: clone from URL
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		jsonError(w, "invalid JSON", http.StatusBadRequest)
		return
	}
	if body.Name == "" {
		jsonError(w, "project name required", http.StatusBadRequest)
		return
	}
	// Create the project
	project, err := models.CreateProject(user.ID, body.Name, body.RemoteURL, false)
	if err != nil {
		jsonError(w, err.Error(), http.StatusBadRequest)
		return
	}
	// If remote URL provided, clone it
	if body.RemoteURL != "" {
		// Validate URL for protocol and shell safety
		if !isValidGitURL(body.RemoteURL) {
			jsonError(w, "invalid repository URL", http.StatusBadRequest)
			return
		}
		sess, err := c.getOrCreateWorkspaceSession(w, r)
		if err != nil || sess == nil {
			jsonError(w, "failed to create workspace session", http.StatusInternalServerError)
			return
		}
		// Ensure workspace is initialized (thread-safe, runs only once)
		if err := sess.InitializeOnce(); err != nil {
			jsonError(w, "failed to initialize workspace: "+err.Error(), http.StatusInternalServerError)
			return
		}
		targetDir := filepath.Join(sess.WorkDir, project.Path)
		// Clone the repository (shallow clone, explicitly clone main branch)
		ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
		defer cancel()
		cmd := gitCommand(ctx, sess.WorkDir, "clone", "--depth", "1", "--branch", "main", "--single-branch", body.RemoteURL, targetDir)
		output, err := cmd.CombinedOutput()
		if err != nil {
			// If main branch doesn't exist, try default branch
			ctx2, cancel2 := context.WithTimeout(context.Background(), gitTimeout)
			defer cancel2()
			cmd = gitCommand(ctx2, sess.WorkDir, "clone", "--depth", "1", body.RemoteURL, targetDir)
			output, err = cmd.CombinedOutput()
			if err != nil {
				// Delete the project if clone failed
				models.DeleteProject(project)
				if ctx2.Err() == context.DeadlineExceeded {
					jsonError(w, "clone timed out", http.StatusGatewayTimeout)
					return
				}
				jsonError(w, "clone failed: "+string(output), http.StatusBadRequest)
				return
			}
		}
		// Configure git user
		gitCommand(context.Background(), targetDir, "config", "user.email", user.Email).Run()
		gitCommand(context.Background(), targetDir, "config", "user.name", user.Name).Run()
		// Sync cloned files to database
		sess.SyncWorkspaceToDB()
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{
		"ok": true,
		"project": map[string]any{
			"id":        project.ID,
			"name":      project.Name,
			"path":      project.Path,
			"remoteUrl": project.RemoteURL,
			"isDefault": project.IsDefault,
		},
	})
}
// DELETE /api/projects/{id} - delete a project
func (c *CodeController) deleteProject(w http.ResponseWriter, r *http.Request) {
	user, _ := c.Users.Authenticate(r)
	projectID := r.PathValue("id")
	if projectID == "" {
		jsonError(w, "project ID required", http.StatusBadRequest)
		return
	}
	project, err := models.GetProject(projectID)
	if err != nil || project == nil {
		jsonError(w, "project not found", http.StatusNotFound)
		return
	}
	if project.OwnerID != user.ID {
		jsonError(w, "project not found", http.StatusNotFound)
		return
	}
	// Delete from workspace if session exists
	if sess, _ := c.getWorkspaceSession(r); sess != nil && sess.IsInitialized() {
		projectDir := filepath.Join(sess.WorkDir, project.Path)
		os.RemoveAll(projectDir)
	}
	if err := models.DeleteProject(project); err != nil {
		jsonError(w, err.Error(), http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
// POST /api/projects/{id}/default - set project as default
func (c *CodeController) setDefaultProject(w http.ResponseWriter, r *http.Request) {
	user, _ := c.Users.Authenticate(r)
	projectID := r.PathValue("id")
	if projectID == "" {
		jsonError(w, "project ID required", http.StatusBadRequest)
		return
	}
	if err := models.SetDefaultProject(user.ID, projectID); err != nil {
		jsonError(w, err.Error(), http.StatusBadRequest)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
// GET /api/git/branches - list branches
func (c *CodeController) gitBranches(w http.ResponseWriter, r *http.Request) {
	sess, err := c.getOrCreateWorkspaceSession(w, r)
	if err != nil || sess == nil {
		jsonError(w, "failed to create workspace session", http.StatusInternalServerError)
		return
	}
	if err := sess.InitializeOnce(); err != nil {
		jsonError(w, "failed to initialize workspace: "+err.Error(), http.StatusInternalServerError)
		return
	}
	workDir := c.getProjectDir(sess, r)
	// Check if git is initialized
	cmd := exec.Command("git", "rev-parse", "--git-dir")
	cmd.Dir = workDir
	if err := cmd.Run(); err != nil {
		jsonError(w, "not a git repository", http.StatusBadRequest)
		return
	}
	// Get all branches
	cmd = exec.Command("git", "branch", "-a", "--format=%(refname:short)")
	cmd.Dir = workDir
	output, err := cmd.Output()
	if err != nil {
		jsonError(w, "failed to list branches", http.StatusInternalServerError)
		return
	}
	// Get current branch
	cmd = exec.Command("git", "branch", "--show-current")
	cmd.Dir = workDir
	currentOutput, _ := cmd.Output()
	current := strings.TrimSpace(string(currentOutput))
	branches := []map[string]any{}
	for _, line := range strings.Split(string(output), "\n") {
		branch := strings.TrimSpace(line)
		if branch == "" {
			continue
		}
		// Skip HEAD pointers
		if strings.Contains(branch, "HEAD") {
			continue
		}
		isRemote := strings.HasPrefix(branch, "origin/")
		branches = append(branches, map[string]any{
			"name":     branch,
			"current":  branch == current,
			"isRemote": isRemote,
		})
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{
		"current":  current,
		"branches": branches,
	})
}
// POST /api/git/checkout - switch branch
func (c *CodeController) gitCheckout(w http.ResponseWriter, r *http.Request) {
	sess, err := c.getOrCreateWorkspaceSession(w, r)
	if err != nil || sess == nil {
		jsonError(w, "failed to create workspace session", http.StatusInternalServerError)
		return
	}
	if err := sess.InitializeOnce(); err != nil {
		jsonError(w, "failed to initialize workspace: "+err.Error(), http.StatusInternalServerError)
		return
	}
	workDir := c.getProjectDir(sess, r)
	var body struct {
		Branch string `json:"branch"`
		Create bool   `json:"create"` // If true, create new branch
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		jsonError(w, "invalid JSON", http.StatusBadRequest)
		return
	}
	if body.Branch == "" {
		jsonError(w, "branch name required", http.StatusBadRequest)
		return
	}
	// Validate branch name for shell safety
	if !isValidGitRef(body.Branch) {
		jsonError(w, "invalid branch name", http.StatusBadRequest)
		return
	}
	args := []string{"checkout"}
	if body.Create {
		args = append(args, "-b")
	}
	args = append(args, body.Branch)
	ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
	defer cancel()
	cmd := gitCommand(ctx, workDir, args...)
	output, err := cmd.CombinedOutput()
	if err != nil {
		if ctx.Err() == context.DeadlineExceeded {
			jsonError(w, "checkout timed out", http.StatusGatewayTimeout)
			return
		}
		jsonError(w, "checkout failed: "+string(output), http.StatusBadRequest)
		return
	}
	// Sync workspace to DB after checkout
	sess.SyncWorkspaceToDB()
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{
		"ok":      true,
		"branch":  body.Branch,
		"message": string(output),
	})
}
// POST /api/git/push - push to remote
func (c *CodeController) gitPush(w http.ResponseWriter, r *http.Request) {
	sess, err := c.getOrCreateWorkspaceSession(w, r)
	if err != nil || sess == nil {
		jsonError(w, "failed to create workspace session", http.StatusInternalServerError)
		return
	}
	if err := sess.InitializeOnce(); err != nil {
		jsonError(w, "failed to initialize workspace: "+err.Error(), http.StatusInternalServerError)
		return
	}
	workDir := c.getProjectDir(sess, r)
	var body struct {
		Remote      string `json:"remote"`      // Default: origin
		Branch      string `json:"branch"`      // Optional: specific branch
		SetUpstream bool   `json:"setUpstream"` // If true, set upstream
		Username    string `json:"username"`    // Optional: for auth retry
		Password    string `json:"password"`    // Optional: for auth retry (token)
	}
	json.NewDecoder(r.Body).Decode(&body)
	remote := body.Remote
	if remote == "" {
		remote = "origin"
	}
	// Validate remote name for shell safety
	if !isValidGitRef(remote) {
		jsonError(w, "invalid remote name", http.StatusBadRequest)
		return
	}
	args := []string{"push"}
	if body.SetUpstream {
		args = append(args, "-u")
	}
	args = append(args, remote)
	if body.Branch != "" {
		// Validate branch name for shell safety
		if !isValidGitRef(body.Branch) {
			jsonError(w, "invalid branch name", http.StatusBadRequest)
			return
		}
		args = append(args, body.Branch)
	}
	ctx, cancel := context.WithTimeout(context.Background(), 2*gitTimeout) // Push may take longer
	defer cancel()
	cmd := gitCommand(ctx, workDir, args...)
	// If credentials provided, set up GIT_ASKPASS
	if body.Username != "" && body.Password != "" {
		cmd = gitCommandWithCredentials(ctx, workDir, body.Username, body.Password, args...)
	}
	output, err := cmd.CombinedOutput()
	if err != nil {
		if ctx.Err() == context.DeadlineExceeded {
			jsonError(w, "push timed out", http.StatusGatewayTimeout)
			return
		}
		// Check if this is an authentication failure
		outputStr := string(output)
		if isAuthError(outputStr) {
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusUnauthorized)
			json.NewEncoder(w).Encode(map[string]any{
				"error":     "authentication required",
				"needsAuth": true,
			})
			return
		}
		jsonError(w, "push failed: "+outputStr, http.StatusBadRequest)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{
		"ok":      true,
		"message": string(output),
	})
}
// GET /clone?repo=URL - public endpoint to clone a repo (redirects to signin if needed)
func (c *CodeController) handleClone(w http.ResponseWriter, r *http.Request) {
	repoURL := r.URL.Query().Get("repo")
	// If no repo param, check if we have one stored in cookie (returning from signin)
	if repoURL == "" {
		if cookie, err := r.Cookie("clone_repo"); err == nil && cookie.Value != "" {
			repoURL = cookie.Value
			// Clear the cookie
			http.SetCookie(w, &http.Cookie{
				Name:     "clone_repo",
				Value:    "",
				Path:     "/",
				HttpOnly: true,
				MaxAge:   -1,
			})
		}
	}
	if repoURL == "" {
		http.Redirect(w, r, "/", http.StatusSeeOther)
		return
	}
	// Validate URL for protocol and shell safety
	if !isValidGitURL(repoURL) {
		http.Error(w, "Invalid repository URL", http.StatusBadRequest)
		return
	}
	// Check authentication
	user, err := c.Users.Authenticate(r)
	if err != nil || user == nil {
		// Not authenticated - store repo URL in cookie and set auth_redirect to come back here
		http.SetCookie(w, &http.Cookie{
			Name:     "clone_repo",
			Value:    repoURL,
			Path:     "/",
			HttpOnly: true,
			Secure:   true,
			SameSite: http.SameSiteLaxMode,
			MaxAge:   cloneCookieMaxAge,
		})
		// Set auth_redirect so skykit brings us back to /clone after signin
		http.SetCookie(w, &http.Cookie{
			Name:     "auth_redirect",
			Value:    "/clone",
			Path:     "/",
			HttpOnly: true,
			Secure:   true,
			SameSite: http.SameSiteLaxMode,
			MaxAge:   cloneCookieMaxAge,
		})
		http.Redirect(w, r, "/auth/signin", http.StatusSeeOther)
		return
	}
	// User is authenticated - clone the repo
	sess, err := c.getOrCreateWorkspaceSession(w, r)
	if err != nil || sess == nil {
		http.Error(w, "Failed to create workspace session", http.StatusInternalServerError)
		return
	}
	// Materialize existing files first (thread-safe, runs only once)
	if err := sess.InitializeOnce(); err != nil {
		http.Error(w, "Failed to initialize workspace: "+err.Error(), http.StatusInternalServerError)
		return
	}
	// Extract folder name from URL
	url := strings.TrimSuffix(repoURL, ".git")
	parts := strings.Split(url, "/")
	folder := "cloned-repo"
	if len(parts) > 0 {
		folder = parts[len(parts)-1]
	}
	folder = models.NormalizePath(folder)
	if folder == "" {
		folder = "cloned-repo"
	}
	targetDir := filepath.Join(sess.WorkDir, folder)
	// Check if folder already exists - if so, just open the editor
	if _, err := os.Stat(targetDir); err == nil {
		// Already cloned, redirect to editor
		http.Redirect(w, r, "/editor", http.StatusSeeOther)
		return
	}
	// Run git clone with timeout (shallow clone, explicitly clone main branch)
	ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
	defer cancel()
	cmd := gitCommand(ctx, sess.WorkDir, "clone", "--depth", "1", "--branch", "main", "--single-branch", repoURL, targetDir)
	output, err := cmd.CombinedOutput()
	if err != nil {
		// If main branch doesn't exist, try default branch
		ctx2, cancel2 := context.WithTimeout(context.Background(), gitTimeout)
		defer cancel2()
		cmd = gitCommand(ctx2, sess.WorkDir, "clone", "--depth", "1", repoURL, targetDir)
		output, err = cmd.CombinedOutput()
		if err != nil {
			if ctx2.Err() == context.DeadlineExceeded {
				http.Error(w, "Clone timed out", http.StatusGatewayTimeout)
				return
			}
			http.Error(w, "Clone failed: "+string(output), http.StatusBadRequest)
			return
		}
	}
	// Configure git user in cloned repo
	gitCommand(context.Background(), targetDir, "config", "user.email", user.Email).Run()
	gitCommand(context.Background(), targetDir, "config", "user.name", user.Name).Run()
	// Create or get project for the cloned repo
	models.GetOrCreateProject(user.ID, folder, folder, repoURL)
	// Sync cloned files to database
	sess.SyncWorkspaceToDB()
	// Redirect to editor
	http.Redirect(w, r, "/editor", http.StatusSeeOther)
}
// POST /api/git/pull - pull from remote
func (c *CodeController) gitPull(w http.ResponseWriter, r *http.Request) {
	sess, err := c.getOrCreateWorkspaceSession(w, r)
	if err != nil || sess == nil {
		jsonError(w, "failed to create workspace session", http.StatusInternalServerError)
		return
	}
	if err := sess.InitializeOnce(); err != nil {
		jsonError(w, "failed to initialize workspace: "+err.Error(), http.StatusInternalServerError)
		return
	}
	workDir := c.getProjectDir(sess, r)
	var body struct {
		Remote string `json:"remote"` // Default: origin
		Branch string `json:"branch"` // Optional: specific branch
	}
	json.NewDecoder(r.Body).Decode(&body)
	remote := body.Remote
	if remote == "" {
		remote = "origin"
	}
	// Validate remote name for shell safety
	if !isValidGitRef(remote) {
		jsonError(w, "invalid remote name", http.StatusBadRequest)
		return
	}
	args := []string{"pull", remote}
	if body.Branch != "" {
		// Validate branch name for shell safety
		if !isValidGitRef(body.Branch) {
			jsonError(w, "invalid branch name", http.StatusBadRequest)
			return
		}
		args = append(args, body.Branch)
	}
	ctx, cancel := context.WithTimeout(context.Background(), 2*gitTimeout) // Pull may take longer
	defer cancel()
	cmd := gitCommand(ctx, workDir, args...)
	output, err := cmd.CombinedOutput()
	if err != nil {
		if ctx.Err() == context.DeadlineExceeded {
			jsonError(w, "pull timed out", http.StatusGatewayTimeout)
			return
		}
		jsonError(w, "pull failed: "+string(output), http.StatusBadRequest)
		return
	}
	// Sync workspace to DB after pull
	sess.SyncWorkspaceToDB()
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{
		"ok":      true,
		"message": string(output),
	})
}
No comments yet.