package db import ( "bytes" "fmt" "log" "os" "os/exec" "path/filepath" "runtime" "runtime/debug" "strconv" "strings" "time" "github.com/fatih/color" ) const ( maxGitRetries = 5 baseGitRetryDelay = 100 * time.Millisecond ) func init() { // ensure git is available cmd := exec.Command("git", "--version") if err := cmd.Run(); err != nil { panic(fmt.Errorf("error running git --version: %v", err)) } } type GitRepo struct { orgId string planId string } func InitGitRepo(orgId, planId string) error { dir := getPlanDir(orgId, planId) return initGitRepo(dir) } func initGitRepo(dir string) error { // Set the default branch name to 'main' for the new repository res, err := exec.Command("git", "-C", dir, "init", "-b", "main").CombinedOutput() if err != nil { return fmt.Errorf("error initializing git repository with 'main' as default branch for dir: %s, err: %v, output: %s", dir, err, string(res)) } // Configure user name and email for the repository if err := setGitConfig(dir, "user.email", "server@plandex.ai"); err != nil { return err } if err := setGitConfig(dir, "user.name", "Plandex"); err != nil { return err } return nil } func getGitRepo(orgId, planId string) *GitRepo { return &GitRepo{ orgId: orgId, planId: planId, } } func (repo *GitRepo) GitAddAndCommit(branch, message string) error { log.Printf("[Git] GitAddAndCommit - orgId: %s, planId: %s, branch: %s, message: %s", repo.orgId, repo.planId, branch, message) orgId := repo.orgId planId := repo.planId dir := getPlanDir(orgId, planId) err := gitWriteOperation(func() error { return gitAdd(dir, ".") }, dir, fmt.Sprintf("GitAddAndCommit > gitAdd: plan=%s branch=%s", planId, branch)) if err != nil { return fmt.Errorf("error adding files to git repository for dir: %s, err: %v", dir, err) } err = gitWriteOperation(func() error { return gitCommit(dir, message) }, dir, fmt.Sprintf("GitAddAndCommit > gitCommit: plan=%s branch=%s", planId, branch)) if err != nil { return fmt.Errorf("error committing files to git repository for dir: %s, err: %v", dir, err) } // log.Println("[Git] GitAddAndCommit - finished, logging repo state") // repo.LogGitRepoState() return nil } func (repo *GitRepo) GitRewindToSha(branch, sha string) error { orgId := repo.orgId planId := repo.planId dir := getPlanDir(orgId, planId) err := gitWriteOperation(func() error { return gitRewindToSha(dir, sha) }, dir, fmt.Sprintf("GitRewindToSha > gitRewindToSha: plan=%s branch=%s", planId, branch)) if err != nil { return fmt.Errorf("error rewinding git repository for dir: %s, err: %v", dir, err) } return nil } func (repo *GitRepo) GetCurrentCommitSha() (sha string, err error) { orgId := repo.orgId planId := repo.planId dir := getPlanDir(orgId, planId) cmd := exec.Command("git", "-C", dir, "rev-parse", "HEAD") output, err := cmd.Output() if err != nil { return "", fmt.Errorf("error getting current commit SHA for dir: %s, err: %v", dir, err) } sha = strings.TrimSpace(string(output)) return sha, nil } func (repo *GitRepo) GetCommitTime(branch, ref string) (time.Time, error) { orgId := repo.orgId planId := repo.planId dir := getPlanDir(orgId, planId) // Use git show to get the commit timestamp cmd := exec.Command("git", "-C", dir, "show", "-s", "--format=%ct", ref) output, err := cmd.Output() if err != nil { return time.Time{}, fmt.Errorf("error getting commit time for ref %s: %v", ref, err) } // Parse the Unix timestamp timestamp, err := strconv.ParseInt(strings.TrimSpace(string(output)), 10, 64) if err != nil { return time.Time{}, fmt.Errorf("error parsing commit timestamp for ref %s: %v", ref, err) } // Convert Unix timestamp to time.Time commitTime := time.Unix(timestamp, 0) return commitTime, nil } func (repo *GitRepo) GitResetToSha(sha string) error { orgId := repo.orgId planId := repo.planId dir := getPlanDir(orgId, planId) err := gitWriteOperation(func() error { cmd := exec.Command("git", "-C", dir, "reset", "--hard", sha) _, err := cmd.Output() if err != nil { return fmt.Errorf("error resetting git repository to SHA for dir: %s, sha: %s, err: %v", dir, sha, err) } return nil }, dir, fmt.Sprintf("GitResetToSha > gitReset: plan=%s sha=%s", planId, sha)) if err != nil { return fmt.Errorf("error resetting git repository to SHA for dir: %s, sha: %s, err: %v", dir, sha, err) } return nil } func (repo *GitRepo) GitCheckoutSha(sha string) error { orgId := repo.orgId planId := repo.planId dir := getPlanDir(orgId, planId) err := gitWriteOperation(func() error { cmd := exec.Command("git", "-C", dir, "checkout", sha) _, err := cmd.Output() if err != nil { return fmt.Errorf("error checking out git repository at SHA for dir: %s, sha: %s, err: %v", dir, sha, err) } return nil }, dir, fmt.Sprintf("GitCheckoutSha > gitCheckout: plan=%s sha=%s", planId, sha)) if err != nil { return fmt.Errorf("error checking out git repository at SHA for dir: %s, sha: %s, err: %v", dir, sha, err) } return nil } func (repo *GitRepo) GetGitCommitHistory(branch string) (body string, shas []string, err error) { orgId := repo.orgId planId := repo.planId dir := getPlanDir(orgId, planId) body, shas, err = getGitCommitHistory(dir) if err != nil { return "", nil, fmt.Errorf("error getting git history for dir: %s, err: %v", dir, err) } return body, shas, nil } func (repo *GitRepo) GetLatestCommit(branch string) (sha, body string, err error) { orgId := repo.orgId planId := repo.planId dir := getPlanDir(orgId, planId) sha, body, err = getLatestCommit(dir) if err != nil { return "", "", fmt.Errorf("error getting latest commit for dir: %s, err: %v", dir, err) } return sha, body, nil } func (repo *GitRepo) GetLatestCommitShaBeforeTime(branch string, before time.Time) (sha string, err error) { orgId := repo.orgId planId := repo.planId dir := getPlanDir(orgId, planId) log.Printf("ADMIN - GetLatestCommitShaBeforeTime - dir: %s, before: %s", dir, before.Format("2006-01-02T15:04:05Z")) // Round up to the next second // roundedTime := before.Add(time.Second).Truncate(time.Second) gitFormattedTime := before.Format("2006-01-02 15:04:05+0000") // log.Printf("ADMIN - Git formatted time: %s", gitFormattedTime) cmd := exec.Command("git", "-C", dir, "log", "-n", "1", "--before="+gitFormattedTime, "--pretty=%h@@|@@%B@>>>@") log.Printf("ADMIN - Executing command: %s", cmd.String()) res, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("error getting latest commit before time for dir: %s, err: %v, output: %s", dir, err, string(res)) } // log.Printf("ADMIN - git log res: %s", string(res)) output := strings.TrimSpace(string(res)) // history := processGitHistoryOutput(strings.TrimSpace(string(res))) // log.Printf("ADMIN - History: %v", history) if output == "" { return "", fmt.Errorf("no commits found before time: %s", before.Format("2006-01-02T15:04:05Z")) } sha = strings.Split(output, "@@|@@")[0] return sha, nil } func (repo *GitRepo) GitListBranches() ([]string, error) { orgId := repo.orgId planId := repo.planId dir := getPlanDir(orgId, planId) var out bytes.Buffer cmd := exec.Command("git", "branch", "--format=%(refname:short)") cmd.Dir = dir cmd.Stdout = &out err := cmd.Run() if err != nil { return nil, fmt.Errorf("error getting git branches for dir: %s, err: %v", dir, err) } branches := strings.Split(strings.TrimSpace(out.String()), "\n") if len(branches) == 0 || (len(branches) == 1 && branches[0] == "") { return []string{"main"}, nil } return branches, nil } func (repo *GitRepo) GitCreateBranch(newBranch string) error { orgId := repo.orgId planId := repo.planId dir := getPlanDir(orgId, planId) err := gitWriteOperation(func() error { res, err := exec.Command("git", "-C", dir, "checkout", "-b", newBranch).CombinedOutput() if err != nil { return fmt.Errorf("error creating git branch for dir: %s, err: %v, output: %s", dir, err, string(res)) } return nil }, dir, fmt.Sprintf("GitCreateBranch > gitCheckout: plan=%s branch=%s", planId, newBranch)) if err != nil { return err } return nil } func (repo *GitRepo) GitDeleteBranch(branchName string) error { orgId := repo.orgId planId := repo.planId dir := getPlanDir(orgId, planId) err := gitWriteOperation(func() error { res, err := exec.Command("git", "-C", dir, "branch", "-D", branchName).CombinedOutput() if err != nil { return fmt.Errorf("error deleting git branch for dir: %s, err: %v, output: %s", dir, err, string(res)) } return nil }, dir, fmt.Sprintf("GitDeleteBranch > gitBranch: plan=%s branch=%s", planId, branchName)) if err != nil { return err } return nil } func (repo *GitRepo) GitClearUncommittedChanges(branch string) error { orgId := repo.orgId planId := repo.planId log.Printf("[Git] GitClearUncommittedChanges - orgId: %s, planId: %s, branch: %s", orgId, planId, branch) dir := getPlanDir(orgId, planId) // first do a lightweight git status to check if there are any uncommitted changes // prevents heavier operations below if there are no changes (the usual case) res, err := exec.Command("git", "status", "--porcelain").CombinedOutput() if err != nil { return fmt.Errorf("error checking for uncommitted changes: %v, output: %s", err, string(res)) } // If there's output, there are uncommitted changes hasChanges := strings.TrimSpace(string(res)) != "" if !hasChanges { log.Printf("[Git] GitClearUncommittedChanges - no changes to clear for plan %s", planId) return nil } err = gitWriteOperation(func() error { // Reset staged changes log.Printf("[Git] GitClearUncommittedChanges - resetting staged changes for plan %s", planId) res, err := exec.Command("git", "-C", dir, "reset", "--hard").CombinedOutput() if err != nil { return fmt.Errorf("error resetting staged changes | err: %v, output: %s", err, string(res)) } log.Printf("[Git] GitClearUncommittedChanges - reset staged changes finished for plan %s", planId) return nil }, dir, fmt.Sprintf("GitClearUncommittedChanges > gitReset: plan=%s", planId)) if err != nil { return err } err = gitWriteOperation(func() error { // Clean untracked files log.Printf("[Git] GitClearUncommittedChanges - cleaning untracked files for plan %s", planId) res, err := exec.Command("git", "-C", dir, "clean", "-d", "-f").CombinedOutput() if err != nil { return fmt.Errorf("error cleaning untracked files | err: %v, output: %s", err, string(res)) } log.Printf("[Git] GitClearUncommittedChanges - clean untracked files finished for plan %s", planId) return nil }, dir, fmt.Sprintf("GitClearUncommittedChanges > gitClean: plan=%s", planId)) return err } func (repo *GitRepo) GitCheckoutBranch(branch string) error { orgId := repo.orgId planId := repo.planId dir := getPlanDir(orgId, planId) err := gitWriteOperation(func() error { return gitCheckoutBranch(dir, branch) }, dir, fmt.Sprintf("GitCheckoutBranch > gitCheckout: plan=%s branch=%s", planId, branch)) if err != nil { return err } return nil } func gitAdd(repoDir, path string) error { if err := gitRemoveIndexLockFileIfExists(repoDir); err != nil { return fmt.Errorf("error removing lock file before add: %v", err) } res, err := exec.Command("git", "-C", repoDir, "add", path).CombinedOutput() if err != nil { return fmt.Errorf("error adding files to git repository for dir: %s, err: %v, output: %s", repoDir, err, string(res)) } return nil } func gitCommit(repoDir, commitMsg string) error { if err := gitRemoveIndexLockFileIfExists(repoDir); err != nil { return fmt.Errorf("error removing lock file before commit: %v", err) } res, err := exec.Command("git", "-C", repoDir, "commit", "-m", commitMsg).CombinedOutput() if err != nil { return fmt.Errorf("error committing files to git repository for dir: %s, err: %v, output: %s", repoDir, err, string(res)) } return nil } func gitCheckoutBranch(repoDir, branch string) error { log.Printf("[Git] gitCheckoutBranch - repoDir: %s, branch: %s", repoDir, branch) if err := gitRemoveIndexLockFileIfExists(repoDir); err != nil { return fmt.Errorf("error removing lock file before checkout: %v", err) } // get current branch and only checkout if it's not the same // trying to check out the same branch will result in an error var out bytes.Buffer cmd := exec.Command("git", "-C", repoDir, "branch", "--show-current") cmd.Stdout = &out err := cmd.Run() if err != nil { return fmt.Errorf("error getting current git branch for dir: %s, err: %v", repoDir, err) } currentBranch := strings.TrimSpace(out.String()) log.Printf("[Git] gitCheckoutBranch - currentBranch: %s", currentBranch) if currentBranch == branch { log.Printf("[Git] gitCheckoutBranch - already on branch %s, skipping", branch) return nil } log.Println("[Git] gitCheckoutBranch - checking out branch:", branch) res, err := exec.Command("git", "-C", repoDir, "checkout", branch).CombinedOutput() if err != nil { return fmt.Errorf("error checking out git branch for dir: %s, err: %v, output: %s", repoDir, err, string(res)) } return nil } func gitRewindToSha(repoDir, sha string) error { res, err := exec.Command("git", "-C", repoDir, "reset", "--hard", sha).CombinedOutput() if err != nil { return fmt.Errorf("error executing git reset for dir: %s, sha: %s, err: %v, output: %s", repoDir, sha, err, string(res)) } return nil } func getLatestCommit(dir string) (sha, body string, err error) { var out bytes.Buffer cmd := exec.Command("git", "log", "--pretty=%h@@|@@%at@@|@@%B@>>>@") cmd.Dir = dir cmd.Stdout = &out err = cmd.Run() if err != nil { return "", "", fmt.Errorf("error getting git history for dir: %s, err: %v", dir, err) } // Process the log output to get it in the desired format. history := processGitHistoryOutput(strings.TrimSpace(out.String())) first := history[0] sha = first[0] body = first[1] return sha, body, nil } func getGitCommitHistory(dir string) (body string, shas []string, err error) { var out bytes.Buffer cmd := exec.Command("git", "log", "--pretty=%h@@|@@%at@@|@@%B@>>>@") cmd.Dir = dir cmd.Stdout = &out err = cmd.Run() if err != nil { return "", nil, fmt.Errorf("error getting git history for dir: %s, err: %v", dir, err) } // Process the log output to get it in the desired format. history := processGitHistoryOutput(strings.TrimSpace(out.String())) var output []string for _, el := range history { shas = append(shas, el[0]) output = append(output, el[1]) } return strings.Join(output, "\n\n"), shas, nil } // processGitHistoryOutput processes the raw output from the git log command and returns a formatted string. func processGitHistoryOutput(raw string) [][2]string { var history [][2]string entries := strings.Split(raw, "@>>>@") // Split entries using the custom separator. for _, entry := range entries { // First clean up any leading/trailing whitespace or newlines from each entry. entry = strings.TrimSpace(entry) // Now split the cleaned entry into its parts. parts := strings.Split(entry, "@@|@@") if len(parts) == 3 { sha := parts[0] timestampStr := parts[1] message := strings.TrimSpace(parts[2]) // Trim whitespace from message as well. // Extract and format timestamp. timestamp, err := strconv.ParseInt(timestampStr, 10, 64) if err != nil { continue // Skip entries with invalid timestamps. } dt := time.Unix(timestamp, 0).UTC() formattedTs := dt.Format("Mon Jan 2, 2006 | 3:04:05pm MST") // Prepare the header with colors. headerColor := color.New(color.FgCyan, color.Bold) dateColor := color.New(color.FgCyan) // Combine sha, formatted timestamp, and message header into one string. header := fmt.Sprintf("%s | %s", headerColor.Sprintf("📝 Update %s", sha), dateColor.Sprintf("%s", formattedTs)) // Combine header and message with a newline only if the message is not empty. fullEntry := header if message != "" { fullEntry += "\n" + message } history = append(history, [2]string{sha, fullEntry}) } } return history } func removeLockFile(lockFilePath string) error { _, err := os.Stat(lockFilePath) exists := err == nil // log.Println("index.lock file exists:", exists) if err != nil && !os.IsNotExist(err) { return fmt.Errorf("error checking lock file: %v", err) } attempts := 0 for exists { if attempts > 10 { return fmt.Errorf("error removing index.lock file: %v after %d attempts", err, attempts) } log.Printf("[Git] removeLockFile - removing index.lock file: %s, attempt: %d", lockFilePath, attempts) if err := os.Remove(lockFilePath); err != nil { if os.IsNotExist(err) { log.Printf("[Git] removeLockFile - %s file not found, skipping removal", lockFilePath) return nil } return fmt.Errorf("error removing lock file: %v", err) } _, err = os.Stat(lockFilePath) exists = err == nil if err != nil && !os.IsNotExist(err) { return fmt.Errorf("error checking lock file: %v", err) } log.Printf("[Git] removeLockFile - after removal, %s file exists: %t", lockFilePath, exists) if exists { log.Printf("[Git] removeLockFile - %s file still exists, retrying after delay", lockFilePath) } else { log.Printf("[Git] removeLockFile - %s file removed successfully", lockFilePath) return nil } attempts++ time.Sleep(20 * time.Millisecond) } return nil } func gitRemoveIndexLockFileIfExists(repoDir string) error { log.Printf("[Git] gitRemoveIndexLockFileIfExists - repoDir: %s", repoDir) paths := []string{ filepath.Join(repoDir, ".git", "index.lock"), filepath.Join(repoDir, ".git", "refs", "heads", "HEAD.lock"), filepath.Join(repoDir, ".git", "HEAD.lock"), } errCh := make(chan error, len(paths)) for _, path := range paths { go func(path string) { defer func() { if r := recover(); r != nil { log.Printf("panic in gitRemoveIndexLockFileIfExists: %v\n%s", r, debug.Stack()) errCh <- fmt.Errorf("panic in gitRemoveIndexLockFileIfExists: %v\n%s", r, debug.Stack()) runtime.Goexit() // don't allow outer function to continue and double-send to channel } }() if err := removeLockFile(path); err != nil { errCh <- err return } errCh <- nil }(path) } errs := []error{} for i := 0; i < len(paths); i++ { err := <-errCh if err != nil { errs = append(errs, err) } } if len(errs) > 0 { return fmt.Errorf("error removing lock files: %v", errs) } return nil } func setGitConfig(repoDir, key, value string) error { res, err := exec.Command("git", "-C", repoDir, "config", key, value).CombinedOutput() if err != nil { return fmt.Errorf("error setting git config %s to %s for dir: %s, err: %v, output: %s", key, value, repoDir, err, string(res)) } return nil } func gitWriteOperation(operation func() error, repoDir, label string) error { log.Printf("[Git] gitWriteOperation - label: %s", label) var err error for attempt := 0; attempt < maxGitRetries; attempt++ { if attempt > 0 { delay := time.Duration(1<