diff --git a/AGENTS.md b/AGENTS.md index 5c84669..86d4839 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,9 +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/vcs`**: VCS provider registry, repository metadata, and worker pool for parallel repo state checking across supported VCS types +- **`internal/vcs`**: VCS provider registry (`Registry`), `Provider` interface for state checks, `ActionProvider` interface for TUI write operations (fetch/push/pull), 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/vcs/jj`**: Jujutsu provider implementation for detecting and checking jj repositories. `commands.go` contains exported jj command wrappers and helpers; `jj.go` holds the `Provider` struct and `CheckRepoState` logic - **`internal/render`**: Three render paths: - `stdout`: Plain table (using `charmbracelet/lipgloss`) or JSON output - `file`: Writes JSON reports to disk @@ -89,7 +89,7 @@ Built with Bubble Tea (Elm architecture): - **Focused Model Pattern**: Different input modes (table navigation, filter text input, help popup) each implement `focusedModel` interface to handle updates and keybindings - **Update Flow**: Messages route through focused model → update appropriate state → return new model + commands - **View**: Composed vertically: header → body (table + optional filter/details) → footer (keybindings) -- **Git Operations**: TUI can trigger git push/pull/fetch via messages that execute git commands and update state +- **VCS Operations**: TUI dispatches fetch/push/pull through the `vcs.ActionProvider` interface via the `Registry`, making actions VCS-agnostic. Providers that don't implement `ActionProvider` (e.g., jj for push/pull) surface an "unsupported action" alert. Action results and repo refresh are handled through `vcsActionResultMsg` and `vcsRefreshRepoResultMsg` messages ## Important Implementation Notes @@ -117,5 +117,10 @@ Tests use standard Go testing: - Flag parsing tests in `cmd/reposcan/*_test.go` - Scan behavior tests in `internal/scan/scan_test.go` - File render tests in `internal/render/file/file_test.go` +- jj provider tests in `internal/vcs/jj/jj_test.go` (require `jj` and `git` binaries — skipped when unavailable) +- jj scan integration tests in `internal/scanGenerator_jj_test.go` (end-to-end filter and JSON field tests) +- VCS registry tests in `internal/vcs/registry_test.go` (ActionProvider dispatch) +- TUI table/column tests in `internal/render/tui/repostable/ui_test.go` +- Stdout table rendering tests in `internal/render/stdout/scanReport_test.go` When writing tests, prefer table-driven tests for multiple scenarios. diff --git a/README.md b/README.md index 8c05f57..9fb6350 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # RepoScan -`reposcan` is a simple command-line tool written in Go that scans your filesystem for Git repositories and reports their status. +`reposcan` is a simple command-line tool written in Go that scans your filesystem for Git and jj repositories and reports their status. It helps you quickly find: - Repositories with **uncommitted files** @@ -77,6 +77,25 @@ reposcan --help More details on flags and config mapping can be found in [docs/cli-flags.md](docs/cli-flags.md). +## VCS support + +RepoScan currently discovers and reports on: + +- Git repositories with a `.git` directory or worktree-style `.git` file. +- jj repositories with a `.jj` directory. + +Reports include a `vcsType` field so JSON consumers and table users can distinguish Git and jj repositories. For jj repositories, RepoScan collects read-only state: repository name, current bookmark/change display, uncommitted file summaries, outgoing commits for tracked bookmarks, and incoming/unpulled counts based on already-fetched remote bookmark state. + +Current jj limitations: + +- TUI fetch, push, and pull keybindings are not active. +- jj fetch has a command wrapper but is not exposed through TUI actions yet. +- jj push and pull behavior is not enabled until per-operation semantics are defined. +- jj incoming/unpulled detection depends on tracked bookmarks and fetched remote bookmark state. +- jj remote status is simplified into a single synthetic status entry. +- JSON reports do not expose incoming commit details directly. +- TUI details show shared repository status fields, with limited jj-specific metadata. + ## ⚙️ Configuration By default, `reposcan` looks for a config file in: ```sh @@ -122,6 +141,7 @@ Each step overrides the one before it ## 🛣 Roadmap - [x] Scan filesystem for repos - [x] Detect uncommitted files, unpushed commits and unpulled commits +- [x] Detect Git and jj repositories - [x] Stdout Ouput in 3 formats: json, interactive, none - [x] Read user customizable `config.toml` file - [x] Export Report to json file diff --git a/cmd/reposcan/rootCmd.go b/cmd/reposcan/rootCmd.go index 7647ff0..1c2614d 100644 --- a/cmd/reposcan/rootCmd.go +++ b/cmd/reposcan/rootCmd.go @@ -1,7 +1,6 @@ package reposcan import ( - "errors" "fmt" "os" "strings" @@ -167,11 +166,5 @@ func run(configs config.Config) error { } } - for _, repoState := range report.RepoStates { - if len(repoState.UncommitedFiles) > 0 { - return errors.New("") - } - } - return nil } diff --git a/docs/cli-flags.md b/docs/cli-flags.md index d97844f..20dd194 100644 --- a/docs/cli-flags.md +++ b/docs/cli-flags.md @@ -6,7 +6,7 @@ This document explains each CLI flag, its equivalent `config.toml` field, what i - `-r, --root PATH` (repeatable) - Config: `roots = ["/path1", "/path2"]` - - Description: Directories to scan for Git repositories. Repeats to add multiple roots. Defaults to `$HOME` if unset. + - Description: Directories to scan for Git and jj repositories. Repeats to add multiple roots. Defaults to `$HOME` if unset. - Example: - CLI: `reposcan -r ~/Code -r ~/work` - TOML: @@ -32,6 +32,7 @@ This document explains each CLI flag, its equivalent `config.toml` field, what i - `unpushed`: only repos with commits ahead of upstream. - `unpulled`: only repos with commits behind upstream. - `all`: all repos discovered. + - jj note: `unpushed` uses outgoing commits for tracked bookmarks. `unpulled` uses incoming commits inferred from already-fetched remote bookmark state. - Examples: - `reposcan --filter dirty` - `reposcan --filter uncommitted` @@ -45,6 +46,7 @@ This document explains each CLI flag, its equivalent `config.toml` field, what i - `json`: machine-readable JSON object. - `none`: print nothing to stdout. - Example: `reposcan -o json` + - JSON note: repository entries include `vcsType`. jj entries currently expose outgoing commits in `remoteStatus[].outgoingCommits`; incoming commit details are used for the behind count but are not exposed directly. - `--json-output-path DIR` - Config: `output.jsonPath = "/path/to/reports"` @@ -53,10 +55,22 @@ This document explains each CLI flag, its equivalent `config.toml` field, what i - `-w, --max-workers N` - Config: `maxWorkers = 16` - - Description: Concurrency for git state checks when scanning many repos. + - Description: Concurrency for VCS state checks when scanning many repos. - Example: `reposcan -w 16` - `--debug true/false` - Config: `debug = true/false` - Description: Enable/disable logging mode. Log file will be in `~/.config/reposcan/logs/` - Example: `--debug=false` or `--debug` same as `--debug=true` + +## jj support notes + +RepoScan supports read-only jj repository reporting. It discovers repositories with `.jj` directories and reports shared fields such as repo name, path, current bookmark/change display, uncommitted files, and remote status. + +Current jj limitations: + +- TUI fetch, push, and pull keybindings are inactive. +- `jj git fetch` has an internal command wrapper, but fetch is not exposed through TUI actions yet. +- jj push and pull are not enabled until their bookmark and pull semantics are defined. +- jj unpulled detection depends on tracked bookmarks and already-fetched remote bookmark state. +- jj remote status is currently represented as one synthetic status entry rather than remote/bookmark-level entries. diff --git a/internal/render/stdout/constants.go b/internal/render/stdout/constants.go index b06e554..72613e8 100644 --- a/internal/render/stdout/constants.go +++ b/internal/render/stdout/constants.go @@ -6,11 +6,12 @@ import ( const ( RepoW = 24 + VCSW = 5 BranchW = 30 UncommW = 3 AheadW = 3 BehindW = 3 - RemoteStateW = 3 + 3 + 3 + 4 //(uncommited files count + aheadW + behindW + 4 space) + RemoteStateW = 40 ) var ( diff --git a/internal/render/stdout/scanReport_test.go b/internal/render/stdout/scanReport_test.go index b166639..e75762b 100644 --- a/internal/render/stdout/scanReport_test.go +++ b/internal/render/stdout/scanReport_test.go @@ -30,8 +30,8 @@ func sampleReport() report.ScanReport { Version: 1, GeneratedAt: time.Date(2025, 8, 31, 22, 0, 0, 0, time.UTC), RepoStates: []report.RepoState{ - {Repo: "clean", Branch: "main", Path: "/tmp/clean"}, - {Repo: "dirty", Branch: "dev", Path: "/tmp/dirty", UncommitedFiles: []string{"a.txt"}}, + {Repo: "clean", VCSType: "git", Branch: "main", Path: "/tmp/clean"}, + {Repo: "dirty", VCSType: "git", Branch: "dev", Path: "/tmp/dirty", UncommitedFiles: []string{"a.txt"}}, }, Warnings: []string{"test warning"}, } @@ -75,9 +75,10 @@ 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", + Repo: "jj-repo", + VCSType: "jj", + Branch: "main", + Path: "/tmp/jj-repo", RemoteStatus: []report.RemoteStatus{ {Ahead: 1, OutgoingCommits: []string{"abc123 change 1"}}, }, @@ -93,3 +94,44 @@ func TestRenderScanReportAsTable_PrintsOutgoingCommitDetails(t *testing.T) { t.Fatalf("missing outgoing commit details: %s", out) } } + +func TestRenderScanReportAsTable_PrintsVCSAndRemoteStateColumns(t *testing.T) { + reportWithVCSState := report.ScanReport{ + Version: 1, + GeneratedAt: time.Date(2025, 8, 31, 22, 0, 0, 0, time.UTC), + RepoStates: []report.RepoState{ + { + Repo: "git-repo", + VCSType: "git", + Branch: "main", + Path: "/tmp/git-repo", + RemoteStatus: []report.RemoteStatus{ + {Remote: "origin", Ahead: 2, Behind: 1}, + }, + }, + { + Repo: "jj-repo", + VCSType: "jj", + Branch: "@", + Path: "/tmp/jj-repo", + RemoteStatus: []report.RemoteStatus{ + {Remote: "upstream", Ahead: 0, Behind: 3}, + }, + }, + }, + } + + out := captureStdout(t, func() { RenderScanReportAsTable(reportWithVCSState) }) + if !strings.Contains(out, "VCS") { + t.Fatalf("missing VCS header: %s", out) + } + if !strings.Contains(out, "git") || !strings.Contains(out, "jj") { + t.Fatalf("missing VCS values: %s", out) + } + if !strings.Contains(out, "↑2") || !strings.Contains(out, "↓1") || !strings.Contains(out, "↓3") { + t.Fatalf("missing ahead/behind state: %s", out) + } + if !strings.Contains(out, "(upstream)") { + t.Fatalf("missing non-origin remote name: %s", out) + } +} diff --git a/internal/render/stdout/table.go b/internal/render/stdout/table.go index ac9a08d..7db2f01 100644 --- a/internal/render/stdout/table.go +++ b/internal/render/stdout/table.go @@ -10,12 +10,13 @@ import ( // RenderReposTable renders the per-repository rows for a ScanReport as a table. func RenderReposTable(r report.ScanReport) { // Table header - fmt.Printf("%s %s %s\n", + fmt.Printf("%s %s %s %s\n", CyanBold("%-*s", RepoW, "Repo"), CyanBold("%-*s", BranchW, "Branch"), + CyanBold("%-*s", VCSW, "VCS"), CyanBold("%-*s", RemoteStateW, "State"), ) - fmt.Println(strings.Repeat("─", RepoW+1+BranchW+RemoteStateW+1)) + fmt.Println(strings.Repeat("─", RepoW+1+BranchW+1+VCSW+1+RemoteStateW)) for _, rs := range r.RepoStates { renderRepoState(rs) @@ -24,13 +25,15 @@ func RenderReposTable(r report.ScanReport) { func renderRepoState(rs report.RepoState) { repoCell := fmt.Sprintf("%-*s", RepoW, truncateRunes(rs.Repo, RepoW)) + vcsCell := fmt.Sprintf("%-*s", VCSW, truncateRunes(rs.VCSType, VCSW)) branchCell := BlueS("%-*s", BranchW, truncateRunes(rs.Branch, BranchW)) remoteStateStr := getStateColumnStr(rs) - fmt.Printf("%s %s %s\n", + fmt.Printf("%s %s %s %s\n", repoCell, branchCell, + vcsCell, remoteStateStr, ) } @@ -45,21 +48,31 @@ func getStateColumnStr(rs report.RepoState) string { stateStr.WriteString(GrayS("⏳%-*d", UncommW, uc)) } - // if rs.Ahead > 0 { - // stateStr.WriteString(GreenS("↑%-*d", AheadW, rs.Ahead)) - // } else if rs.Ahead < 0 { - // stateStr.WriteString(RedS("%-*s ", AheadW, "x")) - // } else { - // stateStr.WriteString(GrayS("↑%-*d", AheadW, 0)) - // } - // - // if rs.Behind > 0 { - // stateStr.WriteString(GreenS("↓%-*d", BehindW, rs.Behind)) - // } else if rs.Behind < 0 { - // stateStr.WriteString(RedS("%-*s ", BehindW, "x")) - // } else { - // stateStr.WriteString(GrayS("↓%-*d", BehindW, 0)) - // } + for i, remoteStatus := range rs.RemoteStatus { + if i > 0 { + stateStr.WriteString(" | ") + } + + if remoteStatus.Ahead > 0 { + stateStr.WriteString(GreenS("↑%-*d", AheadW, remoteStatus.Ahead)) + } else if remoteStatus.Ahead < 0 { + stateStr.WriteString(RedS("%-*s", AheadW, "x")) + } else { + stateStr.WriteString(GrayS("↑%-*d", AheadW, 0)) + } + + if remoteStatus.Behind > 0 { + stateStr.WriteString(YellowS("↓%-*d", BehindW, remoteStatus.Behind)) + } else if remoteStatus.Behind < 0 { + stateStr.WriteString(RedS("%-*s", BehindW, "x")) + } else { + stateStr.WriteString(GrayS("↓%-*d", BehindW, 0)) + } + + if remoteStatus.Remote != "" && !(len(rs.RemoteStatus) == 1 && remoteStatus.Remote == "origin") { + stateStr.WriteString(GrayS("(%s)", remoteStatus.Remote)) + } + } return stateStr.String() } diff --git a/internal/render/tui/main.go b/internal/render/tui/main.go index 950003b..9f06f0a 100644 --- a/internal/render/tui/main.go +++ b/internal/render/tui/main.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "github.com/mabd-dev/reposcan/internal" "github.com/mabd-dev/reposcan/internal/config" "github.com/mabd-dev/reposcan/internal/logger" "github.com/mabd-dev/reposcan/internal/render/tui/alerts" @@ -67,6 +68,7 @@ func Render( m := Model{ configs: configs, + vcsRegistry: internal.NewVCSRegistry(), reposTable: reposTable, repoDetails: repoDetails, rtHeader: reposTableHeader, diff --git a/internal/render/tui/msgGitFunctions.go b/internal/render/tui/msgGitFunctions.go deleted file mode 100644 index ce61167..0000000 --- a/internal/render/tui/msgGitFunctions.go +++ /dev/null @@ -1,118 +0,0 @@ -package tui - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/mabd-dev/reposcan/internal/vcs/git" - "github.com/mabd-dev/reposcan/pkg/report" -) - -type gitFetchResultMsg struct { - Err string - Output string -} - -func gitFetch(m Model) tea.Cmd { - rs := m.reposTable.GetCurrentRepoState() - if rs == nil { - return nil - } - repoPath := rs.Path - - m.reposBeingUpdated = append(m.reposBeingUpdated, rs.ID) - - return func() tea.Msg { - stdout, err := git.GitFetch(repoPath) - - errMessage := "" - if err != nil { - errMessage = err.Error() - } - - return gitFetchResultMsg{ - Err: errMessage, - Output: stdout, - } - } -} - -type gitPullResultMsg struct { - Err string - Output string -} - -func gitPull(m Model) tea.Cmd { - rs := m.reposTable.GetCurrentRepoState() - if rs == nil { - return nil - } - - repoPath := rs.Path - m.reposBeingUpdated = append(m.reposBeingUpdated, rs.ID) - - return func() tea.Msg { - stdout, err := git.GitPull(repoPath) - - errMessage := "" - if err != nil { - errMessage = err.Error() - } - - return gitPullResultMsg{ - Err: errMessage, - Output: stdout, - } - } -} - -type gitPushResultMsg struct { - Err string - Output string -} - -func gitPush(m Model) tea.Cmd { - rs := m.reposTable.GetCurrentRepoState() - if rs == nil { - return nil - } - - repoPath := rs.Path - - return func() tea.Msg { - stdout, err := git.GitPush(repoPath) - - errMessage := "" - if err != nil { - errMessage = err.Error() - } - - return gitPushResultMsg{ - Err: errMessage, - Output: stdout, - } - } -} - -type gitRefreshRepoResultMsg struct { - newRepoState report.RepoState - index int -} - -func gitRefreshRepo(m Model) tea.Cmd { - index := m.reposTable.Cursor() - - rs := m.reposTable.GetRepoStateAt(index) - if rs == nil { - return nil - } - - repoPath := rs.Path - - return func() tea.Msg { - newRepoState, _ := git.CheckRepoState(repoPath) - - return gitRefreshRepoResultMsg{ - newRepoState: newRepoState, - index: index, - } - } -} diff --git a/internal/render/tui/msgVCSFunctions.go b/internal/render/tui/msgVCSFunctions.go new file mode 100644 index 0000000..278fe6a --- /dev/null +++ b/internal/render/tui/msgVCSFunctions.go @@ -0,0 +1,129 @@ +package tui + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/mabd-dev/reposcan/internal/render/tui/alerts" + "github.com/mabd-dev/reposcan/internal/vcs" + "github.com/mabd-dev/reposcan/pkg/report" +) + +type vcsAction string + +const ( + vcsActionFetch vcsAction = "fetch" + vcsActionPull vcsAction = "pull" + vcsActionPush vcsAction = "push" +) + +type vcsActionResultMsg struct { + Action vcsAction + RepoID string + Index int + Err string + Output string +} + +func (m Model) runVCSAction(action vcsAction) (Model, tea.Cmd) { + rs := m.reposTable.GetCurrentRepoState() + if rs == nil { + return m, nil + } + + actionProvider, ok := m.actionProvider(*rs) + if !ok { + return m, unsupportedVCSActionAlert(action, rs.VCSType) + } + + repoID := rs.ID + repoPath := rs.Path + index := m.reposTable.Cursor() + m.reposBeingUpdated = append(m.reposBeingUpdated, repoID) + + return m, func() tea.Msg { + stdout, err := runAction(actionProvider, action, repoPath) + + errMessage := "" + if err != nil { + errMessage = err.Error() + } + + return vcsActionResultMsg{ + Action: action, + RepoID: repoID, + Index: index, + Err: errMessage, + Output: stdout, + } + } +} + +func (m Model) actionProvider(rs report.RepoState) (vcs.ActionProvider, bool) { + if m.vcsRegistry == nil { + return nil, false + } + + return m.vcsRegistry.GetActionProvider(vcs.Type(rs.VCSType)) +} + +func runAction(provider vcs.ActionProvider, action vcsAction, repoPath string) (string, error) { + switch action { + case vcsActionFetch: + return provider.Fetch(repoPath) + case vcsActionPull: + return provider.Pull(repoPath) + case vcsActionPush: + return provider.Push(repoPath) + default: + return "", fmt.Errorf("unsupported VCS action %q", action) + } +} + +func unsupportedVCSActionAlert(action vcsAction, vcsType string) tea.Cmd { + if vcsType == "" { + vcsType = "unknown" + } + + return func() tea.Msg { + return alerts.AddAlertMsg{ + Msg: alerts.Alert{ + Type: alerts.AlertTypeWarning, + Title: "Unsupported action", + Message: fmt.Sprintf("%s is not supported for %s repositories", action, vcsType), + }, + } + } +} + +type vcsRefreshRepoResultMsg struct { + newRepoState report.RepoState + index int +} + +func refreshRepo(m Model, index int) tea.Cmd { + rs := m.reposTable.GetRepoStateAt(index) + if rs == nil { + return nil + } + + if m.vcsRegistry == nil { + return unsupportedVCSActionAlert("refresh", rs.VCSType) + } + + provider, ok := m.vcsRegistry.Get(vcs.Type(rs.VCSType)) + if !ok { + return unsupportedVCSActionAlert("refresh", rs.VCSType) + } + + repoPath := rs.Path + + return func() tea.Msg { + newRepoState, _ := provider.CheckRepoState(repoPath) + + return vcsRefreshRepoResultMsg{ + newRepoState: newRepoState, + index: index, + } + } +} diff --git a/internal/render/tui/repostable/main.go b/internal/render/tui/repostable/main.go index ed03482..c84b76a 100644 --- a/internal/render/tui/repostable/main.go +++ b/internal/render/tui/repostable/main.go @@ -40,7 +40,7 @@ func New( // if no repos, show an empty placeholder row so the table renders nicely if len(rows) == 0 { - t.SetRows([]table.Row{{"", "", ""}}) + t.SetRows([]table.Row{{"", "", "", ""}}) } t.SetStyles(table.Styles{ diff --git a/internal/render/tui/repostable/ui.go b/internal/render/tui/repostable/ui.go index 2fae325..a3a5f19 100644 --- a/internal/render/tui/repostable/ui.go +++ b/internal/render/tui/repostable/ui.go @@ -11,19 +11,22 @@ import ( ) const ( - RepoW = 30 - BranchW = 30 - RemoteStateW = 40 + RepoW = 37 + VCSW = 6 + BranchW = 20 + RemoteStateW = 37 ) func createColumns(maxWidth int) []table.Column { repoW := maxWidth * RepoW / 100 branchW := maxWidth * BranchW / 100 + vcsW := maxWidth * VCSW / 100 remoteStateW := maxWidth * RemoteStateW / 100 return []table.Column{ {Title: "Repo", Width: repoW}, {Title: "Branch", Width: branchW}, + {Title: "VCS", Width: vcsW}, {Title: "State", Width: remoteStateW}, } } @@ -36,6 +39,7 @@ func createRows(repoStates []report.RepoState, theme theme.Theme) []table.Row { rows = append(rows, table.Row{ rs.Repo, rs.Branch, + rs.VCSType, state, }) } diff --git a/internal/render/tui/repostable/ui_test.go b/internal/render/tui/repostable/ui_test.go new file mode 100644 index 0000000..74b4a23 --- /dev/null +++ b/internal/render/tui/repostable/ui_test.go @@ -0,0 +1,45 @@ +package repostable + +import ( + "testing" + + "github.com/mabd-dev/reposcan/internal/theme" + "github.com/mabd-dev/reposcan/pkg/report" +) + +func TestCreateColumnsIncludesVCSColumn(t *testing.T) { + columns := createColumns(100) + + if len(columns) != 4 { + t.Fatalf("expected 4 columns, got %d: %v", len(columns), columns) + } + if columns[2].Title != "VCS" { + t.Fatalf("expected third column to be VCS, got %q", columns[2].Title) + } +} + +func TestCreateRowsIncludesVCSValue(t *testing.T) { + rows := createRows([]report.RepoState{ + { + Repo: "jj-repo", + VCSType: "jj", + Branch: "@", + RemoteStatus: []report.RemoteStatus{ + {Remote: "origin", Ahead: 1, Behind: 2}, + }, + }, + }, theme.Theme{}) + + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if len(rows[0]) != 4 { + t.Fatalf("expected 4 cells, got %d: %v", len(rows[0]), rows[0]) + } + if rows[0][2] != "jj" { + t.Fatalf("expected VCS cell to be jj, got %q", rows[0][2]) + } + if rows[0][3] != "⏳0 ↑1 ↓2" { + t.Fatalf("unexpected state cell: %q", rows[0][3]) + } +} diff --git a/internal/render/tui/types.go b/internal/render/tui/types.go index f27e1f9..aa4f580 100644 --- a/internal/render/tui/types.go +++ b/internal/render/tui/types.go @@ -12,6 +12,7 @@ import ( "github.com/mabd-dev/reposcan/internal/render/tui/repostable" rth "github.com/mabd-dev/reposcan/internal/render/tui/repostableheader" "github.com/mabd-dev/reposcan/internal/theme" + "github.com/mabd-dev/reposcan/internal/vcs" "github.com/mabd-dev/reposcan/pkg/report" ) @@ -24,6 +25,7 @@ type Model struct { // configs configs config.Config + vcsRegistry *vcs.Registry reposBeingUpdated []string // Models diff --git a/internal/render/tui/update.go b/internal/render/tui/update.go index c4c87e6..754c91c 100644 --- a/internal/render/tui/update.go +++ b/internal/render/tui/update.go @@ -25,14 +25,14 @@ func (m Model) updateReposTable(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { - case "q", "esc", "ctrl+c": - return m, tea.Quit // case "p": - // return m, gitPull(m) + // return m.runVCSAction(vcsActionPull) // case "P": - // return m, gitPush(m) + // return m.runVCSAction(vcsActionPush) // case "f": - // return m, gitFetch(m) + // return m.runVCSAction(vcsActionFetch) + case "q", "esc", "ctrl+c": + return m, tea.Quit case "c": rs := m.reposTable.GetCurrentRepoState() if rs == nil { @@ -126,45 +126,25 @@ func defaultUpdate(m Model, msg tea.Msg) (tea.Model, tea.Cmd) { m.width, m.height = msg.Width, msg.Height return m, nil - case gitPushResultMsg: - return m, gitRefreshRepo(m) - - case gitPullResultMsg: - if len(msg.Err) != 0 { - logger.Warn(msg.Err) - return m, nil - } - - rs := m.reposTable.GetCurrentRepoState() - if rs == nil { - return m, nil - } - - index := getRepoIndex(m.reposBeingUpdated, rs.ID) - if index != -1 { - m.reposBeingUpdated = deleteRepo(m.reposBeingUpdated, index) - } - return m, gitRefreshRepo(m) - - case gitFetchResultMsg: + case vcsActionResultMsg: if len(msg.Err) != 0 { logger.Warn(msg.Err) - return m, nil - } - - rs := m.reposTable.GetCurrentRepoState() - if rs == nil { - return m, nil - } - - index := getRepoIndex(m.reposBeingUpdated, rs.ID) - if index != -1 { - m.reposBeingUpdated = deleteRepo(m.reposBeingUpdated, index) + m.removeRepoBeingUpdated(msg.RepoID) + return m, func() tea.Msg { + return alerts.AddAlertMsg{ + Msg: alerts.Alert{ + Type: alerts.MsgTypeError, + Title: "Action failed", + Message: msg.Err, + }, + } + } } - return m, gitRefreshRepo(m) + m.removeRepoBeingUpdated(msg.RepoID) + return m, refreshRepo(m, msg.Index) - case gitRefreshRepoResultMsg: + case vcsRefreshRepoResultMsg: m.reposTable.UpdateRepoState(msg.index, msg.newRepoState) return m, nil @@ -181,3 +161,10 @@ func defaultUpdate(m Model, msg tea.Msg) (tea.Model, tea.Cmd) { return nil, nil } + +func (m *Model) removeRepoBeingUpdated(repoID string) { + index := getRepoIndex(m.reposBeingUpdated, repoID) + if index != -1 { + m.reposBeingUpdated = deleteRepo(m.reposBeingUpdated, index) + } +} diff --git a/internal/scanGenerator.go b/internal/scanGenerator.go index 6959766..8e266a8 100644 --- a/internal/scanGenerator.go +++ b/internal/scanGenerator.go @@ -16,10 +16,7 @@ func GenerateScanReport( ) report.ScanReport { reportWarnings := []string{} - registry := vcs.NewRegistry( - vcsgit.New(), - vcsjj.New(), - ) + registry := NewVCSRegistry() repoPaths, warnings := scan.FindRepos(configs.Roots, configs.DirIgnore) @@ -45,6 +42,13 @@ func GenerateScanReport( } } +func NewVCSRegistry() *vcs.Registry { + return vcs.NewRegistry( + vcsgit.New(), + vcsjj.New(), + ) +} + // Filter repoState based on config only filter // Returns true if repoState should be in output, false otherwise func filter(f config.OnlyFilter, repoState report.RepoState) bool { diff --git a/internal/scanGenerator_jj_test.go b/internal/scanGenerator_jj_test.go new file mode 100644 index 0000000..d0c09b0 --- /dev/null +++ b/internal/scanGenerator_jj_test.go @@ -0,0 +1,248 @@ +package internal + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/mabd-dev/reposcan/internal/config" + "github.com/mabd-dev/reposcan/internal/vcs" +) + +func requireJJAndGit(t *testing.T) { + t.Helper() + + 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") + } +} + +func initScanJJRepo(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 +} + +type scanTrackedJJRepo struct { + SeedPath string + WorkPath string +} + +func initScanTrackedJJRepo(t *testing.T) scanTrackedJJRepo { + 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 scanTrackedJJRepo{ + SeedPath: seedPath, + WorkPath: workPath, + } +} + +func jjScanConfig(root string, only config.OnlyFilter) config.Config { + cfg := config.Defaults() + cfg.Roots = []string{root} + cfg.DirIgnore = nil + cfg.Only = only + cfg.MaxWorkers = 1 + return cfg +} + +func scanOneJJRepo(t *testing.T, root string, only config.OnlyFilter) { + t.Helper() + + scanReport := GenerateScanReport(jjScanConfig(root, only)) + if len(scanReport.Warnings) != 0 { + t.Fatalf("unexpected warnings: %v", scanReport.Warnings) + } + if len(scanReport.RepoStates) != 1 { + t.Fatalf("expected one repo for only=%s, got %d: %#v", only, len(scanReport.RepoStates), scanReport.RepoStates) + } + if scanReport.RepoStates[0].VCSType != string(vcs.TypeJJ) { + t.Fatalf("expected jj repo, got vcsType=%q", scanReport.RepoStates[0].VCSType) + } +} + +func TestGenerateScanReportFiltersJJUncommittedRepos(t *testing.T) { + requireJJAndGit(t) + + root := t.TempDir() + repoPath := initScanJJRepo(t, root, "dirty") + if err := os.WriteFile(filepath.Join(repoPath, "hello.txt"), []byte("hello\n"), 0o644); err != nil { + t.Fatalf("write dirty file: %v", err) + } + + scanOneJJRepo(t, root, config.OnlyUncommitted) + scanOneJJRepo(t, root, config.OnlyDirty) +} + +func TestGenerateScanReportFiltersJJUnpushedRepos(t *testing.T) { + requireJJAndGit(t) + + repoPath := initScanTrackedJJRepo(t).WorkPath + 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("local\n"); err != nil { + t.Fatalf("append local change: %v", err) + } + _ = f.Close() + + if err := exec.Command("jj", "-R", repoPath, "describe", "-m", "local change").Run(); err != nil { + t.Fatalf("jj describe local change: %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) + } + + scanOneJJRepo(t, repoPath, config.OnlyUnpushed) + scanOneJJRepo(t, repoPath, config.OnlyDirty) +} + +func TestGenerateScanReportFiltersJJUnpulledRepos(t *testing.T) { + requireJJAndGit(t) + + repo := initScanTrackedJJRepo(t) + seedPath := repo.SeedPath + workPath := repo.WorkPath + + workFile := filepath.Join(workPath, "README.md") + f, err := os.OpenFile(workFile, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + t.Fatalf("open work file: %v", err) + } + if _, err := f.WriteString("local\n"); err != nil { + t.Fatalf("append local change: %v", err) + } + _ = f.Close() + + if err := exec.Command("jj", "-R", workPath, "describe", "-m", "local change").Run(); err != nil { + t.Fatalf("jj describe local change: %v", err) + } + if err := exec.Command("jj", "-R", workPath, "bookmark", "move", "main", "-t", "@").Run(); err != nil { + t.Fatalf("jj bookmark move main: %v", err) + } + if err := exec.Command("jj", "-R", workPath, "new").Run(); err != nil { + t.Fatalf("jj new: %v", err) + } + + seedFile := filepath.Join(seedPath, "README.md") + f, err = os.OpenFile(seedFile, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + t.Fatalf("open seed file: %v", err) + } + if _, err := f.WriteString("remote\n"); err != nil { + t.Fatalf("append remote change: %v", err) + } + _ = f.Close() + + if err := exec.Command("git", "-C", seedPath, "add", "README.md").Run(); err != nil { + t.Fatalf("git add remote: %v", err) + } + if err := exec.Command("git", "-C", seedPath, "commit", "-m", "remote change").Run(); err != nil { + t.Fatalf("git commit remote: %v", err) + } + if err := exec.Command("git", "-C", seedPath, "push", "origin", "main").Run(); err != nil { + t.Fatalf("git push remote: %v", err) + } + if err := exec.Command("jj", "-R", workPath, "git", "fetch").Run(); err != nil { + t.Fatalf("jj git fetch: %v", err) + } + + scanOneJJRepo(t, workPath, config.OnlyUnpulled) + scanOneJJRepo(t, workPath, config.OnlyDirty) +} + +func TestGenerateScanReportJSONIncludesJJFields(t *testing.T) { + requireJJAndGit(t) + + repoPath := initScanTrackedJJRepo(t).WorkPath + 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("local\n"); err != nil { + t.Fatalf("append local change: %v", err) + } + _ = f.Close() + + if err := exec.Command("jj", "-R", repoPath, "describe", "-m", "local change").Run(); err != nil { + t.Fatalf("jj describe local change: %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) + } + + scanReport := GenerateScanReport(jjScanConfig(repoPath, config.OnlyAll)) + if len(scanReport.Warnings) != 0 { + t.Fatalf("unexpected warnings: %v", scanReport.Warnings) + } + if len(scanReport.RepoStates) != 1 { + t.Fatalf("expected one repo, got %d: %#v", len(scanReport.RepoStates), scanReport.RepoStates) + } + + data, err := json.Marshal(scanReport) + if err != nil { + t.Fatalf("marshal scan report: %v", err) + } + jsonReport := string(data) + for _, field := range []string{`"vcsType":"jj"`, `"remoteStatus"`, `"outgoingCommits"`} { + if !strings.Contains(jsonReport, field) { + t.Fatalf("expected JSON report to include %s, got %s", field, jsonReport) + } + } +} diff --git a/internal/vcs/git/git.go b/internal/vcs/git/git.go index 57e0217..da53ef0 100644 --- a/internal/vcs/git/git.go +++ b/internal/vcs/git/git.go @@ -21,3 +21,15 @@ func (p *Provider) CheckRepoState(path string) (report.RepoState, []string) { return state, warnings } + +func (p *Provider) Fetch(path string) (string, error) { + return GitFetch(path) +} + +func (p *Provider) Push(path string) (string, error) { + return GitPush(path) +} + +func (p *Provider) Pull(path string) (string, error) { + return GitPull(path) +} diff --git a/internal/vcs/jj/commands.go b/internal/vcs/jj/commands.go new file mode 100644 index 0000000..2c083b4 --- /dev/null +++ b/internal/vcs/jj/commands.go @@ -0,0 +1,573 @@ +package jj + +import ( + "bytes" + "errors" + "fmt" + "net/url" + "os/exec" + "path" + "path/filepath" + "regexp" + "strings" +) + +type trackedBookmark struct { + Name string + Remote string +} + +type bookmarkRemoteStatus struct { + Remote string + OutgoingCommits []string + IncomingCommits []string +} + +var ErrJJActionNotImplemented = errors.New("jj action semantics are not implemented") + +type commandError struct { + Binary string + RepoPath string + Args []string + Stderr string + Err error +} + +func (e commandError) Error() string { + command := append([]string{e.Binary, "-R", e.RepoPath}, e.Args...) + message := fmt.Sprintf("command=%q failed: %v", strings.Join(command, " "), e.Err) + if e.Stderr != "" { + message += ": " + e.Stderr + } + return message +} + +func (e commandError) Unwrap() error { + return e.Err +} + +// JJFetch fetches remote bookmark state using jj's Git interop. +func JJFetch(path string) (string, error) { + return RunJJCommand(path, "git", "fetch") +} + +// JJPush is intentionally not wired into vcs.ActionProvider yet. Define the +// bookmark selection/update semantics before enabling this operation. +func JJPush(path string) (string, error) { + return "", fmt.Errorf("%w: push bookmark behavior needs to be defined", ErrJJActionNotImplemented) +} + +// JJPull is intentionally not wired into vcs.ActionProvider yet. jj does not +// have a direct Git-equivalent pull operation, so the desired behavior needs to +// be defined before enabling this operation. +func JJPull(path string) (string, error) { + return "", fmt.Errorf("%w: pull has no direct Git-equivalent jj operation", ErrJJActionNotImplemented) +} + +func GetRepoName(repoPath string) (string, error) { + return getRepoName("jj", repoPath) +} + +func getRepoName(binary string, repoPath string) (string, error) { + remoteURL, err := getFirstRemoteURL(binary, 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 GetFirstRemoteURL(repoPath string) (string, error) { + return getFirstRemoteURL("jj", repoPath) +} + +func getFirstRemoteURL(binary string, repoPath string) (string, error) { + output, err := runJJCommand(binary, 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 GetBranchDisplay(repoPath string) (string, error) { + return getBranchDisplay("jj", repoPath) +} + +func getBranchDisplay(binary string, repoPath string) (string, error) { + output, err := runJJCommand( + binary, + repoPath, + "log", + "-r", + "latest(ancestors(@) & bookmarks()) | @", + "--no-graph", + "-T", + `bookmarks.join(",") ++ "|" ++ change_id.short() ++ "\n"`, + ) + if err != nil { + return "", err + } + + output = strings.TrimSpace(output) + if output == "" { + return "-", nil + } + + fallback := "-" + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + parts := strings.SplitN(line, "|", 2) + if len(parts) != 2 { + if fallback == "-" { + fallback = line + } + continue + } + + if bookmarkDisplay := cleanBookmarkDisplay(parts[0]); bookmarkDisplay != "" { + return bookmarkDisplay, nil + } + + if changeID := strings.TrimSpace(parts[1]); changeID != "" && fallback == "-" { + fallback = changeID + } + } + + return fallback, nil +} + +func cleanBookmarkDisplay(display string) string { + bookmarks := []string{} + + for _, bookmark := range strings.Split(display, ",") { + bookmark = cleanBookmarkName(bookmark) + if strings.Contains(bookmark, "@") { + continue + } + if bookmark != "" { + bookmarks = append(bookmarks, bookmark) + } + } + + return strings.Join(bookmarks, ",") +} + +func cleanBookmarkName(bookmark string) string { + bookmark = strings.TrimSpace(bookmark) + bookmark = strings.TrimRight(bookmark, "*?") + return strings.TrimSpace(bookmark) +} + +func GetUncommittedFiles(repoPath string) ([]string, error) { + return getUncommittedFiles("jj", repoPath) +} + +func getUncommittedFiles(binary string, repoPath string) ([]string, error) { + output, err := runJJCommand(binary, 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 GetOutgoingCommits(repoPath string) ([]string, error) { + return getOutgoingCommits("jj", repoPath) +} + +func getOutgoingCommits(binary string, repoPath string) ([]string, error) { + trackedBookmarks, err := getTrackedBookmarks(binary, repoPath) + if err != nil { + return nil, err + } + + if len(trackedBookmarks) == 0 { + return []string{}, nil + } + + revset := buildTrackedOutgoingRevset(trackedBookmarks) + output, err := runJJCommand( + binary, + repoPath, + "log", + "-r", + revset, + "--no-graph", + "-T", + `commit_id.short() ++ "|" ++ description.first_line() ++ "\n"`, + ) + if err != nil { + return nil, err + } + + return parseCommitSummaries(output), nil +} + +func GetIncomingCommits(repoPath string) ([]string, error) { + return getIncomingCommits("jj", repoPath) +} + +func getIncomingCommits(binary string, repoPath string) ([]string, error) { + trackedBookmarks, err := getTrackedBookmarks(binary, repoPath) + if err != nil { + return nil, err + } + + if len(trackedBookmarks) == 0 { + return []string{}, nil + } + + revset := buildTrackedIncomingRevset(trackedBookmarks) + output, err := runJJCommand( + binary, + repoPath, + "log", + "-r", + revset, + "--no-graph", + "-T", + `commit_id.short() ++ "|" ++ description.first_line() ++ "\n"`, + ) + if err != nil { + return nil, err + } + + return parseCommitSummaries(output), nil +} + +func getBookmarkRemoteStatuses( + binary string, + repoPath string, + bookmarkNames []string, +) ([]bookmarkRemoteStatus, error) { + if len(bookmarkNames) == 0 { + return []bookmarkRemoteStatus{}, nil + } + + trackedBookmarks, err := getTrackedBookmarks(binary, repoPath) + if err != nil { + return nil, err + } + + statuses := []bookmarkRemoteStatus{} + seenBookmarks := map[string]struct{}{} + for _, bookmarkName := range bookmarkNames { + bookmarkName = cleanBookmarkName(bookmarkName) + if bookmarkName == "" { + continue + } + if _, ok := seenBookmarks[bookmarkName]; ok { + continue + } + seenBookmarks[bookmarkName] = struct{}{} + + remotes := matchingRemotes(trackedBookmarks, bookmarkName) + if len(remotes) == 0 { + remotes, err = getUntrackedRemotesForBookmark(binary, repoPath, bookmarkName) + if err != nil { + return nil, err + } + } + + for _, remote := range remotes { + tb := trackedBookmark{Name: bookmarkName, Remote: remote} + + outgoingCommits, err := getCommitsForRevset( + binary, + repoPath, + buildTrackedOutgoingRevset([]trackedBookmark{tb}), + ) + if err != nil { + return nil, err + } + + incomingCommits, err := getCommitsForRevset( + binary, + repoPath, + buildTrackedIncomingRevset([]trackedBookmark{tb}), + ) + if err != nil { + return nil, err + } + + statuses = append(statuses, bookmarkRemoteStatus{ + Remote: remote, + OutgoingCommits: outgoingCommits, + IncomingCommits: incomingCommits, + }) + } + } + + return statuses, nil +} + +func matchingRemotes(bookmarks []trackedBookmark, name string) []string { + var remotes []string + for _, b := range bookmarks { + if b.Name == name { + remotes = append(remotes, b.Remote) + } + } + return remotes +} + +func getUntrackedRemotesForBookmark(binary string, repoPath string, bookmarkName string) ([]string, error) { + output, err := runJJCommand( + binary, + repoPath, + "bookmark", + "list", + "--all", + bookmarkName, + "-T", + `name ++ "|" ++ remote ++ "\n"`, + ) + if err != nil { + return nil, err + } + + var remotes []string + 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 != bookmarkName || remote == "" || remote == "git" { + continue + } + + remotes = append(remotes, remote) + } + + return remotes, nil +} + +func getCommitsForRevset(binary string, repoPath string, revset string) ([]string, error) { + if strings.TrimSpace(revset) == "" { + return []string{}, nil + } + + output, err := runJJCommand( + binary, + repoPath, + "log", + "-r", + revset, + "--no-graph", + "-T", + `commit_id.short() ++ "|" ++ description.first_line() ++ "\n"`, + ) + if err != nil { + return nil, err + } + + return parseCommitSummaries(output), nil +} + +func getTrackedBookmarks(binary string, repoPath string) ([]trackedBookmark, error) { + output, err := runJJCommand( + binary, + 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 +} + +// RunJJCommand executes a jj command in repoPath and returns stdout. +func RunJJCommand(repoPath string, args ...string) (string, error) { + return runJJCommand("jj", repoPath, args...) +} + +func runJJCommand(binary string, repoPath string, args ...string) (string, error) { + cmd := exec.Command(binary, append([]string{"-R", repoPath}, args...)...) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return "", commandError{ + Binary: binary, + RepoPath: repoPath, + Args: args, + Stderr: strings.TrimSpace(stderr.String()), + Err: err, + } + } + + return stdout.String(), nil +} + +func parseCommitSummaries(output string) []string { + 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 +} + +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 buildTrackedIncomingRevset(bookmarks []trackedBookmark) string { + parts := make([]string, 0, len(bookmarks)) + + for _, bookmark := range bookmarks { + parts = append(parts, fmt.Sprintf( + `(remote_bookmarks("%s", remote="git")..remote_bookmarks("%s", remote="%s"))`, + escapeRevsetString(bookmark.Name), + escapeRevsetString(bookmark.Name), + escapeRevsetString(bookmark.Remote), + )) + } + + 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.go b/internal/vcs/jj/jj.go index ee669ca..78490be 100644 --- a/internal/vcs/jj/jj.go +++ b/internal/vcs/jj/jj.go @@ -1,13 +1,9 @@ package jj import ( - "bytes" "fmt" - "net/url" "os/exec" - "path" "path/filepath" - "regexp" "strings" "github.com/mabd-dev/reposcan/internal/utils" @@ -19,11 +15,6 @@ type Provider struct { binary string } -type trackedBookmark struct { - Name string - Remote string -} - func New() *Provider { return &Provider{binary: "jj"} } @@ -54,290 +45,45 @@ func (p *Provider) CheckRepoState(path string) (report.RepoState, []string) { warnings := []string{} - repoName, err := p.getRepoName(path) + repoName, err := getRepoName(p.binary, path) if err != nil { - warnings = append(warnings, "Failed to get jj repo name, path="+path) + warnings = append(warnings, jjWarning("get repo name", path, err)) } else if strings.TrimSpace(repoName) != "" { state.Repo = repoName } - branch, err := p.getBranchDisplay(path) + branch, err := getBranchDisplay(p.binary, path) if err != nil { - warnings = append(warnings, "Failed to get jj branch display, path="+path) + warnings = append(warnings, jjWarning("get branch display", path, err)) } else if strings.TrimSpace(branch) != "" { state.Branch = branch } - uncommittedFiles, err := p.getUncommittedFiles(path) + uncommittedFiles, err := getUncommittedFiles(p.binary, path) if err != nil { - warnings = append(warnings, "Failed to get jj uncommitted files, path="+path) + warnings = append(warnings, jjWarning("get uncommitted files", path, err)) } 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") + remoteStatuses, err := getBookmarkRemoteStatuses(p.binary, path, strings.Split(state.Branch, ",")) 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) + warnings = append(warnings, jjWarning("get remote status", path, err)) + } else if len(remoteStatuses) > 0 { + state.RemoteStatus = make([]report.RemoteStatus, 0, len(remoteStatuses)) + for _, remoteStatus := range remoteStatuses { + state.RemoteStatus = append(state.RemoteStatus, report.RemoteStatus{ + Remote: remoteStatus.Remote, + Ahead: len(remoteStatus.OutgoingCommits), + Behind: len(remoteStatus.IncomingCommits), + OutgoingCommits: remoteStatus.OutgoingCommits, + }) } } - 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 + return state, warnings } -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 +func jjWarning(operation string, path string, err error) string { + return fmt.Sprintf("Failed to %s for jj repo, path=%s: %v", operation, path, err) } diff --git a/internal/vcs/jj/jj_test.go b/internal/vcs/jj/jj_test.go index f6aa50f..1f54eec 100644 --- a/internal/vcs/jj/jj_test.go +++ b/internal/vcs/jj/jj_test.go @@ -21,7 +21,12 @@ func initJJRepo(t *testing.T, root string, name string) string { return repoPath } -func initTrackedJJRepo(t *testing.T) string { +type trackedJJRepo struct { + SeedPath string + WorkPath string +} + +func initTrackedJJRepo(t *testing.T) trackedJJRepo { t.Helper() root := t.TempDir() @@ -60,7 +65,10 @@ func initTrackedJJRepo(t *testing.T) string { t.Fatalf("jj git clone: %v", err) } - return workPath + return trackedJJRepo{ + SeedPath: seedPath, + WorkPath: workPath, + } } func TestProviderCheckRepoStateHandlesMissingRemotesAndBookmarks(t *testing.T) { @@ -149,7 +157,7 @@ func TestProviderCheckRepoStateCollectsTrackedBookmarkOutgoingCommits(t *testing t.Skip("git binary not available") } - repoPath := initTrackedJJRepo(t) + repoPath := initTrackedJJRepo(t).WorkPath filePath := filepath.Join(repoPath, "README.md") f, err := os.OpenFile(filePath, os.O_APPEND|os.O_WRONLY, 0o644) @@ -197,6 +205,14 @@ func TestProviderCheckRepoStateCollectsTrackedBookmarkOutgoingCommits(t *testing t.Fatalf("expected ahead count 1 from tracked bookmark commits, got %d", state.RemoteStatus[0].Ahead) } + if state.RemoteStatus[0].Remote != "origin" { + t.Fatalf("expected remote status to be scoped to origin, got %q", state.RemoteStatus[0].Remote) + } + + if state.Branch != "main" { + t.Fatalf("expected branch display to use the current bookmark, got %q", state.Branch) + } + 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) } @@ -210,6 +226,87 @@ func TestProviderCheckRepoStateCollectsTrackedBookmarkOutgoingCommits(t *testing } } +func TestProviderCheckRepoStateCollectsTrackedBookmarkIncomingCommits(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") + } + + repo := initTrackedJJRepo(t) + seedPath := repo.SeedPath + workPath := repo.WorkPath + + workFile := filepath.Join(workPath, "README.md") + f, err := os.OpenFile(workFile, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + t.Fatalf("open work file: %v", err) + } + if _, err := f.WriteString("local\n"); err != nil { + t.Fatalf("append local change: %v", err) + } + _ = f.Close() + + if err := exec.Command("jj", "-R", workPath, "describe", "-m", "local change").Run(); err != nil { + t.Fatalf("jj describe local change: %v", err) + } + if err := exec.Command("jj", "-R", workPath, "bookmark", "move", "main", "-t", "@").Run(); err != nil { + t.Fatalf("jj bookmark move main: %v", err) + } + if err := exec.Command("jj", "-R", workPath, "new").Run(); err != nil { + t.Fatalf("jj new: %v", err) + } + + seedFile := filepath.Join(seedPath, "README.md") + f, err = os.OpenFile(seedFile, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + t.Fatalf("open seed file: %v", err) + } + if _, err := f.WriteString("remote\n"); err != nil { + t.Fatalf("append remote change: %v", err) + } + _ = f.Close() + + if err := exec.Command("git", "-C", seedPath, "add", "README.md").Run(); err != nil { + t.Fatalf("git add remote: %v", err) + } + if err := exec.Command("git", "-C", seedPath, "commit", "-m", "remote change").Run(); err != nil { + t.Fatalf("git commit remote: %v", err) + } + if err := exec.Command("git", "-C", seedPath, "push", "origin", "main").Run(); err != nil { + t.Fatalf("git push remote: %v", err) + } + if err := exec.Command("jj", "-R", workPath, "git", "fetch").Run(); err != nil { + t.Fatalf("jj git fetch: %v", err) + } + + state, warnings := New().CheckRepoState(workPath) + 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].Behind != 1 { + t.Fatalf("expected behind count 1 from tracked bookmark incoming commits, got %d", state.RemoteStatus[0].Behind) + } + + if state.RemoteStatus[0].Remote != "origin" { + t.Fatalf("expected remote status to be scoped to origin, got %q", state.RemoteStatus[0].Remote) + } + + if state.Branch != "main" { + t.Fatalf("expected branch display to use the current bookmark, got %q", state.Branch) + } + + if state.RemoteStatus[0].Ahead != 1 { + t.Fatalf("expected ahead count 1 from local tracked bookmark commit, got %d", state.RemoteStatus[0].Ahead) + } +} + func TestProviderCheckRepoStateWarnsWhenBinaryMissing(t *testing.T) { repoPath := filepath.Join(t.TempDir(), "repo") @@ -231,3 +328,31 @@ func TestProviderCheckRepoStateWarnsWhenBinaryMissing(t *testing.T) { t.Fatalf("expected missing binary warning, got %v", warnings) } } + +func TestProviderCheckRepoStateWarningsIncludeCommandFailureDetails(t *testing.T) { + if _, err := exec.LookPath("false"); err != nil { + t.Skip("false binary not available") + } + + repoPath := filepath.Join(t.TempDir(), "repo") + + _, warnings := (&Provider{binary: "false"}).CheckRepoState(repoPath) + + if len(warnings) == 0 { + t.Fatal("expected warnings") + } + + warning := warnings[0] + wantParts := []string{ + "Failed to get repo name for jj repo", + "path=" + repoPath, + `command="false -R ` + repoPath + ` git remote list"`, + "failed: exit status 1", + } + + for _, want := range wantParts { + if !strings.Contains(warning, want) { + t.Fatalf("expected warning to contain %q, got %q", want, warning) + } + } +} diff --git a/internal/vcs/provider.go b/internal/vcs/provider.go index edd283a..48add61 100644 --- a/internal/vcs/provider.go +++ b/internal/vcs/provider.go @@ -6,3 +6,9 @@ type Provider interface { Type() Type CheckRepoState(path string) (report.RepoState, []string) } + +type ActionProvider interface { + Fetch(path string) (string, error) + Push(path string) (string, error) + Pull(path string) (string, error) +} diff --git a/internal/vcs/registry.go b/internal/vcs/registry.go index 0794c37..586a7f0 100644 --- a/internal/vcs/registry.go +++ b/internal/vcs/registry.go @@ -35,3 +35,13 @@ func (r *Registry) Get(repoType Type) (Provider, bool) { provider, ok := r.providers[repoType] return provider, ok } + +func (r *Registry) GetActionProvider(repoType Type) (ActionProvider, bool) { + provider, ok := r.Get(repoType) + if !ok { + return nil, false + } + + actionProvider, ok := provider.(ActionProvider) + return actionProvider, ok +} diff --git a/internal/vcs/registry_test.go b/internal/vcs/registry_test.go new file mode 100644 index 0000000..4448078 --- /dev/null +++ b/internal/vcs/registry_test.go @@ -0,0 +1,56 @@ +package vcs + +import ( + "testing" + + "github.com/mabd-dev/reposcan/pkg/report" +) + +type stubActionProvider struct { + repoType Type +} + +func (p stubActionProvider) Type() Type { + return p.repoType +} + +func (p stubActionProvider) CheckRepoState(path string) (report.RepoState, []string) { + return report.RepoState{Path: path, VCSType: string(p.repoType)}, nil +} + +func (p stubActionProvider) Fetch(path string) (string, error) { + return path, nil +} + +func (p stubActionProvider) Push(path string) (string, error) { + return path, nil +} + +func (p stubActionProvider) Pull(path string) (string, error) { + return path, nil +} + +func TestRegistryGetActionProvider(t *testing.T) { + registry := NewRegistry( + stubProvider{repoType: TypeGit}, + stubActionProvider{repoType: TypeJJ}, + ) + + if _, ok := registry.GetActionProvider(TypeGit); ok { + t.Fatal("expected git stub to not implement ActionProvider") + } + + actionProvider, ok := registry.GetActionProvider(TypeJJ) + if !ok { + t.Fatal("expected jj stub to implement ActionProvider") + } + + output, err := actionProvider.Fetch("/tmp/repo") + if err != nil { + t.Fatalf("expected fetch to succeed: %v", err) + } + + if output != "/tmp/repo" { + t.Fatalf("expected fetch output to be repo path, got %q", output) + } +}