├── pkg ├── term │ ├── position.go │ ├── exit_code.go │ ├── field.go │ ├── projectselector.go │ ├── field_test.go │ ├── display.go │ └── terminal.go ├── proj │ ├── project.go │ ├── fuzzymatch.go │ ├── projects.go │ ├── fuzzymatch_test.go │ └── projects_test.go ├── io │ ├── disk.go │ ├── filesystem.go │ └── filesystem_test.go └── config │ └── config.go ├── .gitattributes ├── .gitignore ├── .editorconfig ├── go.mod ├── snapcraft.yaml ├── .github └── workflows │ ├── test.yml │ └── release.yml ├── LICENSE ├── .goreleaser.yml ├── Makefile ├── cmd └── fuzzy-repo-finder │ └── main.go ├── go.sum └── README.md /pkg/term/position.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | type position struct { 4 | x, y int 5 | } 6 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | 3 | *.png binary 4 | *.jpg binary 5 | *.jar binary 6 | *.zip binary 7 | 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.iml 3 | 4 | vendor/ 5 | bin/ 6 | 7 | # goreleaser 8 | dist 9 | 10 | *.snap 11 | .snapcraft 12 | -------------------------------------------------------------------------------- /pkg/term/exit_code.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | type ExitCode int 4 | 5 | const ( 6 | ContinueRunning ExitCode = iota + 1 7 | NormalExit 8 | AbnormalExit 9 | ) 10 | -------------------------------------------------------------------------------- /pkg/proj/project.go: -------------------------------------------------------------------------------- 1 | package proj 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type Project struct { 8 | FullPath string 9 | Group string 10 | Name string 11 | } 12 | 13 | func (p Project) String() string { 14 | return fmt.Sprintf("Name=[%s], Group=[%s], FullPath=[%s]", p.Name, p.Group, p.FullPath) 15 | } 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | insert_final_newline = false 13 | trim_trailing_whitespace = false 14 | 15 | [*.go] 16 | indent_style = tab 17 | 18 | [*.yml] 19 | indent_size = 2 20 | 21 | [Makefile] 22 | indent_style = tab 23 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hoto/fuzzy-repo-finder 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/kylelemons/godebug v1.1.0 // indirect 7 | github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381 8 | github.com/mattn/go-runewidth v0.0.9 // indirect 9 | github.com/nsf/termbox-go v0.0.0-20200418040025-38ba6e5628f1 10 | github.com/sahilm/fuzzy v0.1.0 11 | github.com/stretchr/testify v1.6.1 12 | ) 13 | -------------------------------------------------------------------------------- /pkg/proj/fuzzymatch.go: -------------------------------------------------------------------------------- 1 | package proj 2 | 3 | import ( 4 | "github.com/sahilm/fuzzy" 5 | ) 6 | 7 | func FuzzyMatch(needle string, haystack Projects) Projects { 8 | if len(needle) == 0 { 9 | return haystack 10 | } 11 | matchingProjects := NewProjects() 12 | matches := fuzzy.FindFrom(needle, haystack) 13 | for _, match := range matches { 14 | matchingProjects.Add(haystack.Get(match.Index)) 15 | } 16 | return matchingProjects 17 | } 18 | -------------------------------------------------------------------------------- /snapcraft.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: fuzzy-repo-finder 3 | version: git 4 | summary: Command line tool for navigating git repositories. 5 | description: Command line tool for navigating git repositories. 6 | 7 | confinement: strict 8 | base: core18 9 | 10 | parts: 11 | fuzzy-repo-finder: 12 | plugin: go 13 | go-importpath: github.com/hoto/fuzzy-repo-finder 14 | source: . 15 | source-type: git 16 | 17 | apps: 18 | fuzzy-repo-finder: 19 | command: bin/fuzzy-repo-finder 20 | plugs: [home] 21 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | release: 11 | name: Build and test 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v2 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v2 19 | with: 20 | go-version: 1.14 21 | id: go 22 | 23 | - name: Build 24 | run: make build 25 | 26 | - name: Test 27 | run: make test 28 | 29 | - name: Dry run goreleaser 30 | run: make goreleaser-dry-run 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release - github, brew 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | release: 10 | name: GitHub release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: 1.14 20 | id: go 21 | 22 | - name: Unshallow git repo 23 | run: git fetch --prune --unshallow 24 | 25 | - name: Run goreleaser 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }} 28 | run: make goreleaser-release 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Andrzej Rehmann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /pkg/term/field.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | type field struct { 4 | title []rune 5 | query []rune 6 | } 7 | 8 | func NewField(title string, query string) *field { 9 | return &field{ 10 | title: []rune(title), 11 | query: []rune(query), 12 | } 13 | } 14 | 15 | func (f *field) queryRunes() []rune { 16 | return f.query 17 | } 18 | 19 | func (f *field) queryString() string { 20 | return string(f.query) 21 | } 22 | 23 | func (f *field) querySize() int { 24 | return len(f.query) 25 | } 26 | 27 | func (f *field) queryIsEmpty() bool { 28 | return f.querySize() == 0 29 | } 30 | 31 | func (f *field) appendToQuery(char rune) { 32 | f.query = append(f.query, char) 33 | } 34 | 35 | func (f *field) deleteLastQueryChar() { 36 | if len(f.query) > 0 { 37 | f.query = f.query[:len(f.query)-1] 38 | } 39 | } 40 | 41 | func (f *field) eraseQuery() { 42 | if len(f.query) > 0 { 43 | f.query = []rune{} 44 | } 45 | } 46 | 47 | func (f *field) titleRunes() []rune { 48 | return f.title 49 | } 50 | 51 | func (f *field) titleSize() int { 52 | return len(f.title) 53 | } 54 | 55 | func (f *field) fieldSize() int { 56 | return len(f.title) + len(f.query) 57 | } 58 | -------------------------------------------------------------------------------- /pkg/io/disk.go: -------------------------------------------------------------------------------- 1 | package io 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "os" 8 | ) 9 | 10 | type IDisk interface { 11 | FindDirs(root string, dirName string) []string 12 | } 13 | 14 | type Disk struct{} 15 | 16 | func (Disk) FindDirs(root string, needle string) []string { 17 | matchingPaths := make([]string, 0) 18 | scan(root, needle, &matchingPaths) 19 | return matchingPaths 20 | } 21 | 22 | func scan(dir string, needle string, matchingPaths *[]string) { 23 | haystack, err := ioutil.ReadDir(dir) 24 | check(err) 25 | if containsNeedle(haystack, needle) { 26 | gitPath := dir + "/.git" // TODO return just the dir without the .git 27 | *matchingPaths = append(*matchingPaths, gitPath) 28 | return 29 | } 30 | for _, file := range haystack { 31 | if file.IsDir() { 32 | dir := fmt.Sprintf("%s/%s", dir, file.Name()) 33 | scan(dir, needle, matchingPaths) 34 | } 35 | } 36 | } 37 | 38 | func check(err error) { 39 | if err != nil { 40 | log.Fatal(err) 41 | } 42 | } 43 | 44 | func containsNeedle(files []os.FileInfo, needle string) bool { 45 | for _, file := range files { 46 | if file.Name() == needle { 47 | return true 48 | } 49 | } 50 | return false 51 | } 52 | -------------------------------------------------------------------------------- /pkg/term/projectselector.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | import "github.com/hoto/fuzzy-repo-finder/pkg/proj" 4 | 5 | type projectSelector struct { 6 | projects proj.Projects 7 | selectedProjectIndex int 8 | } 9 | 10 | func NewProjectSelector(projects proj.Projects) *projectSelector { 11 | return &projectSelector{ 12 | projects: projects, 13 | selectedProjectIndex: 0, 14 | } 15 | } 16 | 17 | func (p *projectSelector) selectPreviousProject() { 18 | p.selectedProjectIndex -= 1 19 | p.bindIndexInRange() 20 | } 21 | 22 | func (p *projectSelector) selectNextProject() { 23 | p.selectedProjectIndex += 1 24 | p.bindIndexInRange() 25 | } 26 | 27 | func (p *projectSelector) bindIndexInRange() { 28 | projectsSize := p.projects.Size() 29 | if p.selectedProjectIndex < 0 { 30 | p.selectedProjectIndex = projectsSize - 1 31 | } 32 | if projectsSize < p.selectedProjectIndex+1 { 33 | p.selectedProjectIndex = 0 34 | } 35 | } 36 | 37 | func (p *projectSelector) resetSelectedProject() { 38 | p.selectedProjectIndex = 0 39 | } 40 | 41 | func (p *projectSelector) index() int { 42 | return p.selectedProjectIndex 43 | } 44 | 45 | func (p *projectSelector) setProjects(projects proj.Projects) { 46 | p.projects = projects 47 | } 48 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | --- 2 | builds: 3 | - main: ./cmd/fuzzy-repo-finder/main.go 4 | ldflags: 5 | - '-s -w -X github.com/hoto/fuzzy-repo-finder/pkg/config.Version={{.Version}} -X github.com/hoto/fuzzy-repo-finder/pkg/config.ShortCommit={{.ShortCommit}} -X github.com/hoto/fuzzy-repo-finder/pkg/config.BuildDate={{.Date}}' 6 | env: 7 | - CGO_ENABLED=0 8 | ignore: 9 | - goos: darwin 10 | goarch: 386 11 | archives: 12 | - format: binary 13 | replacements: 14 | darwin: Darwin 15 | linux: Linux 16 | 386: i386 17 | amd64: x86_64 18 | - id: homebrew 19 | name_template: "{{ .Binary }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" 20 | replacements: 21 | darwin: Darwin 22 | linux: Linux 23 | amd64: x86_64 24 | format: zip 25 | checksum: 26 | name_template: 'checksums.txt' 27 | snapshot: 28 | name_template: "{{ .Tag }}-next" 29 | release: 30 | prerelease: auto 31 | changelog: 32 | sort: asc 33 | brews: 34 | - github: 35 | owner: hoto 36 | name: homebrew-repo 37 | folder: Formula 38 | homepage: https://github.com/hoto/fuzzy-repo-finder 39 | description: Navigate locally cloned repos. 40 | test: | 41 | system "#{bin}/fuzzy-repo-finder--version" 42 | 43 | -------------------------------------------------------------------------------- /pkg/io/filesystem.go: -------------------------------------------------------------------------------- 1 | package io 2 | 3 | import ( 4 | "github.com/hoto/fuzzy-repo-finder/pkg/proj" 5 | "os" 6 | "strings" 7 | ) 8 | 9 | var ( 10 | pathSeparator = string(os.PathSeparator) 11 | ) 12 | 13 | type Filesystem struct { 14 | disk IDisk 15 | } 16 | 17 | func NewFilesystem(disk IDisk) Filesystem { 18 | return Filesystem{disk} 19 | } 20 | 21 | func (fs Filesystem) FindGitProjects(root string) proj.Projects { 22 | gitDirs := fs.disk.FindDirs(root, ".git") 23 | var projects = proj.NewProjects() 24 | for _, path := range gitDirs { 25 | tokens := strings.Split(path, pathSeparator) 26 | fullPath := strings.Join(tokens[0:len(tokens)-1], pathSeparator) 27 | group := diffPath(root, fullPath) 28 | name := tokens[len(tokens)-2] 29 | projects.Add(proj.Project{FullPath: fullPath, Group: group, Name: name}) 30 | } 31 | return projects 32 | } 33 | 34 | func diffPath(root string, fullPath string) string { 35 | fullPathTokens := strings.Split(fullPath, pathSeparator) 36 | pathToProject := strings.Join(fullPathTokens[0:len(fullPathTokens)-1], pathSeparator) 37 | if len(root) == len(pathToProject) { 38 | lastFolderFromFullPath := fullPathTokens[len(fullPathTokens)-2 : len(fullPathTokens)-1] 39 | return strings.Join(lastFolderFromFullPath, pathSeparator) 40 | } 41 | return pathToProject[len(root)+1:] 42 | } 43 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean dependencies build test run install github-release github-release-dry-run goreleaser-dry-run snap-build snap-list snap-install snap-remove 2 | 3 | REPO_NAME = github.com/hoto/fuzzy-repo-finder 4 | 5 | clean: 6 | go clean 7 | rm -rf bin/ dist/ *.snap 8 | 9 | dependencies: 10 | go mod download 11 | go mod tidy 12 | go mod verify 13 | 14 | build: dependencies 15 | go build -ldflags="-X '${REPO_NAME}/pkg/config.Version=0.0.0' -X '${REPO_NAME}/pkg/config.ShortCommit=HASH' -X '${REPO_NAME}/pkg/config.BuildDate=DATE'" -o bin/fuzzy-repo-finder ./cmd/fuzzy-repo-finder/main.go 16 | 17 | test: 18 | go test -v ./... 19 | 20 | run: clean build 21 | ./bin/fuzzy-repo-finder $(arg) 22 | 23 | install: clean build 24 | go install -v ./... 25 | 26 | goreleaser-release: clean dependencies 27 | curl -sL https://git.io/goreleaser | VERSION=v0.137.0 bash 28 | 29 | goreleaser-dry-run: clean dependencies 30 | curl -sL https://git.io/goreleaser | VERSION=v0.137.0 bash -s -- --skip-publish --snapshot --rm-dist 31 | 32 | goreleaser-dry-run-local: dependencies 33 | goreleaser release --skip-publish --snapshot --rm-dist 34 | 35 | snap-build: 36 | snapcraft 37 | 38 | snap-list: 39 | unsquashfs -l *.snap 40 | snap list 41 | 42 | snap-install: 43 | sudo snap install --dangerous *.snap 44 | 45 | snap-remove: 46 | sudo snap remove fuzzy-repo-finder 47 | -------------------------------------------------------------------------------- /cmd/fuzzy-repo-finder/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/hoto/fuzzy-repo-finder/pkg/config" 6 | "github.com/hoto/fuzzy-repo-finder/pkg/io" 7 | "github.com/hoto/fuzzy-repo-finder/pkg/proj" 8 | "github.com/hoto/fuzzy-repo-finder/pkg/term" 9 | "github.com/logrusorgru/aurora" 10 | "os" 11 | ) 12 | 13 | func main() { 14 | config.ParseArgsAndFlags() 15 | projects := readProjectsFromDisk() 16 | if config.Debug { 17 | debugLog(projects) 18 | os.Exit(0) 19 | } 20 | os.Exit(loop(projects)) 21 | } 22 | 23 | func readProjectsFromDisk() proj.Projects { 24 | filesystem := io.NewFilesystem(io.Disk{}) 25 | allProjects := proj.NewProjects() 26 | for _, root := range config.ProjectsRoots { 27 | projects := filesystem.FindGitProjects(root) 28 | allProjects.AddAll(projects.List()) 29 | } 30 | return allProjects 31 | } 32 | 33 | func debugLog(projects proj.Projects) { 34 | fmt.Println() 35 | fmt.Println("Projects:") 36 | fmt.Printf(" projects=%s\n", aurora.Cyan(projects)) 37 | } 38 | 39 | func loop(projects proj.Projects) int { 40 | terminal := term.NewTerminal(projects, config.ProjectNameFilter) 41 | terminal.Init() 42 | defer terminal.Close() 43 | 44 | for { 45 | rc := terminal.Cycle() 46 | switch rc { 47 | case term.ContinueRunning: 48 | continue 49 | case term.NormalExit: 50 | fmt.Print(config.SelectedProjectPath) 51 | return 0 52 | case term.AbnormalExit: 53 | return 1 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /pkg/proj/projects.go: -------------------------------------------------------------------------------- 1 | package proj 2 | 3 | type Projects struct { 4 | projects []Project 5 | } 6 | 7 | func NewProjects() Projects { 8 | return Projects{projects: make([]Project, 0)} 9 | } 10 | 11 | func (p *Projects) List() []Project { 12 | return p.projects 13 | } 14 | 15 | func (p *Projects) Add(project Project) { 16 | p.projects = append(p.projects, project) 17 | } 18 | 19 | func (p *Projects) AddAll(projects []Project) { 20 | for _, project := range projects { 21 | p.Add(project) 22 | } 23 | } 24 | 25 | func (p *Projects) ListGroups() []string { 26 | groups := []string{} 27 | for _, project := range p.projects { 28 | groupPresent := false 29 | if len(groups) == 0 { 30 | groups = append(groups, project.Group) 31 | continue 32 | } 33 | for _, group := range groups { 34 | if group == project.Group { 35 | groupPresent = true 36 | break 37 | } 38 | } 39 | if !groupPresent { 40 | groups = append(groups, project.Group) 41 | } 42 | } 43 | return groups 44 | } 45 | 46 | func (p *Projects) Size() int { 47 | return len(p.projects) 48 | } 49 | 50 | // implements fuzzy.Source 51 | func (p Projects) Len() int { 52 | return p.Size() 53 | } 54 | 55 | // implements fuzzy.Source 56 | func (p Projects) String(i int) string { 57 | return p.projects[i].Name 58 | } 59 | 60 | func (p Projects) Copy() Projects { 61 | return p 62 | } 63 | 64 | func (p *Projects) Get(i int) Project { 65 | if p.Size() == 0 { 66 | return Project{} 67 | } 68 | return p.projects[i] 69 | } 70 | 71 | func (p *Projects) GetFirst() Project { 72 | return p.Get(0) 73 | } 74 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "github.com/logrusorgru/aurora" 7 | "os" 8 | "strings" 9 | ) 10 | 11 | var ( 12 | Version string 13 | ShortCommit string 14 | BuildDate string 15 | Debug bool 16 | ProjectsRoots []string 17 | ProjectNameFilter string 18 | SelectedProjectPath = "" 19 | ) 20 | 21 | func ParseArgsAndFlags() { 22 | flag.Usage = overrideUsage() 23 | 24 | flag.BoolVar(&Debug, "debug", false, "Show verbose debug information") 25 | showVersion := flag.Bool("version", false, "Show version") 26 | projectRoots := flag.String("projectRoots", "", 27 | "Comma separated list of project roots directories") 28 | 29 | flag.Parse() 30 | 31 | ProjectNameFilter = strings.Join(flag.Args(), "") 32 | ProjectsRoots = strings.Split(*projectRoots, ",") 33 | 34 | if *showVersion { 35 | fmt.Printf("fuzzy-repo-finder version %s, commit %s, build %s\n", 36 | Version, ShortCommit, BuildDate) 37 | os.Exit(0) 38 | } 39 | 40 | if *projectRoots == "" { 41 | flag.Usage() 42 | os.Exit(1) 43 | } 44 | 45 | if Debug { 46 | debugLog(projectRoots) 47 | } 48 | } 49 | 50 | func overrideUsage() func() { 51 | return func() { 52 | _, _ = fmt.Fprintf( 53 | os.Stdout, 54 | "Usage:"+ 55 | "\n\t"+ 56 | "cd $(fuzzy-repo-finder --projectRoots=\"${HOME}/projects\" [flags] [QUERY])"+ 57 | "\n\n"+ 58 | "Flags:"+ 59 | "\n") 60 | flag.PrintDefaults() 61 | } 62 | } 63 | 64 | func debugLog(projectRoots *string) { 65 | fmt.Println("Flags:") 66 | fmt.Printf(" projectRoots=%s\n", aurora.Cyan(*projectRoots)) 67 | fmt.Println() 68 | fmt.Println("Args:") 69 | fmt.Printf(" args=%s\n", aurora.Cyan(flag.Args())) 70 | fmt.Println() 71 | fmt.Println("Config:") 72 | fmt.Printf(" ProjectRoots=%s\n", aurora.Cyan(ProjectsRoots)) 73 | fmt.Printf(" ProjectNameFilter=%s\n", aurora.Cyan(ProjectNameFilter)) 74 | } 75 | -------------------------------------------------------------------------------- /pkg/term/field_test.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func Test_have_zero_query_size(t *testing.T) { 9 | query := field{} 10 | 11 | assert.Equal(t, 0, query.querySize()) 12 | } 13 | 14 | func Test_have_empty_query(t *testing.T) { 15 | query := NewField("", "") 16 | 17 | assert.Equal(t, true, query.queryIsEmpty()) 18 | } 19 | 20 | func Test_return_query_size(t *testing.T) { 21 | query := NewField("", "QUERY") 22 | 23 | size := query.querySize() 24 | 25 | assert.Equal(t, 5, size) 26 | } 27 | 28 | func Test_return_title_size(t *testing.T) { 29 | query := NewField("TITLE", "") 30 | 31 | size := query.titleSize() 32 | 33 | assert.Equal(t, 5, size) 34 | } 35 | 36 | func Test_return_whole_field_size(t *testing.T) { 37 | query := NewField("TITLE", "QUERY") 38 | 39 | size := query.fieldSize() 40 | 41 | assert.Equal(t, 10, size) 42 | } 43 | 44 | func Test_erase_query(t *testing.T) { 45 | query := NewField("", "QUERY") 46 | 47 | query.eraseQuery() 48 | 49 | assert.Equal(t, true, query.queryIsEmpty()) 50 | } 51 | 52 | func Test_leave_query_empty_when_attempting_to_delete_last_char(t *testing.T) { 53 | query := NewField("", "") 54 | 55 | query.deleteLastQueryChar() 56 | 57 | assert.Equal(t, "", query.queryString()) 58 | } 59 | 60 | func Test_append_char_to_query(t *testing.T) { 61 | query := NewField("", "QUERY") 62 | xRune := rune(120) 63 | 64 | query.appendToQuery(xRune) 65 | 66 | assert.Equal(t, "QUERYx", query.queryString()) 67 | } 68 | 69 | func Test_delete_last_query_char(t *testing.T) { 70 | query := NewField("", "QUERY") 71 | 72 | query.deleteLastQueryChar() 73 | 74 | assert.Equal(t, "QUER", query.queryString()) 75 | } 76 | 77 | func Test_return_query_runes(t *testing.T) { 78 | query := NewField("", "QUERY") 79 | 80 | runes := query.queryRunes() 81 | 82 | assert.Equal(t, []int32{81, 85, 69, 82, 89}, runes) 83 | } 84 | 85 | func Test_return_title_runes(t *testing.T) { 86 | query := NewField("TITLE", "") 87 | 88 | runes := query.titleRunes() 89 | 90 | assert.Equal(t, []int32{84, 73, 84, 76, 69}, runes) 91 | } 92 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 4 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 5 | github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381 h1:bqDmpDG49ZRnB5PcgP0RXtQvnMSgIF14M7CBd2shtXs= 6 | github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= 7 | github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= 8 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 9 | github.com/nsf/termbox-go v0.0.0-20200418040025-38ba6e5628f1 h1:lh3PyZvY+B9nFliSGTn5uFuqQQJGuNrD0MLCokv09ag= 10 | github.com/nsf/termbox-go v0.0.0-20200418040025-38ba6e5628f1/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= 11 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 12 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 13 | github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= 14 | github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 15 | github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= 16 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 17 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 18 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 19 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 20 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 21 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 22 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 23 | -------------------------------------------------------------------------------- /pkg/term/display.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | import ( 4 | "github.com/hoto/fuzzy-repo-finder/pkg/proj" 5 | "github.com/nsf/termbox-go" 6 | ) 7 | 8 | const ( 9 | queryVerticalOffset = 0 10 | projectsVerticalOffset = 1 11 | projectNameHorizontalOffset = 4 12 | ) 13 | 14 | type display struct { 15 | queryCursorPosition position 16 | } 17 | 18 | func NewDisplay() *display { 19 | return &display{ 20 | queryCursorPosition: position{0, 0}, 21 | } 22 | } 23 | 24 | func (display) displayField(field *field) { 25 | for charHorizontalOffset, char := range field.titleRunes() { 26 | termbox.SetCell( 27 | charHorizontalOffset, 28 | queryVerticalOffset, 29 | char, 30 | termbox.ColorCyan, 31 | termbox.ColorDefault) 32 | } 33 | promptHorizontalOffset := field.titleSize() 34 | for charHorizontalOffset, char := range field.queryRunes() { 35 | termbox.SetCell( 36 | promptHorizontalOffset+charHorizontalOffset, 37 | queryVerticalOffset, 38 | char, 39 | termbox.ColorGreen, 40 | termbox.ColorDefault) 41 | } 42 | } 43 | 44 | func (display) displayProjects(projects *proj.Projects, selectedProjectIndex int) { 45 | currentLineNum := projectsVerticalOffset 46 | for _, group := range projects.ListGroups() { 47 | for charOffset, char := range []rune(group) { 48 | termbox.SetCell( 49 | charOffset, 50 | currentLineNum, 51 | char, 52 | termbox.ColorMagenta, 53 | termbox.ColorDefault) 54 | } 55 | currentLineNum += 1 56 | for _, project := range projects.List() { 57 | projectBgColor := highlightedIfSelected(projects, project, selectedProjectIndex) 58 | if project.Group == group { 59 | for charOffset, char := range []rune(project.Name) { 60 | termbox.SetCell( 61 | projectNameHorizontalOffset+charOffset, 62 | currentLineNum, 63 | char, 64 | termbox.ColorDefault, 65 | projectBgColor) 66 | } 67 | currentLineNum += 1 68 | } 69 | } 70 | } 71 | } 72 | 73 | func highlightedIfSelected(projects *proj.Projects, project proj.Project, selectedProjectIndex int) termbox.Attribute { 74 | if project == projects.Get(selectedProjectIndex) { 75 | return termbox.ColorCyan 76 | } 77 | return termbox.ColorDefault 78 | } 79 | 80 | func (d *display) positionCursor(field *field) { 81 | d.queryCursorPosition.x = field.fieldSize() 82 | termbox.SetCursor(d.queryCursorPosition.x, d.queryCursorPosition.y) 83 | } 84 | 85 | func (d *display) refresh() { 86 | d.flush() 87 | d.clear() 88 | } 89 | 90 | func (display) flush() { 91 | err := termbox.Flush() 92 | if err != nil { 93 | panic(err) 94 | } 95 | } 96 | 97 | func (display) clear() { 98 | err := termbox.Clear(termbox.ColorDefault, termbox.ColorDefault) 99 | if err != nil { 100 | panic(err) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /pkg/term/terminal.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | import ( 4 | "github.com/hoto/fuzzy-repo-finder/pkg/config" 5 | "github.com/hoto/fuzzy-repo-finder/pkg/proj" 6 | "github.com/nsf/termbox-go" 7 | ) 8 | 9 | const ( 10 | LettersNumbersAndSpecialCharacters = 0 11 | ) 12 | 13 | type Terminal struct { 14 | display *display 15 | projectNameField *field 16 | allProjects proj.Projects 17 | filteredProjects proj.Projects 18 | projectSelector *projectSelector 19 | } 20 | 21 | func NewTerminal(projects proj.Projects, projectNameFilter string) *Terminal { 22 | return &Terminal{ 23 | display: NewDisplay(), 24 | projectNameField: NewField("Search: ", projectNameFilter), 25 | allProjects: projects, 26 | filteredProjects: projects, 27 | projectSelector: NewProjectSelector(projects), 28 | } 29 | } 30 | 31 | // Initializes terminal. This function must be called before any other functions. 32 | // Terminal must be closed using Close() function before exiting the application. 33 | // 34 | // terminal := terminal.Init() 35 | // defer terminal.Close() 36 | func (Terminal) Init() { 37 | err := termbox.Init() 38 | if err != nil { 39 | panic(err) 40 | } 41 | } 42 | 43 | func (Terminal) Close() { 44 | termbox.Close() 45 | } 46 | 47 | func (t *Terminal) Cycle() ExitCode { 48 | t.filterProjects() 49 | t.projectSelector.setProjects(t.filteredProjects) 50 | t.projectSelector.bindIndexInRange() 51 | t.display.positionCursor(t.projectNameField) 52 | t.display.displayField(t.projectNameField) 53 | t.display.displayProjects(&t.filteredProjects, t.projectSelector.index()) 54 | t.display.refresh() 55 | event := termbox.PollEvent() 56 | if event.Type == termbox.EventKey { 57 | switch event.Key { 58 | case LettersNumbersAndSpecialCharacters: 59 | t.projectNameField.appendToQuery(event.Ch) 60 | t.display.positionCursor(t.projectNameField) 61 | t.projectSelector.resetSelectedProject() 62 | case termbox.KeyBackspace, termbox.KeyBackspace2: 63 | t.projectNameField.deleteLastQueryChar() 64 | t.display.positionCursor(t.projectNameField) 65 | t.projectSelector.resetSelectedProject() 66 | case termbox.KeyCtrlW: 67 | t.projectNameField.eraseQuery() 68 | t.display.positionCursor(t.projectNameField) 69 | t.projectSelector.resetSelectedProject() 70 | case termbox.KeyArrowUp: 71 | t.projectSelector.selectPreviousProject() 72 | case termbox.KeyArrowDown: 73 | t.projectSelector.selectNextProject() 74 | case termbox.KeyEnter: 75 | selectedProject := t.filteredProjects.Get(t.projectSelector.selectedProjectIndex) 76 | config.SelectedProjectPath = selectedProject.FullPath 77 | return NormalExit 78 | case termbox.KeyCtrlC: 79 | return AbnormalExit 80 | } 81 | } 82 | t.filterProjects() 83 | return ContinueRunning 84 | } 85 | 86 | func (t *Terminal) filterProjects() { 87 | t.filteredProjects = proj.FuzzyMatch(t.projectNameField.queryString(), t.allProjects) 88 | } 89 | -------------------------------------------------------------------------------- /pkg/io/filesystem_test.go: -------------------------------------------------------------------------------- 1 | package io 2 | 3 | import ( 4 | "github.com/hoto/fuzzy-repo-finder/pkg/proj" 5 | "github.com/stretchr/testify/assert" 6 | "github.com/stretchr/testify/mock" 7 | "testing" 8 | ) 9 | 10 | const ( 11 | projectsRoot = "/home/user/projects" 12 | git = ".git" 13 | ) 14 | 15 | var ( 16 | emptyProjects = make([]proj.Project, 0) 17 | ) 18 | 19 | func Test_return_empty_list_when_no_directory_matches(t *testing.T) { 20 | disk := new(MockDisk) 21 | disk.On("FindDirs", projectsRoot, git).Return([]string{}) 22 | filesystem := NewFilesystem(disk) 23 | 24 | projects := filesystem.FindGitProjects(projectsRoot) 25 | 26 | assert.Equal(t, emptyProjects, projects.List()) 27 | } 28 | 29 | func Test_return_matching_projects(t *testing.T) { 30 | disk := new(MockDisk) 31 | disk.On("FindDirs", projectsRoot, git).Return([]string{ 32 | "/home/user/projects/project1/.git", 33 | "/home/user/projects/project2/.git", 34 | }) 35 | filesystem := NewFilesystem(disk) 36 | 37 | projects := filesystem.FindGitProjects(projectsRoot) 38 | 39 | project1 := proj.Project{ 40 | Name: "project1", 41 | Group: "projects", 42 | FullPath: "/home/user/projects/project1", 43 | } 44 | project2 := proj.Project{ 45 | Name: "project2", 46 | Group: "projects", 47 | FullPath: "/home/user/projects/project2", 48 | } 49 | expectedProjects := []proj.Project{project1, project2} 50 | assert.Equal(t, expectedProjects, projects.List()) 51 | } 52 | 53 | func Test_return_matching_projects_inside_a_group(t *testing.T) { 54 | disk := new(MockDisk) 55 | disk.On("FindDirs", projectsRoot, git).Return([]string{ 56 | "/home/user/projects/dirA/project1/.git", 57 | "/home/user/projects/dirB/project2/.git", 58 | }) 59 | filesystem := NewFilesystem(disk) 60 | 61 | projects := filesystem.FindGitProjects(projectsRoot) 62 | 63 | project1 := proj.Project{ 64 | Name: "project1", 65 | Group: "dirA", 66 | FullPath: "/home/user/projects/dirA/project1", 67 | } 68 | project2 := proj.Project{ 69 | Name: "project2", 70 | Group: "dirB", 71 | FullPath: "/home/user/projects/dirB/project2", 72 | } 73 | expectedProjects := []proj.Project{project1, project2} 74 | assert.Equal(t, expectedProjects, projects.List()) 75 | } 76 | 77 | func Test_return_matching_projects_inside_a_multiple_level_group(t *testing.T) { 78 | disk := new(MockDisk) 79 | disk.On("FindDirs", projectsRoot, git).Return([]string{ 80 | "/home/user/projects/dirA1/dirA2/dirA3/project1/.git", 81 | "/home/user/projects/dirB1/dirB2/dirB3/project2/.git", 82 | }) 83 | filesystem := NewFilesystem(disk) 84 | 85 | projects := filesystem.FindGitProjects(projectsRoot) 86 | 87 | project1 := proj.Project{ 88 | Name: "project1", 89 | Group: "dirA1/dirA2/dirA3", 90 | FullPath: "/home/user/projects/dirA1/dirA2/dirA3/project1", 91 | } 92 | project2 := proj.Project{ 93 | Name: "project2", 94 | Group: "dirB1/dirB2/dirB3", 95 | FullPath: "/home/user/projects/dirB1/dirB2/dirB3/project2", 96 | } 97 | expectedProjects := []proj.Project{project1, project2} 98 | assert.Equal(t, expectedProjects, projects.List()) 99 | } 100 | 101 | type MockDisk struct { 102 | mock.Mock 103 | } 104 | 105 | func (m *MockDisk) FindDirs(root string, matchDir string) []string { 106 | args := m.Called(root, matchDir) 107 | return args.Get(0).([]string) 108 | } 109 | -------------------------------------------------------------------------------- /pkg/proj/fuzzymatch_test.go: -------------------------------------------------------------------------------- 1 | package proj 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | const ( 9 | emptyQuery = "" 10 | ) 11 | 12 | var ( 13 | emptyProjects = NewProjects() 14 | ) 15 | 16 | func Test_return_empty_projects_when_inputs_are_empty(t *testing.T) { 17 | filteredProjects := FuzzyMatch(emptyQuery, emptyProjects) 18 | 19 | assert.Equal(t, emptyProjects, filteredProjects) 20 | } 21 | 22 | func Test_return_empty_projects_when_query_is_not_empty(t *testing.T) { 23 | filteredProjects := FuzzyMatch("PROJECT_1", emptyProjects) 24 | 25 | assert.Equal(t, emptyProjectsList, filteredProjects.List()) 26 | } 27 | 28 | func Test_return_single_match(t *testing.T) { 29 | projects := NewProjects() 30 | project1 := Project{Name: "PROJECT_1"} 31 | project2 := Project{Name: "PROJECT_2"} 32 | project3 := Project{Name: "PROJECT_3"} 33 | project4 := Project{Name: "PROJECT_4"} 34 | projects.AddAll([]Project{project1, project2, project3, project4}) 35 | 36 | filteredProjects := FuzzyMatch("PROJECT_2", projects) 37 | 38 | assert.Equal(t, []Project{project2}, filteredProjects.List()) 39 | } 40 | 41 | func Test_return_all_projects_when_query_is_empty(t *testing.T) { 42 | projects := NewProjects() 43 | project1 := Project{Name: "PROJECT_1"} 44 | project2 := Project{Name: "PROJECT_2"} 45 | project3 := Project{Name: "PROJECT_3"} 46 | project4 := Project{Name: "PROJECT_4"} 47 | projects.AddAll([]Project{project1, project2, project3, project4}) 48 | 49 | filteredProjects := FuzzyMatch(emptyQuery, projects) 50 | 51 | assert.Equal(t, []Project{project1, project2, project3, project4}, filteredProjects.List()) 52 | } 53 | 54 | func Test_return_all_projects_when_all_are_matching(t *testing.T) { 55 | projects := NewProjects() 56 | project1 := Project{Name: "A_PROJECT_1", FullPath: "FULL_PATH_1"} 57 | project2 := Project{Name: "A_PROJECT_2", FullPath: "FULL_PATH_2"} 58 | project3 := Project{Name: "B_PROJECT_3", FullPath: "FULL_PATH_3"} 59 | project4 := Project{Name: "B_PROJECT_4", FullPath: "FULL_PATH_4"} 60 | projects.AddAll([]Project{project1, project2, project3, project4}) 61 | 62 | filteredProjects := FuzzyMatch("PROJECT_", projects) 63 | 64 | assert.EqualValues(t, []Project{project4, project3, project2, project1}, filteredProjects.List()) 65 | } 66 | 67 | func Test_return_multiple_matches(t *testing.T) { 68 | projects := NewProjects() 69 | project1 := Project{Name: "A_PROJECT_1", FullPath: "FULL_PATH_1"} 70 | project2 := Project{Name: "A_PROJECT_2", FullPath: "FULL_PATH_2"} 71 | project3 := Project{Name: "B_PROJECT_3", FullPath: "FULL_PATH_3"} 72 | project4 := Project{Name: "B_PROJECT_4", FullPath: "FULL_PATH_4"} 73 | projects.AddAll([]Project{project1, project2, project3, project4}) 74 | 75 | filteredProjects := FuzzyMatch("B_PROJECT", projects) 76 | 77 | assert.EqualValues(t, []Project{project4, project3}, filteredProjects.List()) 78 | } 79 | 80 | func Test_sort_using_a_matching_score(t *testing.T) { 81 | projects := NewProjects() 82 | project1 := Project{Name: "myproject1", FullPath: "FULL_PATH_1"} 83 | project2 := Project{Name: "project2", FullPath: "FULL_PATH_2"} 84 | project3 := Project{Name: "someproject3", FullPath: "FULL_PATH_3"} 85 | project4 := Project{Name: "aproject4", FullPath: "FULL_PATH_4"} 86 | projects.AddAll([]Project{project1, project2, project3, project4}) 87 | 88 | filteredProjects := FuzzyMatch("project", projects) 89 | 90 | assert.EqualValues(t, []Project{project2, project4, project1, project3}, filteredProjects.List()) 91 | } 92 | -------------------------------------------------------------------------------- /pkg/proj/projects_test.go: -------------------------------------------------------------------------------- 1 | package proj 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | var ( 9 | emptyProject = Project{} 10 | emptyProjectsList = make([]Project, 0) 11 | ) 12 | 13 | func Test_be_empty(t *testing.T) { 14 | projects := NewProjects() 15 | 16 | assert.Equal(t, 0, projects.Size()) 17 | assert.Equal(t, 0, projects.Len()) 18 | } 19 | 20 | func Test_return_empty_projects(t *testing.T) { 21 | projects := NewProjects() 22 | 23 | assert.Equal(t, emptyProjectsList, projects.List()) 24 | } 25 | 26 | func Test_return_empty_project(t *testing.T) { 27 | projects := NewProjects() 28 | 29 | assert.Equal(t, emptyProject, projects.Get(0)) 30 | } 31 | 32 | func Test_return_empty_first_project(t *testing.T) { 33 | projects := NewProjects() 34 | 35 | assert.Equal(t, emptyProject, projects.GetFirst()) 36 | } 37 | 38 | func Test_return_first_project(t *testing.T) { 39 | project1 := Project{Name: "PROJECT_1"} 40 | project2 := Project{Name: "PROJECT_2"} 41 | projects := NewProjects() 42 | projects.AddAll([]Project{project1, project2}) 43 | 44 | assert.Equal(t, project1, projects.GetFirst()) 45 | } 46 | 47 | func Test_retain_a_project(t *testing.T) { 48 | project := Project{Name: "PROJECT_1"} 49 | projects := NewProjects() 50 | projects.Add(project) 51 | 52 | assert.Equal(t, projects.List(), []Project{project}) 53 | assert.Equal(t, projects.Get(0), project) 54 | } 55 | 56 | func Test_have_one_element(t *testing.T) { 57 | project := Project{Name: "PROJECT_1"} 58 | projects := NewProjects() 59 | projects.Add(project) 60 | 61 | assert.Equal(t, 1, projects.Size()) 62 | } 63 | 64 | func Test_retain_added_projects(t *testing.T) { 65 | projects := NewProjects() 66 | newProjects := []Project{ 67 | {Name: "PROJECT_1"}, 68 | {Name: "PROJECT_2"}, 69 | } 70 | 71 | projects.AddAll(newProjects) 72 | 73 | assert.Equal(t, projects.List(), newProjects) 74 | } 75 | 76 | func Test_return_empty_groups(t *testing.T) { 77 | projects := NewProjects() 78 | 79 | groups := projects.ListGroups() 80 | 81 | assert.Equal(t, []string{}, groups) 82 | } 83 | 84 | func Test_list_groups_in_order(t *testing.T) { 85 | projects := NewProjects() 86 | project1 := Project{Name: "PROJECT_1", Group: "GROUP_1"} 87 | project2 := Project{Name: "PROJECT_2", Group: "GROUP_1"} 88 | project3 := Project{Name: "PROJECT_3", Group: "GROUP_2"} 89 | project4 := Project{Name: "PROJECT_4", Group: "GROUP_3"} 90 | project5 := Project{Name: "PROJECT_5", Group: "GROUP_1"} 91 | projects.AddAll([]Project{project1, project2, project3, project4, project5}) 92 | 93 | groups := projects.ListGroups() 94 | 95 | assert.EqualValues(t, []string{"GROUP_1", "GROUP_2", "GROUP_3"}, groups) 96 | } 97 | 98 | func Test_make_an_empty_copy(t *testing.T) { 99 | projects := NewProjects() 100 | 101 | projectsCopy := projects.Copy() 102 | 103 | assert.Equal(t, projectsCopy, projects) 104 | } 105 | 106 | func Test_make_a_copy(t *testing.T) { 107 | projects := NewProjects() 108 | project1 := Project{Name: "PROJECT_1", Group: "GROUP_1"} 109 | projects.AddAll([]Project{project1}) 110 | 111 | projectsCopy := projects.Copy() 112 | 113 | assert.Equal(t, projectsCopy, projects) 114 | } 115 | 116 | func Test_make_a_deep_copy(t *testing.T) { 117 | projects := NewProjects() 118 | project1 := Project{Name: "PROJECT_1", Group: "GROUP_1"} 119 | project2 := Project{Name: "PROJECT_2", Group: "GROUP_2"} 120 | projects.AddAll([]Project{project1}) 121 | 122 | projectsCopy := projects.Copy() 123 | projects.Add(project2) 124 | 125 | assert.NotEqual(t, projectsCopy, projects) 126 | } 127 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](/LICENSE) 2 | [![Build status](https://github.com/hoto/fuzzy-repo-finder/workflows/Test/badge.svg?branch=master)](https://github.com/hoto/fuzzy-repo-finder/actions) 3 | [![Release](https://img.shields.io/github/release/hoto/fuzzy-repo-finder.svg?style=flat-square)](https://github.com/hoto/fuzzy-repo-finder/releases/latest) 4 | [![Powered By: goreleaser](https://img.shields.io/badge/powered%20by-goreleaser-green.svg?style=flat-square)](https://github.com/goreleaser/goreleaser) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/hoto/fuzzy-repo-finder)](https://goreportcard.com/report/github.com/hoto/fuzzy-repo-finder) 6 | [![Maintainability](https://api.codeclimate.com/v1/badges/27f61a82b9a5589f1a07/maintainability)](https://codeclimate.com/github/hoto/fuzzy-repo-finder/maintainability) 7 | [![fuzzy-repo-finder](https://snapcraft.io//fuzzy-repo-finder/badge.svg)](https://snapcraft.io/fuzzy-repo-finder) 8 | # Fuzzy Repo Finder 9 | 10 | Command line tool for navigating git repositories. 11 | 12 | ### Installation 13 | 14 | Mac: 15 | 16 | brew install hoto/repo/fuzzy-repo-finder 17 | 18 | Mac or Linux: 19 | 20 | sudo curl -L \ 21 | "https://github.com/hoto/fuzzy-repo-finder/releases/download/2.2.1/fuzzy-repo-finder_2.2.1_$(uname -s)_$(uname -m)" \ 22 | -o /usr/local/bin/fuzzy-repo-finder 23 | 24 | sudo chmod +x /usr/local/bin/fuzzy-repo-finder 25 | 26 | Snap: 27 | 28 | sudo snap install fuzzy-repo-finder 29 | 30 | Or manually download binary from [releases](https://github.com/hoto/fuzzy-repo-finder/releases). 31 | 32 | ### Configuration and running 33 | 34 | Add to your `~/.bashrc` or `~/.zshrc` or `~/.profile`: 35 | 36 | function go_to_project() { 37 | cd $(fuzzy-repo-finder --projectRoots "${HOME}/projects,${HOME}/go/src" $@) 38 | } 39 | alias g='go_to_project' 40 | 41 | In terminal: 42 | 43 | $ g 44 | 45 | Find projects by partial name: 46 | 47 | $ g myprojectname 48 | 49 | Debug: 50 | 51 | $ fuzzy-repo-finder --projectRoots "${HOME}/projects,${HOME}/go/src" --debug myprojectname 52 | 53 | Help: 54 | 55 | $ fuzzy-repo-finder --help 56 | 57 | ### Demo 58 | 59 | From directory structure: 60 | 61 | ~/projects 62 | ├── group_A 63 | │   ├── project_1 64 | │   ├── project_2 65 | │   └── project_3 66 | └── group_B 67 | ├── project_1 68 | ├── project_2 69 | └── group_C 70 | └── project_1 71 | 72 | Unfiltered: 73 | 74 | Search: 75 | group_A 76 | project_1 77 | project_2 78 | project_3 79 | group_B 80 | project_1 81 | project_2 82 | project_3 83 | group_B/group_C 84 | project_1 85 | 86 | Filtered: 87 | 88 | Search: pr1 89 | group_A 90 | project_1 91 | group_B 92 | project_1 93 | group_B/group_C 94 | project_1 95 | 96 | ![demo](https://github.com/hoto/fuzzy-repo-finder/wiki/images/001.png) 97 | 98 | ![demo](https://github.com/hoto/fuzzy-repo-finder/wiki/images/002.gif) 99 | 100 | ![demo](https://github.com/hoto/fuzzy-repo-finder/wiki/images/005.gif) 101 | 102 | --- 103 | 104 | ### Development 105 | 106 | Get: 107 | 108 | go get github.com/hoto/fuzzy-repo-finder/cmd/fuzzy-repo-finder/ 109 | 110 | Download dependencies: 111 | 112 | make dependencies 113 | 114 | Build, test and run: 115 | 116 | make clean 117 | make build 118 | make test 119 | make run 120 | 121 | Run with arguments: 122 | 123 | make run arg="--projectRoots=${HOME}/projects project_to_look_for" 124 | 125 | Install to global golang bin directory: 126 | 127 | make install 128 | 129 | ### TODO: 130 | * Fix order when scrolling through projects 131 | * Fix versioning in snapcraft builds (they work only with goreleaser ATM) 132 | 133 | --- 134 | _Following_ [_Standard Go Project Layout_](https://github.com/golang-standards/project-layout) 135 | 136 | 137 | --------------------------------------------------------------------------------