586 lines
16 KiB
Go
586 lines
16 KiB
Go
package workspace
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"chat-switchboard/crypto"
|
|
"chat-switchboard/models"
|
|
"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
|
|
}
|