293 lines
8.6 KiB
Go
293 lines
8.6 KiB
Go
package configloader
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/common/subproc"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
const (
|
|
gitCmdTimeout = 30 * time.Second
|
|
gitNetworkTimeout = 5 * time.Minute
|
|
)
|
|
|
|
// GitStatus holds the git status for a workspace directory.
|
|
type GitStatus struct {
|
|
Branch string `json:"branch"`
|
|
IsDirty bool `json:"is_dirty"`
|
|
HasRemote bool `json:"has_remote"`
|
|
Ahead int `json:"ahead"`
|
|
Behind int `json:"behind"`
|
|
CommitCount int `json:"commit_count"`
|
|
}
|
|
|
|
// GitManager provides git operations for workspace directories.
|
|
type GitManager struct {
|
|
basePath string
|
|
loader *ConfigLoader
|
|
logger *logger.Logger
|
|
}
|
|
|
|
// NewGitManager creates a new git manager for workspace directories.
|
|
func NewGitManager(basePath string, loader *ConfigLoader, log *logger.Logger) *GitManager {
|
|
return &GitManager{
|
|
basePath: basePath,
|
|
loader: loader,
|
|
logger: log.WithFields(zap.String("component", "git-manager")),
|
|
}
|
|
}
|
|
|
|
// workspacePath returns the absolute path for a workspace directory.
|
|
// Callers must have already validated workspaceName via validateWorkspaceName;
|
|
// this method does not re-validate so it stays a pure path helper.
|
|
func (g *GitManager) workspacePath(workspaceName string) string {
|
|
return filepath.Join(g.basePath, "workspaces", workspaceName)
|
|
}
|
|
|
|
// IsGitWorkspace checks whether the workspace directory contains a .git directory.
|
|
func (g *GitManager) IsGitWorkspace(workspaceName string) bool {
|
|
if err := validateWorkspaceName(workspaceName); err != nil {
|
|
return false
|
|
}
|
|
gitDir := filepath.Join(g.workspacePath(workspaceName), ".git")
|
|
info, err := os.Stat(gitDir)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return info.IsDir()
|
|
}
|
|
|
|
// CloneWorkspace clones a git repository into the workspace directory.
|
|
// If the directory already exists and is a git repo, it pulls instead.
|
|
func (g *GitManager) CloneWorkspace(ctx context.Context, repoURL, branch, workspaceName string) error {
|
|
if err := validateWorkspaceName(workspaceName); err != nil {
|
|
return err
|
|
}
|
|
wsPath := g.workspacePath(workspaceName)
|
|
|
|
if g.IsGitWorkspace(workspaceName) {
|
|
g.logger.Info("workspace already cloned, pulling instead",
|
|
zap.String("workspace", workspaceName))
|
|
return g.PullWorkspace(ctx, workspaceName)
|
|
}
|
|
|
|
if !isAllowedRepoURL(repoURL) {
|
|
return fmt.Errorf("git clone: disallowed repository URL scheme: %s", repoURL)
|
|
}
|
|
|
|
args := []string{"clone"}
|
|
if branch != "" {
|
|
args = append(args, "--branch", branch)
|
|
}
|
|
args = append(args, "--", repoURL, wsPath)
|
|
|
|
if _, err := g.runGit(ctx, "", args...); err != nil {
|
|
return fmt.Errorf("git clone: %w", err)
|
|
}
|
|
|
|
g.logger.Info("workspace cloned",
|
|
zap.String("workspace", workspaceName),
|
|
zap.String("repo", repoURL))
|
|
return g.loader.Reload(workspaceName)
|
|
}
|
|
|
|
// PullWorkspace runs git pull in the workspace directory.
|
|
func (g *GitManager) PullWorkspace(ctx context.Context, workspaceName string) error {
|
|
if err := validateWorkspaceName(workspaceName); err != nil {
|
|
return err
|
|
}
|
|
wsPath := g.workspacePath(workspaceName)
|
|
if !g.IsGitWorkspace(workspaceName) {
|
|
return fmt.Errorf("workspace %q is not a git repository", workspaceName)
|
|
}
|
|
|
|
output, err := g.runGit(ctx, wsPath, "pull")
|
|
if err != nil {
|
|
return fmt.Errorf("git pull: %w\n%s", err, output)
|
|
}
|
|
|
|
g.logger.Info("workspace pulled", zap.String("workspace", workspaceName))
|
|
return g.loader.Reload(workspaceName)
|
|
}
|
|
|
|
// PushWorkspace stages all changes, commits with the given message, and pushes.
|
|
// Returns nil if there is nothing to commit.
|
|
func (g *GitManager) PushWorkspace(ctx context.Context, workspaceName, message string) error {
|
|
if err := validateWorkspaceName(workspaceName); err != nil {
|
|
return err
|
|
}
|
|
wsPath := g.workspacePath(workspaceName)
|
|
if !g.IsGitWorkspace(workspaceName) {
|
|
return fmt.Errorf("workspace %q is not a git repository", workspaceName)
|
|
}
|
|
|
|
if err := g.stageAndCommit(ctx, wsPath, message); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := g.runGit(ctx, wsPath, "push"); err != nil {
|
|
return fmt.Errorf("git push: %w", err)
|
|
}
|
|
|
|
g.logger.Info("workspace pushed", zap.String("workspace", workspaceName))
|
|
return nil
|
|
}
|
|
|
|
// stageAndCommit stages all changes and commits. Returns nil if nothing to commit.
|
|
func (g *GitManager) stageAndCommit(ctx context.Context, wsPath, message string) error {
|
|
if _, err := g.runGit(ctx, wsPath, "add", "-A"); err != nil {
|
|
return fmt.Errorf("git add: %w", err)
|
|
}
|
|
|
|
statusOut, err := g.runGit(ctx, wsPath, "status", "--porcelain")
|
|
if err != nil {
|
|
return fmt.Errorf("git status: %w", err)
|
|
}
|
|
if strings.TrimSpace(statusOut) == "" {
|
|
return nil // nothing to commit
|
|
}
|
|
|
|
if _, err := g.runGit(ctx, wsPath, "commit", "-m", message); err != nil {
|
|
return fmt.Errorf("git commit: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetWorkspaceGitStatus returns the git status for a workspace.
|
|
func (g *GitManager) GetWorkspaceGitStatus(ctx context.Context, workspaceName string) (*GitStatus, error) {
|
|
if err := validateWorkspaceName(workspaceName); err != nil {
|
|
return nil, err
|
|
}
|
|
wsPath := g.workspacePath(workspaceName)
|
|
if !g.IsGitWorkspace(workspaceName) {
|
|
return nil, fmt.Errorf("workspace %q is not a git repository", workspaceName)
|
|
}
|
|
|
|
status := &GitStatus{}
|
|
|
|
branch, err := g.readBranch(ctx, wsPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
status.Branch = branch
|
|
status.IsDirty = g.checkDirty(ctx, wsPath)
|
|
status.HasRemote = g.checkHasRemote(ctx, wsPath)
|
|
|
|
if status.HasRemote {
|
|
_ = g.fetchSilent(ctx, wsPath)
|
|
ahead, behind := g.readAheadBehind(ctx, wsPath)
|
|
status.Ahead = ahead
|
|
status.Behind = behind
|
|
}
|
|
|
|
status.CommitCount = g.readCommitCount(ctx, wsPath)
|
|
return status, nil
|
|
}
|
|
|
|
// readBranch returns the current branch name.
|
|
func (g *GitManager) readBranch(ctx context.Context, wsPath string) (string, error) {
|
|
out, err := g.runGit(ctx, wsPath, "rev-parse", "--abbrev-ref", "HEAD")
|
|
if err != nil {
|
|
return "", fmt.Errorf("read branch: %w", err)
|
|
}
|
|
return strings.TrimSpace(out), nil
|
|
}
|
|
|
|
// checkDirty returns true if the working tree has uncommitted changes.
|
|
func (g *GitManager) checkDirty(ctx context.Context, wsPath string) bool {
|
|
out, err := g.runGit(ctx, wsPath, "status", "--porcelain")
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return strings.TrimSpace(out) != ""
|
|
}
|
|
|
|
// checkHasRemote returns true if the repository has a remote configured.
|
|
func (g *GitManager) checkHasRemote(ctx context.Context, wsPath string) bool {
|
|
out, err := g.runGit(ctx, wsPath, "remote")
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return strings.TrimSpace(out) != ""
|
|
}
|
|
|
|
// fetchSilent fetches from all remotes without merging.
|
|
func (g *GitManager) fetchSilent(ctx context.Context, wsPath string) error {
|
|
_, err := g.runGit(ctx, wsPath, "fetch", "--all", "--quiet")
|
|
return err
|
|
}
|
|
|
|
// readAheadBehind returns the number of commits ahead and behind the upstream.
|
|
func (g *GitManager) readAheadBehind(ctx context.Context, wsPath string) (ahead, behind int) {
|
|
out, err := g.runGit(ctx, wsPath, "rev-list", "--left-right", "--count", "HEAD...@{upstream}")
|
|
if err != nil {
|
|
return 0, 0
|
|
}
|
|
parts := strings.Fields(strings.TrimSpace(out))
|
|
if len(parts) == 2 {
|
|
ahead, _ = strconv.Atoi(parts[0])
|
|
behind, _ = strconv.Atoi(parts[1])
|
|
}
|
|
return ahead, behind
|
|
}
|
|
|
|
// readCommitCount returns the total number of commits in the repository.
|
|
func (g *GitManager) readCommitCount(ctx context.Context, wsPath string) int {
|
|
out, err := g.runGit(ctx, wsPath, "rev-list", "--count", "HEAD")
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
count, _ := strconv.Atoi(strings.TrimSpace(out))
|
|
return count
|
|
}
|
|
|
|
// isAllowedRepoURL validates that the repository URL uses a known safe scheme,
|
|
// preventing argument injection via URLs that start with "--".
|
|
func isAllowedRepoURL(u string) bool {
|
|
for _, prefix := range []string{"https://", "git://", "ssh://", "git@", "file://", "/"} {
|
|
if strings.HasPrefix(u, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// runGit executes a git command and returns combined stdout. If dir is empty,
|
|
// the command runs in the current directory.
|
|
func (g *GitManager) runGit(ctx context.Context, dir string, args ...string) (string, error) {
|
|
output, runErr, execCtxErr := subproc.RunGitCombinedAfterAcquire(ctx, subproc.GitLifecycle, gitOperationTimeout(args), func(execCtx context.Context) *exec.Cmd {
|
|
cmd := subproc.NewGitCommand(execCtx, args...)
|
|
if dir != "" {
|
|
cmd.Dir = dir
|
|
}
|
|
return cmd
|
|
})
|
|
if runErr != nil || execCtxErr != nil {
|
|
if runErr == nil {
|
|
runErr = execCtxErr
|
|
}
|
|
return "", fmt.Errorf("%s: %s", runErr, strings.TrimSpace(string(output)))
|
|
}
|
|
return string(output), nil
|
|
}
|
|
|
|
func gitOperationTimeout(args []string) time.Duration {
|
|
if len(args) > 0 {
|
|
switch args[0] {
|
|
case "clone", "push", "submodule":
|
|
return gitNetworkTimeout
|
|
}
|
|
}
|
|
return gitCmdTimeout
|
|
}
|