Skip to content

Commit 0d5e7c0

Browse files
authored
Merge pull request #31 from frittlechasm/feat/jjsupport
feat(vcs): add read-only jj repository support
2 parents 4580eca + 420736b commit 0d5e7c0

21 files changed

Lines changed: 1245 additions & 165 deletions

AGENTS.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ go run . -o json
5151
### Core Flow
5252
1. **Configuration Loading** (`internal/config`): Loads defaults → reads `~/.config/reposcan/config.toml` → applies CLI flags (in that order of precedence)
5353
2. **Repository Discovery** (`internal/scan`): Walks filesystem from root directories, applying `dirIgnore` glob patterns, identifying `.git` directories
54-
3. **Git State Checking** (`internal/gitx`): Concurrently checks each repo using a worker pool (`gitxConcurrent.go`) to gather branch, uncommitted files, ahead/behind counts
54+
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
5555
4. **Filtering** (`cmd/reposcan/rootCmd.go`): Applies `OnlyFilter` (all/dirty/uncommitted/unpushed/unpulled) to determine which repos to include in output
5656
5. **Rendering** (`internal/render`): Outputs results in chosen format (stdout table/json, interactive TUI, or file output)
5757

@@ -60,7 +60,9 @@ go run . -o json
6060
- **`cmd/reposcan`**: CLI entry point, Cobra command setup, flag parsing, orchestration of the scan→filter→render pipeline
6161
- **`internal/config`**: Configuration types, validation, defaults, TOML loading. The `Config` struct in `types.go` is the central configuration object
6262
- **`internal/scan`**: Filesystem walking with `filepath.WalkDir`, directory ignore matching using `doublestar` globs, git repo detection
63-
- **`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
63+
- **`internal/vcs`**: VCS provider registry, repository metadata, and worker pool for parallel repo state checking across supported VCS types
64+
- **`internal/vcs/git`**: Git provider implementation and Git command wrappers for state checks, push, pull, and fetch operations
65+
- **`internal/vcs/jj`**: Jujutsu provider implementation for detecting and checking jj repositories
6466
- **`internal/render`**: Three render paths:
6567
- `stdout`: Plain table (using `charmbracelet/lipgloss`) or JSON output
6668
- `file`: Writes JSON reports to disk
@@ -75,10 +77,10 @@ Values are merged in this order (later overrides earlier):
7577
3. CLI flags
7678

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

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

108110
### Git Command Wrapper
109-
`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.
111+
`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.
110112

111113
## Testing Patterns
112114

internal/gitx/gitxConcurrent.go

Lines changed: 0 additions & 74 deletions
This file was deleted.

internal/render/stdout/scanReport.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,27 @@ func renderReportHeader(r report.ScanReport, totalRepos int, dirtyRepos int) {
5656
func renderDirtyReposDetails(r report.ScanReport) {
5757
fmt.Printf("\n%s\n", CyanBold("Details:"))
5858
for _, rs := range r.RepoStates {
59-
if len(rs.UncommitedFiles) == 0 {
59+
outgoingCommits := rs.OutgoingCommits()
60+
if len(rs.UncommitedFiles) == 0 && len(outgoingCommits) == 0 {
6061
continue
6162
}
6263
fmt.Printf("\n%s %s\n%s %s\n",
6364
MagBold("Repo:"), rs.Repo,
6465
MagBold("Path:"), rs.Path,
6566
)
66-
for _, f := range rs.UncommitedFiles {
67-
fmt.Printf(" %s\n", GrayS("- %s", f))
67+
68+
if len(rs.UncommitedFiles) > 0 {
69+
fmt.Printf("%s\n", MagBold("File Changes:"))
70+
for _, f := range rs.UncommitedFiles {
71+
fmt.Printf(" %s\n", GrayS("- %s", f))
72+
}
73+
}
74+
75+
if len(outgoingCommits) > 0 {
76+
fmt.Printf("%s\n", MagBold("Outgoing Commits:"))
77+
for _, commit := range outgoingCommits {
78+
fmt.Printf(" %s\n", GrayS("- %s", commit))
79+
}
6880
}
6981
}
7082
}

internal/render/stdout/scanReport_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,28 @@ func TestRenderScanReportAsTable_PrintsHeaderAndDetails(t *testing.T) {
6868
t.Fatalf("missing warnings: %s", out)
6969
}
7070
}
71+
72+
func TestRenderScanReportAsTable_PrintsOutgoingCommitDetails(t *testing.T) {
73+
reportWithOutgoing := report.ScanReport{
74+
Version: 1,
75+
GeneratedAt: time.Date(2025, 8, 31, 22, 0, 0, 0, time.UTC),
76+
RepoStates: []report.RepoState{
77+
{
78+
Repo: "jj-repo",
79+
Branch: "main",
80+
Path: "/tmp/jj-repo",
81+
RemoteStatus: []report.RemoteStatus{
82+
{Ahead: 1, OutgoingCommits: []string{"abc123 change 1"}},
83+
},
84+
},
85+
},
86+
}
87+
88+
out := captureStdout(t, func() { RenderScanReportAsTable(reportWithOutgoing) })
89+
if !strings.Contains(out, "Outgoing Commits:") {
90+
t.Fatalf("missing outgoing commits section: %s", out)
91+
}
92+
if !strings.Contains(out, "abc123 change 1") {
93+
t.Fatalf("missing outgoing commit details: %s", out)
94+
}
95+
}

internal/render/tui/msgGitFunctions.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ package tui
22

33
import (
44
tea "github.com/charmbracelet/bubbletea"
5-
"github.com/mabd-dev/reposcan/internal/gitx"
5+
"github.com/mabd-dev/reposcan/internal/vcs/git"
66
"github.com/mabd-dev/reposcan/pkg/report"
77
)
88

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

2323
return func() tea.Msg {
24-
stdout, err := gitx.GitFetch(repoPath)
24+
stdout, err := git.GitFetch(repoPath)
2525

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

5252
return func() tea.Msg {
53-
stdout, err := gitx.GitPull(repoPath)
53+
stdout, err := git.GitPull(repoPath)
5454

5555
errMessage := ""
5656
if err != nil {
@@ -78,7 +78,7 @@ func gitPush(m Model) tea.Cmd {
7878
repoPath := rs.Path
7979

8080
return func() tea.Msg {
81-
stdout, err := gitx.GitPush(repoPath)
81+
stdout, err := git.GitPush(repoPath)
8282

8383
errMessage := ""
8484
if err != nil {
@@ -108,7 +108,7 @@ func gitRefreshRepo(m Model) tea.Cmd {
108108
repoPath := rs.Path
109109

110110
return func() tea.Msg {
111-
newRepoState, _ := gitx.CheckRepoState(repoPath)
111+
newRepoState, _ := git.CheckRepoState(repoPath)
112112

113113
return gitRefreshRepoResultMsg{
114114
newRepoState: newRepoState,

internal/render/tui/repodetails/view.go

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,29 +18,56 @@ func (m *Model) View() string {
1818
lines := []string{
1919
//m.theme.Styles.Base.Foreground(m.theme.Colors.Muted).Italic(true).Render("Details"),
2020
fmt.Sprintf("%s %s", style.Render("Path:"), m.repoState.Path),
21-
style.Render("File Changes:"),
2221
}
23-
if len(m.repoState.UncommitedFiles) > 0 {
24-
files := m.repoState.UncommitedFiles
25-
26-
maxUncommitedFilesToShow := m.height - len(lines) - 1
27-
trimUncommitedFiles := len(files) > maxUncommitedFilesToShow
2822

29-
if trimUncommitedFiles {
30-
files = files[:maxUncommitedFilesToShow]
31-
}
23+
if len(m.repoState.UncommitedFiles) > 0 {
24+
lines = append(lines, style.Render("File Changes:"))
25+
lines = appendTrimmedList(lines, m.repoState.UncommitedFiles, m.height, func(s string) string {
26+
return m.theme.Styles.Muted.Render(s)
27+
})
28+
}
3229

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

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

4543
return lipgloss.JoinVertical(lipgloss.Left, lines...)
4644
}
45+
46+
func appendTrimmedList(
47+
lines []string,
48+
items []string,
49+
height int,
50+
render func(string) string,
51+
) []string {
52+
maxItemsToShow := height - len(lines) - 1
53+
if maxItemsToShow < 0 {
54+
maxItemsToShow = 0
55+
}
56+
57+
trimmedItems := items
58+
trimmed := len(items) > maxItemsToShow
59+
if trimmed {
60+
trimmedItems = items[:maxItemsToShow]
61+
}
62+
63+
for _, item := range trimmedItems {
64+
lines = append(lines, " "+render(item))
65+
}
66+
67+
if trimmed {
68+
more := len(items) - maxItemsToShow
69+
lines = append(lines, render(" ... (+"+strconv.Itoa(more)+" more)"))
70+
}
71+
72+
return lines
73+
}

0 commit comments

Comments
 (0)