From 0bde8da820f643ce882272771dc0e9c33d2edd7c Mon Sep 17 00:00:00 2001 From: frittlechasm <91785542+frittlechasm@users.noreply.github.com> Date: Fri, 20 Mar 2026 23:02:01 +0530 Subject: [PATCH 1/5] feat(vcs): add read-only jj repository support Introduce a VCS provider layer and generalize repo discovery so reposcan can detect and scan both git and jj repositories. --- internal/gitx/gitx.go | 2 + internal/render/stdout/scanReport.go | 17 +- internal/render/stdout/scanReport_test.go | 26 ++ internal/render/tui/repodetails/view.go | 60 ++-- internal/scan/scan.go | 72 +++-- internal/scan/scan_test.go | 107 +++++-- internal/scanGenerator.go | 16 +- internal/vcs/concurrent.go | 82 ++++++ internal/vcs/concurrent_test.go | 96 ++++++ internal/vcs/git/git.go | 24 ++ internal/vcs/git/git_test.go | 40 +++ internal/vcs/jj/jj.go | 343 ++++++++++++++++++++++ internal/vcs/jj/jj_test.go | 233 +++++++++++++++ internal/vcs/provider.go | 8 + internal/vcs/registry.go | 37 +++ internal/vcs/types.go | 13 + pkg/report/report.go | 6 +- 17 files changed, 1109 insertions(+), 73 deletions(-) create mode 100644 internal/vcs/concurrent.go create mode 100644 internal/vcs/concurrent_test.go create mode 100644 internal/vcs/git/git.go create mode 100644 internal/vcs/git/git_test.go create mode 100644 internal/vcs/jj/jj.go create mode 100644 internal/vcs/jj/jj_test.go create mode 100644 internal/vcs/provider.go create mode 100644 internal/vcs/registry.go create mode 100644 internal/vcs/types.go diff --git a/internal/gitx/gitx.go b/internal/gitx/gitx.go index 9eac35d..adafab3 100644 --- a/internal/gitx/gitx.go +++ b/internal/gitx/gitx.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/mabd-dev/reposcan/internal/utils" + "github.com/mabd-dev/reposcan/internal/vcs" "github.com/mabd-dev/reposcan/pkg/report" ) @@ -69,6 +70,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/render/stdout/scanReport.go b/internal/render/stdout/scanReport.go index 0683a64..c7e9457 100644 --- a/internal/render/stdout/scanReport.go +++ b/internal/render/stdout/scanReport.go @@ -56,15 +56,26 @@ 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 { + if len(rs.UncommitedFiles) == 0 && len(rs.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(rs.OutgoingCommits) > 0 { + fmt.Printf("%s\n", MagBold("Outgoing Commits:")) + for _, commit := range rs.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..cd526ae 100644 --- a/internal/render/stdout/scanReport_test.go +++ b/internal/render/stdout/scanReport_test.go @@ -68,3 +68,29 @@ 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", + OutgoingCommits: []string{"abc123 change 1"}, + RemoteStatus: []report.RemoteStatus{ + {Ahead: 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/repodetails/view.go b/internal/render/tui/repodetails/view.go index 585d858..b32cf9f 100644 --- a/internal/render/tui/repodetails/view.go +++ b/internal/render/tui/repodetails/view.go @@ -18,29 +18,55 @@ 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)) - } + if len(m.repoState.OutgoingCommits) > 0 { + lines = append(lines, style.Render("Outgoing Commits:")) + lines = appendTrimmedList(lines, m.repoState.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(m.repoState.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..0aebd81 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) { +) (repoPaths []vcs.RepoPath, 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 { + repoPaths = append(repoPaths, vcs.RepoPath{ + Path: path, + Type: repoType, + }) return fs.SkipDir } return nil }) } - return removeDuplicates(gitReposPaths), warnings + return removeDuplicateRepoPaths(repoPaths), 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 removeDuplicateRepoPaths(repoPaths []vcs.RepoPath) []vcs.RepoPath { + seen := make(map[string]struct{}, len(repoPaths)) + distinct := make([]vcs.RepoPath, 0, len(repoPaths)) - for _, s := range strs { - if _, ok := seen[s]; !ok { - seen[s] = struct{}{} - distinct = append(distinct, s) + for _, repoPath := range repoPaths { + if _, ok := seen[repoPath.Path]; !ok { + seen[repoPath.Path] = struct{}{} + distinct = append(distinct, repoPath) } } + return distinct } diff --git a/internal/scan/scan_test.go b/internal/scan/scan_test.go index 246a20e..46f9c22 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.RepoPath) 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..f7b7e2c --- /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 []RepoPath, + registry *Registry, + maxWorkers int, +) ([]report.RepoState, []string) { + if maxWorkers <= 0 { + maxWorkers = 1 + } + + states := []report.RepoState{} + warnings := []string{} + + jobs := make(chan RepoPath, 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..c3ab0ea --- /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 := []RepoPath{ + {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 := []RepoPath{{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/vcs/git/git.go b/internal/vcs/git/git.go new file mode 100644 index 0000000..853aa4a --- /dev/null +++ b/internal/vcs/git/git.go @@ -0,0 +1,24 @@ +package git + +import ( + "github.com/mabd-dev/reposcan/internal/gitx" + "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 := gitx.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..473d280 --- /dev/null +++ b/internal/vcs/git/git_test.go @@ -0,0 +1,40 @@ +package git + +import ( + "os/exec" + "path/filepath" + "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) + } +} diff --git a/internal/vcs/jj/jj.go b/internal/vcs/jj/jj.go new file mode 100644 index 0000000..779e6d7 --- /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.OutgoingCommits = outgoingCommits + state.RemoteStatus[0].Ahead = len(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..c60fd64 --- /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.OutgoingCommits) != 0 { + t.Fatalf("expected no outgoing commits, got %v", state.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.OutgoingCommits) != 1 { + t.Fatalf("expected exactly 1 outgoing commit, got %d: %v", len(state.OutgoingCommits), state.OutgoingCommits) + } + + if !strings.Contains(state.OutgoingCommits[0], "change 1") { + t.Fatalf("expected outgoing commit list to mention tracked bookmark commit, got %v", state.OutgoingCommits) + } + + if strings.Contains(state.OutgoingCommits[0], "change 2") { + t.Fatalf("did not expect working-copy descendant to be treated as tracked-bookmark outgoing commit, got %v", state.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..d400d79 --- /dev/null +++ b/internal/vcs/types.go @@ -0,0 +1,13 @@ +package vcs + +type Type string + +const ( + TypeGit Type = "git" + TypeJJ Type = "jj" +) + +type RepoPath struct { + Path string + Type Type +} diff --git a/pkg/report/report.go b/pkg/report/report.go index 312da27..bc897a9 100644 --- a/pkg/report/report.go +++ b/pkg/report/report.go @@ -13,18 +13,20 @@ type RemoteStatus struct { Behind int `json:"behind"` } -// 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"` + OutgoingCommits []string `json:"outgoingCommits"` 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"` From 493d8770b7d486cf1dfc9efa02f33375bab7c290 Mon Sep 17 00:00:00 2001 From: frittlechasm <91785542+frittlechasm@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:04:10 +0530 Subject: [PATCH 2/5] refactor(vcs): rename RepoPath to RepoInfo --- internal/scan/scan.go | 20 ++++++++++---------- internal/scan/scan_test.go | 2 +- internal/vcs/concurrent.go | 4 ++-- internal/vcs/concurrent_test.go | 4 ++-- internal/vcs/types.go | 2 +- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/internal/scan/scan.go b/internal/scan/scan.go index 0aebd81..099191c 100644 --- a/internal/scan/scan.go +++ b/internal/scan/scan.go @@ -18,7 +18,7 @@ import ( func FindRepos( roots []string, dirignore []string, -) (repoPaths []vcs.RepoPath, warnings []string) { +) (repos []vcs.RepoInfo, warnings []string) { matcher := NewIgnoreMatcher(roots, dirignore) visitedDir := map[string]struct{}{} @@ -47,7 +47,7 @@ func FindRepos( visitedDir[path] = struct{}{} if repoType, ok := detectRepoType(path); ok { - repoPaths = append(repoPaths, vcs.RepoPath{ + repos = append(repos, vcs.RepoInfo{ Path: path, Type: repoType, }) @@ -57,7 +57,7 @@ func FindRepos( }) } - return removeDuplicateRepoPaths(repoPaths), warnings + return removeDuplicateRepoInfo(repos), warnings } func detectRepoType(path string) (vcs.Type, bool) { @@ -100,14 +100,14 @@ func isJJRepo(path string) bool { return info.IsDir() } -func removeDuplicateRepoPaths(repoPaths []vcs.RepoPath) []vcs.RepoPath { - seen := make(map[string]struct{}, len(repoPaths)) - distinct := make([]vcs.RepoPath, 0, len(repoPaths)) +func removeDuplicateRepoInfo(repos []vcs.RepoInfo) []vcs.RepoInfo { + seen := make(map[string]struct{}, len(repos)) + distinct := make([]vcs.RepoInfo, 0, len(repos)) - for _, repoPath := range repoPaths { - if _, ok := seen[repoPath.Path]; !ok { - seen[repoPath.Path] = struct{}{} - distinct = append(distinct, repoPath) + for _, repo := range repos { + if _, ok := seen[repo.Path]; !ok { + seen[repo.Path] = struct{}{} + distinct = append(distinct, repo) } } diff --git a/internal/scan/scan_test.go b/internal/scan/scan_test.go index 46f9c22..74ea6fc 100644 --- a/internal/scan/scan_test.go +++ b/internal/scan/scan_test.go @@ -23,7 +23,7 @@ func writeFile(t *testing.T, path string, contents string) { } } -func repoTypeByPath(repos []vcs.RepoPath) map[string]vcs.Type { +func repoTypeByPath(repos []vcs.RepoInfo) map[string]vcs.Type { result := map[string]vcs.Type{} for _, repo := range repos { result[repo.Path] = repo.Type diff --git a/internal/vcs/concurrent.go b/internal/vcs/concurrent.go index f7b7e2c..2f3a8c5 100644 --- a/internal/vcs/concurrent.go +++ b/internal/vcs/concurrent.go @@ -15,7 +15,7 @@ type repoResult struct { } func GetRepoStatesConcurrent( - repos []RepoPath, + repos []RepoInfo, registry *Registry, maxWorkers int, ) ([]report.RepoState, []string) { @@ -26,7 +26,7 @@ func GetRepoStatesConcurrent( states := []report.RepoState{} warnings := []string{} - jobs := make(chan RepoPath, maxWorkers*2) + jobs := make(chan RepoInfo, maxWorkers*2) results := make(chan repoResult, maxWorkers*2) var wg sync.WaitGroup diff --git a/internal/vcs/concurrent_test.go b/internal/vcs/concurrent_test.go index c3ab0ea..a4464b4 100644 --- a/internal/vcs/concurrent_test.go +++ b/internal/vcs/concurrent_test.go @@ -28,7 +28,7 @@ func (p stubProvider) CheckRepoState(path string) (report.RepoState, []string) { } func TestGetRepoStatesConcurrent_SortsResultsAndPreservesWarnings(t *testing.T) { - repos := []RepoPath{ + repos := []RepoInfo{ {Path: "/tmp/z-jj", Type: TypeJJ}, {Path: "/tmp/a-git", Type: TypeGit}, {Path: "/tmp/m-jj", Type: TypeJJ}, @@ -78,7 +78,7 @@ func TestGetRepoStatesConcurrent_SortsResultsAndPreservesWarnings(t *testing.T) } func TestGetRepoStatesConcurrent_WarnsWhenProviderIsMissing(t *testing.T) { - repos := []RepoPath{{Path: "/tmp/repo", Type: TypeJJ}} + repos := []RepoInfo{{Path: "/tmp/repo", Type: TypeJJ}} states, warnings := GetRepoStatesConcurrent(repos, NewRegistry(), 1) diff --git a/internal/vcs/types.go b/internal/vcs/types.go index d400d79..d88af3d 100644 --- a/internal/vcs/types.go +++ b/internal/vcs/types.go @@ -7,7 +7,7 @@ const ( TypeJJ Type = "jj" ) -type RepoPath struct { +type RepoInfo struct { Path string Type Type } From ce2f80b9976d29f8a8733603dfdde8c22b9c2764 Mon Sep 17 00:00:00 2001 From: frittlechasm <91785542+frittlechasm@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:25:46 +0530 Subject: [PATCH 3/5] removed gitxConccurrent.go and update AGENTS.md for the same --- AGENTS.md | 11 +++-- internal/gitx/gitxConcurrent.go | 74 --------------------------------- 2 files changed, 7 insertions(+), 78 deletions(-) delete mode 100644 internal/gitx/gitxConcurrent.go diff --git a/AGENTS.md b/AGENTS.md index 89589d3..842293b 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,10 @@ 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 that adapts `internal/gitx` state checks to the VCS provider interface +- **`internal/vcs/jj`**: Jujutsu provider implementation for detecting and checking jj repositories +- **`internal/gitx`**: Git operations via `exec.Command`. `gitFunctions.go` wraps individual git commands (status, branch, rev-list) - **`internal/render`**: Three render paths: - `stdout`: Plain table (using `charmbracelet/lipgloss`) or JSON output - `file`: Writes JSON reports to disk @@ -75,10 +78,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`) 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 -} From f35655f7353763c22956e91c2df0ab8f31d5eba9 Mon Sep 17 00:00:00 2001 From: frittlechasm <91785542+frittlechasm@users.noreply.github.com> Date: Fri, 1 May 2026 09:51:29 +0530 Subject: [PATCH 4/5] refactor(report): move outgoing commits to remote level --- internal/gitx/gitFunctions.go | 14 ++++ internal/gitx/gitx.go | 13 +++- internal/render/stdout/scanReport.go | 7 +- internal/render/stdout/scanReport_test.go | 9 ++- internal/render/tui/repodetails/view.go | 7 +- internal/vcs/git/git_test.go | 78 +++++++++++++++++++++++ internal/vcs/jj/jj.go | 2 +- internal/vcs/jj/jj_test.go | 16 ++--- pkg/report/report.go | 25 ++++++-- 9 files changed, 144 insertions(+), 27 deletions(-) diff --git a/internal/gitx/gitFunctions.go b/internal/gitx/gitFunctions.go index 8aa3b3a..6625ae8 100644 --- a/internal/gitx/gitFunctions.go +++ b/internal/gitx/gitFunctions.go @@ -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/gitx/gitx.go b/internal/gitx/gitx.go index adafab3..58d9eab 100644 --- a/internal/gitx/gitx.go +++ b/internal/gitx/gitx.go @@ -46,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, }) } } diff --git a/internal/render/stdout/scanReport.go b/internal/render/stdout/scanReport.go index c7e9457..51d1fec 100644 --- a/internal/render/stdout/scanReport.go +++ b/internal/render/stdout/scanReport.go @@ -56,7 +56,8 @@ 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 && len(rs.OutgoingCommits) == 0 { + outgoingCommits := rs.OutgoingCommits() + if len(rs.UncommitedFiles) == 0 && len(outgoingCommits) == 0 { continue } fmt.Printf("\n%s %s\n%s %s\n", @@ -71,9 +72,9 @@ func renderDirtyReposDetails(r report.ScanReport) { } } - if len(rs.OutgoingCommits) > 0 { + if len(outgoingCommits) > 0 { fmt.Printf("%s\n", MagBold("Outgoing Commits:")) - for _, commit := range rs.OutgoingCommits { + 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 cd526ae..b166639 100644 --- a/internal/render/stdout/scanReport_test.go +++ b/internal/render/stdout/scanReport_test.go @@ -75,12 +75,11 @@ func TestRenderScanReportAsTable_PrintsOutgoingCommitDetails(t *testing.T) { GeneratedAt: time.Date(2025, 8, 31, 22, 0, 0, 0, time.UTC), RepoStates: []report.RepoState{ { - Repo: "jj-repo", - Branch: "main", - Path: "/tmp/jj-repo", - OutgoingCommits: []string{"abc123 change 1"}, + Repo: "jj-repo", + Branch: "main", + Path: "/tmp/jj-repo", RemoteStatus: []report.RemoteStatus{ - {Ahead: 1}, + {Ahead: 1, OutgoingCommits: []string{"abc123 change 1"}}, }, }, }, diff --git a/internal/render/tui/repodetails/view.go b/internal/render/tui/repodetails/view.go index b32cf9f..e39241c 100644 --- a/internal/render/tui/repodetails/view.go +++ b/internal/render/tui/repodetails/view.go @@ -27,14 +27,15 @@ func (m *Model) View() string { }) } - if len(m.repoState.OutgoingCommits) > 0 { + outgoingCommits := m.repoState.OutgoingCommits() + if len(outgoingCommits) > 0 { lines = append(lines, style.Render("Outgoing Commits:")) - lines = appendTrimmedList(lines, m.repoState.OutgoingCommits, m.height, func(s string) string { + lines = appendTrimmedList(lines, outgoingCommits, m.height, func(s string) string { return m.theme.Styles.Muted.Render(s) }) } - if len(m.repoState.UncommitedFiles) == 0 && len(m.repoState.OutgoingCommits) == 0 { + 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")) } diff --git a/internal/vcs/git/git_test.go b/internal/vcs/git/git_test.go index 473d280..0063ab1 100644 --- a/internal/vcs/git/git_test.go +++ b/internal/vcs/git/git_test.go @@ -1,8 +1,10 @@ package git import ( + "os" "os/exec" "path/filepath" + "strings" "testing" "github.com/mabd-dev/reposcan/internal/vcs" @@ -38,3 +40,79 @@ func TestProviderCheckRepoStateSetsVCSType(t *testing.T) { 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/vcs/jj/jj.go b/internal/vcs/jj/jj.go index 779e6d7..ee669ca 100644 --- a/internal/vcs/jj/jj.go +++ b/internal/vcs/jj/jj.go @@ -79,8 +79,8 @@ func (p *Provider) CheckRepoState(path string) (report.RepoState, []string) { if err != nil { warnings = append(warnings, "Failed to get jj outgoing commits, path="+path) } else { - state.OutgoingCommits = outgoingCommits state.RemoteStatus[0].Ahead = len(outgoingCommits) + state.RemoteStatus[0].OutgoingCommits = outgoingCommits } return state, warnings diff --git a/internal/vcs/jj/jj_test.go b/internal/vcs/jj/jj_test.go index c60fd64..f6aa50f 100644 --- a/internal/vcs/jj/jj_test.go +++ b/internal/vcs/jj/jj_test.go @@ -109,8 +109,8 @@ func TestProviderCheckRepoStateHandlesMissingRemotesAndBookmarks(t *testing.T) { ) } - if len(state.OutgoingCommits) != 0 { - t.Fatalf("expected no outgoing commits, got %v", state.OutgoingCommits) + if len(state.RemoteStatus[0].OutgoingCommits) != 0 { + t.Fatalf("expected no outgoing commits, got %v", state.RemoteStatus[0].OutgoingCommits) } } @@ -197,16 +197,16 @@ func TestProviderCheckRepoStateCollectsTrackedBookmarkOutgoingCommits(t *testing t.Fatalf("expected ahead count 1 from tracked bookmark commits, got %d", state.RemoteStatus[0].Ahead) } - if len(state.OutgoingCommits) != 1 { - t.Fatalf("expected exactly 1 outgoing commit, got %d: %v", len(state.OutgoingCommits), state.OutgoingCommits) + 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.OutgoingCommits[0], "change 1") { - t.Fatalf("expected outgoing commit list to mention tracked bookmark commit, got %v", state.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.OutgoingCommits[0], "change 2") { - t.Fatalf("did not expect working-copy descendant to be treated as tracked-bookmark outgoing commit, got %v", state.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) } } diff --git a/pkg/report/report.go b/pkg/report/report.go index bc897a9..3c0821e 100644 --- a/pkg/report/report.go +++ b/pkg/report/report.go @@ -8,9 +8,10 @@ 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 repository discovered during a scan. @@ -21,7 +22,6 @@ type RepoState struct { VCSType string `json:"vcsType"` Branch string `json:"branch"` UncommitedFiles []string `json:"uncommitedFiles"` - OutgoingCommits []string `json:"outgoingCommits"` RemoteStatus []RemoteStatus `json:"remoteStatus"` } @@ -63,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 From 420736b1f57b42b098369d42a2216b8529d8633d Mon Sep 17 00:00:00 2001 From: frittlechasm <91785542+frittlechasm@users.noreply.github.com> Date: Fri, 1 May 2026 12:38:21 +0530 Subject: [PATCH 5/5] refactor(git): move git operations into vcs/git provider --- AGENTS.md | 5 ++--- internal/render/tui/msgGitFunctions.go | 10 +++++----- internal/{gitx/gitFunctions.go => vcs/git/commands.go} | 2 +- internal/vcs/git/git.go | 3 +-- internal/{gitx/gitx.go => vcs/git/state.go} | 2 +- 5 files changed, 10 insertions(+), 12 deletions(-) rename internal/{gitx/gitFunctions.go => vcs/git/commands.go} (99%) rename internal/{gitx/gitx.go => vcs/git/state.go} (99%) diff --git a/AGENTS.md b/AGENTS.md index 842293b..5c84669 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,9 +61,8 @@ go run . -o json - **`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/vcs`**: VCS provider registry, repository metadata, and worker pool for parallel repo state checking across supported VCS types -- **`internal/vcs/git`**: Git provider implementation that adapts `internal/gitx` state checks to the VCS provider interface +- **`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/gitx`**: Git operations via `exec.Command`. `gitFunctions.go` wraps individual git commands (status, branch, rev-list) - **`internal/render`**: Three render paths: - `stdout`: Plain table (using `charmbracelet/lipgloss`) or JSON output - `file`: Writes JSON reports to disk @@ -109,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/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/gitx/gitFunctions.go b/internal/vcs/git/commands.go similarity index 99% rename from internal/gitx/gitFunctions.go rename to internal/vcs/git/commands.go index 6625ae8..e914036 100644 --- a/internal/gitx/gitFunctions.go +++ b/internal/vcs/git/commands.go @@ -1,4 +1,4 @@ -package gitx +package git import ( "bytes" diff --git a/internal/vcs/git/git.go b/internal/vcs/git/git.go index 853aa4a..57e0217 100644 --- a/internal/vcs/git/git.go +++ b/internal/vcs/git/git.go @@ -1,7 +1,6 @@ package git import ( - "github.com/mabd-dev/reposcan/internal/gitx" "github.com/mabd-dev/reposcan/internal/vcs" "github.com/mabd-dev/reposcan/pkg/report" ) @@ -17,7 +16,7 @@ func (p *Provider) Type() vcs.Type { } func (p *Provider) CheckRepoState(path string) (report.RepoState, []string) { - state, warnings := gitx.CheckRepoState(path) + state, warnings := CheckRepoState(path) state.VCSType = string(vcs.TypeGit) return state, warnings diff --git a/internal/gitx/gitx.go b/internal/vcs/git/state.go similarity index 99% rename from internal/gitx/gitx.go rename to internal/vcs/git/state.go index 58d9eab..f4610ea 100644 --- a/internal/gitx/gitx.go +++ b/internal/vcs/git/state.go @@ -1,4 +1,4 @@ -package gitx +package git import ( "fmt"