Skip to content
Closed

wip #13

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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ settings.local.json
dist/
dirtygit
dirtygit.exe

# generated files
report*.json*
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,17 @@ Run the following to see all available make targets:
make help
```

## Future
## TODO

A few possibilities listed below, none of this is promised or scheduled.
This stuff needs doing:

- replace runGit and other git operations with a git package

consider also:

- ? unify view current working directory with other branches

and maybe in future:

- **More tool integration** - beyond `e` (edit) and `t` (terminal), maybe a git gui. And better configurability.
- **Machine-readable output** — JSON or similar (flag or subcommand) for scripting and CI (e.g. exit non-zero if
Expand Down
3 changes: 3 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ func main() {
CommandNotFound: func(ctx context.Context, cmd *cli.Command, name string) {
fmt.Printf("ERROR: Unknown command '%s'\n", name)
},
Commands: []*cli.Command{
reportCommand(),
},
Flags: []cli.Flag{
&cli.StringFlag{
Name: "config",
Expand Down
36 changes: 36 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,49 @@
package main

import (
"os"
"strings"
"testing"

"github.com/boyvinall/dirtygit/scanner"
)

func expandEnvInConfig(config *scanner.Config) {
for i := range config.ScanDirs.Include {
config.ScanDirs.Include[i] = os.ExpandEnv(config.ScanDirs.Include[i])
}
for i := range config.ScanDirs.Exclude {
config.ScanDirs.Exclude[i] = os.ExpandEnv(config.ScanDirs.Exclude[i])
}
}

func TestGetDefaultConfigPathUsesHomeAndSuffix(t *testing.T) {
p := getDefaultConfigPath()
if !strings.HasSuffix(p, ".dirtygit.yml") {
t.Fatalf("expected path to end with .dirtygit.yml, got %q", p)
}
}

func TestValidateReport_Integration(t *testing.T) {
configPath := getDefaultConfigPath()
if _, err := os.Stat(configPath); err != nil {
t.Skipf("default config not found at %s", configPath)
}

config, err := scanner.ParseConfigFile(configPath, defaultConfig)
if err != nil {
t.Fatalf("parse config: %v", err)
}
expandEnvInConfig(config)

mgs, err := scanner.Scan(config)
if err != nil {
t.Fatalf("scan: %v", err)
}

r := buildReport(config, mgs)

for _, v := range validateReport(config, r) {

Check failure on line 46 in main_test.go

View workflow job for this annotation

GitHub Actions / ci

undefined: validateReport (typecheck)
t.Error(v)
}
}
180 changes: 180 additions & 0 deletions report.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
package main

import (
"context"
"encoding/json"
"fmt"
"os"

"github.com/urfave/cli/v3"

"github.com/boyvinall/dirtygit/scanner"
)

// reportLocationEntry mirrors scanner.BranchLocation for JSON output.
type reportLocationEntry struct {
Name string `json:"name"`
Exists bool `json:"exists"`
TipHash string `json:"tip_hash"`
TipUnix int64 `json:"tip_unix"`
UniqueCount int `json:"unique_count"`
Incoming int `json:"incoming"`
Outgoing int `json:"outgoing"`
HistoriesUnrelated bool `json:"histories_unrelated"`
}

// reportBranchEntry is one local branch row in the report.
type reportBranchEntry struct {
scanner.LocalBranchRef

// ShownInTUI is true when this branch appears in the TUI Branches pane for its
// repository (HasTipMismatchAcrossRemotes or listed under branches.default).
ShownInTUI bool `json:"shown_in_tui"`
IsLocalOnly bool `json:"is_local_only"`
}

// reportFileEntry is one porcelain status entry for a file.
type reportFileEntry struct {
// Staging and Worktree are the single-character git status codes (e.g. "M", "A", "?").
Staging string `json:"staging"`
Worktree string `json:"worktree"`
Path string `json:"path"`
OriginalPath string `json:"original_path"`
}

// reportRepo is the per-repository section of the report.
type reportRepo struct {
Path string `json:"path"`
IsClean bool `json:"is_clean"`
// Files are the uncommitted working-tree changes (porcelain entries).
Files []reportFileEntry `json:"files"`
// CurrentBranch is the checked-out branch short name, or the short HEAD hash when detached.
CurrentBranch string `json:"current_branch"`
Detached bool `json:"detached"`
// Branches lists all local branches including those excluded by config (see ExcludedByConfig).
Branches []reportBranchEntry `json:"branches"`
}

// report is the top-level JSON structure for the report subcommand.
type report struct {
// Repos are the repositories shown in the TUI repository pane (dirty or diverged),
// in alphabetical order.
Repos []reportRepo `json:"repos"`
}

func buildReport(config *scanner.Config, mgs *scanner.MultiGitStatus) report {
paths := mgs.SortedRepoPaths()
repos := make([]reportRepo, 0, len(paths))

for _, path := range paths {
rs, ok := mgs.Get(path)
if !ok {
continue
}

// Files
files := make([]reportFileEntry, 0, len(rs.Porcelain.Entries))
for _, e := range rs.Porcelain.Entries {
files = append(files, reportFileEntry{
Staging: string(e.Staging),
Worktree: string(e.Worktree),
Path: e.Path,
OriginalPath: e.OriginalPath,
})
}

// Build a set of branch names that survived FilterLocalOnlyForConfig.
filteredSet := make(map[string]struct{}, len(rs.FilteredBranches))
for _, lb := range rs.FilteredBranches {
filteredSet[lb.Name] = struct{}{}
}

branches := make([]reportBranchEntry, 0, len(rs.Branches.LocalBranches))
for _, lb := range rs.Branches.LocalBranches {
_, survived := filteredSet[lb.Name]

branches = append(branches, reportBranchEntry{
LocalBranchRef: lb,
ShownInTUI: survived,
IsLocalOnly: lb.IsLocalOnly(),
})
}

repos = append(repos, reportRepo{
Path: path,
IsClean: rs.IsClean(),
Files: files,
CurrentBranch: rs.Branches.Branch,
Detached: rs.Branches.Detached,
Branches: branches,
})
}

return report{Repos: repos}
}

func runReport(config *scanner.Config, outputFile string) error {
mgs, err := scanner.Scan(config)
if err != nil {
return err
}

r := buildReport(config, mgs)
if outputFile != "" {
f, err := os.Create(outputFile)
if err != nil {
return err
}
defer f.Close()
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
err = enc.Encode(r)
if err != nil {
return err
}
}

printReportSummary(r)
return nil
}

func printReportSummary(r report) {
for _, repo := range r.Repos {
fmt.Println(repo.Path)
for _, b := range repo.Branches {
if b.ShownInTUI {
fmt.Printf(" %s\n", b.GetDisplayName())
}
}
}
}

func reportCommand() *cli.Command {
return &cli.Command{
Name: "report",
Usage: "Report on dirty/diverged repositories (equivalent to TUI state)",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "output-file",
Aliases: []string{"o"},
Usage: "Write json report to this file",
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
config, err := scanner.ParseConfigFile(cmd.Root().String("config"), defaultConfig)
if err != nil {
return err
}
if cmd.Args().Len() > 0 {
config.ScanDirs.Include = cmd.Args().Slice()
}
for i := range config.ScanDirs.Include {
config.ScanDirs.Include[i] = os.ExpandEnv(config.ScanDirs.Include[i])
}
for i := range config.ScanDirs.Exclude {
config.ScanDirs.Exclude[i] = os.ExpandEnv(config.ScanDirs.Exclude[i])
}
return runReport(config, cmd.String("output-file"))
},
}
}
Loading
Loading