Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ go run . -o json
### Core Flow
1. **Configuration Loading** (`internal/config`): Loads defaults → reads `~/.config/reposcan/config.toml` → applies CLI flags (in that order of precedence)
2. **Repository Discovery** (`internal/scan`): Walks filesystem from root directories, applying `dirIgnore` glob patterns, identifying `.git` directories
3. **Git State Checking** (`internal/gitx`): Concurrently checks each repo using a worker pool (`gitxConcurrent.go`) to gather branch, uncommitted files, ahead/behind counts
3. **VCS State Checking** (`internal/vcs`): Concurrently checks each repo using registered VCS providers to gather branch, uncommitted files, ahead/behind counts, and VCS-specific metadata
4. **Filtering** (`cmd/reposcan/rootCmd.go`): Applies `OnlyFilter` (all/dirty/uncommitted/unpushed/unpulled) to determine which repos to include in output
5. **Rendering** (`internal/render`): Outputs results in chosen format (stdout table/json, interactive TUI, or file output)

Expand All @@ -60,7 +60,9 @@ go run . -o json
- **`cmd/reposcan`**: CLI entry point, Cobra command setup, flag parsing, orchestration of the scan→filter→render pipeline
- **`internal/config`**: Configuration types, validation, defaults, TOML loading. The `Config` struct in `types.go` is the central configuration object
- **`internal/scan`**: Filesystem walking with `filepath.WalkDir`, directory ignore matching using `doublestar` globs, git repo detection
- **`internal/gitx`**: Git operations via `exec.Command`. `gitFunctions.go` wraps individual git commands (status, branch, rev-list). `gitxConcurrent.go` implements worker pool pattern for parallel repo checking
- **`internal/vcs`**: VCS provider registry, repository metadata, and worker pool for parallel repo state checking across supported VCS types
- **`internal/vcs/git`**: Git provider implementation and Git command wrappers for state checks, push, pull, and fetch operations
- **`internal/vcs/jj`**: Jujutsu provider implementation for detecting and checking jj repositories
- **`internal/render`**: Three render paths:
- `stdout`: Plain table (using `charmbracelet/lipgloss`) or JSON output
- `file`: Writes JSON reports to disk
Expand All @@ -75,10 +77,10 @@ Values are merged in this order (later overrides earlier):
3. CLI flags

### Concurrency Model
The `gitx.GetGitRepoStatesConcurrent` function uses a worker pool pattern:
The `vcs.GetRepoStatesConcurrent` function uses a worker pool pattern:
- Creates buffered channels for jobs and results
- Spawns `maxWorkers` goroutines (default: 8)
- Each worker pulls repo paths from the jobs channel and checks git state
- Each worker pulls discovered repositories from the jobs channel, resolves the matching VCS provider, and checks repo state
- Results are collected, sorted by path, and returned

### TUI Architecture (`internal/render/tui`)
Expand Down Expand Up @@ -106,7 +108,7 @@ The `filter` function in `rootCmd.go` applies `OnlyFilter` after all repos are d
`scan.FindGitRepos` collects warnings (e.g., permission denied) but continues walking. Warnings are included in `ScanReport.Warnings`.

### Git Command Wrapper
`gitx.RunGitCommand` uses `git -C <dir>` to run commands in a specific directory without changing the process's working directory. Stderr is captured but only used for error detection—stdout is returned.
`git.RunGitCommand` uses `git -C <dir>` to run commands in a specific directory without changing the process's working directory. Stderr is captured but only used for error detection—stdout is returned.

## Testing Patterns

Expand Down
74 changes: 0 additions & 74 deletions internal/gitx/gitxConcurrent.go

This file was deleted.

18 changes: 15 additions & 3 deletions internal/render/stdout/scanReport.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,27 @@ func renderReportHeader(r report.ScanReport, totalRepos int, dirtyRepos int) {
func renderDirtyReposDetails(r report.ScanReport) {
fmt.Printf("\n%s\n", CyanBold("Details:"))
for _, rs := range r.RepoStates {
if len(rs.UncommitedFiles) == 0 {
outgoingCommits := rs.OutgoingCommits()
if len(rs.UncommitedFiles) == 0 && len(outgoingCommits) == 0 {
continue
}
fmt.Printf("\n%s %s\n%s %s\n",
MagBold("Repo:"), rs.Repo,
MagBold("Path:"), rs.Path,
)
for _, f := range rs.UncommitedFiles {
fmt.Printf(" %s\n", GrayS("- %s", f))

if len(rs.UncommitedFiles) > 0 {
fmt.Printf("%s\n", MagBold("File Changes:"))
for _, f := range rs.UncommitedFiles {
fmt.Printf(" %s\n", GrayS("- %s", f))
}
}

if len(outgoingCommits) > 0 {
fmt.Printf("%s\n", MagBold("Outgoing Commits:"))
for _, commit := range outgoingCommits {
fmt.Printf(" %s\n", GrayS("- %s", commit))
}
}
}
}
Expand Down
25 changes: 25 additions & 0 deletions internal/render/stdout/scanReport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,28 @@ func TestRenderScanReportAsTable_PrintsHeaderAndDetails(t *testing.T) {
t.Fatalf("missing warnings: %s", out)
}
}

func TestRenderScanReportAsTable_PrintsOutgoingCommitDetails(t *testing.T) {
reportWithOutgoing := report.ScanReport{
Version: 1,
GeneratedAt: time.Date(2025, 8, 31, 22, 0, 0, 0, time.UTC),
RepoStates: []report.RepoState{
{
Repo: "jj-repo",
Branch: "main",
Path: "/tmp/jj-repo",
RemoteStatus: []report.RemoteStatus{
{Ahead: 1, OutgoingCommits: []string{"abc123 change 1"}},
},
},
},
}

out := captureStdout(t, func() { RenderScanReportAsTable(reportWithOutgoing) })
if !strings.Contains(out, "Outgoing Commits:") {
t.Fatalf("missing outgoing commits section: %s", out)
}
if !strings.Contains(out, "abc123 change 1") {
t.Fatalf("missing outgoing commit details: %s", out)
}
}
10 changes: 5 additions & 5 deletions internal/render/tui/msgGitFunctions.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package tui

import (
tea "github.com/charmbracelet/bubbletea"
"github.com/mabd-dev/reposcan/internal/gitx"
"github.com/mabd-dev/reposcan/internal/vcs/git"
"github.com/mabd-dev/reposcan/pkg/report"
)

Expand All @@ -21,7 +21,7 @@ func gitFetch(m Model) tea.Cmd {
m.reposBeingUpdated = append(m.reposBeingUpdated, rs.ID)

return func() tea.Msg {
stdout, err := gitx.GitFetch(repoPath)
stdout, err := git.GitFetch(repoPath)

errMessage := ""
if err != nil {
Expand Down Expand Up @@ -50,7 +50,7 @@ func gitPull(m Model) tea.Cmd {
m.reposBeingUpdated = append(m.reposBeingUpdated, rs.ID)

return func() tea.Msg {
stdout, err := gitx.GitPull(repoPath)
stdout, err := git.GitPull(repoPath)

errMessage := ""
if err != nil {
Expand Down Expand Up @@ -78,7 +78,7 @@ func gitPush(m Model) tea.Cmd {
repoPath := rs.Path

return func() tea.Msg {
stdout, err := gitx.GitPush(repoPath)
stdout, err := git.GitPush(repoPath)

errMessage := ""
if err != nil {
Expand Down Expand Up @@ -108,7 +108,7 @@ func gitRefreshRepo(m Model) tea.Cmd {
repoPath := rs.Path

return func() tea.Msg {
newRepoState, _ := gitx.CheckRepoState(repoPath)
newRepoState, _ := git.CheckRepoState(repoPath)

return gitRefreshRepoResultMsg{
newRepoState: newRepoState,
Expand Down
61 changes: 44 additions & 17 deletions internal/render/tui/repodetails/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,29 +18,56 @@ func (m *Model) View() string {
lines := []string{
//m.theme.Styles.Base.Foreground(m.theme.Colors.Muted).Italic(true).Render("Details"),
fmt.Sprintf("%s %s", style.Render("Path:"), m.repoState.Path),
style.Render("File Changes:"),
}
if len(m.repoState.UncommitedFiles) > 0 {
files := m.repoState.UncommitedFiles

maxUncommitedFilesToShow := m.height - len(lines) - 1
trimUncommitedFiles := len(files) > maxUncommitedFilesToShow

if trimUncommitedFiles {
files = files[:maxUncommitedFilesToShow]
}
if len(m.repoState.UncommitedFiles) > 0 {
lines = append(lines, style.Render("File Changes:"))
lines = appendTrimmedList(lines, m.repoState.UncommitedFiles, m.height, func(s string) string {
return m.theme.Styles.Muted.Render(s)
})
}

for _, f := range files {
lines = append(lines, " "+m.theme.Styles.Muted.Render(f))
}
outgoingCommits := m.repoState.OutgoingCommits()
if len(outgoingCommits) > 0 {
lines = append(lines, style.Render("Outgoing Commits:"))
lines = appendTrimmedList(lines, outgoingCommits, m.height, func(s string) string {
return m.theme.Styles.Muted.Render(s)
})
}

if trimUncommitedFiles {
more := len(m.repoState.UncommitedFiles) - maxUncommitedFilesToShow
lines = append(lines, m.theme.Styles.Muted.Render(" ... (+"+strconv.Itoa(more)+" more)"))
}
} else {
if len(m.repoState.UncommitedFiles) == 0 && len(outgoingCommits) == 0 {
lines = append(lines, style.Render("Changes:"))
lines = append(lines, m.theme.Styles.Muted.Render(" no changes"))
}

return lipgloss.JoinVertical(lipgloss.Left, lines...)
}

func appendTrimmedList(
lines []string,
items []string,
height int,
render func(string) string,
) []string {
maxItemsToShow := height - len(lines) - 1
if maxItemsToShow < 0 {
maxItemsToShow = 0
}

trimmedItems := items
trimmed := len(items) > maxItemsToShow
if trimmed {
trimmedItems = items[:maxItemsToShow]
}

for _, item := range trimmedItems {
lines = append(lines, " "+render(item))
}

if trimmed {
more := len(items) - maxItemsToShow
lines = append(lines, render(" ... (+"+strconv.Itoa(more)+" more)"))
}

return lines
}
Loading