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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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.
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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**
Expand Down Expand Up @@ -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:
Comment thread
frittlechasm marked this conversation as resolved.

- 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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 0 additions & 7 deletions cmd/reposcan/rootCmd.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package reposcan

import (
"errors"
"fmt"
"os"
"strings"
Expand Down Expand Up @@ -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
}
18 changes: 16 additions & 2 deletions docs/cli-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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`
Expand All @@ -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"`
Expand All @@ -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.
3 changes: 2 additions & 1 deletion internal/render/stdout/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
52 changes: 47 additions & 5 deletions internal/render/stdout/scanReport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
}
Expand Down Expand Up @@ -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"}},
},
Expand All @@ -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)
}
}
49 changes: 31 additions & 18 deletions internal/render/stdout/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
)
}
Expand All @@ -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()
}
2 changes: 2 additions & 0 deletions internal/render/tui/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -67,6 +68,7 @@ func Render(

m := Model{
configs: configs,
vcsRegistry: internal.NewVCSRegistry(),
reposTable: reposTable,
repoDetails: repoDetails,
rtHeader: reposTableHeader,
Expand Down
Loading