diff --git a/AGENTS.md b/AGENTS.md
index 89589d3..5c84669 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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)
@@ -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
@@ -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`)
@@ -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
` 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 ` 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
diff --git a/internal/gitx/gitxConcurrent.go b/internal/gitx/gitxConcurrent.go
deleted file mode 100644
index 63f24ad..0000000
--- a/internal/gitx/gitxConcurrent.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package gitx
-
-import (
- "sort"
- "sync"
-
- "github.com/mabd-dev/reposcan/pkg/report"
-)
-
-type gitRepoResult struct {
- State report.RepoState
- Warnings []string
-}
-
-// GetGitRepoStatesConcurrent gathers RepoState for each path concurrently using
-// up to maxWorkers goroutines. It returns states sorted by Path and any warnings.
-func GetGitRepoStatesConcurrent(
- paths []string,
- maxWorkers int,
-) ([]report.RepoState, []string) {
-
- if maxWorkers <= 0 {
- maxWorkers = 1
- }
-
- states := []report.RepoState{}
- warnings := []string{}
-
- jobs := make(chan string, maxWorkers*2)
- results := make(chan gitRepoResult, maxWorkers*2)
-
- var wg sync.WaitGroup
-
- wg.Add(maxWorkers)
- for i := 0; i < maxWorkers; i++ {
- go func() {
- defer wg.Done()
-
- for p := range jobs {
- // handle warnigns
- state, warnings := CheckRepoState(p)
-
- result := gitRepoResult{
- State: state,
- Warnings: warnings,
- }
- results <- result
- }
- }()
- }
-
- // Feed jobs in a separate goroutine, then close the jobs channel.
- go func() {
- for _, p := range paths {
- jobs <- p
- }
- close(jobs)
- }()
-
- // when all workers are done, close result
- go func() {
- wg.Wait()
- close(results)
- }()
-
- for x := range results {
- states = append(states, x.State)
- warnings = append(warnings, x.Warnings...)
- }
-
- sort.Slice(states, func(i, j int) bool { return states[i].Path < states[j].Path })
-
- return states, warnings
-}
diff --git a/internal/render/stdout/scanReport.go b/internal/render/stdout/scanReport.go
index 0683a64..51d1fec 100644
--- a/internal/render/stdout/scanReport.go
+++ b/internal/render/stdout/scanReport.go
@@ -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))
+ }
}
}
}
diff --git a/internal/render/stdout/scanReport_test.go b/internal/render/stdout/scanReport_test.go
index e199ac8..b166639 100644
--- a/internal/render/stdout/scanReport_test.go
+++ b/internal/render/stdout/scanReport_test.go
@@ -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)
+ }
+}
diff --git a/internal/render/tui/msgGitFunctions.go b/internal/render/tui/msgGitFunctions.go
index 2962b0d..ce61167 100644
--- a/internal/render/tui/msgGitFunctions.go
+++ b/internal/render/tui/msgGitFunctions.go
@@ -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"
)
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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,
diff --git a/internal/render/tui/repodetails/view.go b/internal/render/tui/repodetails/view.go
index 585d858..e39241c 100644
--- a/internal/render/tui/repodetails/view.go
+++ b/internal/render/tui/repodetails/view.go
@@ -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
+}
diff --git a/internal/scan/scan.go b/internal/scan/scan.go
index 76fde50..099191c 100644
--- a/internal/scan/scan.go
+++ b/internal/scan/scan.go
@@ -5,17 +5,20 @@ import (
"os"
"path/filepath"
"strings"
+
+ "github.com/mabd-dev/reposcan/internal/vcs"
)
-// FindGitRepos walks each root and returns directories that look like Git worktrees.
+// FindRepos walks each root and returns directories that look like supported repos.
// Simple rules:
-// - A directory containing `.git` (directory) is a repo root.
+// - A directory containing `.jj` is treated as a jj repo.
+// - A directory containing `.git` (directory) is treated as a Git repo.
// - Or a `.git` file whose contents include "gitdir:" (worktrees/submodules).
// - When we find a repo root, we SkipDir to avoid descending into nested repos (for now).
-func FindGitRepos(
+func FindRepos(
roots []string,
dirignore []string,
-) (gitReposPaths []string, warnings []string) {
+) (repos []vcs.RepoInfo, warnings []string) {
matcher := NewIgnoreMatcher(roots, dirignore)
visitedDir := map[string]struct{}{}
@@ -43,15 +46,30 @@ func FindGitRepos(
}
visitedDir[path] = struct{}{}
- if isGitRepo(path) {
- gitReposPaths = append(gitReposPaths, path)
+ if repoType, ok := detectRepoType(path); ok {
+ repos = append(repos, vcs.RepoInfo{
+ Path: path,
+ Type: repoType,
+ })
return fs.SkipDir
}
return nil
})
}
- return removeDuplicates(gitReposPaths), warnings
+ return removeDuplicateRepoInfo(repos), warnings
+}
+
+func detectRepoType(path string) (vcs.Type, bool) {
+ if isJJRepo(path) {
+ return vcs.TypeJJ, true
+ }
+
+ if isGitRepo(path) {
+ return vcs.TypeGit, true
+ }
+
+ return "", false
}
func isGitRepo(path string) bool {
@@ -59,27 +77,39 @@ func isGitRepo(path string) bool {
if file, err := os.Lstat(gitPath); err == nil {
if file.IsDir() {
return true
- } else {
- // git worktrees has gitdir: folder
- b, err := os.ReadFile(path)
- if err != nil {
- return false
- }
- return strings.Contains(string(b), "gitdir:")
}
+
+ // git worktrees/submodules use a `.git` file containing `gitdir: ...`
+ b, err := os.ReadFile(gitPath)
+ if err != nil {
+ return false
+ }
+ return strings.Contains(string(b), "gitdir:")
}
+
return false
}
-func removeDuplicates(strs []string) []string {
- seen := make(map[string]struct{}, len(strs))
- distinct := make([]string, 0, len(strs))
+func isJJRepo(path string) bool {
+ jjPath := filepath.Join(path, ".jj")
+ info, err := os.Lstat(jjPath)
+ if err != nil {
+ return false
+ }
+
+ return info.IsDir()
+}
+
+func removeDuplicateRepoInfo(repos []vcs.RepoInfo) []vcs.RepoInfo {
+ seen := make(map[string]struct{}, len(repos))
+ distinct := make([]vcs.RepoInfo, 0, len(repos))
- for _, s := range strs {
- if _, ok := seen[s]; !ok {
- seen[s] = struct{}{}
- distinct = append(distinct, s)
+ for _, repo := range repos {
+ if _, ok := seen[repo.Path]; !ok {
+ seen[repo.Path] = struct{}{}
+ distinct = append(distinct, repo)
}
}
+
return distinct
}
diff --git a/internal/scan/scan_test.go b/internal/scan/scan_test.go
index 246a20e..74ea6fc 100644
--- a/internal/scan/scan_test.go
+++ b/internal/scan/scan_test.go
@@ -4,6 +4,8 @@ import (
"os"
"path/filepath"
"testing"
+
+ "github.com/mabd-dev/reposcan/internal/vcs"
)
func makeDir(t *testing.T, path string) string {
@@ -14,46 +16,101 @@ func makeDir(t *testing.T, path string) string {
return path
}
-func touchDir(t *testing.T, path string) string { return makeDir(t, path) }
+func writeFile(t *testing.T, path string, contents string) {
+ t.Helper()
+ if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
+ t.Fatalf("write file %s: %v", path, err)
+ }
+}
+
+func repoTypeByPath(repos []vcs.RepoInfo) map[string]vcs.Type {
+ result := map[string]vcs.Type{}
+ for _, repo := range repos {
+ result[repo.Path] = repo.Type
+ }
+ return result
+}
-func TestFindGitRepos_FindsDotGitDirs(t *testing.T) {
+func TestFindRepos_DetectsSupportedRepoTypes(t *testing.T) {
root := t.TempDir()
- // repo1 at root/repo1/.git
- repo1 := filepath.Join(root, "repo1")
- touchDir(t, filepath.Join(repo1, ".git"))
- // repo2 nested in a subdir
- repo2 := filepath.Join(root, "parent", "repo2")
- touchDir(t, filepath.Join(repo2, ".git"))
+ gitRepo := filepath.Join(root, "git-repo")
+ makeDir(t, filepath.Join(gitRepo, ".git"))
+
+ jjRepo := filepath.Join(root, "jj-repo")
+ makeDir(t, filepath.Join(jjRepo, ".jj"))
- repos, warnings := FindGitRepos([]string{root}, nil)
+ gitFileRepo := filepath.Join(root, "git-file-repo")
+ makeDir(t, gitFileRepo)
+ writeFile(t, filepath.Join(gitFileRepo, ".git"), "gitdir: /tmp/worktrees/git-file-repo")
+
+ repos, warnings := FindRepos([]string{root}, nil)
if len(warnings) != 0 {
t.Fatalf("unexpected warnings: %v", warnings)
}
- if len(repos) != 2 {
- t.Fatalf("expected 2 repos, got %d: %v", len(repos), repos)
+
+ got := repoTypeByPath(repos)
+ want := map[string]vcs.Type{
+ gitRepo: vcs.TypeGit,
+ jjRepo: vcs.TypeJJ,
+ gitFileRepo: vcs.TypeGit,
+ }
+
+ if len(got) != len(want) {
+ t.Fatalf("expected %d repos, got %d: %v", len(want), len(got), repos)
+ }
+
+ for path, repoType := range want {
+ if got[path] != repoType {
+ t.Fatalf("expected %s to be %s, got %s", path, repoType, got[path])
+ }
}
}
-func TestFindGitRepos_RespectsDirIgnore(t *testing.T) {
+func TestFindRepos_RespectsDirIgnoreForGitAndJJ(t *testing.T) {
root := t.TempDir()
- // repo outside ignored path
- repo1 := filepath.Join(root, "repo1")
- touchDir(t, filepath.Join(repo1, ".git"))
- // repo under node_modules should be ignored
- nmRepo := filepath.Join(root, "node_modules", "x")
- touchDir(t, filepath.Join(nmRepo, ".git"))
+ keepRepo := filepath.Join(root, "keep")
+ makeDir(t, filepath.Join(keepRepo, ".git"))
- // repo under absolute ignored folder
- ignoredAbs := filepath.Join(root, "ignored")
- repo3 := filepath.Join(ignoredAbs, "repo3")
- touchDir(t, filepath.Join(repo3, ".git"))
+ ignoredJJRepo := filepath.Join(root, "node_modules", "jj-repo")
+ makeDir(t, filepath.Join(ignoredJJRepo, ".jj"))
+
+ ignoredGitRepo := filepath.Join(root, "ignored", "git-repo")
+ makeDir(t, filepath.Join(ignoredGitRepo, ".git"))
patterns := []string{"**/node_modules/**", "/ignored/**"}
- repos, _ := FindGitRepos([]string{root}, patterns)
+ repos, warnings := FindRepos([]string{root}, patterns)
+ if len(warnings) != 0 {
+ t.Fatalf("unexpected warnings: %v", warnings)
+ }
+
+ if len(repos) != 1 {
+ t.Fatalf("expected 1 repo, got %d: %v", len(repos), repos)
+ }
+
+ if repos[0].Path != keepRepo || repos[0].Type != vcs.TypeGit {
+ t.Fatalf("expected only %s as git repo, got %v", keepRepo, repos)
+ }
+}
+
+func TestFindRepos_PrefersJJWhenGitAndJJAreCoLocated(t *testing.T) {
+ root := t.TempDir()
+ mixedRepo := filepath.Join(root, "mixed")
+
+ makeDir(t, filepath.Join(mixedRepo, ".git"))
+ makeDir(t, filepath.Join(mixedRepo, ".jj"))
+
+ repos, warnings := FindRepos([]string{root}, nil)
+ if len(warnings) != 0 {
+ t.Fatalf("unexpected warnings: %v", warnings)
+ }
+
+ if len(repos) != 1 {
+ t.Fatalf("expected 1 repo, got %d: %v", len(repos), repos)
+ }
- if len(repos) != 1 || repos[0] != repo1 {
- t.Fatalf("expected only %s, got %v", repo1, repos)
+ if repos[0].Path != mixedRepo || repos[0].Type != vcs.TypeJJ {
+ t.Fatalf("expected %s to be detected as jj repo, got %v", mixedRepo, repos[0])
}
}
diff --git a/internal/scanGenerator.go b/internal/scanGenerator.go
index cfc7478..6959766 100644
--- a/internal/scanGenerator.go
+++ b/internal/scanGenerator.go
@@ -4,8 +4,10 @@ import (
"time"
"github.com/mabd-dev/reposcan/internal/config"
- "github.com/mabd-dev/reposcan/internal/gitx"
"github.com/mabd-dev/reposcan/internal/scan"
+ "github.com/mabd-dev/reposcan/internal/vcs"
+ vcsgit "github.com/mabd-dev/reposcan/internal/vcs/git"
+ vcsjj "github.com/mabd-dev/reposcan/internal/vcs/jj"
"github.com/mabd-dev/reposcan/pkg/report"
)
@@ -14,14 +16,18 @@ func GenerateScanReport(
) report.ScanReport {
reportWarnings := []string{}
- // Find git repos at defined configs.Roots
- gitReposPaths, warnings := scan.FindGitRepos(configs.Roots, configs.DirIgnore)
+ registry := vcs.NewRegistry(
+ vcsgit.New(),
+ vcsjj.New(),
+ )
+
+ repoPaths, warnings := scan.FindRepos(configs.Roots, configs.DirIgnore)
reportWarnings = append(reportWarnings, warnings...)
- repoStates := make([]report.RepoState, 0, len(gitReposPaths))
+ repoStates := make([]report.RepoState, 0, len(repoPaths))
- allRepoStates, warnings := gitx.GetGitRepoStatesConcurrent(gitReposPaths, configs.MaxWorkers)
+ allRepoStates, warnings := vcs.GetRepoStatesConcurrent(repoPaths, registry, configs.MaxWorkers)
reportWarnings = append(reportWarnings, warnings...)
// filter repo states based on config OnlyFilter
diff --git a/internal/vcs/concurrent.go b/internal/vcs/concurrent.go
new file mode 100644
index 0000000..2f3a8c5
--- /dev/null
+++ b/internal/vcs/concurrent.go
@@ -0,0 +1,82 @@
+package vcs
+
+import (
+ "fmt"
+ "sort"
+ "sync"
+
+ "github.com/mabd-dev/reposcan/pkg/report"
+)
+
+type repoResult struct {
+ State report.RepoState
+ Warnings []string
+ Include bool
+}
+
+func GetRepoStatesConcurrent(
+ repos []RepoInfo,
+ registry *Registry,
+ maxWorkers int,
+) ([]report.RepoState, []string) {
+ if maxWorkers <= 0 {
+ maxWorkers = 1
+ }
+
+ states := []report.RepoState{}
+ warnings := []string{}
+
+ jobs := make(chan RepoInfo, maxWorkers*2)
+ results := make(chan repoResult, maxWorkers*2)
+
+ var wg sync.WaitGroup
+
+ wg.Add(maxWorkers)
+ for i := 0; i < maxWorkers; i++ {
+ go func() {
+ defer wg.Done()
+
+ for repo := range jobs {
+ provider, ok := registry.Get(repo.Type)
+ if !ok {
+ results <- repoResult{
+ Warnings: []string{
+ fmt.Sprintf("No provider registered for vcs type=%q, path=%s", repo.Type, repo.Path),
+ },
+ }
+ continue
+ }
+
+ state, repoWarnings := provider.CheckRepoState(repo.Path)
+ results <- repoResult{
+ State: state,
+ Warnings: repoWarnings,
+ Include: true,
+ }
+ }
+ }()
+ }
+
+ go func() {
+ for _, repo := range repos {
+ jobs <- repo
+ }
+ close(jobs)
+ }()
+
+ go func() {
+ wg.Wait()
+ close(results)
+ }()
+
+ for result := range results {
+ if result.Include {
+ states = append(states, result.State)
+ }
+ warnings = append(warnings, result.Warnings...)
+ }
+
+ sort.Slice(states, func(i, j int) bool { return states[i].Path < states[j].Path })
+
+ return states, warnings
+}
diff --git a/internal/vcs/concurrent_test.go b/internal/vcs/concurrent_test.go
new file mode 100644
index 0000000..a4464b4
--- /dev/null
+++ b/internal/vcs/concurrent_test.go
@@ -0,0 +1,96 @@
+package vcs
+
+import (
+ "reflect"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/mabd-dev/reposcan/pkg/report"
+)
+
+type stubProvider struct {
+ repoType Type
+ states map[string]report.RepoState
+ warnings map[string][]string
+}
+
+func (p stubProvider) Type() Type {
+ return p.repoType
+}
+
+func (p stubProvider) CheckRepoState(path string) (report.RepoState, []string) {
+ state := p.states[path]
+ state.Path = path
+ state.VCSType = string(p.repoType)
+
+ return state, p.warnings[path]
+}
+
+func TestGetRepoStatesConcurrent_SortsResultsAndPreservesWarnings(t *testing.T) {
+ repos := []RepoInfo{
+ {Path: "/tmp/z-jj", Type: TypeJJ},
+ {Path: "/tmp/a-git", Type: TypeGit},
+ {Path: "/tmp/m-jj", Type: TypeJJ},
+ }
+
+ registry := NewRegistry(
+ stubProvider{
+ repoType: TypeGit,
+ states: map[string]report.RepoState{
+ "/tmp/a-git": {Repo: "a"},
+ },
+ warnings: map[string][]string{
+ "/tmp/a-git": {"git warning"},
+ },
+ },
+ stubProvider{
+ repoType: TypeJJ,
+ states: map[string]report.RepoState{
+ "/tmp/z-jj": {Repo: "z"},
+ "/tmp/m-jj": {Repo: "m"},
+ },
+ warnings: map[string][]string{
+ "/tmp/z-jj": {"jj warning"},
+ },
+ },
+ )
+
+ states, warnings := GetRepoStatesConcurrent(repos, registry, 2)
+
+ gotPaths := []string{}
+ for _, state := range states {
+ gotPaths = append(gotPaths, state.Path)
+ }
+
+ wantPaths := []string{"/tmp/a-git", "/tmp/m-jj", "/tmp/z-jj"}
+ if !reflect.DeepEqual(gotPaths, wantPaths) {
+ t.Fatalf("expected sorted paths %v, got %v", wantPaths, gotPaths)
+ }
+
+ slices.Sort(warnings)
+ wantWarnings := []string{"git warning", "jj warning"}
+ slices.Sort(wantWarnings)
+
+ if !reflect.DeepEqual(warnings, wantWarnings) {
+ t.Fatalf("expected warnings %v, got %v", wantWarnings, warnings)
+ }
+}
+
+func TestGetRepoStatesConcurrent_WarnsWhenProviderIsMissing(t *testing.T) {
+ repos := []RepoInfo{{Path: "/tmp/repo", Type: TypeJJ}}
+
+ states, warnings := GetRepoStatesConcurrent(repos, NewRegistry(), 1)
+
+ if len(states) != 0 {
+ t.Fatalf("expected no states, got %v", states)
+ }
+
+ if len(warnings) != 1 {
+ t.Fatalf("expected 1 warning, got %d: %v", len(warnings), warnings)
+ }
+
+ if !strings.Contains(warnings[0], `No provider registered for vcs type="jj"`) {
+ t.Fatalf("unexpected warning: %v", warnings)
+ }
+}
diff --git a/internal/gitx/gitFunctions.go b/internal/vcs/git/commands.go
similarity index 92%
rename from internal/gitx/gitFunctions.go
rename to internal/vcs/git/commands.go
index 8aa3b3a..e914036 100644
--- a/internal/gitx/gitFunctions.go
+++ b/internal/vcs/git/commands.go
@@ -1,4 +1,4 @@
-package gitx
+package git
import (
"bytes"
@@ -133,6 +133,20 @@ func GetUpstreamStatusForAllRemotes(
}, nil
}
+func GetOutgoingCommitsForRemote(path string, remote string, currentBranch string) ([]string, error) {
+ remoteBranchRef := remote + "/" + currentBranch
+
+ str, err := RunGitCommand(path, "log", "--format=%h %s", remoteBranchRef+"..HEAD")
+ if err != nil {
+ return []string{}, err
+ }
+
+ commits := strings.Split(strings.TrimRight(str, "\n"), "\n")
+ commits = removeEmptyStrings(commits)
+
+ return commits, nil
+}
+
// GetRepoName tries to extract the repository name from its remote URL,
// falling back to the first remote name or the local folder name if needed.
func GetRepoName(repoPath string) (string, error) {
diff --git a/internal/vcs/git/git.go b/internal/vcs/git/git.go
new file mode 100644
index 0000000..57e0217
--- /dev/null
+++ b/internal/vcs/git/git.go
@@ -0,0 +1,23 @@
+package git
+
+import (
+ "github.com/mabd-dev/reposcan/internal/vcs"
+ "github.com/mabd-dev/reposcan/pkg/report"
+)
+
+type Provider struct{}
+
+func New() *Provider {
+ return &Provider{}
+}
+
+func (p *Provider) Type() vcs.Type {
+ return vcs.TypeGit
+}
+
+func (p *Provider) CheckRepoState(path string) (report.RepoState, []string) {
+ state, warnings := CheckRepoState(path)
+ state.VCSType = string(vcs.TypeGit)
+
+ return state, warnings
+}
diff --git a/internal/vcs/git/git_test.go b/internal/vcs/git/git_test.go
new file mode 100644
index 0000000..0063ab1
--- /dev/null
+++ b/internal/vcs/git/git_test.go
@@ -0,0 +1,118 @@
+package git
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/mabd-dev/reposcan/internal/vcs"
+)
+
+func TestProviderCheckRepoStateSetsVCSType(t *testing.T) {
+ if _, err := exec.LookPath("git"); err != nil {
+ t.Skip("git binary not available")
+ }
+
+ root := t.TempDir()
+ repoPath := filepath.Join(root, "repo")
+
+ if err := exec.Command("git", "init", repoPath).Run(); err != nil {
+ t.Fatalf("git init: %v", err)
+ }
+
+ state, _ := New().CheckRepoState(repoPath)
+
+ if state.Path != repoPath {
+ t.Fatalf("expected path %s, got %s", repoPath, state.Path)
+ }
+
+ if state.Repo != "repo" {
+ t.Fatalf("expected repo name %q, got %q", "repo", state.Repo)
+ }
+
+ if state.ID == "" {
+ t.Fatalf("expected non-empty repo id")
+ }
+
+ if state.VCSType != string(vcs.TypeGit) {
+ t.Fatalf("expected vcs type %q, got %q", vcs.TypeGit, state.VCSType)
+ }
+}
+
+func TestProviderCheckRepoStateCollectsOutgoingCommits(t *testing.T) {
+ if _, err := exec.LookPath("git"); err != nil {
+ t.Skip("git binary not available")
+ }
+
+ root := t.TempDir()
+ remotePath := filepath.Join(root, "remote.git")
+ repoPath := filepath.Join(root, "repo")
+
+ if err := exec.Command("git", "init", "--bare", remotePath).Run(); err != nil {
+ t.Fatalf("git init --bare: %v", err)
+ }
+ if err := exec.Command("git", "init", repoPath).Run(); err != nil {
+ t.Fatalf("git init: %v", err)
+ }
+ if err := exec.Command("git", "-C", repoPath, "config", "user.name", "test").Run(); err != nil {
+ t.Fatalf("git config user.name: %v", err)
+ }
+ if err := exec.Command("git", "-C", repoPath, "config", "user.email", "test@example.com").Run(); err != nil {
+ t.Fatalf("git config user.email: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(repoPath, "README.md"), []byte("one\n"), 0o644); err != nil {
+ t.Fatalf("write README: %v", err)
+ }
+ if err := exec.Command("git", "-C", repoPath, "add", "README.md").Run(); err != nil {
+ t.Fatalf("git add initial: %v", err)
+ }
+ if err := exec.Command("git", "-C", repoPath, "commit", "-m", "initial").Run(); err != nil {
+ t.Fatalf("git commit initial: %v", err)
+ }
+ if err := exec.Command("git", "-C", repoPath, "branch", "-M", "main").Run(); err != nil {
+ t.Fatalf("git branch -M main: %v", err)
+ }
+ if err := exec.Command("git", "-C", repoPath, "remote", "add", "origin", remotePath).Run(); err != nil {
+ t.Fatalf("git remote add origin: %v", err)
+ }
+ if err := exec.Command("git", "-C", repoPath, "push", "-u", "origin", "main").Run(); err != nil {
+ t.Fatalf("git push origin main: %v", err)
+ }
+
+ if err := os.WriteFile(filepath.Join(repoPath, "README.md"), []byte("one\ntwo\n"), 0o644); err != nil {
+ t.Fatalf("update README: %v", err)
+ }
+ if err := exec.Command("git", "-C", repoPath, "add", "README.md").Run(); err != nil {
+ t.Fatalf("git add outgoing: %v", err)
+ }
+ if err := exec.Command("git", "-C", repoPath, "commit", "-m", "local change").Run(); err != nil {
+ t.Fatalf("git commit outgoing: %v", err)
+ }
+
+ state, warnings := New().CheckRepoState(repoPath)
+ if len(warnings) != 0 {
+ t.Fatalf("unexpected warnings: %v", warnings)
+ }
+
+ if len(state.RemoteStatus) != 1 {
+ t.Fatalf("expected one remote status, got %d: %v", len(state.RemoteStatus), state.RemoteStatus)
+ }
+
+ if state.RemoteStatus[0].Remote != "origin" {
+ t.Fatalf("expected origin remote, got %q", state.RemoteStatus[0].Remote)
+ }
+
+ if state.RemoteStatus[0].Ahead != 1 {
+ t.Fatalf("expected ahead count 1, got %d", state.RemoteStatus[0].Ahead)
+ }
+
+ if len(state.RemoteStatus[0].OutgoingCommits) != 1 {
+ t.Fatalf("expected 1 outgoing commit, got %d: %v", len(state.RemoteStatus[0].OutgoingCommits), state.RemoteStatus[0].OutgoingCommits)
+ }
+
+ if !strings.Contains(state.RemoteStatus[0].OutgoingCommits[0], "local change") {
+ t.Fatalf("expected outgoing commit summary to include commit message, got %v", state.RemoteStatus[0].OutgoingCommits)
+ }
+}
diff --git a/internal/gitx/gitx.go b/internal/vcs/git/state.go
similarity index 81%
rename from internal/gitx/gitx.go
rename to internal/vcs/git/state.go
index 9eac35d..f4610ea 100644
--- a/internal/gitx/gitx.go
+++ b/internal/vcs/git/state.go
@@ -1,10 +1,11 @@
-package gitx
+package git
import (
"fmt"
"strings"
"github.com/mabd-dev/reposcan/internal/utils"
+ "github.com/mabd-dev/reposcan/internal/vcs"
"github.com/mabd-dev/reposcan/pkg/report"
)
@@ -45,10 +46,17 @@ func CheckRepoState(path string) (repoState report.RepoState, warnings []string)
Behind: -1,
})
} else {
+ outgoingCommits, err := GetOutgoingCommitsForRemote(path, remote, branch)
+ if err != nil {
+ msg := fmt.Sprintf("Failed to get outgoing commits for remote=%s, path=%s", remote, path)
+ warnings = append(warnings, msg)
+ }
+
remoteStatuses = append(remoteStatuses, report.RemoteStatus{
- Remote: remote,
- Ahead: remoteStatus.Ahead,
- Behind: remoteStatus.Behind,
+ Remote: remote,
+ Ahead: remoteStatus.Ahead,
+ Behind: remoteStatus.Behind,
+ OutgoingCommits: outgoingCommits,
})
}
}
@@ -69,6 +77,7 @@ func CheckRepoState(path string) (repoState report.RepoState, warnings []string)
ID: utils.Hash(path),
Path: path,
Repo: repoName,
+ VCSType: string(vcs.TypeGit),
Branch: branch,
UncommitedFiles: uncommitedFiles,
RemoteStatus: remoteStatuses,
diff --git a/internal/vcs/jj/jj.go b/internal/vcs/jj/jj.go
new file mode 100644
index 0000000..ee669ca
--- /dev/null
+++ b/internal/vcs/jj/jj.go
@@ -0,0 +1,343 @@
+package jj
+
+import (
+ "bytes"
+ "fmt"
+ "net/url"
+ "os/exec"
+ "path"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "github.com/mabd-dev/reposcan/internal/utils"
+ "github.com/mabd-dev/reposcan/internal/vcs"
+ "github.com/mabd-dev/reposcan/pkg/report"
+)
+
+type Provider struct {
+ binary string
+}
+
+type trackedBookmark struct {
+ Name string
+ Remote string
+}
+
+func New() *Provider {
+ return &Provider{binary: "jj"}
+}
+
+func (p *Provider) Type() vcs.Type {
+ return vcs.TypeJJ
+}
+
+func (p *Provider) CheckRepoState(path string) (report.RepoState, []string) {
+ state := report.RepoState{
+ ID: utils.Hash(path),
+ Path: path,
+ Repo: filepath.Base(path),
+ Branch: "-",
+ VCSType: string(vcs.TypeJJ),
+ RemoteStatus: []report.RemoteStatus{{
+ Remote: "",
+ Ahead: 0,
+ Behind: 0,
+ }},
+ }
+
+ if _, err := exec.LookPath(p.binary); err != nil {
+ return state, []string{
+ fmt.Sprintf("Failed to inspect jj repo, path=%s: %v", path, err),
+ }
+ }
+
+ warnings := []string{}
+
+ repoName, err := p.getRepoName(path)
+ if err != nil {
+ warnings = append(warnings, "Failed to get jj repo name, path="+path)
+ } else if strings.TrimSpace(repoName) != "" {
+ state.Repo = repoName
+ }
+
+ branch, err := p.getBranchDisplay(path)
+ if err != nil {
+ warnings = append(warnings, "Failed to get jj branch display, path="+path)
+ } else if strings.TrimSpace(branch) != "" {
+ state.Branch = branch
+ }
+
+ uncommittedFiles, err := p.getUncommittedFiles(path)
+ if err != nil {
+ warnings = append(warnings, "Failed to get jj uncommitted files, path="+path)
+ } else {
+ state.UncommitedFiles = uncommittedFiles
+ }
+
+ outgoingCommits, err := p.getOutgoingCommits(path)
+ if err != nil {
+ warnings = append(warnings, "Failed to get jj outgoing commits, path="+path)
+ } else {
+ state.RemoteStatus[0].Ahead = len(outgoingCommits)
+ state.RemoteStatus[0].OutgoingCommits = outgoingCommits
+ }
+
+ return state, warnings
+}
+
+func (p *Provider) getRepoName(repoPath string) (string, error) {
+ remoteURL, err := p.getFirstRemoteURL(repoPath)
+ if err != nil {
+ return "", err
+ }
+
+ if remoteURL == "" {
+ return filepath.Base(repoPath), nil
+ }
+
+ if repoName, ok := parseRepoName(remoteURL); ok {
+ return repoName, nil
+ }
+
+ return filepath.Base(repoPath), nil
+}
+
+func (p *Provider) getFirstRemoteURL(repoPath string) (string, error) {
+ output, err := p.run(repoPath, "git", "remote", "list")
+ if err != nil {
+ return "", err
+ }
+
+ for _, line := range strings.Split(output, "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.HasPrefix(line, "Done importing changes") {
+ continue
+ }
+
+ parts := strings.Fields(line)
+ if len(parts) >= 2 {
+ return parts[1], nil
+ }
+ }
+
+ return "", nil
+}
+
+func (p *Provider) getBranchDisplay(repoPath string) (string, error) {
+ output, err := p.run(
+ repoPath,
+ "log",
+ "-r",
+ "@",
+ "--no-graph",
+ "-T",
+ `bookmarks.join(",") ++ "|" ++ change_id.short() ++ "\n"`,
+ )
+ if err != nil {
+ return "", err
+ }
+
+ line := strings.TrimSpace(output)
+ if line == "" {
+ return "-", nil
+ }
+
+ parts := strings.SplitN(line, "|", 2)
+ if len(parts) != 2 {
+ return line, nil
+ }
+
+ if strings.TrimSpace(parts[0]) != "" {
+ return strings.TrimSpace(parts[0]), nil
+ }
+
+ if strings.TrimSpace(parts[1]) != "" {
+ return strings.TrimSpace(parts[1]), nil
+ }
+
+ return "-", nil
+}
+
+func (p *Provider) getUncommittedFiles(repoPath string) ([]string, error) {
+ output, err := p.run(repoPath, "diff", "--summary")
+ if err != nil {
+ return nil, err
+ }
+
+ files := []string{}
+ for _, line := range strings.Split(strings.TrimRight(output, "\n"), "\n") {
+ line = strings.TrimSpace(line)
+ if line != "" {
+ files = append(files, line)
+ }
+ }
+
+ return files, nil
+}
+
+func (p *Provider) getOutgoingCommits(repoPath string) ([]string, error) {
+ trackedBookmarks, err := p.getTrackedBookmarks(repoPath)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(trackedBookmarks) == 0 {
+ return []string{}, nil
+ }
+
+ revset := buildTrackedOutgoingRevset(trackedBookmarks)
+ output, err := p.run(
+ repoPath,
+ "log",
+ "-r",
+ revset,
+ "--no-graph",
+ "-T",
+ `commit_id.short() ++ "|" ++ description.first_line() ++ "\n"`,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ commits := []string{}
+ seen := map[string]struct{}{}
+
+ for _, line := range strings.Split(strings.TrimRight(output, "\n"), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+
+ parts := strings.SplitN(line, "|", 2)
+ commitID := strings.TrimSpace(parts[0])
+ if commitID == "" {
+ continue
+ }
+ if _, ok := seen[commitID]; ok {
+ continue
+ }
+ seen[commitID] = struct{}{}
+
+ description := ""
+ if len(parts) == 2 {
+ description = strings.TrimSpace(parts[1])
+ }
+ if description == "" {
+ description = "(no description set)"
+ }
+
+ commits = append(commits, commitID+" "+description)
+ }
+
+ return commits, nil
+}
+
+func (p *Provider) getTrackedBookmarks(repoPath string) ([]trackedBookmark, error) {
+ output, err := p.run(
+ repoPath,
+ "bookmark",
+ "list",
+ "--tracked",
+ "-T",
+ `name ++ "|" ++ remote ++ "\n"`,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ bookmarks := []trackedBookmark{}
+ seen := map[string]struct{}{}
+
+ for _, line := range strings.Split(strings.TrimRight(output, "\n"), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+
+ parts := strings.SplitN(line, "|", 2)
+ if len(parts) != 2 {
+ continue
+ }
+
+ name := strings.TrimSpace(parts[0])
+ remote := strings.TrimSpace(parts[1])
+ if name == "" || remote == "" {
+ continue
+ }
+
+ key := name + "|" + remote
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+
+ bookmarks = append(bookmarks, trackedBookmark{
+ Name: name,
+ Remote: remote,
+ })
+ }
+
+ return bookmarks, nil
+}
+
+func (p *Provider) run(repoPath string, args ...string) (string, error) {
+ cmd := exec.Command(p.binary, append([]string{"-R", repoPath}, args...)...)
+
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ if err := cmd.Run(); err != nil {
+ if msg := strings.TrimSpace(stderr.String()); msg != "" {
+ return "", fmt.Errorf("%w: %s", err, msg)
+ }
+ return "", err
+ }
+
+ return stdout.String(), nil
+}
+
+func buildTrackedOutgoingRevset(bookmarks []trackedBookmark) string {
+ parts := make([]string, 0, len(bookmarks))
+
+ for _, bookmark := range bookmarks {
+ parts = append(parts, fmt.Sprintf(
+ `(remote_bookmarks("%s", remote="%s")..bookmarks("%s"))`,
+ escapeRevsetString(bookmark.Name),
+ escapeRevsetString(bookmark.Remote),
+ escapeRevsetString(bookmark.Name),
+ ))
+ }
+
+ return strings.Join(parts, " | ")
+}
+
+func escapeRevsetString(s string) string {
+ s = strings.ReplaceAll(s, `\`, `\\`)
+ s = strings.ReplaceAll(s, `"`, `\"`)
+
+ return s
+}
+
+func parseRepoName(remote string) (string, bool) {
+ if strings.Contains(remote, ":") && strings.Contains(remote, "@") && !strings.Contains(remote, "://") {
+ parts := strings.SplitN(remote, ":", 2)
+ if len(parts) == 2 {
+ remote = "ssh://" + parts[0] + "/" + parts[1]
+ }
+ }
+
+ if u, err := url.Parse(remote); err == nil && u.Path != "" {
+ base := path.Base(u.Path)
+ base = strings.TrimSuffix(base, ".git")
+ return base, true
+ }
+
+ re := regexp.MustCompile(`([^/\\]+?)(?:\.git)?[/\\]?$`)
+ if match := re.FindStringSubmatch(remote); len(match) > 1 {
+ return match[1], true
+ }
+
+ return "", false
+}
diff --git a/internal/vcs/jj/jj_test.go b/internal/vcs/jj/jj_test.go
new file mode 100644
index 0000000..f6aa50f
--- /dev/null
+++ b/internal/vcs/jj/jj_test.go
@@ -0,0 +1,233 @@
+package jj
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/mabd-dev/reposcan/internal/vcs"
+)
+
+func initJJRepo(t *testing.T, root string, name string) string {
+ t.Helper()
+
+ repoPath := filepath.Join(root, name)
+ if err := exec.Command("jj", "git", "init", repoPath).Run(); err != nil {
+ t.Fatalf("jj git init: %v", err)
+ }
+
+ return repoPath
+}
+
+func initTrackedJJRepo(t *testing.T) string {
+ t.Helper()
+
+ root := t.TempDir()
+ remotePath := filepath.Join(root, "remote.git")
+ seedPath := filepath.Join(root, "seed")
+ workPath := filepath.Join(root, "work")
+
+ if err := exec.Command("git", "init", "--bare", remotePath).Run(); err != nil {
+ t.Fatalf("git init --bare: %v", err)
+ }
+ if err := exec.Command("git", "clone", remotePath, seedPath).Run(); err != nil {
+ t.Fatalf("git clone: %v", err)
+ }
+ if err := exec.Command("git", "-C", seedPath, "config", "user.name", "test").Run(); err != nil {
+ t.Fatalf("git config user.name: %v", err)
+ }
+ if err := exec.Command("git", "-C", seedPath, "config", "user.email", "test@example.com").Run(); err != nil {
+ t.Fatalf("git config user.email: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(seedPath, "README.md"), []byte("one\n"), 0o644); err != nil {
+ t.Fatalf("write seed README: %v", err)
+ }
+ if err := exec.Command("git", "-C", seedPath, "add", "README.md").Run(); err != nil {
+ t.Fatalf("git add: %v", err)
+ }
+ if err := exec.Command("git", "-C", seedPath, "commit", "-m", "initial").Run(); err != nil {
+ t.Fatalf("git commit: %v", err)
+ }
+ if err := exec.Command("git", "-C", seedPath, "branch", "-M", "main").Run(); err != nil {
+ t.Fatalf("git branch -M main: %v", err)
+ }
+ if err := exec.Command("git", "-C", seedPath, "push", "origin", "main").Run(); err != nil {
+ t.Fatalf("git push origin main: %v", err)
+ }
+ if err := exec.Command("jj", "git", "clone", remotePath, workPath).Run(); err != nil {
+ t.Fatalf("jj git clone: %v", err)
+ }
+
+ return workPath
+}
+
+func TestProviderCheckRepoStateHandlesMissingRemotesAndBookmarks(t *testing.T) {
+ if _, err := exec.LookPath("jj"); err != nil {
+ t.Skip("jj binary not available")
+ }
+
+ root := t.TempDir()
+ repoPath := initJJRepo(t, root, "repo")
+
+ state, warnings := New().CheckRepoState(repoPath)
+
+ if len(warnings) != 0 {
+ t.Fatalf("unexpected warnings: %v", warnings)
+ }
+
+ if state.Path != repoPath {
+ t.Fatalf("expected path %s, got %s", repoPath, state.Path)
+ }
+
+ if state.Repo != "repo" {
+ t.Fatalf("expected repo name %q, got %q", "repo", state.Repo)
+ }
+
+ if state.ID == "" {
+ t.Fatalf("expected non-empty repo id")
+ }
+
+ if state.VCSType != string(vcs.TypeJJ) {
+ t.Fatalf("expected vcs type %q, got %q", vcs.TypeJJ, state.VCSType)
+ }
+
+ if strings.TrimSpace(state.Branch) == "" || state.Branch == "-" {
+ t.Fatalf("expected branch display to fall back to a change id, got %q", state.Branch)
+ }
+
+ if len(state.RemoteStatus) != 1 {
+ t.Fatalf("expected one jj remote status entry, got %d: %v", len(state.RemoteStatus), state.RemoteStatus)
+ }
+
+ if state.RemoteStatus[0].Ahead != 0 || state.RemoteStatus[0].Behind != 0 {
+ t.Fatalf(
+ "expected jj ahead/behind defaults to be 0/0, got %d/%d",
+ state.RemoteStatus[0].Ahead,
+ state.RemoteStatus[0].Behind,
+ )
+ }
+
+ if len(state.RemoteStatus[0].OutgoingCommits) != 0 {
+ t.Fatalf("expected no outgoing commits, got %v", state.RemoteStatus[0].OutgoingCommits)
+ }
+}
+
+func TestProviderCheckRepoStateCollectsUncommittedFiles(t *testing.T) {
+ if _, err := exec.LookPath("jj"); err != nil {
+ t.Skip("jj binary not available")
+ }
+
+ root := t.TempDir()
+ repoPath := initJJRepo(t, root, "dirty")
+
+ filePath := filepath.Join(repoPath, "hello.txt")
+ if err := os.WriteFile(filePath, []byte("hello\n"), 0o644); err != nil {
+ t.Fatalf("write dirty file: %v", err)
+ }
+
+ state, warnings := New().CheckRepoState(repoPath)
+ if len(warnings) != 0 {
+ t.Fatalf("unexpected warnings: %v", warnings)
+ }
+
+ if len(state.UncommitedFiles) != 1 {
+ t.Fatalf("expected 1 uncommitted file, got %d: %v", len(state.UncommitedFiles), state.UncommitedFiles)
+ }
+
+ if !strings.Contains(state.UncommitedFiles[0], "hello.txt") {
+ t.Fatalf("expected summary to mention hello.txt, got %v", state.UncommitedFiles)
+ }
+}
+
+func TestProviderCheckRepoStateCollectsTrackedBookmarkOutgoingCommits(t *testing.T) {
+ if _, err := exec.LookPath("jj"); err != nil {
+ t.Skip("jj binary not available")
+ }
+ if _, err := exec.LookPath("git"); err != nil {
+ t.Skip("git binary not available")
+ }
+
+ repoPath := initTrackedJJRepo(t)
+
+ filePath := filepath.Join(repoPath, "README.md")
+ f, err := os.OpenFile(filePath, os.O_APPEND|os.O_WRONLY, 0o644)
+ if err != nil {
+ t.Fatalf("open repo file: %v", err)
+ }
+ if _, err := f.WriteString("two\n"); err != nil {
+ t.Fatalf("append change 1: %v", err)
+ }
+ _ = f.Close()
+
+ if err := exec.Command("jj", "-R", repoPath, "describe", "-m", "change 1").Run(); err != nil {
+ t.Fatalf("jj describe change 1: %v", err)
+ }
+ if err := exec.Command("jj", "-R", repoPath, "bookmark", "move", "main", "-t", "@").Run(); err != nil {
+ t.Fatalf("jj bookmark move main: %v", err)
+ }
+ if err := exec.Command("jj", "-R", repoPath, "new").Run(); err != nil {
+ t.Fatalf("jj new: %v", err)
+ }
+
+ f, err = os.OpenFile(filePath, os.O_APPEND|os.O_WRONLY, 0o644)
+ if err != nil {
+ t.Fatalf("open repo file for change 2: %v", err)
+ }
+ if _, err := f.WriteString("three\n"); err != nil {
+ t.Fatalf("append change 2: %v", err)
+ }
+ _ = f.Close()
+
+ if err := exec.Command("jj", "-R", repoPath, "describe", "-m", "change 2").Run(); err != nil {
+ t.Fatalf("jj describe change 2: %v", err)
+ }
+
+ state, warnings := New().CheckRepoState(repoPath)
+ if len(warnings) != 0 {
+ t.Fatalf("unexpected warnings: %v", warnings)
+ }
+
+ if len(state.RemoteStatus) != 1 {
+ t.Fatalf("expected one jj remote status entry, got %d: %v", len(state.RemoteStatus), state.RemoteStatus)
+ }
+
+ if state.RemoteStatus[0].Ahead != 1 {
+ t.Fatalf("expected ahead count 1 from tracked bookmark commits, got %d", state.RemoteStatus[0].Ahead)
+ }
+
+ if len(state.RemoteStatus[0].OutgoingCommits) != 1 {
+ t.Fatalf("expected exactly 1 outgoing commit, got %d: %v", len(state.RemoteStatus[0].OutgoingCommits), state.RemoteStatus[0].OutgoingCommits)
+ }
+
+ if !strings.Contains(state.RemoteStatus[0].OutgoingCommits[0], "change 1") {
+ t.Fatalf("expected outgoing commit list to mention tracked bookmark commit, got %v", state.RemoteStatus[0].OutgoingCommits)
+ }
+
+ if strings.Contains(state.RemoteStatus[0].OutgoingCommits[0], "change 2") {
+ t.Fatalf("did not expect working-copy descendant to be treated as tracked-bookmark outgoing commit, got %v", state.RemoteStatus[0].OutgoingCommits)
+ }
+}
+
+func TestProviderCheckRepoStateWarnsWhenBinaryMissing(t *testing.T) {
+ repoPath := filepath.Join(t.TempDir(), "repo")
+
+ state, warnings := (&Provider{binary: "jj-does-not-exist"}).CheckRepoState(repoPath)
+
+ if state.Path != repoPath {
+ t.Fatalf("expected path %s, got %s", repoPath, state.Path)
+ }
+
+ if state.VCSType != string(vcs.TypeJJ) {
+ t.Fatalf("expected vcs type %q, got %q", vcs.TypeJJ, state.VCSType)
+ }
+
+ if len(warnings) != 1 {
+ t.Fatalf("expected 1 warning, got %d: %v", len(warnings), warnings)
+ }
+
+ if !strings.Contains(warnings[0], "Failed to inspect jj repo") {
+ t.Fatalf("expected missing binary warning, got %v", warnings)
+ }
+}
diff --git a/internal/vcs/provider.go b/internal/vcs/provider.go
new file mode 100644
index 0000000..edd283a
--- /dev/null
+++ b/internal/vcs/provider.go
@@ -0,0 +1,8 @@
+package vcs
+
+import "github.com/mabd-dev/reposcan/pkg/report"
+
+type Provider interface {
+ Type() Type
+ CheckRepoState(path string) (report.RepoState, []string)
+}
diff --git a/internal/vcs/registry.go b/internal/vcs/registry.go
new file mode 100644
index 0000000..0794c37
--- /dev/null
+++ b/internal/vcs/registry.go
@@ -0,0 +1,37 @@
+package vcs
+
+type Registry struct {
+ providers map[Type]Provider
+}
+
+func NewRegistry(providers ...Provider) *Registry {
+ registry := &Registry{
+ providers: map[Type]Provider{},
+ }
+
+ for _, provider := range providers {
+ if provider == nil {
+ continue
+ }
+ registry.Register(provider)
+ }
+
+ return registry
+}
+
+func (r *Registry) Register(provider Provider) {
+ if r.providers == nil {
+ r.providers = map[Type]Provider{}
+ }
+
+ r.providers[provider.Type()] = provider
+}
+
+func (r *Registry) Get(repoType Type) (Provider, bool) {
+ if r == nil {
+ return nil, false
+ }
+
+ provider, ok := r.providers[repoType]
+ return provider, ok
+}
diff --git a/internal/vcs/types.go b/internal/vcs/types.go
new file mode 100644
index 0000000..d88af3d
--- /dev/null
+++ b/internal/vcs/types.go
@@ -0,0 +1,13 @@
+package vcs
+
+type Type string
+
+const (
+ TypeGit Type = "git"
+ TypeJJ Type = "jj"
+)
+
+type RepoInfo struct {
+ Path string
+ Type Type
+}
diff --git a/pkg/report/report.go b/pkg/report/report.go
index 312da27..3c0821e 100644
--- a/pkg/report/report.go
+++ b/pkg/report/report.go
@@ -8,23 +8,25 @@ import (
)
type RemoteStatus struct {
- Remote string `json:"remote"`
- Ahead int `json:"ahead"`
- Behind int `json:"behind"`
+ Remote string `json:"remote"`
+ Ahead int `json:"ahead"`
+ Behind int `json:"behind"`
+ OutgoingCommits []string `json:"outgoingCommits,omitempty"`
}
-// RepoState describes the state of a single Git repository discovered during a scan.
+// RepoState describes the state of a single repository discovered during a scan.
type RepoState struct {
ID string `json:"id"`
Path string `json:"path"`
Repo string `json:"repo"`
+ VCSType string `json:"vcsType"`
Branch string `json:"branch"`
UncommitedFiles []string `json:"uncommitedFiles"`
RemoteStatus []RemoteStatus `json:"remoteStatus"`
}
// ScanReport aggregates the results of scanning one or more directories for
-// Git repositories and summarizing their status.
+// repositories and summarizing their status.
type ScanReport struct {
Version int `json:"version"`
RepoStates []RepoState `json:"repoStates"`
@@ -61,6 +63,23 @@ func (r *RepoState) HaveUnpulledCommits() bool {
return false
}
+func (r *RepoState) OutgoingCommits() []string {
+ commits := []string{}
+ includeRemoteName := len(r.RemoteStatus) > 1
+
+ for _, remoteStatus := range r.RemoteStatus {
+ for _, commit := range remoteStatus.OutgoingCommits {
+ if includeRemoteName && remoteStatus.Remote != "" {
+ commits = append(commits, remoteStatus.Remote+": "+commit)
+ } else {
+ commits = append(commits, commit)
+ }
+ }
+ }
+
+ return commits
+}
+
// DirtyReposCount count all dirty repos based on [IsDirty] function on RepoState struct
func (sc *ScanReport) DirtyReposCount() int {
dirtyRepos := 0