From ecbb671492befff4d3790593e3df34233f186f0c Mon Sep 17 00:00:00 2001 From: Harry Dhillon Date: Sun, 19 Oct 2025 21:24:54 -0600 Subject: [PATCH] feat: Decouple discovery and operations logic in the ` git ` package, remove cache for simplified scanning. - Remove `discovery-cache` mechanism to avoid outdated repository data and simplify logic. - Split functionality: move complex Git operations to `operations.go` for maintainability. - Update `scan` command to support `--detailed` flag for enhanced repository state output. - Add comprehensive tests for added Git operations and repository state handling. Signed-off-by: Harry Dhillon --- internal/cli/scan.go | 252 +++++++++++++---- internal/git/cache.go | 112 -------- internal/git/discovery.go | 59 +++- internal/git/operations.go | 445 +++++++++++++++++++++++++++++ internal/git/operations_test.go | 482 ++++++++++++++++++++++++++++++++ 5 files changed, 1182 insertions(+), 168 deletions(-) delete mode 100644 internal/git/cache.go create mode 100644 internal/git/operations.go create mode 100644 internal/git/operations_test.go diff --git a/internal/cli/scan.go b/internal/cli/scan.go index bd9de89..c00c717 100644 --- a/internal/cli/scan.go +++ b/internal/cli/scan.go @@ -21,8 +21,6 @@ var scanCmd = &cobra.Command{ Long: ` Scan configured workspace directories to discover git repositories. -The scan results are cached for 24 hours to improve performance. Use --refresh to force a new scan. - Workspace directories can be configured in ~/.ork/config.yml: workspaces: @@ -35,40 +33,62 @@ If no configuration exists, ork will scan default directories: ~/code, ~/project } const ( - bulletFormat = " • %s" - tableRowFormat = "%s %s %s\n" + bulletFormat = " • %s" + tableRowFormat = "%s %s %s\n" + detailedTableFormat = "%s %s %s %s %s\n" + noReposMessage = "No git repositories found" + workspaceConfigMsg = "Make sure you have repositories in your workspace directories:" + scanDepth = 3 + + // Column width limits + maxNameWidth = 25 + maxPathWidth = 40 + maxBranchWidth = 35 + maxStatusWidth = 30 + minBranchWidth = 15 ) +// detailedColumnWidths holds the column widths for a detailed view +type detailedColumnWidths struct { + name int + path int + branch int + commit int + status int +} + +// detailedStyles holds all the lipgloss styles for a detailed view +type detailedStyles struct { + header lipgloss.Style + separator lipgloss.Style + name lipgloss.Style + path lipgloss.Style + branch lipgloss.Style + commit lipgloss.Style + clean lipgloss.Style + dirty lipgloss.Style +} + var ( - scanRefresh bool + scanDetailed bool ) func init() { rootCmd.AddCommand(scanCmd) - scanCmd.Flags().BoolVar(&scanRefresh, "refresh", false, "Force a fresh scan, ignoring cache") + scanCmd.Flags().BoolVarP(&scanDetailed, "detailed", "d", false, "Show detailed git state (branch, commit, changes)") } -func runScan(cmd *cobra.Command, args []string) error { +// ============================================================================ +// Main Command Logic +// ============================================================================ + +func runScan(_ *cobra.Command, _ []string) error { // Load global config globalConfig, err := config.LoadGlobal() if err != nil { return fmt.Errorf("failed to load global config: %w", err) } - // Try to load from cache if not refreshing - if !scanRefresh { - if repos := tryLoadCache(globalConfig.Workspaces); repos != nil { - return nil // Cache was loaded and displayed - } - } - - // Invalidate cache if refreshing - if scanRefresh { - if err := git.InvalidateCache(); err != nil { - return fmt.Errorf("failed to invalidate cache: %w", err) - } - } - // Filter and validate workspaces existingWorkspaces := filterExistingWorkspaces(globalConfig.Workspaces) if len(existingWorkspaces) == 0 { @@ -84,29 +104,18 @@ func runScan(cmd *cobra.Command, args []string) error { return err } - // Save to cache (non-fatal if it fails) - saveCacheIfPossible(repos) - // Display results displayResults(repos, elapsed, globalConfig.Workspaces) return nil } -func tryLoadCache(workspaces []string) []git.Repository { - cached, err := git.LoadCache() - if err == nil && cached != nil { - ui.Success("Loaded repositories from cache") - printRepositories(cached, workspaces) - fmt.Println() - fmt.Println(ui.Dim("Use 'ork scan --refresh' to force a fresh scan")) - return cached - } - return nil -} +// ============================================================================ +// Workspace Management +// ============================================================================ func filterExistingWorkspaces(workspaces []string) []string { - existing := []string{} + var existing []string for _, workspace := range workspaces { if workspaceExists(workspace) { existing = append(existing, workspace) @@ -134,23 +143,29 @@ func handleNoWorkspaces(configuredWorkspaces []string) error { ui.Warning("No workspace directories found") fmt.Println() fmt.Println("Configure workspaces in ~/.ork/config.yml or ensure these directories exist:") - for _, workspace := range configuredWorkspaces { - fmt.Println(ui.Dim(fmt.Sprintf(bulletFormat, workspace))) - } + printWorkspaceList(configuredWorkspaces) return nil } -func displayScanningMessage(workspaces []string) { - ui.Info(fmt.Sprintf("Scanning %d workspace(s)...", len(workspaces))) +func printWorkspaceList(workspaces []string) { for _, workspace := range workspaces { fmt.Println(ui.Dim(fmt.Sprintf(bulletFormat, workspace))) } +} + +func displayScanningMessage(workspaces []string) { + ui.Info(fmt.Sprintf("Scanning %d workspace(s)...", len(workspaces))) + printWorkspaceList(workspaces) fmt.Println() } +// ============================================================================ +// Repository Discovery +// ============================================================================ + func performDiscovery(workspaces []string) ([]git.Repository, time.Duration, error) { start := time.Now() - repos, err := git.DiscoverRepositories(workspaces, 3) + repos, err := git.DiscoverRepositories(workspaces, scanDepth) if err != nil { return nil, 0, fmt.Errorf("failed to discover repositories: %w", err) } @@ -158,26 +173,22 @@ func performDiscovery(workspaces []string) ([]git.Repository, time.Duration, err return repos, elapsed, nil } -func saveCacheIfPossible(repos []git.Repository) { - if err := git.SaveCache(repos); err != nil { - ui.Warning(fmt.Sprintf("Warning: Failed to save cache: %v", err)) - } -} - func displayResults(repos []git.Repository, elapsed time.Duration, workspaces []string) { ui.Success(fmt.Sprintf("Found %d repositories in %v", len(repos), elapsed.Round(time.Millisecond))) fmt.Println() printRepositories(repos, workspaces) } +// ============================================================================ +// Output Formatting - Basic View +// ============================================================================ + func printRepositories(repos []git.Repository, workspaces []string) { if len(repos) == 0 { - ui.Warning("No git repositories found") + ui.Warning(noReposMessage) fmt.Println() - fmt.Println("Make sure you have repositories in your workspace directories:") - for _, workspace := range workspaces { - fmt.Println(ui.Dim(fmt.Sprintf(bulletFormat, workspace))) - } + fmt.Println(workspaceConfigMsg) + printWorkspaceList(workspaces) return } @@ -186,6 +197,12 @@ func printRepositories(repos []git.Repository, workspaces []string) { return repos[i].Name < repos[j].Name }) + // Use the detailed view if a flag is set + if scanDetailed { + printDetailedRepositories(repos) + return + } + // Create header style headerStyle := lipgloss.NewStyle(). Bold(true). @@ -250,6 +267,10 @@ func printRepositories(repos []git.Repository, workspaces []string) { } } +// ============================================================================ +// Utility Functions +// ============================================================================ + func truncate(s string, maxLen int) string { if len(s) <= maxLen { return s @@ -274,3 +295,128 @@ func repeatChar(char string, count int) string { } return result } + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +// ============================================================================ +// Output Formatting - Detailed View +// ============================================================================ + +// printDetailedRepositories displays repositories with git state information +func printDetailedRepositories(repos []git.Repository) { + styles := createDetailedStyles() + widths := calculateDetailedColumnWidths(repos) + printDetailedHeader(styles, widths) + printDetailedRows(repos, styles, widths) +} + +// createDetailedStyles creates all lipgloss styles for the detailed view +func createDetailedStyles() detailedStyles { + return detailedStyles{ + header: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")), + separator: lipgloss.NewStyle().Foreground(lipgloss.Color("8")), + name: lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true), + path: lipgloss.NewStyle().Foreground(lipgloss.Color("8")), + branch: lipgloss.NewStyle().Foreground(lipgloss.Color("13")), + commit: lipgloss.NewStyle().Foreground(lipgloss.Color("11")), + clean: lipgloss.NewStyle().Foreground(lipgloss.Color("10")), + dirty: lipgloss.NewStyle().Foreground(lipgloss.Color("9")), + } +} + +// calculateDetailedColumnWidths calculates optimal column widths based on content +func calculateDetailedColumnWidths(repos []git.Repository) detailedColumnWidths { + widths := detailedColumnWidths{ + name: len("NAME"), + path: len("PATH"), + branch: minBranchWidth, + commit: len("COMMIT"), + status: len("STATUS"), + } + + // Calculate based on actual data + for _, repo := range repos { + widths.name = maxInt(widths.name, len(repo.Name)) + widths.path = maxInt(widths.path, len(repo.Path)) + + if state, err := git.GetRepoState(repo.Path); err == nil { + widths.branch = maxInt(widths.branch, len(state.Branch)) + widths.status = maxInt(widths.status, len(state.UncommittedSummary)) + } + } + + // Apply max limits + widths.name = minInt(widths.name, maxNameWidth) + widths.path = minInt(widths.path, maxPathWidth) + widths.branch = minInt(widths.branch, maxBranchWidth) + widths.status = minInt(widths.status, maxStatusWidth) + + return widths +} + +// printDetailedHeader prints the header row for a detailed view +func printDetailedHeader(styles detailedStyles, widths detailedColumnWidths) { + // Print header + fmt.Printf(detailedTableFormat, + styles.header.Render(padRight("NAME", widths.name)), + styles.header.Render(padRight("PATH", widths.path)), + styles.header.Render(padRight("BRANCH", widths.branch)), + styles.header.Render(padRight("COMMIT", widths.commit)), + styles.header.Render(padRight("STATUS", widths.status))) + + // Print separator + fmt.Printf(detailedTableFormat, + styles.separator.Render(repeatChar("─", widths.name)), + styles.separator.Render(repeatChar("─", widths.path)), + styles.separator.Render(repeatChar("─", widths.branch)), + styles.separator.Render(repeatChar("─", widths.commit)), + styles.separator.Render(repeatChar("─", widths.status))) +} + +// printDetailedRows prints all repository rows with git state +func printDetailedRows(repos []git.Repository, styles detailedStyles, widths detailedColumnWidths) { + for _, repo := range repos { + printDetailedRow(repo, styles, widths) + } +} + +// printDetailedRow prints a single repository row with git state +func printDetailedRow(repo git.Repository, styles detailedStyles, widths detailedColumnWidths) { + state, err := git.GetRepoState(repo.Path) + if err != nil { + printDetailedErrorRow(repo, err.Error(), styles, widths) + return + } + + statusStyle := styles.clean + if state.HasUncommitted { + statusStyle = styles.dirty + } + + fmt.Printf(detailedTableFormat, + styles.name.Render(padRight(truncate(repo.Name, widths.name), widths.name)), + styles.path.Render(padRight(truncate(repo.Path, widths.path), widths.path)), + styles.branch.Render(padRight(truncate(state.Branch, widths.branch), widths.branch)), + styles.commit.Render(padRight(state.CommitHash, widths.commit)), + statusStyle.Render(state.UncommittedSummary)) +} + +// printDetailedErrorRow prints an error row for a repository that failed to load +func printDetailedErrorRow(repo git.Repository, errMsg string, styles detailedStyles, widths detailedColumnWidths) { + fmt.Printf(tableRowFormat, + styles.name.Render(padRight(truncate(repo.Name, widths.name), widths.name)), + styles.path.Render(padRight(truncate(repo.Path, widths.path), widths.path)), + styles.dirty.Render("error: "+errMsg)) +} diff --git a/internal/git/cache.go b/internal/git/cache.go deleted file mode 100644 index 3fc57e0..0000000 --- a/internal/git/cache.go +++ /dev/null @@ -1,112 +0,0 @@ -package git - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "time" -) - -// cacheEntry represents a cached discovery result -type cacheEntry struct { - Timestamp time.Time `json:"timestamp"` - Repositories []Repository `json:"repositories"` -} - -const ( - cacheFileName = "discovery-cache.json" - cacheMaxAge = 24 * time.Hour // Cache is valid for 24 hours -) - -// getCachePath returns the path to the discovery cache file -func getCachePath() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get user home directory: %w", err) - } - - orkDir := filepath.Join(home, ".ork") - return filepath.Join(orkDir, cacheFileName), nil -} - -// LoadCache loads cached repositories if the cache is still valid -// Returns nil if the cache doesn't exist or is expired -func LoadCache() ([]Repository, error) { - cachePath, err := getCachePath() - if err != nil { - return nil, err - } - - // Check if the cache file exists - if _, err := os.Stat(cachePath); os.IsNotExist(err) { - return nil, nil // No cache - } - - // Read the cache file - data, err := os.ReadFile(cachePath) - if err != nil { - return nil, fmt.Errorf("failed to read cache file: %w", err) - } - - // Parse cache - var entry cacheEntry - if err := json.Unmarshal(data, &entry); err != nil { - return nil, fmt.Errorf("failed to parse cache file: %w", err) - } - - // Check if the cache is expired - if time.Since(entry.Timestamp) > cacheMaxAge { - return nil, nil // Expired cache - } - - return entry.Repositories, nil -} - -// SaveCache saves repositories to the cache file -func SaveCache(repos []Repository) error { - cachePath, err := getCachePath() - if err != nil { - return err - } - - // Ensure .ork directory exists - orkDir := filepath.Dir(cachePath) - if err := os.MkdirAll(orkDir, 0755); err != nil { - return fmt.Errorf("failed to create .ork directory: %w", err) - } - - // Create a cache entry - entry := cacheEntry{ - Timestamp: time.Now(), - Repositories: repos, - } - - // Marshal to JSON - data, err := json.MarshalIndent(entry, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal cache: %w", err) - } - - // Write to the file - if err := os.WriteFile(cachePath, data, 0644); err != nil { - return fmt.Errorf("failed to write cache file: %w", err) - } - - return nil -} - -// InvalidateCache removes the cache file -func InvalidateCache() error { - cachePath, err := getCachePath() - if err != nil { - return err - } - - // Remove cache file (ignore error if it doesn't exist) - if err := os.Remove(cachePath); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to remove cache file: %w", err) - } - - return nil -} diff --git a/internal/git/discovery.go b/internal/git/discovery.go index 4db6072..f476388 100644 --- a/internal/git/discovery.go +++ b/internal/git/discovery.go @@ -9,6 +9,10 @@ import ( "github.com/go-git/go-git/v5" ) +// ============================================================================ +// Type Definitions +// ============================================================================ + // Repository represents a discovered git repository type Repository struct { Name string // Repository name (e.g., "frontend", "api") @@ -16,8 +20,32 @@ type Repository struct { URL string // Git remote URL (e.g., "github.com/org/repo") } -// DiscoverRepositories scans workspace directories and finds git repositories -// It searches up to maxDepth levels deep (default: 3) +// ============================================================================ +// Public Discovery API +// ============================================================================ + +// DiscoverRepositories scans workspace directories and finds git repositories. +// It searches up to maxDepth levels deep (default: 3 if maxDepth <= 0). +// Automatically skips hidden directories (except .ork), node_modules, vendor, dist, and build. +// +// Parameters: +// - workspaceDirs: List of directories to scan (supports ~ for home directory) +// - maxDepth: Maximum directory depth to search (0 or negative uses default of 3) +// +// Returns: +// - Deduplicated list of discovered repositories +// - Error if scanning fails +// +// Example: +// +// workspaces := []string{"~/code", "~/projects"} +// repos, err := DiscoverRepositories(workspaces, 3) +// if err != nil { +// return err +// } +// for _, repo := range repos { +// fmt.Printf("%s: %s\n", repo.Name, repo.Path) +// } func DiscoverRepositories(workspaceDirs []string, maxDepth int) ([]Repository, error) { if maxDepth <= 0 { maxDepth = 3 // Default depth @@ -43,6 +71,10 @@ func DiscoverRepositories(workspaceDirs []string, maxDepth int) ([]Repository, e return repos, nil } +// ============================================================================ +// Internal Helper Functions - Path Operations +// ============================================================================ + // expandHomePath expands ~ to the home directory func expandHomePath(path string) string { if !strings.HasPrefix(path, "~/") { @@ -74,6 +106,10 @@ func deduplicateRepos(existing, found []Repository, seen map[string]bool) []Repo return existing } +// ============================================================================ +// Internal Helper Functions - Directory Scanning +// ============================================================================ + // scanDirectory recursively searches for git repositories up to maxDepth func scanDirectory(dir string, currentDepth, maxDepth int) ([]Repository, error) { if currentDepth > maxDepth { @@ -145,6 +181,10 @@ func shouldSkipDirectory(entry os.DirEntry) bool { return false } +// ============================================================================ +// Internal Helper Functions - Git Operations +// ============================================================================ + // isGitRepository checks if a directory is a git repository func isGitRepository(dir string) bool { gitDir := filepath.Join(dir, ".git") @@ -212,7 +252,20 @@ func normalizeGitURL(url string) string { return url } -// FindRepository searches for a repository by git URL in the discovered repos +// ============================================================================ +// Public Repository Lookup +// ============================================================================ + +// FindRepository searches for a repository by git URL in the discovered repos. +// The URL is normalized before comparison, so it works with both SSH and HTTPS URLs. +// +// Example: +// +// repos, _ := DiscoverRepositories(workspaces, 3) +// repo := FindRepository(repos, "github.com/user/project") +// if repo != nil { +// fmt.Println("Found at:", repo.Path) +// } func FindRepository(repos []Repository, gitURL string) *Repository { normalized := normalizeGitURL(gitURL) diff --git a/internal/git/operations.go b/internal/git/operations.go new file mode 100644 index 0000000..aceaeba --- /dev/null +++ b/internal/git/operations.go @@ -0,0 +1,445 @@ +package git + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" +) + +// ============================================================================ +// Constants +// ============================================================================ + +// Error message constants +const ( + errOpenRepository = "failed to open repository: %w" + errGetHead = "failed to get HEAD: %w" + errGetWorktree = "failed to get worktree: %w" + errGetStatus = "failed to get status: %w" + errGetRemotes = "failed to get remotes: %w" + errGetCommit = "failed to get commit: %w" + errGetLocalCommit = "failed to get local commit: %w" + errGetRemoteCommit = "failed to get remote commit: %w" + errNoRemotes = "no remotes found" + errNoRemoteURLs = "remote has no URLs" + errDetachedHead = "repository is in detached HEAD state" + errCheckUncommitted = "failed to check for uncommitted changes: %w" +) + +// State constants +const ( + stateDetachedHead = "detached HEAD" + stateNoCommits = "no commits" + stateClean = "clean" +) + +// ============================================================================ +// Type Definitions +// ============================================================================ + +// RepoState represents the current state of a git repository +type RepoState struct { + Exists bool // Whether the repository exists at the given path + Branch string // Current branch name (e.g., "main") + CommitHash string // Short commit hash (e.g., "a1b2c3d") + CommitHashFull string // Full commit hash + HasUncommitted bool // Whether there are uncommitted changes + UncommittedSummary string // Summary of uncommitted changes (e.g., "2 modified, 1 untracked") +} + +// ============================================================================ +// Internal Helper Functions +// ============================================================================ + +// openRepo opens a git repository and returns it or an error +func openRepo(path string) (*git.Repository, error) { + repo, err := git.PlainOpen(path) + if err != nil { + return nil, fmt.Errorf(errOpenRepository, err) + } + return repo, nil +} + +// getHead returns the HEAD reference of a repository +func getHead(repo *git.Repository) (*plumbing.Reference, error) { + head, err := repo.Head() + if err != nil { + return nil, fmt.Errorf(errGetHead, err) + } + return head, nil +} + +// ============================================================================ +// Core Repository State Functions +// ============================================================================ + +// GetRepoState returns comprehensive state information about a git repository. +// This is the primary function for getting all repository information in a single call. +// Returns a RepoState with Exists=false if the path is not a git repository. +// +// Example: +// +// state, err := GetRepoState("/path/to/repo") +// if err != nil { +// return err +// } +// if !state.Exists { +// fmt.Println("Not a git repository") +// return +// } +// fmt.Printf("Branch: %s, Commit: %s, Status: %s\n", +// state.Branch, state.CommitHash, state.UncommittedSummary) +func GetRepoState(path string) (*RepoState, error) { + state := &RepoState{ + Exists: false, + } + + // Check if the directory exists + if !RepoExistsAt(path) { + return state, nil + } + + state.Exists = true + + // Get current branch + branch, err := GetCurrentBranch(path) + if err != nil { + // Not a fatal error - might be in a detached HEAD state + state.Branch = stateDetachedHead + } else { + state.Branch = branch + } + + // Get commit hash + hash, fullHash, err := GetCommitHash(path) + if err != nil { + // Not a fatal error - might be a new repository with no commits + state.CommitHash = stateNoCommits + state.CommitHashFull = "" + } else { + state.CommitHash = hash + state.CommitHashFull = fullHash + } + + // Check for uncommitted changes + hasChanges, summary, err := HasUncommittedChanges(path) + if err != nil { + return state, fmt.Errorf(errCheckUncommitted, err) + } + state.HasUncommitted = hasChanges + state.UncommittedSummary = summary + + return state, nil +} + +// RepoExistsAt checks if a git repository exists at the given path +func RepoExistsAt(path string) bool { + // Expand the home path if needed + expandedPath := expandHomePath(path) + + // Check if the directory exists + if _, err := os.Stat(expandedPath); os.IsNotExist(err) { + return false + } + + // Check if the .git directory exists + gitDir := filepath.Join(expandedPath, ".git") + info, err := os.Stat(gitDir) + if err != nil { + return false + } + + return info.IsDir() +} + +// ============================================================================ +// Branch and Commit Operations +// ============================================================================ + +// GetCurrentBranch returns the name of the current branch. +// Returns an error if the repository is in a detached HEAD state or if the branch cannot be determined. +// +// Example: +// +// branch, err := GetCurrentBranch("/path/to/repo") +// if err != nil { +// fmt.Println("Error:", err) +// return +// } +// fmt.Println("Current branch:", branch) +func GetCurrentBranch(path string) (string, error) { + repo, err := openRepo(path) + if err != nil { + return "", err + } + + head, err := getHead(repo) + if err != nil { + return "", err + } + + // Check if we're in a detached HEAD state + if !head.Name().IsBranch() { + return "", fmt.Errorf(errDetachedHead) + } + + // Get the branch name + return head.Name().Short(), nil +} + +// GetCommitHash returns the current commit hash (both short and full versions) +// Returns (shortHash, fullHash, error) +func GetCommitHash(path string) (string, string, error) { + repo, err := openRepo(path) + if err != nil { + return "", "", err + } + + head, err := getHead(repo) + if err != nil { + return "", "", err + } + + // Get the commit hash + hash := head.Hash() + shortHash := hash.String()[:7] // First 7 characters + fullHash := hash.String() + + return shortHash, fullHash, nil +} + +// ============================================================================ +// Change Detection +// ============================================================================ + +// HasUncommittedChanges checks if the repository has uncommitted changes. +// Returns (hasChanges, summary, error) where summary is a human-readable +// description like "2 modified, 1 untracked" or "clean" if no changes. +// +// Example: +// +// hasChanges, summary, err := HasUncommittedChanges("/path/to/repo") +// if err != nil { +// return err +// } +// if hasChanges { +// fmt.Printf("Uncommitted changes: %s\n", summary) +// } else { +// fmt.Println("Working tree is clean") +// } +func HasUncommittedChanges(path string) (bool, string, error) { + repo, err := openRepo(path) + if err != nil { + return false, "", err + } + + // Get the working tree + worktree, err := repo.Worktree() + if err != nil { + return false, "", fmt.Errorf(errGetWorktree, err) + } + + // Get the status + status, err := worktree.Status() + if err != nil { + return false, "", fmt.Errorf(errGetStatus, err) + } + + // Count different types of changes + var modified, added, deleted, untracked int + for _, fileStatus := range status { + switch fileStatus.Staging { + case git.Added: + added++ + case git.Modified: + modified++ + case git.Deleted: + deleted++ + } + + // Check worktree status for untracked files + if fileStatus.Worktree == git.Untracked { + untracked++ + } else if fileStatus.Worktree == git.Modified { + modified++ + } else if fileStatus.Worktree == git.Deleted { + deleted++ + } + } + + // Check if there are any changes + hasChanges := len(status) > 0 + + // Build summary + summary := buildChangesSummary(modified, added, deleted, untracked) + + return hasChanges, summary, nil +} + +// buildChangesSummary creates a human-readable summary of changes +func buildChangesSummary(modified, added, deleted, untracked int) string { + if modified == 0 && added == 0 && deleted == 0 && untracked == 0 { + return stateClean + } + + var parts []string + if modified > 0 { + parts = append(parts, fmt.Sprintf("%d modified", modified)) + } + if added > 0 { + parts = append(parts, fmt.Sprintf("%d added", added)) + } + if deleted > 0 { + parts = append(parts, fmt.Sprintf("%d deleted", deleted)) + } + if untracked > 0 { + parts = append(parts, fmt.Sprintf("%d untracked", untracked)) + } + + summary := "" + for i, part := range parts { + if i > 0 { + summary += ", " + } + summary += part + } + + return summary +} + +// IsBranchDirty checks if the current branch has uncommitted changes +// This is a convenience wrapper around HasUncommittedChanges +func IsBranchDirty(path string) (bool, error) { + hasChanges, _, err := HasUncommittedChanges(path) + return hasChanges, err +} + +// ============================================================================ +// Remote Operations +// ============================================================================ + +// GetRemoteURL returns the URL of the remote repository. +// Returns the URL of the first remote (typically "origin"). +// The URL is normalized to a consistent format (e.g., "github.com/user/repo"). +// +// Example: +// +// url, err := GetRemoteURL("/path/to/repo") +// if err != nil { +// return err +// } +// fmt.Println("Remote URL:", url) +func GetRemoteURL(path string) (string, error) { + repo, err := openRepo(path) + if err != nil { + return "", err + } + + remotes, err := repo.Remotes() + if err != nil { + return "", fmt.Errorf(errGetRemotes, err) + } + + if len(remotes) == 0 { + return "", fmt.Errorf(errNoRemotes) + } + + // Get the first remote (usually "origin") + remote := remotes[0] + if len(remote.Config().URLs) == 0 { + return "", fmt.Errorf(errNoRemoteURLs) + } + + return normalizeGitURL(remote.Config().URLs[0]), nil +} + +// GetLatestCommitMessage returns the message of the latest commit. +// +// Example: +// +// msg, err := GetLatestCommitMessage("/path/to/repo") +// if err != nil { +// return err +// } +// fmt.Println("Latest commit:", msg) +func GetLatestCommitMessage(path string) (string, error) { + repo, err := openRepo(path) + if err != nil { + return "", err + } + + head, err := getHead(repo) + if err != nil { + return "", err + } + + // Get the commit object + commit, err := repo.CommitObject(head.Hash()) + if err != nil { + return "", fmt.Errorf(errGetCommit, err) + } + + return commit.Message, nil +} + +// IsAheadOfRemote checks if the local branch is ahead of the remote branch. +// Returns the number of commits the local branch is ahead. +// Returns 0 if the branches are in sync or if the remote branch doesn't exist. +// +// Note: This is a simplified implementation that returns 1 if there are any differences. +// A full implementation would walk the commit graph to count exact commits ahead. +// +// Example: +// +// ahead, err := IsAheadOfRemote("/path/to/repo") +// if err != nil { +// return err +// } +// if ahead > 0 { +// fmt.Printf("Local branch is %d commit(s) ahead of remote\n", ahead) +// } +func IsAheadOfRemote(path string) (int, error) { + repo, err := openRepo(path) + if err != nil { + return 0, err + } + + head, err := getHead(repo) + if err != nil { + return 0, err + } + + // Get the current branch name + branchName := head.Name().Short() + + // Get the remote tracking branch + remoteBranchName := plumbing.NewRemoteReferenceName("origin", branchName) + remoteBranch, err := repo.Reference(remoteBranchName, true) + if err != nil { + // Remote branch might not exist + return 0, nil + } + + // Count commits between local and remote + localCommit, err := repo.CommitObject(head.Hash()) + if err != nil { + return 0, fmt.Errorf(errGetLocalCommit, err) + } + + remoteCommit, err := repo.CommitObject(remoteBranch.Hash()) + if err != nil { + return 0, fmt.Errorf(errGetRemoteCommit, err) + } + + // Simple check: if hashes are different, we might be ahead + if localCommit.Hash == remoteCommit.Hash { + return 0, nil + } + + // For a more accurate count, we'd need to walk the commit graph + // For now, we'll just return 1 if they're different (simplified) + return 1, nil +} diff --git a/internal/git/operations_test.go b/internal/git/operations_test.go new file mode 100644 index 0000000..53cbdc0 --- /dev/null +++ b/internal/git/operations_test.go @@ -0,0 +1,482 @@ +package git + +import ( + "os" + "path/filepath" + "testing" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// createTestRepo creates a temporary git repository for testing +func createTestRepo(t *testing.T) (string, *git.Repository) { + t.Helper() + + // Create temporary directory + tmpDir := t.TempDir() + + // Initialize git repository + repo, err := git.PlainInit(tmpDir, false) + require.NoError(t, err) + + return tmpDir, repo +} + +// createTestCommit creates a test commit in the repository +func createTestCommit(t *testing.T, repo *git.Repository, repoPath, filename, content string) { + t.Helper() + + // Create a test file + filePath := filepath.Join(repoPath, filename) + err := os.WriteFile(filePath, []byte(content), 0644) + require.NoError(t, err) + + // Get the worktree + w, err := repo.Worktree() + require.NoError(t, err) + + // Add the file + _, err = w.Add(filename) + require.NoError(t, err) + + // Create commit + _, err = w.Commit("Test commit", &git.CommitOptions{ + Author: &object.Signature{ + Name: "Test User", + Email: "test@example.com", + }, + }) + require.NoError(t, err) +} + +func TestRepoExistsAt(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) string + expected bool + }{ + { + name: "existing git repository", + setup: func(t *testing.T) string { + repoPath, _ := createTestRepo(t) + return repoPath + }, + expected: true, + }, + { + name: "non-existent directory", + setup: func(t *testing.T) string { + return "/path/that/does/not/exist" + }, + expected: false, + }, + { + name: "directory without .git", + setup: func(t *testing.T) string { + tmpDir := t.TempDir() + return tmpDir + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := tt.setup(t) + result := RepoExistsAt(path) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetCurrentBranch(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) string + expectedBranch string + expectError bool + }{ + { + name: "repository with commits on main", + setup: func(t *testing.T) string { + repoPath, repo := createTestRepo(t) + createTestCommit(t, repo, repoPath, "test.txt", "content") + return repoPath + }, + expectedBranch: "master", // go-git creates "master" by default + expectError: false, + }, + { + name: "non-existent repository", + setup: func(t *testing.T) string { + return "/path/that/does/not/exist" + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := tt.setup(t) + branch, err := GetCurrentBranch(path) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedBranch, branch) + } + }) + } +} + +func TestGetCommitHash(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) string + expectError bool + }{ + { + name: "repository with commits", + setup: func(t *testing.T) string { + repoPath, repo := createTestRepo(t) + createTestCommit(t, repo, repoPath, "test.txt", "content") + return repoPath + }, + expectError: false, + }, + { + name: "non-existent repository", + setup: func(t *testing.T) string { + return "/path/that/does/not/exist" + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := tt.setup(t) + shortHash, fullHash, err := GetCommitHash(path) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Len(t, shortHash, 7, "short hash should be 7 characters") + assert.Len(t, fullHash, 40, "full hash should be 40 characters") + assert.Equal(t, fullHash[:7], shortHash, "short hash should match first 7 chars of full hash") + } + }) + } +} + +func TestHasUncommittedChanges(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) string + expectChanges bool + expectError bool + expectedSummary string + }{ + { + name: "clean repository", + setup: func(t *testing.T) string { + repoPath, repo := createTestRepo(t) + createTestCommit(t, repo, repoPath, "test.txt", "content") + return repoPath + }, + expectChanges: false, + expectError: false, + expectedSummary: "clean", + }, + { + name: "repository with untracked file", + setup: func(t *testing.T) string { + repoPath, repo := createTestRepo(t) + createTestCommit(t, repo, repoPath, "test.txt", "content") + // Add an untracked file + untrackedFile := filepath.Join(repoPath, "untracked.txt") + err := os.WriteFile(untrackedFile, []byte("untracked content"), 0644) + require.NoError(t, err) + return repoPath + }, + expectChanges: true, + expectError: false, + expectedSummary: "1 untracked", + }, + { + name: "repository with modified file", + setup: func(t *testing.T) string { + repoPath, repo := createTestRepo(t) + createTestCommit(t, repo, repoPath, "test.txt", "content") + // Modify the tracked file + modifiedFile := filepath.Join(repoPath, "test.txt") + err := os.WriteFile(modifiedFile, []byte("modified content"), 0644) + require.NoError(t, err) + return repoPath + }, + expectChanges: true, + expectError: false, + expectedSummary: "1 modified", + }, + { + name: "non-existent repository", + setup: func(t *testing.T) string { + return "/path/that/does/not/exist" + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := tt.setup(t) + hasChanges, summary, err := HasUncommittedChanges(path) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectChanges, hasChanges) + assert.Equal(t, tt.expectedSummary, summary) + } + }) + } +} + +func TestGetRepoState(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) string + expectExists bool + expectError bool + checkBranch bool + expectedBranch string + }{ + { + name: "clean repository with commits", + setup: func(t *testing.T) string { + repoPath, repo := createTestRepo(t) + createTestCommit(t, repo, repoPath, "test.txt", "content") + return repoPath + }, + expectExists: true, + expectError: false, + checkBranch: true, + expectedBranch: "master", + }, + { + name: "non-existent repository", + setup: func(t *testing.T) string { + return "/path/that/does/not/exist" + }, + expectExists: false, + expectError: false, + }, + { + name: "repository with uncommitted changes", + setup: func(t *testing.T) string { + repoPath, repo := createTestRepo(t) + createTestCommit(t, repo, repoPath, "test.txt", "content") + // Add an untracked file + untrackedFile := filepath.Join(repoPath, "untracked.txt") + err := os.WriteFile(untrackedFile, []byte("untracked content"), 0644) + require.NoError(t, err) + return repoPath + }, + expectExists: true, + expectError: false, + checkBranch: true, + expectedBranch: "master", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := tt.setup(t) + state, err := GetRepoState(path) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.NotNil(t, state) + assert.Equal(t, tt.expectExists, state.Exists) + + if tt.checkBranch && tt.expectExists { + assert.Equal(t, tt.expectedBranch, state.Branch) + assert.NotEmpty(t, state.CommitHash) + assert.NotEmpty(t, state.CommitHashFull) + } + } + }) + } +} + +func TestIsBranchDirty(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) string + expectDirty bool + expectError bool + }{ + { + name: "clean repository", + setup: func(t *testing.T) string { + repoPath, repo := createTestRepo(t) + createTestCommit(t, repo, repoPath, "test.txt", "content") + return repoPath + }, + expectDirty: false, + expectError: false, + }, + { + name: "dirty repository", + setup: func(t *testing.T) string { + repoPath, repo := createTestRepo(t) + createTestCommit(t, repo, repoPath, "test.txt", "content") + // Add an untracked file + untrackedFile := filepath.Join(repoPath, "untracked.txt") + err := os.WriteFile(untrackedFile, []byte("untracked content"), 0644) + require.NoError(t, err) + return repoPath + }, + expectDirty: true, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := tt.setup(t) + isDirty, err := IsBranchDirty(path) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectDirty, isDirty) + } + }) + } +} + +func TestGetLatestCommitMessage(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) string + expectedMessage string + expectError bool + }{ + { + name: "repository with commit", + setup: func(t *testing.T) string { + repoPath, repo := createTestRepo(t) + createTestCommit(t, repo, repoPath, "test.txt", "content") + return repoPath + }, + expectedMessage: "Test commit", + expectError: false, + }, + { + name: "non-existent repository", + setup: func(t *testing.T) string { + return "/path/that/does/not/exist" + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := tt.setup(t) + message, err := GetLatestCommitMessage(path) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedMessage, message) + } + }) + } +} + +func TestBuildChangesSummary(t *testing.T) { + tests := []struct { + name string + modified int + added int + deleted int + untracked int + expected string + }{ + { + name: "no changes", + modified: 0, + added: 0, + deleted: 0, + untracked: 0, + expected: "clean", + }, + { + name: "only modified", + modified: 2, + added: 0, + deleted: 0, + untracked: 0, + expected: "2 modified", + }, + { + name: "only added", + modified: 0, + added: 1, + deleted: 0, + untracked: 0, + expected: "1 added", + }, + { + name: "only deleted", + modified: 0, + added: 0, + deleted: 3, + untracked: 0, + expected: "3 deleted", + }, + { + name: "only untracked", + modified: 0, + added: 0, + deleted: 0, + untracked: 5, + expected: "5 untracked", + }, + { + name: "mixed changes", + modified: 2, + added: 1, + deleted: 1, + untracked: 3, + expected: "2 modified, 1 added, 1 deleted, 3 untracked", + }, + { + name: "modified and untracked", + modified: 1, + added: 0, + deleted: 0, + untracked: 2, + expected: "1 modified, 2 untracked", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildChangesSummary(tt.modified, tt.added, tt.deleted, tt.untracked) + assert.Equal(t, tt.expected, result) + }) + } +}